From 8a6f1ee7c89176ba244bfec4af98746aaecd0501 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:45:48 +1000 Subject: [PATCH 001/146] refactor(media): move the media model out of JellyfinTypes The structs every screen and the player consume are Spool's media model, not a Jellyfin artefact. They now live in src/media/MediaTypes.h, in the core (shell) source group. The two Jellyfin-only types move to the provider side: AuthSession to src/api/JellyfinSession.h and DiscoveredServer to src/discovery/DiscoveredServer.h. SeriesAudioSelection is playback policy, so it joins the player group. No field changes. --- CMakeLists.txt | 10 ++++---- src/api/JellyfinApiFacade.h | 3 ++- src/api/JellyfinSession.h | 16 +++++++++++++ src/api/PlaybackNegotiation.h | 2 +- src/app/AppController.h | 2 +- src/app/ArtworkService.h | 2 +- src/app/BrowseSessionController.h | 2 +- src/app/HomeModelController.h | 2 +- src/app/LibraryManagementController.h | 2 +- src/app/LibraryPrefetchController.h | 2 +- src/app/LibraryQuery.h | 2 +- src/app/QuickConnectController.h | 3 ++- src/app/RemoteControlController.h | 2 +- src/app/SessionController.h | 3 ++- src/app/SettingsController.h | 2 +- src/app/SettingsSchema.cpp | 2 +- src/app/SyncPlayController.cpp | 2 +- src/cache/DatabaseManager.h | 2 +- src/common/AsyncTask.h | 2 +- src/common/MetaJson.h | 8 +++---- src/common/SeriesAudioSelection.h | 2 +- src/diagnostics/Diagnostics.cpp | 2 +- src/discovery/DiscoveredServer.h | 23 +++++++++++++++++++ src/discovery/DiscoveryController.h | 2 +- src/main.cpp | 2 +- .../MediaTypes.cpp} | 4 ++-- .../JellyfinTypes.h => media/MediaTypes.h} | 20 ---------------- src/models/DiscoveredServerModel.h | 2 +- src/models/LibraryListModel.h | 2 +- src/models/MovieGridModel.h | 2 +- src/player/MpvOptionProfile.h | 2 +- src/player/PlayQueueController.h | 2 +- src/player/PlaybackFailurePolicy.h | 2 +- src/player/PlaybackReporter.h | 2 +- src/player/PlaybackTimeline.h | 2 +- src/player/PlayerController.cpp | 2 +- src/player/PlayerController.h | 2 +- tests/common/BrowseDescriptorTest.cpp | 2 +- tests/common/DiagnosticRedactionTest.cpp | 2 +- tests/common/MetaJsonTest.cpp | 1 + 40 files changed, 88 insertions(+), 63 deletions(-) create mode 100644 src/api/JellyfinSession.h create mode 100644 src/discovery/DiscoveredServer.h rename src/{common/JellyfinTypes.cpp => media/MediaTypes.cpp} (99%) rename src/{common/JellyfinTypes.h => media/MediaTypes.h} (97%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9900d5a8..8fe6cd69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,8 @@ set(SPOOL_PLAYER_SOURCES src/player/PlayerController.h src/player/RenderTargetProfile.cpp src/player/RenderTargetProfile.h + src/common/SeriesAudioSelection.cpp + src/common/SeriesAudioSelection.h ) # Application plumbing that does not name a media backend. @@ -221,6 +223,8 @@ set(SPOOL_SHELL_SOURCES src/common/TlsTrust.h src/common/VariantUtils.cpp src/common/VariantUtils.h + src/media/MediaTypes.cpp + src/media/MediaTypes.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp @@ -239,6 +243,7 @@ set(SPOOL_JELLYFIN_SOURCES src/api/ItemsQuery.h src/api/JellyfinApiFacade.cpp src/api/JellyfinApiFacade.h + src/api/JellyfinSession.h src/api/PlaybackBandwidthPolicy.cpp src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp @@ -284,10 +289,7 @@ set(SPOOL_JELLYFIN_SOURCES src/app/SyncPlayController.h src/app/UserItemStateController.cpp src/app/UserItemStateController.h - src/common/JellyfinTypes.cpp - src/common/JellyfinTypes.h - src/common/SeriesAudioSelection.cpp - src/common/SeriesAudioSelection.h + src/discovery/DiscoveredServer.h src/discovery/DiscoveryController.cpp src/discovery/DiscoveryController.h src/models/DiscoveredServerModel.cpp diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h index 004b8c0f..fa3d9a40 100644 --- a/src/api/JellyfinApiFacade.h +++ b/src/api/JellyfinApiFacade.h @@ -1,7 +1,8 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "HttpRequestPolicy.h" +#include "JellyfinSession.h" #include "PlaybackBandwidthPolicy.h" #include diff --git a/src/api/JellyfinSession.h b/src/api/JellyfinSession.h new file mode 100644 index 00000000..20b65343 --- /dev/null +++ b/src/api/JellyfinSession.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace JellyfinNative { + +// The credentials a Jellyfin sign-in yields. Provider-owned: nothing in core +// reads a user id or server id. +struct AuthSession { + QString userId; + QString userName; + QString accessToken; + QString serverId; +}; + +} // namespace JellyfinNative diff --git a/src/api/PlaybackNegotiation.h b/src/api/PlaybackNegotiation.h index c37a1c5f..97ec19eb 100644 --- a/src/api/PlaybackNegotiation.h +++ b/src/api/PlaybackNegotiation.h @@ -1,5 +1,5 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/app/AppController.h b/src/app/AppController.h index 8d699e47..240093d7 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -1,9 +1,9 @@ #pragma once #include "../cache/DatabaseManager.h" -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" #include "../discovery/DiscoveryController.h" +#include "../media/MediaTypes.h" #include "../models/DiscoveredServerModel.h" #include "../models/LibraryListModel.h" #include "../models/MovieGridModel.h" diff --git a/src/app/ArtworkService.h b/src/app/ArtworkService.h index 0f8f4e6d..8816d6a7 100644 --- a/src/app/ArtworkService.h +++ b/src/app/ArtworkService.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "ArtworkPrefetcher.h" #include diff --git a/src/app/BrowseSessionController.h b/src/app/BrowseSessionController.h index 19df4560..e8a8a297 100644 --- a/src/app/BrowseSessionController.h +++ b/src/app/BrowseSessionController.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../models/MovieGridModel.h" #include diff --git a/src/app/HomeModelController.h b/src/app/HomeModelController.h index 9b387df3..47bf0e16 100644 --- a/src/app/HomeModelController.h +++ b/src/app/HomeModelController.h @@ -1,7 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" #include "../models/MovieGridModel.h" #include #include diff --git a/src/app/LibraryManagementController.h b/src/app/LibraryManagementController.h index 6ffbe4a3..fafc5d70 100644 --- a/src/app/LibraryManagementController.h +++ b/src/app/LibraryManagementController.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/app/LibraryPrefetchController.h b/src/app/LibraryPrefetchController.h index a0f706d6..bde785b9 100644 --- a/src/app/LibraryPrefetchController.h +++ b/src/app/LibraryPrefetchController.h @@ -1,7 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/app/LibraryQuery.h b/src/app/LibraryQuery.h index ca4e3d60..62f0c797 100644 --- a/src/app/LibraryQuery.h +++ b/src/app/LibraryQuery.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/app/QuickConnectController.h b/src/app/QuickConnectController.h index 7cd425c1..bb78aba2 100644 --- a/src/app/QuickConnectController.h +++ b/src/app/QuickConnectController.h @@ -1,6 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../api/JellyfinSession.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/app/RemoteControlController.h b/src/app/RemoteControlController.h index a106f4d7..4127312f 100644 --- a/src/app/RemoteControlController.h +++ b/src/app/RemoteControlController.h @@ -1,7 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" #include "../player/PlaybackTimeline.h" #include "SpoolLink.h" diff --git a/src/app/SessionController.h b/src/app/SessionController.h index 0f38a6a2..b7bdaea8 100644 --- a/src/app/SessionController.h +++ b/src/app/SessionController.h @@ -1,6 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../api/JellyfinSession.h" +#include "../media/MediaTypes.h" #include "AccountProfile.h" #include diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index 3c034f98..e6ebc904 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -1,7 +1,7 @@ #pragma once -#include "../common/JellyfinTypes.h" #include "../common/RequestGeneration.h" +#include "../media/MediaTypes.h" #include "../platform/MpvConfigPolicy.h" #include diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index de63423d..fcc11675 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -1,6 +1,6 @@ #include "SettingsSchema.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../platform/PlatformSettingsPolicy.h" #include diff --git a/src/app/SyncPlayController.cpp b/src/app/SyncPlayController.cpp index 22cf6f20..c6e0bfa9 100644 --- a/src/app/SyncPlayController.cpp +++ b/src/app/SyncPlayController.cpp @@ -2,8 +2,8 @@ #include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" -#include "../common/JellyfinTypes.h" #include "../common/TlsTrust.h" +#include "../media/MediaTypes.h" #include "../player/PlayQueueController.h" #include "../player/PlayerController.h" diff --git a/src/cache/DatabaseManager.h b/src/cache/DatabaseManager.h index 02360d67..ba1fe54a 100644 --- a/src/cache/DatabaseManager.h +++ b/src/cache/DatabaseManager.h @@ -13,7 +13,7 @@ #include #include "../app/AccountProfile.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/common/AsyncTask.h b/src/common/AsyncTask.h index b872966d..b4c54bab 100644 --- a/src/common/AsyncTask.h +++ b/src/common/AsyncTask.h @@ -1,6 +1,6 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "RequestGeneration.h" #include diff --git a/src/common/MetaJson.h b/src/common/MetaJson.h index 682d8599..9487ab85 100644 --- a/src/common/MetaJson.h +++ b/src/common/MetaJson.h @@ -1,6 +1,6 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include @@ -50,7 +50,7 @@ namespace MetaJsonDetail { return object.value(QStringLiteral("ProviderIds")).toObject().value(QStringLiteral("Tmdb")); if (policy == MetaJsonKeyPolicy::PascalCase && key == QStringLiteral("Year")) return object.value(QStringLiteral("ProductionYear")); - return { }; + return {}; } inline QJsonArray stringListToJson(const QStringList& items) @@ -143,7 +143,7 @@ namespace MetaJsonDetail { return listToJson(qvariant_cast>(value), policy); qFatal("Unsupported MetaJson property type: %s", type.name()); - return { }; + return {}; } inline QVariant variantFromJson(const QJsonValue& value, QMetaType type, MetaJsonKeyPolicy policy) @@ -175,7 +175,7 @@ namespace MetaJsonDetail { return QVariant::fromValue(listFromJson(value, policy)); qFatal("Unsupported MetaJson property type: %s", type.name()); - return { }; + return {}; } template QJsonObject metaToJson(const T& value, MetaJsonKeyPolicy policy) diff --git a/src/common/SeriesAudioSelection.h b/src/common/SeriesAudioSelection.h index 93663680..c295e944 100644 --- a/src/common/SeriesAudioSelection.h +++ b/src/common/SeriesAudioSelection.h @@ -1,6 +1,6 @@ #pragma once -#include "JellyfinTypes.h" +#include "../media/MediaTypes.h" namespace JellyfinNative { diff --git a/src/diagnostics/Diagnostics.cpp b/src/diagnostics/Diagnostics.cpp index 8c71235f..c0499b13 100644 --- a/src/diagnostics/Diagnostics.cpp +++ b/src/diagnostics/Diagnostics.cpp @@ -1,6 +1,6 @@ #include "Diagnostics.h" -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/discovery/DiscoveredServer.h b/src/discovery/DiscoveredServer.h new file mode 100644 index 00000000..bc9aed81 --- /dev/null +++ b/src/discovery/DiscoveredServer.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace JellyfinNative { + +struct DiscoveredServer { + Q_GADGET + Q_PROPERTY(QString id MEMBER id) + Q_PROPERTY(QString name MEMBER name) + Q_PROPERTY(QString address MEMBER address) + +public: + QString id; + QString name; + QString address; +}; + +} // namespace JellyfinNative + +Q_DECLARE_METATYPE(JellyfinNative::DiscoveredServer) diff --git a/src/discovery/DiscoveryController.h b/src/discovery/DiscoveryController.h index 30aeb28d..a4796db6 100644 --- a/src/discovery/DiscoveryController.h +++ b/src/discovery/DiscoveryController.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "DiscoveredServer.h" #include #include diff --git a/src/main.cpp b/src/main.cpp index b5d3404a..0c004ba2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,7 +9,6 @@ #include "app/SessionController.h" #include "app/UserItemStateController.h" #include "cache/DatabaseManager.h" -#include "common/JellyfinTypes.h" #include "common/LogRotation.h" #include "common/TlsTrust.h" #include "diagnostics/Diagnostics.h" @@ -17,6 +16,7 @@ #include "diagnostics/RenderBenchmark.h" #include "diagnostics/SystemPerformanceMonitor.h" #include "discovery/DiscoveryController.h" +#include "media/MediaTypes.h" #include "platform/NativeAppWindow.h" #include "platform/PlatformApplicationServices.h" #include "platform/PlatformCapabilities.h" diff --git a/src/common/JellyfinTypes.cpp b/src/media/MediaTypes.cpp similarity index 99% rename from src/common/JellyfinTypes.cpp rename to src/media/MediaTypes.cpp index 5c755b72..0beb01c0 100644 --- a/src/common/JellyfinTypes.cpp +++ b/src/media/MediaTypes.cpp @@ -1,6 +1,6 @@ -#include "JellyfinTypes.h" +#include "MediaTypes.h" +#include "../common/MetaJson.h" #include "../platform/PlatformSettingsPolicy.h" -#include "MetaJson.h" #include #include diff --git a/src/common/JellyfinTypes.h b/src/media/MediaTypes.h similarity index 97% rename from src/common/JellyfinTypes.h rename to src/media/MediaTypes.h index 2e8fc918..58408441 100644 --- a/src/common/JellyfinTypes.h +++ b/src/media/MediaTypes.h @@ -15,18 +15,6 @@ namespace JellyfinNative { -struct DiscoveredServer { - Q_GADGET - Q_PROPERTY(QString id MEMBER id) - Q_PROPERTY(QString name MEMBER name) - Q_PROPERTY(QString address MEMBER address) - -public: - QString id; - QString name; - QString address; -}; - struct LibraryItem { Q_GADGET Q_PROPERTY(QString id MEMBER id) @@ -319,13 +307,6 @@ QString itemEpisodeCode(const MovieItem& item); bool isGenericEpisodeTitle(const MovieItem& item); int episodicPlaybackStartIndex(const std::vector& episodes); -struct AuthSession { - QString userId; - QString userName; - QString accessToken; - QString serverId; -}; - struct MediaSegment { Q_GADGET Q_PROPERTY(QString id MEMBER id) @@ -432,7 +413,6 @@ QVariantMap formatMediaInfo(const MovieItem& item, const QString& preferredAudio } // namespace JellyfinNative -Q_DECLARE_METATYPE(JellyfinNative::DiscoveredServer) Q_DECLARE_METATYPE(JellyfinNative::LibraryItem) Q_DECLARE_METATYPE(JellyfinNative::PersonItem) Q_DECLARE_METATYPE(JellyfinNative::MediaStreamInfo) diff --git a/src/models/DiscoveredServerModel.h b/src/models/DiscoveredServerModel.h index 878d4f4b..55ba2b2d 100644 --- a/src/models/DiscoveredServerModel.h +++ b/src/models/DiscoveredServerModel.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../discovery/DiscoveredServer.h" #include #include diff --git a/src/models/LibraryListModel.h b/src/models/LibraryListModel.h index db773461..b24e246a 100644 --- a/src/models/LibraryListModel.h +++ b/src/models/LibraryListModel.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include diff --git a/src/models/MovieGridModel.h b/src/models/MovieGridModel.h index 02f87dd9..903fbc62 100644 --- a/src/models/MovieGridModel.h +++ b/src/models/MovieGridModel.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include diff --git a/src/player/MpvOptionProfile.h b/src/player/MpvOptionProfile.h index 91097909..3c35c963 100644 --- a/src/player/MpvOptionProfile.h +++ b/src/player/MpvOptionProfile.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../platform/MpvConfigPolicy.h" #include diff --git a/src/player/PlayQueueController.h b/src/player/PlayQueueController.h index ff9cb184..ae7a63ff 100644 --- a/src/player/PlayQueueController.h +++ b/src/player/PlayQueueController.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" // Full definition, not a forward declaration: moc needs the pointed-to type // of a Q_PROPERTY to be complete. The outline only forward-declares this // controller in turn, so there is no cycle. diff --git a/src/player/PlaybackFailurePolicy.h b/src/player/PlaybackFailurePolicy.h index 1f3b3d52..f13690b2 100644 --- a/src/player/PlaybackFailurePolicy.h +++ b/src/player/PlaybackFailurePolicy.h @@ -1,5 +1,5 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include diff --git a/src/player/PlaybackReporter.h b/src/player/PlaybackReporter.h index cfd96192..d8aa6843 100644 --- a/src/player/PlaybackReporter.h +++ b/src/player/PlaybackReporter.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include #include diff --git a/src/player/PlaybackTimeline.h b/src/player/PlaybackTimeline.h index 90b345a9..0ea7608b 100644 --- a/src/player/PlaybackTimeline.h +++ b/src/player/PlaybackTimeline.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include diff --git a/src/player/PlayerController.cpp b/src/player/PlayerController.cpp index 17370721..3d4273ee 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -1,10 +1,10 @@ #include "PlayerController.h" #include "../api/JellyfinApiFacade.h" -#include "../common/JellyfinTypes.h" #include "../common/LogRotation.h" #include "../common/TlsTrust.h" #include "../diagnostics/Diagnostics.h" +#include "../media/MediaTypes.h" #include "../platform/MpvConfigPolicy.h" #include "../platform/NativeAppWindow.h" #include "../platform/PlatformPaths.h" diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index ff35f81a..2b1d2358 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -1,6 +1,6 @@ #pragma once -#include "../common/JellyfinTypes.h" +#include "../media/MediaTypes.h" #include "../platform/MpvConfigPolicy.h" #include "MpvLifecycle.h" #include "MpvOptionProfile.h" diff --git a/tests/common/BrowseDescriptorTest.cpp b/tests/common/BrowseDescriptorTest.cpp index c277634c..9c986197 100644 --- a/tests/common/BrowseDescriptorTest.cpp +++ b/tests/common/BrowseDescriptorTest.cpp @@ -1,4 +1,4 @@ -#include "common/JellyfinTypes.h" +#include "media/MediaTypes.h" #include "TestMain.h" diff --git a/tests/common/DiagnosticRedactionTest.cpp b/tests/common/DiagnosticRedactionTest.cpp index a53ab6d6..fdb2eca9 100644 --- a/tests/common/DiagnosticRedactionTest.cpp +++ b/tests/common/DiagnosticRedactionTest.cpp @@ -1,5 +1,5 @@ -#include "common/JellyfinTypes.h" #include "diagnostics/Diagnostics.h" +#include "media/MediaTypes.h" #include "TestMain.h" diff --git a/tests/common/MetaJsonTest.cpp b/tests/common/MetaJsonTest.cpp index 30dc2379..a9821992 100644 --- a/tests/common/MetaJsonTest.cpp +++ b/tests/common/MetaJsonTest.cpp @@ -1,4 +1,5 @@ #include "common/MetaJson.h" +#include "discovery/DiscoveredServer.h" #include "TestMain.h" From 3a6b3e0b4ac980eb2939b6f40fa484d4779a2d83 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:50:01 +1000 Subject: [PATCH 002/146] refactor(player): put the Jellyfin facade behind PlaybackSource src/provider/PlaybackSource.h is the whole surface the player used from JellyfinApiFacade: media request headers, the TLS origin, the parallel request budget, trickplay tile URLs, series episodes for queue continuation, the three playback reports, and the two signals that invalidate a prepared mpv. The facade implements it; PlayerController, PlayQueueController and PlaybackReporter take the interface. The X-Emby-Token header the player used to build itself now comes from the Jellyfin side, so nothing under src/player includes src/api any more. --- CMakeLists.txt | 1 + src/api/JellyfinApiFacade.cpp | 25 +++++++++++- src/api/JellyfinApiFacade.h | 24 +++++++----- src/player/PlayQueueController.cpp | 9 ++--- src/player/PlayQueueController.h | 6 +-- src/player/PlaybackReporter.cpp | 3 +- src/player/PlaybackReporter.h | 7 ++-- src/player/PlayerController.cpp | 13 +++---- src/player/PlayerController.h | 6 +-- src/provider/PlaybackSource.h | 59 +++++++++++++++++++++++++++++ tests/app/SessionControllerTest.cpp | 2 +- 11 files changed, 119 insertions(+), 36 deletions(-) create mode 100644 src/provider/PlaybackSource.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fe6cd69..e2b4fdf2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,7 @@ set(SPOOL_SHELL_SOURCES src/common/VariantUtils.h src/media/MediaTypes.cpp src/media/MediaTypes.h + src/provider/PlaybackSource.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp index 436fa384..a4b6b2f3 100644 --- a/src/api/JellyfinApiFacade.cpp +++ b/src/api/JellyfinApiFacade.cpp @@ -272,7 +272,7 @@ namespace { JellyfinApiFacade::JellyfinApiFacade( QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) + : PlaybackSource(parent) , m_networkAccessManager(networkAccessManager) , m_rest(networkAccessManager, this) { @@ -382,7 +382,7 @@ void JellyfinApiFacade::setSession(const AuthSession& session) // probe that follows refreshes it. if (!m_session.accessToken.isEmpty()) restoreRememberedMeasurement(); - emit sessionTokenChanged(); + emit credentialsChanged(); } preconnectToServer(); } @@ -452,6 +452,27 @@ void JellyfinApiFacade::setRemoteControlTargetEnabled(bool enabled) emit deviceProfileChanged(); } +QByteArray JellyfinApiFacade::mediaRequestHeaders() const +{ + const QByteArray token = m_session.accessToken.toUtf8(); + return token.isEmpty() ? QByteArray {} : QByteArrayLiteral("X-Emby-Token: ") + token; +} + +QUrl JellyfinApiFacade::mediaOrigin() const +{ + return QUrl(serverUrl()); +} + +bool JellyfinApiFacade::signedIn() const +{ + return !m_session.accessToken.isEmpty(); +} + +QCoro::Task> JellyfinApiFacade::fetchSeriesEpisodes(QString seriesId) +{ + return fetchEpisodes(std::move(seriesId)); +} + int JellyfinApiFacade::playbackParallelRequests() const { return m_playbackParallelRequests; diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h index fa3d9a40..7a3c88bc 100644 --- a/src/api/JellyfinApiFacade.h +++ b/src/api/JellyfinApiFacade.h @@ -1,6 +1,7 @@ #pragma once #include "../media/MediaTypes.h" +#include "../provider/PlaybackSource.h" #include "HttpRequestPolicy.h" #include "JellyfinSession.h" #include "PlaybackBandwidthPolicy.h" @@ -25,7 +26,7 @@ namespace JellyfinNative { class TlsTrustController; -class JellyfinApiFacade final : public QObject { +class JellyfinApiFacade final : public PlaybackSource { Q_OBJECT public: @@ -59,7 +60,13 @@ class JellyfinApiFacade final : public QObject { { return m_remoteControlTargetEnabled; } - int playbackParallelRequests() const; + int playbackParallelRequests() const override; + // PlaybackSource: the access token rides an X-Emby-Token header on + // every media request, and the server URL owns the TLS trust decision. + QByteArray mediaRequestHeaders() const override; + QUrl mediaOrigin() const override; + bool signedIn() const override; + QCoro::Task> fetchSeriesEpisodes(QString seriesId) override; QCoro::Task refreshPlaybackNetworkState(); // Measuring pulls several megabytes from the server, so it must never run @@ -130,7 +137,7 @@ class JellyfinApiFacade final : public QObject { QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks); QCoro::Task> fetchMediaSegments(QString itemId); QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override; QCoro::Task negotiatePlayback(MovieItem movie, bool forceTranscode = false); QCoro::Task fetchControllableSessions(); @@ -163,18 +170,17 @@ class JellyfinApiFacade final : public QObject { QCoro::Task syncPlaySetPlaylistItem(QString playlistItemId); QCoro::Task postCapabilities(); - QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted); - QCoro::Task reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted); + QCoro::Task reportPlaybackStart( + PlaybackSession session, double playbackRate, int volume, bool muted) override; + QCoro::Task reportPlaybackProgress(PlaybackSession session, qint64 positionTicks, bool paused, + double playbackRate, int volume, bool muted) override; QCoro::Task reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate = 1.0); + PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) override; signals: void authenticationExpired(const QString& message); void deviceProfileChanged(); - void playbackNetworkProfileChanged(); void streamingBitrateChanged(); - void sessionTokenChanged(); private: enum class HttpMethod { diff --git a/src/player/PlayQueueController.cpp b/src/player/PlayQueueController.cpp index 409e7aa3..33715723 100644 --- a/src/player/PlayQueueController.cpp +++ b/src/player/PlayQueueController.cpp @@ -1,7 +1,7 @@ #include "PlayQueueController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" +#include "../provider/PlaybackSource.h" #include #include @@ -105,7 +105,7 @@ namespace { } // namespace -PlayQueueController::PlayQueueController(JellyfinApiFacade *api, QObject *parent) +PlayQueueController::PlayQueueController(PlaybackSource *api, QObject *parent) : QAbstractListModel(parent) , m_api(api) , m_outline(new PlayQueueOutlineModel(this, this)) @@ -565,12 +565,11 @@ bool PlayQueueController::moveRange(int from, int count, int to) void PlayQueueController::enqueueEpisodeSuccessors(const MovieItem& episode) { - if (!m_api || episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty() - || m_api->session().accessToken.isEmpty()) { + if (!m_api || episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty() || !m_api->signedIn()) { return; } Async::runScoped( - this, m_api->fetchEpisodes(episode.seriesId), + this, m_api->fetchSeriesEpisodes(episode.seriesId), [this, episode](const std::vector& episodes) { auto current = std::find_if(episodes.begin(), episodes.end(), [&episode](const MovieItem& candidate) { return candidate.id == episode.id; }); diff --git a/src/player/PlayQueueController.h b/src/player/PlayQueueController.h index ae7a63ff..e6b40ea6 100644 --- a/src/player/PlayQueueController.h +++ b/src/player/PlayQueueController.h @@ -12,7 +12,7 @@ #include namespace JellyfinNative { -class JellyfinApiFacade; +class PlaybackSource; class PlayQueueController final : public QAbstractListModel { Q_OBJECT @@ -65,7 +65,7 @@ class PlayQueueController final : public QAbstractListModel { UserQueuedRole, }; - explicit PlayQueueController(JellyfinApiFacade *api = nullptr, QObject *parent = nullptr); + explicit PlayQueueController(PlaybackSource *api = nullptr, QObject *parent = nullptr); PlayQueueOutlineModel *outline() const { @@ -157,7 +157,7 @@ class PlayQueueController final : public QAbstractListModel { void setCurrentOrderIndex(int orderIndex); void emitQueueStateChanged(int previousCurrentIndex); - JellyfinApiFacade *m_api = nullptr; + PlaybackSource *m_api = nullptr; PlayQueueOutlineModel *m_outline = nullptr; std::vector m_entries; // Parallel to m_entries. Provenance belongs to this queue, not to the diff --git a/src/player/PlaybackReporter.cpp b/src/player/PlaybackReporter.cpp index 86f3eb95..ccc1ad02 100644 --- a/src/player/PlaybackReporter.cpp +++ b/src/player/PlaybackReporter.cpp @@ -1,6 +1,5 @@ #include "PlaybackReporter.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" namespace JellyfinNative { @@ -12,7 +11,7 @@ namespace { } // namespace -PlaybackReporter::PlaybackReporter(JellyfinApiFacade *api, QObject *parent) +PlaybackReporter::PlaybackReporter(PlaybackSource *api, QObject *parent) : QObject(parent) , m_api(api) { diff --git a/src/player/PlaybackReporter.h b/src/player/PlaybackReporter.h index d8aa6843..7522d371 100644 --- a/src/player/PlaybackReporter.h +++ b/src/player/PlaybackReporter.h @@ -1,6 +1,7 @@ #pragma once #include "../media/MediaTypes.h" +#include "../provider/PlaybackSource.h" #include #include @@ -8,13 +9,11 @@ namespace JellyfinNative { -class JellyfinApiFacade; - class PlaybackReporter final : public QObject { Q_OBJECT public: - explicit PlaybackReporter(JellyfinApiFacade *api, QObject *parent = nullptr); + explicit PlaybackReporter(PlaybackSource *api, QObject *parent = nullptr); void start(const PlaybackSession& session, double playbackRate, int volume, bool muted); bool setStreamIndexes(int audioStreamIndex, int subtitleStreamIndex); @@ -29,7 +28,7 @@ class PlaybackReporter final : public QObject { void sendProgress(); void sendStop(const PlaybackSession& session, qint64 positionTicks, bool failed, double playbackRate, int attempt); - JellyfinApiFacade *m_api = nullptr; + PlaybackSource *m_api = nullptr; PlaybackSession m_session; QTimer m_startRetryTimer; QTimer m_progressRetryTimer; diff --git a/src/player/PlayerController.cpp b/src/player/PlayerController.cpp index 3d4273ee..01132cd7 100644 --- a/src/player/PlayerController.cpp +++ b/src/player/PlayerController.cpp @@ -1,6 +1,5 @@ #include "PlayerController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/LogRotation.h" #include "../common/TlsTrust.h" #include "../diagnostics/Diagnostics.h" @@ -10,6 +9,7 @@ #include "../platform/PlatformPaths.h" #include "../platform/PlatformPlaybackSurface.h" #include "../platform/PlatformSystemProbes.h" +#include "../provider/PlaybackSource.h" #include "MpvOptionProfile.h" #include "MpvVideoItem.h" #include "PlaybackFailurePolicy.h" @@ -176,7 +176,7 @@ namespace { } // namespace -PlayerController::PlayerController(NativeAppWindow *window, JellyfinApiFacade *api, TlsTrustController *tlsTrust, +PlayerController::PlayerController(NativeAppWindow *window, PlaybackSource *api, TlsTrustController *tlsTrust, const QString& subtitleFontsPath, QObject *parent) : QObject(parent) , m_window(window) @@ -199,7 +199,7 @@ PlayerController::PlayerController(NativeAppWindow *window, JellyfinApiFacade *a }); } if (m_api) { - connect(m_api, &JellyfinApiFacade::sessionTokenChanged, this, [this]() { + connect(m_api, &PlaybackSource::credentialsChanged, this, [this]() { if (auto *handle = m_mpvLifecycle.handle()) setMpvProperty(handle, "http-header-fields", ""); }); @@ -291,7 +291,7 @@ PlayerController::PlayerController(NativeAppWindow *window, JellyfinApiFacade *a { { QStringLiteral("operation"), operation }, { QStringLiteral("message"), message } }); }); if (m_api) { - connect(m_api, &JellyfinApiFacade::playbackNetworkProfileChanged, this, + connect(m_api, &PlaybackSource::playbackNetworkProfileChanged, this, [this]() { discardPreparedMpvForOptionChange("network profile change"); }); } scheduleIdleMpvPreparation(); @@ -1278,8 +1278,7 @@ void PlayerController::play(const PlaybackSession& session, bool startPaused) } const QByteArray urlBytes = session.url.toUtf8(); - const QByteArray token = m_api ? m_api->session().accessToken.toUtf8() : QByteArray {}; - const QByteArray header = token.isEmpty() ? QByteArray {} : QByteArrayLiteral("X-Emby-Token: ") + token; + const QByteArray header = m_api ? m_api->mediaRequestHeaders() : QByteArray {}; if (!setRequiredMpvProperty(handle, "http-header-fields", header.constData())) { m_mpvLifecycle.cancelFileLoad(); m_errorText = QStringLiteral("libmpv rejected the authenticated media request."); @@ -1293,7 +1292,7 @@ void PlayerController::play(const PlaybackSession& session, bool startPaused) return; } if (m_api && m_tlsTrust) { - const QSslCertificate certificate = m_tlsTrust->trustedCertificate(QUrl(m_api->serverUrl())); + const QSslCertificate certificate = m_tlsTrust->trustedCertificate(m_api->mediaOrigin()); if (!certificate.isNull()) { const QString trustDirectory = QDir(startupCacheRoot({})).filePath(QStringLiteral("tls")); const QString trustPath = trustDirectory + QLatin1Char('/') diff --git a/src/player/PlayerController.h b/src/player/PlayerController.h index 2b1d2358..cbe622a2 100644 --- a/src/player/PlayerController.h +++ b/src/player/PlayerController.h @@ -25,8 +25,8 @@ struct mpv_handle; namespace JellyfinNative { -class JellyfinApiFacade; class NativeAppWindow; +class PlaybackSource; class TlsTrustController; class PlayerController final : public QObject { @@ -79,7 +79,7 @@ class PlayerController final : public QObject { Q_PROPERTY(QStringList trickplaySheetUrls READ trickplaySheetUrls NOTIFY trickplayChanged) public: - PlayerController(NativeAppWindow *window, JellyfinApiFacade *api, TlsTrustController *tlsTrust, + PlayerController(NativeAppWindow *window, PlaybackSource *api, TlsTrustController *tlsTrust, const QString& subtitleFontsPath, QObject *parent = nullptr); ~PlayerController() override; @@ -325,7 +325,7 @@ class PlayerController final : public QObject { int m_videoWidth = 0; int m_videoHeight = 0; NativeAppWindow *m_window = nullptr; - JellyfinApiFacade *m_api = nullptr; + PlaybackSource *m_api = nullptr; PlaybackSession m_session; PlaybackReporter m_reporter; MpvLifecycle m_mpvLifecycle; diff --git a/src/provider/PlaybackSource.h b/src/provider/PlaybackSource.h new file mode 100644 index 00000000..7e2ed495 --- /dev/null +++ b/src/provider/PlaybackSource.h @@ -0,0 +1,59 @@ +#pragma once + +#include "../media/MediaTypes.h" + +#include + +#include +#include +#include +#include + +#include + +namespace JellyfinNative { + +// What the player needs from wherever media comes from. This is exactly the +// surface src/player used from the Jellyfin facade and nothing more; a +// provider that keeps no playback state server-side implements the report +// calls as completed no-ops. +class PlaybackSource : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + // Header lines mpv sends with every media request, one per line in the + // "Name: value" form libmpv's http-header-fields takes. Empty when the + // source needs none. + virtual QByteArray mediaRequestHeaders() const = 0; + // The origin whose remembered TLS trust decision covers media URLs. + virtual QUrl mediaOrigin() const = 0; + // How many parallel range requests the network profile allows per stream. + virtual int playbackParallelRequests() const = 0; + // Whether reports and follow-up lookups can be made right now. + virtual bool signedIn() const = 0; + virtual QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const = 0; + + // Every episode of a series in play order, so the queue can continue + // past the one the viewer started. + virtual QCoro::Task> fetchSeriesEpisodes(QString seriesId) = 0; + + virtual QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted) + = 0; + virtual QCoro::Task reportPlaybackProgress( + PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) + = 0; + virtual QCoro::Task reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) + = 0; + +signals: + // The media request headers are no longer valid; the player drops the + // ones it handed to mpv. + void credentialsChanged(); + // The parallel request budget changed; a prepared mpv must be rebuilt. + void playbackNetworkProfileChanged(); +}; + +} // namespace JellyfinNative diff --git a/tests/app/SessionControllerTest.cpp b/tests/app/SessionControllerTest.cpp index 6a310acd..399fc8ed 100644 --- a/tests/app/SessionControllerTest.cpp +++ b/tests/app/SessionControllerTest.cpp @@ -105,7 +105,7 @@ JELLYFIN_TEST_MAIN("session-controller") TlsTrustController tlsTrust; JellyfinApiFacade api(&network, &tlsTrust); int tokenChanges = 0; - QObject::connect(&api, &JellyfinApiFacade::sessionTokenChanged, [&tokenChanges]() { ++tokenChanges; }); + QObject::connect(&api, &JellyfinApiFacade::credentialsChanged, [&tokenChanges]() { ++tokenChanges; }); SessionController session(&database, &api); require(QCoro::waitFor(session.initializeAsync()), "saved profiles should be detected"); require(!session.authenticated(), "startup should never activate a saved token"); From 0780390457ee29324825c381ec1a0bf88da93710 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:50:01 +1000 Subject: [PATCH 003/146] refactor(platform): report video codec capabilities through an applier configurePlatformPlaybackCapabilities() took the Jellyfin facade so the webOS probe could push its codec list straight into the device profile. It now takes a std::function applier; main.cpp supplies the one that reaches the facade. The platform layer no longer includes src/api. --- src/main.cpp | 8 +++++++- src/platform/PlatformPlaybackRuntime.h | 13 ++++++++++--- src/platform/desktop/DesktopPlaybackRuntime.cpp | 2 +- src/platform/webos/WebOSPlaybackRuntime.cpp | 14 +++++--------- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0c004ba2..fda4e8d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -610,7 +610,13 @@ int main(int argc, char **argv) apiPointer, [apiPointer, name]() { apiPointer->setDeviceName(name); }, Qt::QueuedConnection); }); #endif - JellyfinNative::configurePlatformPlaybackCapabilities(*api, app); + JellyfinNative::configurePlatformPlaybackCapabilities( + [guardedApi = QPointer(api.get())]( + const QStringList& videoCodecs, bool restrictVideoCodecs) { + if (guardedApi) + guardedApi->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); + }, + app); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, diff --git a/src/platform/PlatformPlaybackRuntime.h b/src/platform/PlatformPlaybackRuntime.h index d6c552fb..3d6fadf8 100644 --- a/src/platform/PlatformPlaybackRuntime.h +++ b/src/platform/PlatformPlaybackRuntime.h @@ -1,14 +1,21 @@ #pragma once +#include #include +#include + class QObject; namespace JellyfinNative { -class JellyfinApiFacade; - qint64 platformAudioDecodeCpuTimeNs(); -void configurePlatformPlaybackCapabilities(JellyfinApiFacade& api, QObject& callbackContext); + +// Hands the platform's decodable video codecs to whoever negotiates +// playback. The platform may call the applier more than once: webOS reports +// a software set immediately and the hardware set once its probe answers, +// marshalled onto callbackContext's thread. +using VideoCodecCapabilityApplier = std::function; +void configurePlatformPlaybackCapabilities(VideoCodecCapabilityApplier apply, QObject& callbackContext); } // namespace JellyfinNative diff --git a/src/platform/desktop/DesktopPlaybackRuntime.cpp b/src/platform/desktop/DesktopPlaybackRuntime.cpp index 826cd171..2d125ecb 100644 --- a/src/platform/desktop/DesktopPlaybackRuntime.cpp +++ b/src/platform/desktop/DesktopPlaybackRuntime.cpp @@ -9,6 +9,6 @@ qint64 platformAudioDecodeCpuTimeNs() return static_cast(mpv_get_audio_decode_cpu_time_ns()); } -void configurePlatformPlaybackCapabilities(JellyfinApiFacade&, QObject&) { } +void configurePlatformPlaybackCapabilities(VideoCodecCapabilityApplier, QObject&) { } } // namespace JellyfinNative diff --git a/src/platform/webos/WebOSPlaybackRuntime.cpp b/src/platform/webos/WebOSPlaybackRuntime.cpp index 87178ee1..5edd9b72 100644 --- a/src/platform/webos/WebOSPlaybackRuntime.cpp +++ b/src/platform/webos/WebOSPlaybackRuntime.cpp @@ -1,6 +1,5 @@ #include "platform/PlatformPlaybackRuntime.h" -#include "api/JellyfinApiFacade.h" #include "platform/webos/WebOSMpvRuntime.h" #include @@ -29,24 +28,21 @@ qint64 platformAudioDecodeCpuTimeNs() return WebOSMpvRuntime::audioDecodeCpuTimeNs(); } -void configurePlatformPlaybackCapabilities(JellyfinApiFacade& api, QObject& callbackContext) +void configurePlatformPlaybackCapabilities(VideoCodecCapabilityApplier apply, QObject& callbackContext) { - api.setVideoCodecCapabilities(withSoftwareVideoCodecs({ QStringLiteral("h264") }), true); - QPointer guardedApi(&api); + apply(withSoftwareVideoCodecs({ QStringLiteral("h264") }), true); QPointer guardedContext(&callbackContext); - WebOSMpvRuntime::probeStarfishVideoCodecsAsync([guardedApi, guardedContext](const QStringList& codecs) { + WebOSMpvRuntime::probeStarfishVideoCodecsAsync([apply, guardedContext](const QStringList& codecs) { if (!guardedContext) return; QMetaObject::invokeMethod( guardedContext, - [guardedApi, codecs] { - if (!guardedApi) - return; + [apply, codecs] { if (codecs.isEmpty()) { qWarning() << "playback capabilities: Starfish probe returned no codecs; retaining software set"; return; } - guardedApi->setVideoCodecCapabilities(withSoftwareVideoCodecs(codecs), true); + apply(withSoftwareVideoCodecs(codecs), true); }, Qt::QueuedConnection); }); From fdca5f081c5dfbda84b76922c8a0c2076b673a5c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:51:52 +1000 Subject: [PATCH 004/146] refactor(settings): move the server half of settings into a Jellyfin bridge SettingsController no longer holds the Jellyfin facade. It emits the playback preferences, the remote-control target flag and the updated user configuration, asks for its remote half with remoteLoadRequested(), and takes cultures and user configuration back through two apply methods. JellyfinSettingsBridge in src/api does what the controller used to do against the facade, including the once-per-session load guard and the signed-in checks. AppController wires the pair together. --- CMakeLists.txt | 2 + src/api/JellyfinSettingsBridge.cpp | 53 +++++++++++ src/api/JellyfinSettingsBridge.h | 31 ++++++ src/app/AppController.cpp | 5 +- src/app/SettingsController.cpp | 135 +++++++++++---------------- src/app/SettingsController.h | 23 ++++- tests/app/SettingsControllerTest.cpp | 4 +- 7 files changed, 166 insertions(+), 87 deletions(-) create mode 100644 src/api/JellyfinSettingsBridge.cpp create mode 100644 src/api/JellyfinSettingsBridge.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e2b4fdf2..de6a6ab9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,8 @@ set(SPOOL_JELLYFIN_SOURCES src/api/JellyfinApiFacade.cpp src/api/JellyfinApiFacade.h src/api/JellyfinSession.h + src/api/JellyfinSettingsBridge.cpp + src/api/JellyfinSettingsBridge.h src/api/PlaybackBandwidthPolicy.cpp src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp diff --git a/src/api/JellyfinSettingsBridge.cpp b/src/api/JellyfinSettingsBridge.cpp new file mode 100644 index 00000000..ed25b2ff --- /dev/null +++ b/src/api/JellyfinSettingsBridge.cpp @@ -0,0 +1,53 @@ +#include "JellyfinSettingsBridge.h" + +#include "../app/SettingsController.h" +#include "../common/AsyncTask.h" +#include "JellyfinApiFacade.h" + +#include +#include +#include + +namespace JellyfinNative { + +JellyfinSettingsBridge::JellyfinSettingsBridge(SettingsController *settings, JellyfinApiFacade *api, QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_api(api) +{ + connect( + m_settings, &SettingsController::playbackPreferencesChanged, m_api, &JellyfinApiFacade::setPlaybackPreferences); + connect(m_settings, &SettingsController::remoteControlTargetEnabledChanged, m_api, + &JellyfinApiFacade::setRemoteControlTargetEnabled); + connect(m_settings, &SettingsController::remoteLoadRequested, this, &JellyfinSettingsBridge::loadRemote); + connect(m_settings, &SettingsController::remoteCleared, this, [this]() { m_remoteLoadStarted = false; }); + connect(m_settings, &SettingsController::userConfigurationChanged, this, [this](const QJsonObject& configuration) { + if (!m_api->signedIn()) + return; + Async::runScoped( + this, m_api->updateUserConfiguration(configuration), []() {}, + [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); + }); +} + +void JellyfinSettingsBridge::loadRemote() +{ + if (m_remoteLoadStarted || !m_api->signedIn()) + return; + m_remoteLoadStarted = true; + + Async::runScoped( + this, m_api->fetchCultures(), [this](const QJsonArray& cultures) { m_settings->applyRemoteCultures(cultures); }, + [](const std::exception_ptr& error) { + qWarning() << "subtitles: culture list failed" << exceptionMessage(error); + }); + + Async::runScoped( + this, m_api->fetchUserConfiguration(), + [this](const QJsonObject& configuration) { m_settings->applyRemoteUserConfiguration(configuration); }, + [](const std::exception_ptr& error) { + qWarning() << "subtitles: user configuration failed" << exceptionMessage(error); + }); +} + +} // namespace JellyfinNative diff --git a/src/api/JellyfinSettingsBridge.h b/src/api/JellyfinSettingsBridge.h new file mode 100644 index 00000000..ef86ff5a --- /dev/null +++ b/src/api/JellyfinSettingsBridge.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace JellyfinNative { + +class JellyfinApiFacade; +class SettingsController; + +// The Jellyfin half of settings: pushes the playback and remote-control +// preferences into the facade, and fetches the account's cultures and user +// configuration when the controller asks for them. The controller itself +// knows nothing about the server. +class JellyfinSettingsBridge final : public QObject { + Q_OBJECT + +public: + JellyfinSettingsBridge(SettingsController *settings, JellyfinApiFacade *api, QObject *parent = nullptr); + +signals: + void errorOccurred(const QString& message); + +private: + void loadRemote(); + + SettingsController *m_settings = nullptr; + JellyfinApiFacade *m_api = nullptr; + bool m_remoteLoadStarted = false; +}; + +} // namespace JellyfinNative diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 61ecd2df..73c8eadc 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -3,6 +3,7 @@ #include "ArtworkService.h" #include "../api/JellyfinApiFacade.h" +#include "../api/JellyfinSettingsBridge.h" #include "../common/AsyncTask.h" #include "../common/MetaJson.h" #include "../common/SeriesAudioSelection.h" @@ -105,7 +106,8 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis m_syncPlay = new SyncPlayController(api, player, m_playQueue, tlsTrust, this); m_remoteControl = new RemoteControlController(api, this); m_quickConnect = new QuickConnectController(api, this); - m_settings = new SettingsController(database, api, player, artwork, this); + m_settings = new SettingsController(database, player, artwork, this); + auto *settingsBridge = new JellyfinSettingsBridge(m_settings, api, this); m_session = new SessionController(database, api, this); m_prefetch = new LibraryPrefetchController(api, artwork, this); m_browse = new BrowseSessionController(m_prefetch, this); @@ -164,6 +166,7 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis connect(m_quickConnect, &QuickConnectController::busyChanged, this, &AppController::setBusy); connect(m_quickConnect, &QuickConnectController::errorOccurred, this, &AppController::setErrorText); connect(m_settings, &SettingsController::errorOccurred, this, &AppController::showToast); + connect(settingsBridge, &JellyfinSettingsBridge::errorOccurred, this, &AppController::showToast); connect(m_session, &SessionController::busyChanged, this, &AppController::setBusy); connect(m_session, &SessionController::errorOccurred, this, &AppController::setErrorText); connect(m_session, &SessionController::accountProfilesChanged, this, [this]() { diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index 544eb96d..a619ba18 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -1,6 +1,5 @@ #include "SettingsController.h" -#include "../api/JellyfinApiFacade.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" #include "../platform/PlatformSettingsPolicy.h" @@ -36,11 +35,10 @@ namespace { } // namespace -SettingsController::SettingsController(DatabaseManager *database, JellyfinApiFacade *api, PlayerController *player, - ArtworkService *artwork, QObject *parent) +SettingsController::SettingsController( + DatabaseManager *database, PlayerController *player, ArtworkService *artwork, QObject *parent) : QObject(parent) , m_database(database) - , m_api(api) , m_player(player) , m_artwork(artwork) , m_uiScalePercent(platformDefaultUiScalePercent()) @@ -221,74 +219,62 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) void SettingsController::loadRemote() { - if (m_remoteLoadStarted || !m_api || m_api->session().accessToken.isEmpty()) - return; - m_remoteLoadStarted = true; - - Async::runScoped( - this, m_api->fetchCultures(), - [this](const QJsonArray& cultures) { - QStringList codes { QString() }; - QStringList labels { QStringLiteral("Any language") }; - QSet seen { QString() }; - - for (const QJsonValue& value : cultures) { - const QJsonObject culture = value.toObject(); - const QString code = culture.value(QStringLiteral("ThreeLetterISOLanguageName")).toString(); - if (code.isEmpty() || seen.contains(code)) - continue; - QString label = culture.value(QStringLiteral("DisplayName")).toString(); - if (label.isEmpty()) - label = code.toUpper(); - seen.insert(code); - codes.push_back(code); - labels.push_back(label); - } - - if (!m_subtitlePreferences.language.isEmpty() && !seen.contains(m_subtitlePreferences.language)) { - codes.push_back(m_subtitlePreferences.language); - labels.push_back(m_subtitlePreferences.language.toUpper()); - } - - m_subtitleLanguageCodes = codes; - m_subtitleLanguageLabels = labels; - emit subtitleSettingsChanged(); - }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: culture list failed" << exceptionMessage(error); - }); - - Async::runScoped( - this, m_api->fetchUserConfiguration(), - [this](const QJsonObject& configuration) { - m_userConfiguration = configuration; - m_subtitlePreferences.audioLanguage - = configuration.value(QStringLiteral("AudioLanguagePreference")).toString(); - const SettingSpec& languageSpec = specForKey("subtitles/language"); - const SettingSpec& modeSpec = specForKey("subtitles/mode"); - const SettingSpec& audioModeSpec = specForKey("audio/trackMode"); - setSchemaValue(languageSpec, configuration.value(QStringLiteral("SubtitleLanguagePreference")).toString(), - true, false, false); - setSchemaValue(modeSpec, - configuration.value(QStringLiteral("SubtitleMode")).toString(QStringLiteral("Default")), true, false, - false); - setSchemaValue(audioModeSpec, - configuration.value(QStringLiteral("PlayDefaultAudioTrack")).toBool(true) ? QStringLiteral("Default") - : QStringLiteral("Smart"), - true, false, false); - applySubtitlePreferencesToPlayer(); - emit settingsValuesChanged(); - emit subtitleSettingsChanged(); - }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: user configuration failed" << exceptionMessage(error); - }); + emit remoteLoadRequested(); +} + +void SettingsController::applyRemoteCultures(const QJsonArray& cultures) +{ + QStringList codes { QString() }; + QStringList labels { QStringLiteral("Any language") }; + QSet seen { QString() }; + + for (const QJsonValue& value : cultures) { + const QJsonObject culture = value.toObject(); + const QString code = culture.value(QStringLiteral("ThreeLetterISOLanguageName")).toString(); + if (code.isEmpty() || seen.contains(code)) + continue; + QString label = culture.value(QStringLiteral("DisplayName")).toString(); + if (label.isEmpty()) + label = code.toUpper(); + seen.insert(code); + codes.push_back(code); + labels.push_back(label); + } + + if (!m_subtitlePreferences.language.isEmpty() && !seen.contains(m_subtitlePreferences.language)) { + codes.push_back(m_subtitlePreferences.language); + labels.push_back(m_subtitlePreferences.language.toUpper()); + } + + m_subtitleLanguageCodes = codes; + m_subtitleLanguageLabels = labels; + emit subtitleSettingsChanged(); +} + +void SettingsController::applyRemoteUserConfiguration(const QJsonObject& configuration) +{ + m_userConfiguration = configuration; + m_subtitlePreferences.audioLanguage = configuration.value(QStringLiteral("AudioLanguagePreference")).toString(); + const SettingSpec& languageSpec = specForKey("subtitles/language"); + const SettingSpec& modeSpec = specForKey("subtitles/mode"); + const SettingSpec& audioModeSpec = specForKey("audio/trackMode"); + setSchemaValue( + languageSpec, configuration.value(QStringLiteral("SubtitleLanguagePreference")).toString(), true, false, false); + setSchemaValue(modeSpec, configuration.value(QStringLiteral("SubtitleMode")).toString(QStringLiteral("Default")), + true, false, false); + setSchemaValue(audioModeSpec, + configuration.value(QStringLiteral("PlayDefaultAudioTrack")).toBool(true) ? QStringLiteral("Default") + : QStringLiteral("Smart"), + true, false, false); + applySubtitlePreferencesToPlayer(); + emit settingsValuesChanged(); + emit subtitleSettingsChanged(); } void SettingsController::clearRemote() { m_userConfiguration = {}; - m_remoteLoadStarted = false; + emit remoteCleared(); } void SettingsController::completePlayerControlTooltipSession() @@ -483,8 +469,7 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian break; case SettingTarget::RemoteControlTargetEnabled: m_remoteControlTargetEnabled = value.toBool(); - if (m_api) - m_api->setRemoteControlTargetEnabled(m_remoteControlTargetEnabled); + emit remoteControlTargetEnabledChanged(m_remoteControlTargetEnabled); break; case SettingTarget::ToneMappingVisualization: m_toneMappingVisualizationEnabled = value.toBool(); @@ -858,11 +843,9 @@ void SettingsController::applyArtworkEncoding() void SettingsController::applyPlaybackPreferences() { - if (!m_api) - return; const qint64 manualBitrate = m_manualStreamingBitrate ? static_cast(m_maxStreamingBitrateMbps) * 1'000'000 : 0; - m_api->setPlaybackPreferences(manualBitrate, m_unlimitedLocalBitrate, m_preferRemux, m_maxStreamingHeight); + emit playbackPreferencesChanged(manualBitrate, m_unlimitedLocalBitrate, m_preferRemux, m_maxStreamingHeight); } void SettingsController::applyAudioDelayToPlayer() @@ -942,19 +925,13 @@ void SettingsController::applyLoadedAudioDelay(const QString& output, int delayM void SettingsController::saveSubtitleUserConfiguration() { - if (!m_api || m_api->session().accessToken.isEmpty()) - return; - QJsonObject configuration = m_userConfiguration; configuration.insert(QStringLiteral("SubtitleLanguagePreference"), m_subtitlePreferences.language); configuration.insert(QStringLiteral("SubtitleMode"), m_subtitlePreferences.mode); const bool smartAudio = m_subtitlePreferences.audioMode == QStringLiteral("Smart"); configuration.insert(QStringLiteral("PlayDefaultAudioTrack"), !smartAudio); m_userConfiguration = configuration; - - Async::runScoped( - this, m_api->updateUserConfiguration(configuration), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); + emit userConfigurationChanged(configuration); } void SettingsController::applyMpvConfigPolicy() diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index e6ebc904..a69fd92c 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -5,6 +5,7 @@ #include "../platform/MpvConfigPolicy.h" #include +#include #include #include #include @@ -15,7 +16,6 @@ namespace JellyfinNative { class DatabaseManager; class ArtworkService; -class JellyfinApiFacade; class PlayerController; struct SettingSpec; @@ -57,8 +57,8 @@ class SettingsController final : public QObject { // the next playback starts where this one ended up. QString stepDownRenderQuality(); - SettingsController(DatabaseManager *database, JellyfinApiFacade *api, PlayerController *player, - ArtworkService *artwork, QObject *parent = nullptr); + SettingsController( + DatabaseManager *database, PlayerController *player, ArtworkService *artwork, QObject *parent = nullptr); int uiScalePercent() const { @@ -97,8 +97,14 @@ class SettingsController final : public QObject { void applyLocalValues(const QVariantMap& storedValues); QCoro::Task loadLocalAsync(); + // The half of settings a media source keeps for the account lives with + // that source. loadRemote() asks whoever is listening to fetch it, and + // the apply methods below take what comes back; this class never talks + // to a server itself. Q_INVOKABLE void loadRemote(); void clearRemote(); + void applyRemoteCultures(const QJsonArray& cultures); + void applyRemoteUserConfiguration(const QJsonObject& configuration); Q_INVOKABLE void setValue(const QString& key, const QVariant& value); Q_INVOKABLE void previewValue(const QString& key, const QVariant& value); Q_INVOKABLE void completePlayerControlTooltipSession(); @@ -119,6 +125,15 @@ class SettingsController final : public QObject { void updateAudioOutputRoute(const QString& output, int displayLatencyMs, int outputLatencyMs); signals: + // Pushes toward the media source. + void playbackPreferencesChanged( + qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight); + void remoteControlTargetEnabledChanged(bool enabled); + void userConfigurationChanged(const QJsonObject& configuration); + // Pulls from it. + void remoteLoadRequested(); + void remoteCleared(); + void nightModeChanged(); void audioDelayChanged(); void audioOutputDeviceChanged(); @@ -145,14 +160,12 @@ class SettingsController final : public QObject { void applyMpvConfigPolicy(); DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; PlayerController *m_player = nullptr; ArtworkService *m_artwork = nullptr; QString m_artworkFormat = QStringLiteral("auto"); int m_artworkWebpQuality = 75; int m_artworkJpegQuality = 82; QVariantMap m_values; - bool m_remoteLoadStarted = false; bool m_nightModeEnabled = false; bool m_castButtonEnabled = true; bool m_remoteControlTargetEnabled = true; diff --git a/tests/app/SettingsControllerTest.cpp b/tests/app/SettingsControllerTest.cpp index 1ac1f9bb..435647f9 100644 --- a/tests/app/SettingsControllerTest.cpp +++ b/tests/app/SettingsControllerTest.cpp @@ -49,7 +49,7 @@ JELLYFIN_TEST_MAIN("settings-controller") const QVariant posterValue = QVariant::fromValue(poster); const auto posterUrl = [&] { return artwork.url(posterValue, QStringLiteral("poster")); }; - SettingsController settings(&database, nullptr, nullptr, &artwork); + SettingsController settings(&database, nullptr, &artwork); QCoro::waitFor(settings.loadLocalAsync()); // A fresh profile leaves the codec to the platform, and the artwork @@ -166,7 +166,7 @@ JELLYFIN_TEST_MAIN("settings-controller") == QStringLiteral("3"), "completed control-tooltip sessions were not persisted"); - SettingsController restored(&database, nullptr, nullptr, nullptr); + SettingsController restored(&database, nullptr, nullptr); QCoro::waitFor(restored.loadLocalAsync()); require(restored.uiScalePercent() == 135, "persisted UI scale was not restored"); require(restored.audioDelayMs() == 120, "persisted global desktop audio delay was not restored"); From cf5f3e6ffcb5db3b621ce99f463d0fe71b5288ac Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:54:29 +1000 Subject: [PATCH 005/146] build: check the module seam and move source-agnostic files into core tools/check-module-seam.sh fails when any core file (the platform, player and shell groups, or anything under the core directories) includes src/api or src/discovery, and runs as the module-seam ctest. Its --strict mode also counts SPOOL_JELLYFIN_SOURCES files as provider; that still reports the composition-root leaks through AppController, ArtworkService and AccountProfile that the provider registry work removes, so CI runs the default. LibraryQuery, SpoolLinkCodec, SpoolRemoteProtocol, LibraryListModel and MovieGridModel include nothing from the provider and move into the shell group. --- AGENTS.md | 22 +++++++--- CMakeLists.txt | 23 +++++----- tools/check-module-seam.sh | 86 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 16 deletions(-) create mode 100755 tools/check-module-seam.sh diff --git a/AGENTS.md b/AGENTS.md index 5fef2129..e3e5e4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,12 +35,22 @@ spool-plex, spool-stremio). `CMakeLists.txt` groups sources into `SPOOL_JELLYFIN_SOURCES` to mark where that cut goes. They still build as one library; the grouping exists so the split stays mechanical. -- Do not add a `src/api/` or `src/discovery/` include to the platform, player - or shell groups. Four such dependencies already exist and are the work a - split has to undo first: `PlayerController`, `PlaybackReporter`, - `PlayQueueController` and `SettingsController` each take a - `JellyfinApiFacade`, and `configurePlatformPlaybackCapabilities()` is - handed one. +- Core (the platform, player and shell groups plus everything under + `src/platform`, `src/player`, `src/media`, `src/provider`, `src/common`, + `src/cache` and `src/diagnostics`) never includes `src/api/` or + `src/discovery/`. `tools/check-module-seam.sh` enforces that and runs as + the `module-seam` ctest; `--strict` also treats every file still listed in + `SPOOL_JELLYFIN_SOURCES` as provider and currently reports the + composition-root leaks left for the provider registry work + (`PlatformApplicationServices`, `RenderBenchmark` and `DatabaseManager` + reach `AppController`, `ArtworkService` or `AccountProfile`). +- The player talks to its media source only through + `src/provider/PlaybackSource.h`; `JellyfinApiFacade` implements it. + `SettingsController` emits the preferences a source needs and takes the + account's remote settings back through `applyRemote*()`; + `JellyfinSettingsBridge` in `src/api` is the Jellyfin side of that. + `configurePlatformPlaybackCapabilities()` takes an applier callback, not + the facade. - `qml/primitives` and `qml/theme` reach exactly two singletons, `Art.url` and `Settings.uiScalePercent`. Keep it that way; page- and shell-level QML is where backend-shaped data belongs. diff --git a/CMakeLists.txt b/CMakeLists.txt index de6a6ab9..faf7f8b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -203,6 +203,8 @@ set(SPOOL_SHELL_SOURCES src/app/CpuTopology.h src/app/LocalizationManager.cpp src/app/LocalizationManager.h + src/app/LibraryQuery.cpp + src/app/LibraryQuery.h src/app/MemoryBudget.cpp src/app/MemoryBudget.h src/app/RouterController.cpp @@ -211,6 +213,10 @@ set(SPOOL_SHELL_SOURCES src/app/SettingsController.h src/app/SettingsSchema.cpp src/app/SettingsSchema.h + src/app/SpoolLinkCodec.cpp + src/app/SpoolLinkCodec.h + src/app/SpoolRemoteProtocol.cpp + src/app/SpoolRemoteProtocol.h src/app/UpdateManifest.cpp src/app/UpdateManifest.h src/cache/DatabaseManager.cpp @@ -225,6 +231,10 @@ set(SPOOL_SHELL_SOURCES src/common/VariantUtils.h src/media/MediaTypes.cpp src/media/MediaTypes.h + src/models/LibraryListModel.cpp + src/models/LibraryListModel.h + src/models/MovieGridModel.cpp + src/models/MovieGridModel.h src/provider/PlaybackSource.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h @@ -270,18 +280,12 @@ set(SPOOL_JELLYFIN_SOURCES src/app/LibraryManagementController.h src/app/LibraryPrefetchController.cpp src/app/LibraryPrefetchController.h - src/app/LibraryQuery.cpp - src/app/LibraryQuery.h src/app/QuickConnectController.cpp src/app/QuickConnectController.h src/app/RemoteControlController.cpp src/app/RemoteControlController.h src/app/SpoolLink.cpp src/app/SpoolLink.h - src/app/SpoolLinkCodec.cpp - src/app/SpoolLinkCodec.h - src/app/SpoolRemoteProtocol.cpp - src/app/SpoolRemoteProtocol.h src/app/SearchController.cpp src/app/SearchController.h src/app/SessionController.cpp @@ -297,10 +301,6 @@ set(SPOOL_JELLYFIN_SOURCES src/discovery/DiscoveryController.h src/models/DiscoveredServerModel.cpp src/models/DiscoveredServerModel.h - src/models/LibraryListModel.cpp - src/models/LibraryListModel.h - src/models/MovieGridModel.cpp - src/models/MovieGridModel.h ) set(JELLYFIN_CORE_SOURCES @@ -952,6 +952,9 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" ) + add_test(NAME module-seam + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tools/check-module-seam.sh" + ) add_test(NAME network-shaper COMMAND "${Python3_EXECUTABLE}" diff --git a/tools/check-module-seam.sh b/tools/check-module-seam.sh new file mode 100755 index 00000000..623213eb --- /dev/null +++ b/tools/check-module-seam.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Fails when core source reaches into the Jellyfin provider. +# +# Core is everything CMakeLists.txt lists in SPOOL_PLATFORM_SOURCES, +# SPOOL_PLAYER_SOURCES and SPOOL_SHELL_SOURCES, plus every file under the +# directories that are core by construction. A core file may include only +# core files, Qt and system headers: any include that resolves into src/api/ +# or src/discovery/ is a seam violation. With --strict, a file that only +# SPOOL_JELLYFIN_SOURCES lists counts as provider too; that mode still +# reports the composition-root leaks (AppController reached from the +# platform and diagnostics layers) that the provider registry work removes, +# so CI runs the default. The check is on direct includes; because every +# core file is checked, a transitive path through core is caught at the file +# that opens it. +set -euo pipefail + +strict=0 +for arg in "$@"; do + case "$arg" in + --strict) strict=1 ;; + *) echo "usage: $0 [--strict]" >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +core_dirs=(src/platform src/player src/media src/provider src/common src/cache src/diagnostics) +provider_dirs=(src/api src/discovery) + +group_files() { + sed -n "/^set($1/,/^)/p" CMakeLists.txt | grep -E '^\s+src/' | sed -E 's/^\s+//' +} + +declare -A core=() +for group in SPOOL_PLATFORM_SOURCES SPOOL_PLAYER_SOURCES SPOOL_SHELL_SOURCES; do + while IFS= read -r file; do + [[ -n "$file" ]] && core["$file"]=1 + done < <(group_files "$group") +done +for dir in "${core_dirs[@]}"; do + while IFS= read -r file; do + core["$file"]=1 + done < <(find "$dir" -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.mm' \) | sort) +done + +declare -A provider=() +while IFS= read -r file; do + [[ -n "$file" ]] && provider["$file"]=1 +done < <(group_files SPOOL_JELLYFIN_SOURCES) + +is_provider_path() { + local path="$1" + for dir in "${provider_dirs[@]}"; do + [[ "$path" == "$dir/"* ]] && return 0 + done + (( strict )) && [[ -n "${provider[$path]:-}" ]] && [[ -z "${core[$path]:-}" ]] +} + +status=0 +for file in "${!core[@]}"; do + [[ -f "$file" ]] || continue + dir="$(dirname "$file")" + while IFS= read -r include; do + # Quoted includes resolve against the including file's directory first + # and the source root second; that is how the build resolves them too. + resolved="" + for candidate in "$dir/$include" "src/$include" "$include"; do + if [[ -f "$candidate" ]]; then + resolved="$(realpath --relative-to="$ROOT" "$candidate")" + break + fi + done + [[ -n "$resolved" ]] || continue + if is_provider_path "$resolved"; then + echo "seam: $file includes provider file $resolved" >&2 + status=1 + fi + done < <(grep -oE '^\s*#\s*include\s+"[^"]+"' "$file" | sed -E 's/.*"([^"]+)"/\1/') +done + +if (( status != 0 )); then + echo "error: core sources may not include provider files" >&2 + exit 1 +fi +echo "module seam: core sources are free of provider includes" From efbb7a05ec1f28844113057c1e31d6d3ef607ab4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:54:32 +1000 Subject: [PATCH 006/146] refactor(ui): gate provider-specific controls on ProviderCapabilities Shared shell and page QML assumed the Jellyfin provider was there: the top bar always drew a SyncPlay button, the shell always built a remote now-playing bar, and Session, RemoteControl, SyncPlay and Management were read unconditionally. Add a ProviderCapabilities singleton with one flag per optional capability and read each provider-shaped singleton only behind its flag, so a source without the capability has no such control and none of its bindings ever run. Every flag is true for now; the Phase 3 C++ registry replaces the file under the same names. The top bar's index space moves into TopBarNavigation.js so a gated-out button drops out of the Left/Right sequence instead of leaving a dead index, and a QML test pins both the capability names and that arithmetic. --- CMakeLists.txt | 4 + qml/pages/PlayerOverlayChrome.qml | 29 +++-- qml/pages/PlayerOverlayPage.qml | 33 +++-- qml/pages/PlayerQueuePanel.qml | 2 +- qml/pages/PlayerTransportBar.qml | 4 +- qml/pages/SettingsPage.qml | 16 +-- qml/shell/AppShell.qml | 69 +++++++---- qml/shell/DiagnosticsOverlay.qml | 31 +++-- qml/shell/ItemContextMenu.qml | 15 ++- qml/shell/ProviderCapabilities.qml | 35 ++++++ qml/shell/RouteStack.qml | 6 +- qml/shell/TopBar.qml | 163 +++++++++++++++---------- qml/shell/TopBarNavigation.js | 50 ++++++++ tests/qml/provider-capabilities/qmldir | 1 + tests/qml/tst_ProviderGating.qml | 52 ++++++++ 15 files changed, 365 insertions(+), 145 deletions(-) create mode 100644 qml/shell/ProviderCapabilities.qml create mode 100644 qml/shell/TopBarNavigation.js create mode 100644 tests/qml/provider-capabilities/qmldir create mode 100644 tests/qml/tst_ProviderGating.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index faf7f8b3..f10c4def 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -440,6 +440,7 @@ endif() set_source_files_properties( qml/primitives/InputKeys.qml + qml/shell/ProviderCapabilities.qml qml/theme/Metrics.qml qml/theme/Theme.qml qml/theme/Typography.qml @@ -554,6 +555,7 @@ qt_add_qml_module(jellyfin-native qml/shell/MediaInfoOverlay.qml qml/shell/OverlayDialog.qml qml/shell/PageReadiness.js + qml/shell/ProviderCapabilities.qml qml/shell/RoutePolicy.js qml/shell/RouteStack.qml qml/shell/RemoteControlMenu.qml @@ -563,6 +565,7 @@ qt_add_qml_module(jellyfin-native qml/shell/UpdateDialog.qml qml/shell/ToastLayer.qml qml/shell/TopBar.qml + qml/shell/TopBarNavigation.js qml/shell/VideoSurface.qml qml/theme/Metrics.qml qml/theme/Theme.qml @@ -995,6 +998,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) player-seek-bar-hover tests/qml/tst_PlayerSeekBarHover.qml list-scroll-bar tests/qml/tst_ListScrollBar.qml inline-slider tests/qml/tst_InlineSlider.qml + provider-gating tests/qml/tst_ProviderGating.qml ) # The subtitle panel test has never run on macOS; it was narrowed to # Windows alone while chasing that, which silently dropped Linux too. diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index 9fb1f858..92ac19e0 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -11,7 +11,7 @@ Item { id: root required property var overlay - readonly property bool syncPlayMenuOpen: syncPlayMenu.menuOpen + readonly property bool syncPlayMenuOpen: syncPlayMenu.item ? syncPlayMenu.item.menuOpen : false property int lastPointerX: -1 property int lastPointerY: -1 @@ -72,20 +72,24 @@ Item { } function openSyncPlayMenu() { - SyncPlay.refreshGroups() - syncPlayMenu.openMenu() + if (!syncPlayMenu.item) + return + root.overlay.syncPlay.refreshGroups() + syncPlayMenu.item.openMenu() } function closeSyncPlayMenu() { - syncPlayMenu.closeMenu() + if (syncPlayMenu.item) + syncPlayMenu.item.closeMenu() } function routeSyncPlayMenuKey(key, repeat) { - return syncPlayMenu.routeKey(key, "press", repeat) + return syncPlayMenu.item ? syncPlayMenu.item.routeKey(key, "press", repeat) : false } function activateSyncPlayMenu() { - syncPlayMenu.activate() + if (syncPlayMenu.item) + syncPlayMenu.item.activate() } function routeMenuKey(key, repeat) { @@ -244,7 +248,7 @@ Item { anchors.centerIn: parent width: root.dp(116) height: width - visible: SyncPlay.enabled && SyncPlay.waitingForPlayback + visible: root.overlay.syncPlayWaiting z: 20 Rectangle { @@ -416,7 +420,9 @@ Item { } } - Shell.SyncPlayMenu { + // The group menu exists only for a source with SyncPlay; the loader keeps + // its bindings from ever running otherwise. + Loader { id: syncPlayMenu anchors.right: parent.right anchors.bottom: hud.top @@ -424,7 +430,10 @@ Item { anchors.bottomMargin: root.dp(18) width: root.dp(420) z: 55 - onRequestClose: root.overlay.closeMenu() + active: root.overlay.syncPlay !== null + sourceComponent: Shell.SyncPlayMenu { + onRequestClose: root.overlay.closeMenu() + } } PlayerAudioSyncPanel { @@ -547,7 +556,7 @@ Item { checked: root.overlay.menuItemSelected(index) highlighted: menuList.currentIndex === index stepperVisible: root.overlay.debugAction(index) === "speed" - stepperEnabled: !SyncPlay.enabled + stepperEnabled: !root.overlay.syncPlayActive stepperText: root.overlay.formatPlaybackSpeed(root.overlay.player.effectivePlaybackSpeed) stepperEditText: Number(root.overlay.player.effectivePlaybackSpeed || 1).toFixed(2) stepperEditable: stepperVisible && root.overlay.desktopControlsAvailable diff --git a/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index dad08b67..d9b286ae 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -63,6 +63,12 @@ FocusScope { // get(currentIndex) went on titling the overlay with what had been playing // before -- which is how an episode lost the series line above it. readonly property var currentQueueItem: playQueue ? playQueue.currentEntry : ({}) + // SyncPlay is only read behind its capability, so a source without group + // watch never has the singleton evaluated; the chrome and the transport + // bar take it from here rather than reaching for it themselves. + readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null + readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false + readonly property bool syncPlayWaiting: syncPlayActive && syncPlay.waitingForPlayback readonly property bool syncPlayMenuOpen: chrome.syncPlayMenuOpen readonly property string episodeContextText: { if (!episodeQueue) @@ -126,7 +132,8 @@ FocusScope { values.push("audio") if (playQueue && playQueue.count > 0) values.push("queue") - values.push("syncplay") + if (syncPlay) + values.push("syncplay") if (desktopControlsAvailable) values.push("fullscreen") values.push("debug") @@ -199,7 +206,7 @@ FocusScope { function actionIcon(action) { if (action === "back") return "fast_rewind" - if (action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback) + if (action === "pause" && syncPlayWaiting) return "schedule" if (action === "pause") return hasPlayer && player.paused ? "play_arrow" : "pause" @@ -231,7 +238,7 @@ FocusScope { return "Back 10 seconds" if (action === "forward") return "Forward 10 seconds" - if (action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback) + if (action === "pause" && syncPlayWaiting) return "Waiting for group playback" if (action === "pause") return hasPlayer && player.paused ? "Resume" : "Pause" @@ -246,7 +253,7 @@ FocusScope { if (action === "queue") return "Play queue" if (action === "syncplay") - return SyncPlay.enabled ? "SyncPlay group" : "Join or create a SyncPlay group" + return syncPlayActive ? "SyncPlay group" : "Join or create a SyncPlay group" if (action === "fullscreen") return NativeWindow.fullScreen ? "Exit fullscreen" : "Fullscreen" return "Playback settings" @@ -426,7 +433,7 @@ FocusScope { if (menuKind === "quality") return String(qualityOptions[index] && qualityOptions[index].detail || "") // Playback speed is the one row a SyncPlay group takes away. - return debugAction(index) === "speed" && SyncPlay.enabled ? "Disabled by SyncPlay" : "" + return debugAction(index) === "speed" && syncPlayActive ? "Disabled by SyncPlay" : "" } function menuItemSelected(index) { @@ -456,6 +463,8 @@ FocusScope { } function openSyncPlayMenu() { + if (!syncPlay) + return if (syncPlayMenuOpen) { closeMenu() return @@ -649,7 +658,7 @@ FocusScope { } function adjustPlaybackSpeed(direction) { - if (!hasPlayer || SyncPlay.enabled || direction === 0) + if (!hasPlayer || syncPlayActive || direction === 0) return player.setPlaybackSpeed(Math.max(0.25, Math.min(4, Number(player.playbackSpeed) + direction * 0.25))) } @@ -748,22 +757,22 @@ FocusScope { } function togglePlayback() { - if (SyncPlay.enabled) - SyncPlay.requestTogglePause() + if (syncPlayActive) + syncPlay.requestTogglePause() else player.togglePause() } function seekTo(seconds) { - if (SyncPlay.enabled) - SyncPlay.requestSeek(clampSeconds(seconds)) + if (syncPlayActive) + syncPlay.requestSeek(clampSeconds(seconds)) else player.seek(clampSeconds(seconds)) } function seekRelative(seconds) { - if (SyncPlay.enabled) - SyncPlay.requestRelativeSeek(seconds) + if (syncPlayActive) + syncPlay.requestRelativeSeek(seconds) else if (seconds < 0) player.seekBack() else diff --git a/qml/pages/PlayerQueuePanel.qml b/qml/pages/PlayerQueuePanel.qml index a1a898d1..cac83b1d 100644 --- a/qml/pages/PlayerQueuePanel.qml +++ b/qml/pages/PlayerQueuePanel.qml @@ -277,7 +277,7 @@ FocusScope { anchors.right: parent.right anchors.top: headingText.bottom height: visible ? implicitHeight : 0 - visible: root.reordering || (SyncPlay.enabled && list.count > 0) + visible: root.reordering || (root.overlay.syncPlayActive && list.count > 0) text: root.reordering ? "Moving — Up/Down to place, OK to drop, Back to cancel" : "Changes apply to everyone in the group" color: root.reordering ? Theme.accent : Theme.textMuted diff --git a/qml/pages/PlayerTransportBar.qml b/qml/pages/PlayerTransportBar.qml index d1db549a..2522a391 100644 --- a/qml/pages/PlayerTransportBar.qml +++ b/qml/pages/PlayerTransportBar.qml @@ -25,7 +25,7 @@ RowLayout { readonly property bool emphasized: focused || selected || (hover.hovered && Metrics.pointerActive) readonly property string tooltip: Settings.playerControlTooltipsEnabled ? root.overlay.actionTooltip(action) : "" - readonly property bool waitingForSyncPlay: action === "pause" && SyncPlay.enabled && SyncPlay.waitingForPlayback + readonly property bool waitingForSyncPlay: action === "pause" && root.overlay.syncPlayWaiting readonly property string badge: action === "prevQueue" || action === "nextQueue" ? "EP" : action === "prevChapter" || action === "nextChapter" ? "CH" : "" @@ -154,7 +154,7 @@ RowLayout { TapHandler { onTapped: { if (!root.overlay.hasPlayer) - return + return const volume = Number(root.overlay.player.volume) if (volume > 0) { volumeControls.lastAudibleVolume = volume diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index ffc27ed1..c91fe631 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -265,7 +265,7 @@ FocusScope { function rowDescription(row) { if (row.key === "session/account") - return Session.serverUrl + return ProviderCapabilities.auth ? Session.serverUrl : "" if (row.key === "subtitles/mode" || row.key === "audio/trackMode") { const index = rowCurrentIndex(row) const labels = rowOptions(row) @@ -303,7 +303,9 @@ FocusScope { if (row.key === "action/clearLatencyStatistics" || row.key === "action/clearLogs") return "Clear" if (row.key === "session/account") - return Session.activeProfileLabel.length > 0 ? Session.activeProfileLabel : "Offline" + return ProviderCapabilities.auth && Session.activeProfileLabel.length > 0 ? Session.activeProfileLabel : + "Offline" + if (row.key === "about/version") return "v" + Qt.application.version if (row.key === "about/locale") @@ -575,12 +577,12 @@ FocusScope { focus: true onActiveFocusChanged: if (activeFocus) - focusEntry() + focusEntry() onVisibleChanged: { if (visible) - ensureRowsBuilt() + ensureRowsBuilt() if (visible && activeFocus) - Qt.callLater(focusEntry) + Qt.callLater(focusEntry) } property bool rowsBuilt: false @@ -648,14 +650,14 @@ FocusScope { spacing: Metrics.scaled(10) onCurrentIndexChanged: { if (root.reconcilingSettingsRows) - return + return root.currentIndex = currentIndex root.selectedRowKey = currentIndex >= 0 && currentIndex < settingsRows.count ? settingsRows.get( currentIndex).rowKey : "" } onAccepted: index => root.activateRow(root.rowAtVisibleIndex(index), index) onEdgeUp: if (root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() delegate: Column { id: settingsDelegate required property int index diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 5e8b170f..be9b5a1c 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -127,13 +127,20 @@ KeyRouter { readonly property string route: Router.route readonly property var routeArgs: Router.args || ({}) + // Provider-shaped singletons are only read behind their capability, so a + // source without sign-in or remote control never has them evaluated. A + // source with no sign-in is simply always signed in. + readonly property var session: ProviderCapabilities.auth ? Session : null + readonly property bool signedIn: session ? session.authenticated : true + readonly property var remoteControl: ProviderCapabilities.remoteControl ? RemoteControl : null + readonly property bool remoteTargetSelected: remoteControl ? remoteControl.targetSelected : false property bool remoteConnectionKnown: false property string lastRemoteTargetName: "" onRouteChanged: { root.exitArmedAt = 0 - if (route === "remoteControl" && !RemoteControl.targetSelected) { + if (route === "remoteControl" && !root.remoteTargetSelected) { Qt.callLater(function () { - if (root.route === "remoteControl" && !RemoteControl.targetSelected) + if (root.route === "remoteControl" && !root.remoteTargetSelected) root.goHome() }) } @@ -330,10 +337,10 @@ KeyRouter { } Connections { - target: RemoteControl + target: root.remoteControl function onTargetChanged() { - if (RemoteControl.targetSelected) { - const name = RemoteControl.selectedTargetName || "remote device" + if (root.remoteTargetSelected) { + const name = root.remoteControl.selectedTargetName || "remote device" if (!root.remoteConnectionKnown || root.lastRemoteTargetName !== name) toast.show("Connected to " + name, toast.briefDurationMs) root.remoteConnectionKnown = true @@ -355,11 +362,11 @@ KeyRouter { } function defaultRoute() { - return Session.authenticated ? "home" : "login" + return root.signedIn ? "home" : "login" } function restoreRecoveredRoute() { - if (!Router.recoveryPending || !Session.authenticated) + if (!Router.recoveryPending || !root.signedIn) return false const args = root.routeArgs if (root.route === "libraryGrid") { @@ -407,24 +414,24 @@ KeyRouter { if (Router.recoveryPending) { if (root.restoreRecoveredRoute()) return - if (Session.authenticated) + if (root.signedIn) return } Router.reset(root.defaultRoute()) } Connections { - target: Session + target: root.session function onAuthenticatedStateChanged() { - if (Session.authenticated && root.switchUserReturnPending) { + if (root.signedIn && root.switchUserReturnPending) { root.completeSwitchUserReturn() return } - if (Session.authenticated) + if (root.signedIn) root.clearSwitchUserReturn() - if (Session.authenticated && root.restoreRecoveredRoute()) + if (root.signedIn && root.restoreRecoveredRoute()) return - if (Session.authenticated) + if (root.signedIn) Router.replace(root.defaultRoute()) else Router.reset(root.defaultRoute()) @@ -470,8 +477,8 @@ KeyRouter { // is what raises the on-screen keyboard, so the row stays the D-pad // target until Select is pressed. Theme.textEntryFollowsFocus = !Platform.isTV - root.remoteConnectionKnown = RemoteControl.targetSelected - root.lastRemoteTargetName = RemoteControl.targetSelected ? RemoteControl.selectedTargetName : "" + root.remoteConnectionKnown = root.remoteTargetSelected + root.lastRemoteTargetName = root.remoteTargetSelected ? root.remoteControl.selectedTargetName : "" if (App.initialized) root.applyInitializedRoute() } @@ -642,7 +649,9 @@ KeyRouter { } function switchUser() { - switchUserReturnProfileId = Session.activeProfileId + if (!root.session) + return + switchUserReturnProfileId = root.session.activeProfileId switchUserReturnRoute = route switchUserReturnArgs = Object.assign({}, routeArgs) switchUserReturnPending = false @@ -675,7 +684,7 @@ KeyRouter { } function completeSwitchUserReturn() { - if (!switchUserReturnPending || !Session.authenticated) + if (!switchUserReturnPending || !root.signedIn) return false const returnRoute = switchUserReturnRoute const returnArgs = switchUserReturnArgs @@ -801,7 +810,8 @@ KeyRouter { } function openItemMenu(item, anchorItem, context) { - Management.loadCurrentUserPolicy() + if (ProviderCapabilities.libraryManagement) + Management.loadCurrentUserPolicy() itemMenuLoaded = true return itemContextMenuLoader.item ? itemContextMenuLoader.item.openForItem(item || ({}), anchorItem || null, context || ({})) : false @@ -841,6 +851,8 @@ KeyRouter { } function openManagement(mode, item) { + if (!ProviderCapabilities.libraryManagement) + return managementMode = mode managementItem = item || ({}) managementOverlayVisible = true @@ -1081,7 +1093,9 @@ KeyRouter { root.navigationTarget = routeStack } - RemoteNowPlayingBar { + // Only a source that can drive another device has a bar for it; the + // loader keeps the bar's bindings from ever running otherwise. + Loader { id: nowPlayingBar objectName: "shellRemoteNowPlayingBar" anchors.left: parent.left @@ -1090,11 +1104,16 @@ KeyRouter { // viewport edge when it does not. y: root.navBarAtBottom ? Math.max(0, navBar.y - height) : Math.max(0, parent.height - height) z: 2 - // The remote control page is this bar in full, so it would only - // duplicate itself there. - visible: shown && root.chromeRoute !== "remoteControl" && root.chromeRoute !== "login" - enabled: visible - onOpenRequested: root.pushRoute("remoteControl") + active: ProviderCapabilities.remoteControl + height: item ? item.height : 0 + visible: item ? item.visible : false + sourceComponent: RemoteNowPlayingBar { + // The remote control page is this bar in full, so it would only + // duplicate itself there. + visible: shown && root.chromeRoute !== "remoteControl" && root.chromeRoute !== "login" + enabled: visible + onOpenRequested: root.pushRoute("remoteControl") + } } } } @@ -1231,7 +1250,7 @@ KeyRouter { id: managementOverlayLoader anchors.fill: parent z: 57 - active: root.managementOverlayVisible + active: ProviderCapabilities.libraryManagement && root.managementOverlayVisible asynchronous: true sourceComponent: ManagementDialog { mode: root.managementMode diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index adc77c78..5716ea5c 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -8,6 +8,10 @@ Item { id: root property string route: "" property string focusedItemId: "" + // Only read behind the capability: a source without SyncPlay has no such + // singleton to report on. + readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null + readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false function formatBytes(bytes) { const value = Math.max(0, Number(bytes || 0)) @@ -111,27 +115,30 @@ Item { + " samples " + InputLatency.sampleCount } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "SyncPlay " + (SyncPlay.groupState || "Unknown") + (SyncPlay.groupStateReason ? " / " - + SyncPlay.groupStateReason : - "") + (SyncPlay.waitingForPlayback - ? " · waiting to play" : - "") + text: !root.syncPlayActive ? "" : "SyncPlay " + (root.syncPlay.groupState || "Unknown") + ( + root.syncPlay.groupStateReason ? " / " + + root.syncPlay.groupStateReason : + "") + (root.syncPlay.waitingForPlayback + ? " · waiting to play" : "") elide: Text.ElideRight } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "Time sync " + SyncPlay.timeSyncDevice + " offset " + root.signedMs(SyncPlay.clockOffsetMs) - + " ping " + Number(SyncPlay.pingMs || 0).toFixed(2) + " ms" + text: !root.syncPlayActive ? "" : "Time sync " + root.syncPlay.timeSyncDevice + " offset " + + root.signedMs(root.syncPlay.clockOffsetMs) + " ping " + Number( + root.syncPlay.pingMs || 0).toFixed(2) + " ms" elide: Text.ElideRight } SecondaryText { - visible: SyncPlay.enabled + visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: "Playback drift " + (SyncPlay.playbackDiffValid ? root.signedMs(SyncPlay.playbackDiffMs) : "—") + " method " - + SyncPlay.syncMethod + " (ignore <100 ms; seek ≥400 ms)" + text: !root.syncPlayActive ? "" : "Playback drift " + (root.syncPlay.playbackDiffValid ? root.signedMs( + root.syncPlay.playbackDiffMs) : + "—") + " method " + + root.syncPlay.syncMethod + " (ignore <100 ms; seek ≥400 ms)" elide: Text.ElideRight } SecondaryText { diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index 4a21831a..6944b3ed 100644 --- a/qml/shell/ItemContextMenu.qml +++ b/qml/shell/ItemContextMenu.qml @@ -42,10 +42,13 @@ FocusScope { readonly property bool inCollection: currentViewKind === "boxset" || currentViewKind === "collection" readonly property bool collectionEligible: actionable && (itemType === "Movie" || itemType === "Series" || itemType === "Episode") - readonly property bool canManagePlaylists: Management.currentUserCanManagePlaylists - readonly property bool canManageCollections: Management.currentUserCanManageCollections - readonly property bool canRenameItem: Management.currentUserCanRenameItems - readonly property bool canDeleteItem: Management.currentUserCanDeleteItems + // Management only exists on a source that has playlists and collections, + // so it is only consulted behind the capability. + readonly property var management: ProviderCapabilities.libraryManagement ? Management : null + readonly property bool canManagePlaylists: management ? management.currentUserCanManagePlaylists : false + readonly property bool canManageCollections: management ? management.currentUserCanManageCollections : false + readonly property bool canRenameItem: management ? management.currentUserCanRenameItems : false + readonly property bool canDeleteItem: management ? management.currentUserCanDeleteItems : false visible: opened focus: opened @@ -157,7 +160,7 @@ FocusScope { label: "Add to collection", checked: false }) - if (inPlaylist && item.playlistItemId) { + if (inPlaylist && item.playlistItemId && management) { options.push({ action: "moveUp", icon: "keyboard_arrow_up", @@ -305,7 +308,7 @@ FocusScope { } else if (action === "removeParent") { shell.openManagement("remove", item) } else if (action === "moveUp" || action === "moveDown") { - Management.movePlaylistItemInCurrent(item, action === "moveUp" ? -1 : 1) + management.movePlaylistItemInCurrent(item, action === "moveUp" ? -1 : 1) } else if (action === "rename" || action === "delete") { shell.openManagement(action, item) } else if (action === "info") { diff --git a/qml/shell/ProviderCapabilities.qml b/qml/shell/ProviderCapabilities.qml new file mode 100644 index 00000000..9aa1b923 --- /dev/null +++ b/qml/shell/ProviderCapabilities.qml @@ -0,0 +1,35 @@ +pragma Singleton +import QtQuick + +// Which optional provider capabilities the active media source serves. Shared +// shell and page QML gates every provider-specific control on one of these, +// so a source that has no SyncPlay or remote control simply has no such +// buttons, and nothing else changes. +// +// Placeholder: every flag is true until the Phase 3 C++ provider registry +// replaces this file with a qmlRegisterSingletonInstance under the same name +// and the same property names. The contract is the names. +QtObject { + // The provider supplies its own login flow and session (Session). + readonly property bool auth: true + // Servers can be found on the local network (Discovery, DiscoveredServers). + readonly property bool discovery: true + // Text search and suggestions (Search). + readonly property bool search: true + // Favourite, played and playback position live on the source (ItemState). + readonly property bool userItemState: true + // Playback start, progress and stop are reported back to the source. + readonly property bool playbackReporting: true + // Intro and credit markers for the skip cards. + readonly property bool segments: true + // Playlists, collections, rename and delete (Management). + readonly property bool libraryManagement: true + // Group watch with a shared clock (SyncPlay). + readonly property bool syncPlay: true + // Casting to and driving other sessions of the same source (RemoteControl). + readonly property bool remoteControl: true + // Sign-in by code on another device (QuickConnect). + readonly property bool quickConnect: true + // A datagram path between two Spool instances relayed by the source. + readonly property bool peerRelay: true +} diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index 4b0b9886..7c09aa67 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -172,7 +172,7 @@ FocusScope { // while it builds. What made a television slow was never the // prewarming; it was dropping every page the moment it left the // screen and paying to build it again on the way back. - if (Session.authenticated && !prewarmScheduled) { + if ((!ProviderCapabilities.auth || Session.authenticated) && !prewarmScheduled) { prewarmScheduled = true // Most-wanted first. The budget stops this queue partway through on // a small television, so whatever stands at the front is what @@ -234,7 +234,7 @@ FocusScope { } function evictBeyondBudget() { - const shown = useOrder.filter(key => Boolean(pages[key])); + const shown = useOrder.filter(key => Boolean(pages[key])) // Prewarmed pages never pass through noteUse(), so an order taken from // useOrder alone could not see them: the budget said three while the // process was holding five. Count every resident page, and spend the @@ -248,7 +248,7 @@ FocusScope { if (!loader || loader === activeLoader || key === pinnedPageKey) continue delete pages[key] - loader.destroy() + loader.destroy(); --excess console.info("route host: released", key) } diff --git a/qml/shell/TopBar.qml b/qml/shell/TopBar.qml index 3c727566..7b7a461f 100644 --- a/qml/shell/TopBar.qml +++ b/qml/shell/TopBar.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" +import "TopBarNavigation.js" as TopBarNavigation // Horizontal top navigation bar. Hosts primary routes on the left and playback // destination, remote-control, and SyncPlay actions on the right. D-pad: @@ -23,9 +24,16 @@ FocusScope { property bool syncPlayMenuLoaded: false readonly property bool remoteControlMenuOpen: remoteMenuLoader.item ? remoteMenuLoader.item.menuOpen : false property bool remoteControlMenuLoaded: false - readonly property bool castVisible: Settings.castButtonEnabled - readonly property bool remoteVisible: RemoteControl.targetSelected - readonly property var syncPlay: SyncPlay + // Provider-shaped singletons are only touched behind their capability, so + // a source without them never has the bindings evaluated. + readonly property var session: ProviderCapabilities.auth ? Session : null + readonly property var remoteControl: ProviderCapabilities.remoteControl ? RemoteControl : null + readonly property bool remoteTargetSelected: remoteControl ? remoteControl.targetSelected : false + readonly property string remoteTargetName: remoteControl ? remoteControl.selectedTargetName : "" + readonly property bool castVisible: remoteControl !== null && Settings.castButtonEnabled + readonly property bool remoteVisible: remoteTargetSelected + readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null + readonly property bool syncVisible: syncPlay !== null readonly property bool syncActive: syncPlay ? syncPlay.enabled : false readonly property var syncGroups: syncPlay ? syncPlay.groups : [] readonly property bool syncAvailable: syncGroups && syncGroups.length > 0 @@ -39,9 +47,19 @@ FocusScope { // few pixels either side of each button were dead. readonly property int railCellWidth: Math.max(Metrics.scaled(50), Metrics.touchTargetPx) - // Index space: navigation buttons, optional Remote and Cast buttons, then SyncPlay. + // Index space: navigation buttons, then the optional Remote, Cast and + // SyncPlay buttons. TopBarNavigation.js owns the arithmetic so a gated-out + // button drops out of the sequence rather than leaving a dead index. function lastIndex() { - return railRepeater.count + (castVisible ? 1 : 0) + (remoteVisible ? 1 : 0) + return TopBarNavigation.lastIndex(railRepeater.count, remoteVisible, castVisible, syncVisible) + } + + function slotIndex(kind) { + return TopBarNavigation.indexOf(kind, railRepeater.count, remoteVisible, castVisible, syncVisible) + } + + function slotAt(index) { + return TopBarNavigation.slotAt(index, railRepeater.count, remoteVisible, castVisible, syncVisible) } function focusedIndex() { @@ -51,31 +69,29 @@ FocusScope { return i } if (remoteVisible && remoteButton.activeFocus) - return railRepeater.count + return slotIndex("remote") if (castVisible && castButton.activeFocus) - return railRepeater.count + (remoteVisible ? 1 : 0) - if (syncButton.activeFocus) - return lastIndex() + return slotIndex("cast") + if (syncVisible && syncButton.activeFocus) + return slotIndex("sync") return 0 } function focusIndex(index) { - const clamped = Math.max(0, Math.min(lastIndex(), index)) - if (clamped < railRepeater.count) { - const item = railRepeater.itemAt(clamped) + const slot = slotAt(index) + if (!slot) + return + if (slot.kind === "rail") { + const item = railRepeater.itemAt(slot.railIndex) if (item) item.forceButtonFocus() - return - } - if (remoteVisible && clamped === railRepeater.count) { + } else if (slot.kind === "remote") { InputKeys.focus(remoteButton) - return - } - if (castVisible && clamped === railRepeater.count + (remoteVisible ? 1 : 0)) { + } else if (slot.kind === "cast") { InputKeys.focus(castButton) - return + } else { + InputKeys.focus(syncButton) } - InputKeys.focus(syncButton) } function focusCurrent() { @@ -97,6 +113,8 @@ FocusScope { } function openRemoteMenu() { + if (!castVisible) + return closeSyncPlayMenu(false) const menu = remoteMenu() if (menu) @@ -117,9 +135,10 @@ FocusScope { } function openSyncMenu() { + if (!syncVisible) + return closeRemoteMenu(false) - if (syncPlay) - syncPlay.refreshGroups() + syncPlay.refreshGroups() const menu = syncMenu() if (menu) menu.openMenu() @@ -129,7 +148,7 @@ FocusScope { const menu = syncMenuLoader.item if (menu) menu.closeMenu() - if (restoreFocus !== false) + if (restoreFocus !== false && syncVisible) InputKeys.focus(syncButton) } @@ -178,16 +197,18 @@ FocusScope { sync.activate() return } - const index = focusedIndex() - if (remoteVisible && index === railRepeater.count) { + const slot = slotAt(focusedIndex()) + if (!slot) + return + if (slot.kind === "remote") { closeRemoteMenu(false) navigate("remoteControl") - } else if (castVisible && index === railRepeater.count + (remoteVisible ? 1 : 0)) { + } else if (slot.kind === "cast") { openRemoteMenu() - } else if (index >= lastIndex()) { + } else if (slot.kind === "sync") { openSyncMenu() } else { - const item = railRepeater.itemAt(index) + const item = railRepeater.itemAt(slot.railIndex) if (item) navigate(item.route) } @@ -206,7 +227,7 @@ FocusScope { } onActiveFocusChanged: if (!activeFocus) - closeMenus(false) + closeMenus(false) function routeKey(key, phase, repeat) { const remote = remoteMenuLoader.item @@ -267,28 +288,34 @@ FocusScope { Repeater { id: railRepeater - model: [ - { - label: "My Media", - route: "home", - icon: "home" - }, - { - label: "Search", - route: "search", - icon: "search" - }, - { - label: "Switch user", - route: "switchUser", - icon: "person" - }, - { - label: "Settings", - route: "settings", - icon: "settings" - } - ] + // Switching user is the provider's sign-in flow, so a source + // without one has no such button. + model: { + const entries = [ + { + label: "My Media", + route: "home", + icon: "home" + }, + { + label: "Search", + route: "search", + icon: "search" + } + ] + if (ProviderCapabilities.auth) + entries.push({ + label: "Switch user", + route: "switchUser", + icon: "person" + }) + entries.push({ + label: "Settings", + route: "settings", + icon: "settings" + }) + return entries + } delegate: Item { id: railDelegate @@ -307,8 +334,10 @@ FocusScope { id: button anchors.centerIn: parent iconName: modelData.icon - accessibleName: modelData.route === "switchUser" && Session.activeProfileLabel.length > 0 - ? "Switch user — " + Session.activeProfileLabel : modelData.label + accessibleName: modelData.route === "switchUser" && root.session + && root.session.activeProfileLabel.length > 0 ? "Switch user — " + + root.session.activeProfileLabel : + modelData.label railStyle: true // The rail changes pages; the selection goes with them. focusOnClick: false @@ -335,7 +364,7 @@ FocusScope { AppText { id: switchTooltip anchors.centerIn: parent - text: "Switch user — " + Session.activeProfileLabel + text: "Switch user — " + (root.session ? root.session.activeProfileLabel : "") color: Theme.textPrimary font.pixelSize: Metrics.metaSizePx maximumLineCount: 1 @@ -357,20 +386,19 @@ FocusScope { IconButton { id: castButton anchors.centerIn: parent - iconName: RemoteControl.targetSelected ? "cast_connected" : "cast" - accessibleName: RemoteControl.targetSelected ? "Play on — " + RemoteControl.selectedTargetName : - "Play on" + iconName: root.remoteTargetSelected ? "cast_connected" : "cast" + accessibleName: root.remoteTargetSelected ? "Play on — " + root.remoteTargetName : "Play on" railStyle: true selected: root.remoteControlMenuOpen onClicked: { if (root.remoteControlMenuOpen) - root.closeRemoteMenu(false) + root.closeRemoteMenu(false) else - root.openRemoteMenu() + root.openRemoteMenu() } Rectangle { - visible: RemoteControl.targetSelected + visible: root.remoteTargetSelected width: Metrics.scaled(9) height: width radius: width / 2 @@ -394,7 +422,7 @@ FocusScope { id: remoteButton anchors.centerIn: parent iconName: "settings_remote" - accessibleName: "Remote control — " + RemoteControl.selectedTargetName + accessibleName: "Remote control — " + root.remoteTargetName railStyle: true focusOnClick: false selected: root.currentRoute === "remoteControl" @@ -404,7 +432,7 @@ FocusScope { } Rectangle { - visible: RemoteControl.playbackPending + visible: root.remoteControl ? root.remoteControl.playbackPending : false width: Metrics.scaled(9) height: width radius: width / 2 @@ -420,8 +448,9 @@ FocusScope { } Item { + visible: root.syncVisible Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: root.railCellWidth + Layout.preferredWidth: visible ? root.railCellWidth : 0 Layout.fillHeight: true IconButton { @@ -434,9 +463,9 @@ FocusScope { acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { if (root.syncPlayMenuOpen) - root.closeSyncPlayMenu(false) + root.closeSyncPlayMenu(false) else - root.openSyncMenu() + root.openSyncMenu() } // Status dot: accent when in a group, green when groups exist to join. @@ -465,7 +494,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(6) anchors.rightMargin: Metrics.scaled(64) z: 50 - active: root.remoteControlMenuLoaded + active: root.remoteControl !== null && root.remoteControlMenuLoaded sourceComponent: RemoteControlMenu { onRequestClose: root.closeRemoteMenu() } @@ -479,7 +508,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(6) anchors.rightMargin: Metrics.scaled(14) z: 50 - active: root.syncPlayMenuLoaded + active: root.syncVisible && root.syncPlayMenuLoaded sourceComponent: SyncPlayMenu { onRequestClose: root.closeSyncPlayMenu() } diff --git a/qml/shell/TopBarNavigation.js b/qml/shell/TopBarNavigation.js new file mode 100644 index 00000000..446657a3 --- /dev/null +++ b/qml/shell/TopBarNavigation.js @@ -0,0 +1,50 @@ +.pragma library + +// The rail's index space: the navigation buttons first, then whichever of the +// optional Remote, Cast and SyncPlay buttons the provider and the settings put +// up, in that order. A button that is not there takes no index, so Left and +// Right never land on something invisible. +function slots(railCount, remoteVisible, castVisible, syncVisible) { + const result = [] + for (let index = 0; index < railCount; ++index) + result.push({ + "kind": "rail", + "railIndex": index + }) + if (remoteVisible) + result.push({ + "kind": "remote" + }) + if (castVisible) + result.push({ + "kind": "cast" + }) + if (syncVisible) + result.push({ + "kind": "sync" + }) + return result +} + +function lastIndex(railCount, remoteVisible, castVisible, syncVisible) { + return Math.max(0, slots(railCount, remoteVisible, castVisible, syncVisible).length - 1) +} + +// The slot an index resolves to, clamped into the rail; null only when the +// rail is empty. +function slotAt(index, railCount, remoteVisible, castVisible, syncVisible) { + const all = slots(railCount, remoteVisible, castVisible, syncVisible) + if (all.length === 0) + return null + return all[Math.max(0, Math.min(all.length - 1, index))] +} + +// Where a kind of button sits, or -1 when it is not on the rail. +function indexOf(kind, railCount, remoteVisible, castVisible, syncVisible) { + const all = slots(railCount, remoteVisible, castVisible, syncVisible) + for (let index = 0; index < all.length; ++index) { + if (all[index].kind === kind) + return index + } + return -1 +} diff --git a/tests/qml/provider-capabilities/qmldir b/tests/qml/provider-capabilities/qmldir new file mode 100644 index 00000000..726bbc5e --- /dev/null +++ b/tests/qml/provider-capabilities/qmldir @@ -0,0 +1 @@ +singleton ProviderCapabilities 1.0 ../../../qml/shell/ProviderCapabilities.qml diff --git a/tests/qml/tst_ProviderGating.qml b/tests/qml/tst_ProviderGating.qml new file mode 100644 index 00000000..6f2d424f --- /dev/null +++ b/tests/qml/tst_ProviderGating.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtTest +import "provider-capabilities" as Provider +import "../../qml/shell/TopBarNavigation.js" as TopBarNavigation + +// The contract between shared QML and the provider registry: the capability +// names the shell gates on, and the rail's index space staying continuous when +// a gated-out button is missing, so Left and Right never land on nothing. +TestCase { + name: "ProviderGating" + + readonly property var capabilityNames: ["auth", "discovery", "search", "userItemState", "playbackReporting", + "segments", "libraryManagement", "syncPlay", "remoteControl", "quickConnect", "peerRelay"] + + function test_capabilityNamesAreBooleans() { + for (let index = 0; index < capabilityNames.length; ++index) { + const name = capabilityNames[index] + compare(typeof Provider.ProviderCapabilities[name], "boolean", name) + } + } + + function test_everyButtonTakesAnIndexWhenAllArePresent() { + compare(TopBarNavigation.lastIndex(4, true, true, true), 6) + compare(TopBarNavigation.slotAt(4, 4, true, true, true).kind, "remote") + compare(TopBarNavigation.slotAt(5, 4, true, true, true).kind, "cast") + compare(TopBarNavigation.slotAt(6, 4, true, true, true).kind, "sync") + compare(TopBarNavigation.indexOf("sync", 4, true, true, true), 6) + } + + function test_gatedOutButtonsTakeNoIndex() { + // No SyncPlay: the rail ends at Cast and Right from Cast stays there. + compare(TopBarNavigation.lastIndex(4, false, true, false), 4) + compare(TopBarNavigation.slotAt(4, 4, false, true, false).kind, "cast") + compare(TopBarNavigation.slotAt(5, 4, false, true, false).kind, "cast") + compare(TopBarNavigation.indexOf("sync", 4, false, true, false), -1) + + // No remote control at all: the rail is only the navigation buttons. + compare(TopBarNavigation.lastIndex(3, false, false, true), 3) + compare(TopBarNavigation.slotAt(3, 3, false, false, true).kind, "sync") + compare(TopBarNavigation.slotAt(9, 3, false, false, false).kind, "rail") + compare(TopBarNavigation.slotAt(9, 3, false, false, false).railIndex, 2) + compare(TopBarNavigation.indexOf("remote", 3, false, false, false), -1) + compare(TopBarNavigation.indexOf("cast", 3, false, false, false), -1) + } + + function test_indexesClampIntoTheRail() { + compare(TopBarNavigation.slotAt(-3, 4, true, true, true).kind, "rail") + compare(TopBarNavigation.slotAt(-3, 4, true, true, true).railIndex, 0) + compare(TopBarNavigation.slotAt(0, 0, false, false, false), null) + compare(TopBarNavigation.lastIndex(0, false, false, false), 0) + } +} From e3916472e0f6c38bcb5091562795b0698b1512ae Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 18:56:32 +1000 Subject: [PATCH 007/146] refactor(ui): move Jellyfin-only QML under qml/providers/jellyfin The login flow, the remote control page and its menu and now-playing bar, the SyncPlay menu and the management dialog only exist for the Jellyfin provider. Give them a directory of their own so the provider can take them with it when it moves out, and point the shell, the overlay chrome and the route stack at the new place. Same QML module, so type names resolve as before. --- .qmlls.ini | 4 ++-- CMakeLists.txt | 24 +++++++++---------- qml/pages/PlayerOverlayChrome.qml | 3 ++- .../jellyfin}/LoginAccountStep.qml | 4 ++-- .../jellyfin}/LoginBrandPanel.qml | 4 ++-- .../jellyfin}/LoginPage.qml | 6 ++--- .../jellyfin}/LoginProfileDialogs.qml | 4 ++-- .../jellyfin}/LoginProfilePicker.qml | 12 +++++----- .../jellyfin}/LoginServerStep.qml | 8 +++---- .../jellyfin}/ManagementDialog.qml | 5 ++-- .../jellyfin}/ProfileNavigation.js | 0 .../jellyfin}/RemoteControlMenu.qml | 4 ++-- .../jellyfin}/RemoteControlPage.qml | 8 +++---- .../jellyfin}/RemoteNowPlayingBar.qml | 14 +++++------ .../jellyfin}/SyncPlayMenu.qml | 4 ++-- qml/shell/AppShell.qml | 1 + qml/shell/RouteStack.qml | 4 ++-- qml/shell/TopBar.qml | 1 + tests/qml/tst_ProfileNavigation.qml | 2 +- 19 files changed, 58 insertions(+), 54 deletions(-) rename qml/{pages => providers/jellyfin}/LoginAccountStep.qml (99%) rename qml/{pages => providers/jellyfin}/LoginBrandPanel.qml (98%) rename qml/{pages => providers/jellyfin}/LoginPage.qml (99%) rename qml/{pages => providers/jellyfin}/LoginProfileDialogs.qml (99%) rename qml/{pages => providers/jellyfin}/LoginProfilePicker.qml (96%) rename qml/{pages => providers/jellyfin}/LoginServerStep.qml (98%) rename qml/{shell => providers/jellyfin}/ManagementDialog.qml (99%) rename qml/{pages => providers/jellyfin}/ProfileNavigation.js (100%) rename qml/{shell => providers/jellyfin}/RemoteControlMenu.qml (99%) rename qml/{pages => providers/jellyfin}/RemoteControlPage.qml (99%) rename qml/{shell => providers/jellyfin}/RemoteNowPlayingBar.qml (98%) rename qml/{shell => providers/jellyfin}/SyncPlayMenu.qml (99%) diff --git a/.qmlls.ini b/.qmlls.ini index 86b224af..83ced9d7 100644 --- a/.qmlls.ini +++ b/.qmlls.ini @@ -1,4 +1,4 @@ [General] -; QML files live at qml/ and qml/*/, so include both relative depths. -buildDir=../build/linux-dev/app:../../build/linux-dev/app +; QML files live at qml/, qml/*/ and qml/providers/*/, so include every relative depth. +buildDir=../build/linux-dev/app:../../build/linux-dev/app:../../../build/linux-dev/app no-cmake-calls=true diff --git a/CMakeLists.txt b/CMakeLists.txt index f10c4def..ec2ae056 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -476,13 +476,6 @@ qt_add_qml_module(jellyfin-native qml/pages/LibraryNavigation.js qml/shell/ItemActivation.js qml/primitives/ModelAccess.js - qml/pages/LoginPage.qml - qml/pages/LoginAccountStep.qml - qml/pages/LoginBrandPanel.qml - qml/pages/LoginProfileDialogs.qml - qml/pages/LoginProfilePicker.qml - qml/pages/LoginServerStep.qml - qml/pages/ProfileNavigation.js qml/pages/OpenSourceNoticesPage.qml qml/pages/PersonDetailsPage.qml qml/pages/PlayerBrowsePanel.qml @@ -498,12 +491,23 @@ qt_add_qml_module(jellyfin-native qml/pages/PlayerStatsPanel.qml qml/pages/PlayerTransportBar.qml qml/pages/PlayerTrickplayPreview.qml - qml/pages/RemoteControlPage.qml qml/pages/SearchPage.qml qml/pages/SettingsPage.qml qml/pages/SettingsNavigation.js qml/pages/SubtitleSettingsPanel.qml qml/primitives/ActionButton.qml + qml/providers/jellyfin/LoginPage.qml + qml/providers/jellyfin/LoginAccountStep.qml + qml/providers/jellyfin/LoginBrandPanel.qml + qml/providers/jellyfin/LoginProfileDialogs.qml + qml/providers/jellyfin/LoginProfilePicker.qml + qml/providers/jellyfin/LoginServerStep.qml + qml/providers/jellyfin/ProfileNavigation.js + qml/providers/jellyfin/RemoteControlPage.qml + qml/providers/jellyfin/RemoteControlMenu.qml + qml/providers/jellyfin/RemoteNowPlayingBar.qml + qml/providers/jellyfin/SyncPlayMenu.qml + qml/providers/jellyfin/ManagementDialog.qml qml/primitives/AppText.qml qml/primitives/AtomicViewReveal.qml qml/primitives/BusySpinner.qml @@ -550,7 +554,6 @@ qt_add_qml_module(jellyfin-native qml/shell/DiagnosticsOverlay.qml qml/shell/InputLatencyWarning.qml qml/shell/ItemContextMenu.qml - qml/shell/ManagementDialog.qml qml/shell/RememberedCertificatesDialog.qml qml/shell/MediaInfoOverlay.qml qml/shell/OverlayDialog.qml @@ -558,9 +561,6 @@ qt_add_qml_module(jellyfin-native qml/shell/ProviderCapabilities.qml qml/shell/RoutePolicy.js qml/shell/RouteStack.qml - qml/shell/RemoteControlMenu.qml - qml/shell/RemoteNowPlayingBar.qml - qml/shell/SyncPlayMenu.qml qml/shell/TlsTrustDialog.qml qml/shell/UpdateDialog.qml qml/shell/ToastLayer.qml diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index 92ac19e0..ea66653e 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -6,6 +6,7 @@ import JellyfinWebOS import "../theme" import "../primitives" import "../shell" as Shell +import "../providers/jellyfin" as Provider Item { id: root @@ -431,7 +432,7 @@ Item { width: root.dp(420) z: 55 active: root.overlay.syncPlay !== null - sourceComponent: Shell.SyncPlayMenu { + sourceComponent: Provider.SyncPlayMenu { onRequestClose: root.overlay.closeMenu() } } diff --git a/qml/pages/LoginAccountStep.qml b/qml/providers/jellyfin/LoginAccountStep.qml similarity index 99% rename from qml/pages/LoginAccountStep.qml rename to qml/providers/jellyfin/LoginAccountStep.qml index 6b1c1e1c..a7eaf763 100644 --- a/qml/pages/LoginAccountStep.qml +++ b/qml/providers/jellyfin/LoginAccountStep.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // Signing in to the server the previous step settled on. // diff --git a/qml/pages/LoginBrandPanel.qml b/qml/providers/jellyfin/LoginBrandPanel.qml similarity index 98% rename from qml/pages/LoginBrandPanel.qml rename to qml/providers/jellyfin/LoginBrandPanel.qml index efb7f4ac..1e536a86 100644 --- a/qml/pages/LoginBrandPanel.qml +++ b/qml/providers/jellyfin/LoginBrandPanel.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // The header of the sign-in screen: where you are, and the way back out. // Nothing else in here takes focus, so a remote never walks through it on the diff --git a/qml/pages/LoginPage.qml b/qml/providers/jellyfin/LoginPage.qml similarity index 99% rename from qml/pages/LoginPage.qml rename to qml/providers/jellyfin/LoginPage.qml index 052a07ca..6dbcce2d 100644 --- a/qml/pages/LoginPage.qml +++ b/qml/providers/jellyfin/LoginPage.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // The way into the app: pick an account, or add one by finding a server and // signing in to it. @@ -182,7 +182,7 @@ FocusScope { return true } - const current = focusedControl(); + const current = focusedControl() // A step gets first refusal, because a grid or a list moves inside // itself before the page moves between the things it holds. if (currentStep().moveInside(current, key)) diff --git a/qml/pages/LoginProfileDialogs.qml b/qml/providers/jellyfin/LoginProfileDialogs.qml similarity index 99% rename from qml/pages/LoginProfileDialogs.qml rename to qml/providers/jellyfin/LoginProfileDialogs.qml index 5f246a7f..1cf6e4fe 100644 --- a/qml/pages/LoginProfileDialogs.qml +++ b/qml/providers/jellyfin/LoginProfileDialogs.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // The three things you can do to a saved account: sign in again, correct the // server it points at, or forget it. They share one anchor and one key route, diff --git a/qml/pages/LoginProfilePicker.qml b/qml/providers/jellyfin/LoginProfilePicker.qml similarity index 96% rename from qml/pages/LoginProfilePicker.qml rename to qml/providers/jellyfin/LoginProfilePicker.qml index da8a8934..a2c5c38b 100644 --- a/qml/pages/LoginProfilePicker.qml +++ b/qml/providers/jellyfin/LoginProfilePicker.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" import "ProfileNavigation.js" as ProfileNavigation // The accounts already saved on this device, plus the way to add another. @@ -120,7 +120,7 @@ FocusScope { model: root.profileCount + 1 currentIndex: 0 onCountChanged: if (currentIndex >= count) - currentIndex = Math.max(0, count - 1) + currentIndex = Math.max(0, count - 1) function columnCount() { return Math.max(1, Math.floor(width / Math.max(1, cellWidth))) @@ -176,13 +176,13 @@ FocusScope { grid.currentIndex = cell.index InputKeys.focus(grid) if (cell.profile) - root.profileChosen(String(cell.profile.profileId || "")) + root.profileChosen(String(cell.profile.profileId || "")) else - root.addRequested() + root.addRequested() } onContextRequested: { if (!cell.profile) - return + return grid.currentIndex = cell.index InputKeys.focus(grid) root.contextRequested(String(cell.profile.profileId || ""), tile, tile.serverName, String( diff --git a/qml/pages/LoginServerStep.qml b/qml/providers/jellyfin/LoginServerStep.qml similarity index 98% rename from qml/pages/LoginServerStep.qml rename to qml/providers/jellyfin/LoginServerStep.qml index 119afbbd..4ec1116f 100644 --- a/qml/pages/LoginServerStep.qml +++ b/qml/providers/jellyfin/LoginServerStep.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // Choosing a server: type an address, or take one off the network. // @@ -343,10 +343,10 @@ FocusScope { currentIndex: count > 0 ? 0 : -1 onCountChanged: { if (count > 0 && !addressRow.editing && !probeRow.activeFocus) - Qt.callLater(root.focusDefault) + Qt.callLater(root.focusDefault) } onCurrentIndexChanged: if (currentIndex >= 0) - positionViewAtIndex(currentIndex, ListView.Contain) + positionViewAtIndex(currentIndex, ListView.Contain) FastWheelHandler { flickable: discoveredList diff --git a/qml/shell/ManagementDialog.qml b/qml/providers/jellyfin/ManagementDialog.qml similarity index 99% rename from qml/shell/ManagementDialog.qml rename to qml/providers/jellyfin/ManagementDialog.qml index 9f6d8294..9f9d2bd7 100644 --- a/qml/shell/ManagementDialog.qml +++ b/qml/providers/jellyfin/ManagementDialog.qml @@ -2,8 +2,9 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" +import "../../shell" OverlayDialog { id: root diff --git a/qml/pages/ProfileNavigation.js b/qml/providers/jellyfin/ProfileNavigation.js similarity index 100% rename from qml/pages/ProfileNavigation.js rename to qml/providers/jellyfin/ProfileNavigation.js diff --git a/qml/shell/RemoteControlMenu.qml b/qml/providers/jellyfin/RemoteControlMenu.qml similarity index 99% rename from qml/shell/RemoteControlMenu.qml rename to qml/providers/jellyfin/RemoteControlMenu.qml index e206af5f..45057334 100644 --- a/qml/shell/RemoteControlMenu.qml +++ b/qml/providers/jellyfin/RemoteControlMenu.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" FocusScope { id: menu diff --git a/qml/pages/RemoteControlPage.qml b/qml/providers/jellyfin/RemoteControlPage.qml similarity index 99% rename from qml/pages/RemoteControlPage.qml rename to qml/providers/jellyfin/RemoteControlPage.qml index a3ced644..c8e8a86e 100644 --- a/qml/pages/RemoteControlPage.qml +++ b/qml/providers/jellyfin/RemoteControlPage.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" FocusScope { id: root @@ -94,7 +94,7 @@ FocusScope { } onActiveFocusChanged: if (activeFocus) - InputKeys.focus(RemoteControl.targetSelected ? disconnectButton : refreshButton) + InputKeys.focus(RemoteControl.targetSelected ? disconnectButton : refreshButton) Connections { target: RemoteControl @@ -120,7 +120,7 @@ FocusScope { repeat: true running: root.visible && RemoteControl.targetSelected && !RemoteControl.paused onTriggered: if (!positionSlider.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() + root.shownPositionTicks = RemoteControl.predictedPositionTicks() } Flickable { diff --git a/qml/shell/RemoteNowPlayingBar.qml b/qml/providers/jellyfin/RemoteNowPlayingBar.qml similarity index 98% rename from qml/shell/RemoteNowPlayingBar.qml rename to qml/providers/jellyfin/RemoteNowPlayingBar.qml index 1a13fabf..b8424484 100644 --- a/qml/shell/RemoteNowPlayingBar.qml +++ b/qml/providers/jellyfin/RemoteNowPlayingBar.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // A compact transport for the client this device is driving, kept in reach // while the user browses everything else. Dragging its bar previews on the @@ -55,7 +55,7 @@ Item { enabled: shown onShownChanged: if (!shown) - openMenu = "" + openMenu = "" Connections { target: RemoteControl @@ -70,7 +70,7 @@ Item { repeat: true running: root.shown && root.visible && !RemoteControl.paused onTriggered: if (!seekBar.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() + root.shownPositionTicks = RemoteControl.predictedPositionTicks() } // An enlarged frame while scrubbing, clear of the thumb doing the @@ -153,9 +153,9 @@ Item { anchors.fill: parent onClicked: { if (root.openMenu.length > 0) - root.openMenu = "" + root.openMenu = "" else - root.openRequested() + root.openRequested() } } @@ -404,7 +404,7 @@ Item { checked: modelData.selected === true onActivated: { if (panel.chooser) - panel.chooser(Number(modelData.index)) + panel.chooser(Number(modelData.index)) root.openMenu = "" } } diff --git a/qml/shell/SyncPlayMenu.qml b/qml/providers/jellyfin/SyncPlayMenu.qml similarity index 99% rename from qml/shell/SyncPlayMenu.qml rename to qml/providers/jellyfin/SyncPlayMenu.qml index 3c9487e9..03349c0b 100644 --- a/qml/shell/SyncPlayMenu.qml +++ b/qml/providers/jellyfin/SyncPlayMenu.qml @@ -2,8 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../theme" -import "../primitives" +import "../../theme" +import "../../primitives" // Dropdown menu for the top-bar SyncPlay button. Mirrors jellyfin-web's // SyncPlayMenu: when in a group it shows the active group and a Leave action; diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index be9b5a1c..9e38a5de 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" +import "../providers/jellyfin" import "RoutePolicy.js" as RoutePolicy KeyRouter { diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index 7c09aa67..e479e519 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -54,7 +54,7 @@ FocusScope { function pageSource(key) { switch (key) { case "login": - return Qt.resolvedUrl("../pages/LoginPage.qml") + return Qt.resolvedUrl("../providers/jellyfin/LoginPage.qml") case "libraryGrid": return Qt.resolvedUrl("../pages/LibraryGridPage.qml") case "itemDetails": @@ -64,7 +64,7 @@ FocusScope { case "search": return Qt.resolvedUrl("../pages/SearchPage.qml") case "remoteControl": - return Qt.resolvedUrl("../pages/RemoteControlPage.qml") + return Qt.resolvedUrl("../providers/jellyfin/RemoteControlPage.qml") case "openSourceNotices": return Qt.resolvedUrl("../pages/OpenSourceNoticesPage.qml") case "settings": diff --git a/qml/shell/TopBar.qml b/qml/shell/TopBar.qml index 7b7a461f..cd2ea31f 100644 --- a/qml/shell/TopBar.qml +++ b/qml/shell/TopBar.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" +import "../providers/jellyfin" import "TopBarNavigation.js" as TopBarNavigation // Horizontal top navigation bar. Hosts primary routes on the left and playback diff --git a/tests/qml/tst_ProfileNavigation.qml b/tests/qml/tst_ProfileNavigation.qml index f7bbe2af..bd974384 100644 --- a/tests/qml/tst_ProfileNavigation.qml +++ b/tests/qml/tst_ProfileNavigation.qml @@ -1,6 +1,6 @@ import QtQuick import QtTest -import "../../qml/pages/ProfileNavigation.js" as ProfileNavigation +import "../../qml/providers/jellyfin/ProfileNavigation.js" as ProfileNavigation TestCase { name: "ProfileNavigation" From 2d3d08e54b12a2583a98ad2dda17987a57956446 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:05:39 +1000 Subject: [PATCH 008/146] feat(provider): add Provider, ProviderRegistry and ProviderCapabilities A Provider declares which optional capabilities its media source serves; the registry holds the active one and publishes its flags as the eleven booleans shared QML already gates on. Nothing uses them yet. --- CMakeLists.txt | 4 + src/provider/Provider.h | 68 +++++++++++++ src/provider/ProviderRegistry.cpp | 68 +++++++++++++ src/provider/ProviderRegistry.h | 130 ++++++++++++++++++++++++ tests/app/ProviderRegistryTest.cpp | 157 +++++++++++++++++++++++++++++ 5 files changed, 427 insertions(+) create mode 100644 src/provider/Provider.h create mode 100644 src/provider/ProviderRegistry.cpp create mode 100644 src/provider/ProviderRegistry.h create mode 100644 tests/app/ProviderRegistryTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ec2ae056..393d608b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -236,6 +236,9 @@ set(SPOOL_SHELL_SOURCES src/models/MovieGridModel.cpp src/models/MovieGridModel.h src/provider/PlaybackSource.h + src/provider/Provider.h + src/provider/ProviderRegistry.cpp + src/provider/ProviderRegistry.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp @@ -766,6 +769,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) sync-play-drift-policy tests/app/SyncPlayDriftPolicyTest.cpp update-manifest tests/app/UpdateManifestTest.cpp artwork-url tests/app/ArtworkUrlTest.cpp + provider-registry tests/app/ProviderRegistryTest.cpp ) set(JELLYFIN_TESTS_cache database-manager tests/cache/DatabaseManagerTest.cpp diff --git a/src/provider/Provider.h b/src/provider/Provider.h new file mode 100644 index 00000000..bcad112b --- /dev/null +++ b/src/provider/Provider.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include + +namespace JellyfinNative { + +class PlaybackSource; + +// A source of media the app can sit on: a Jellyfin server, a debrid account, +// a folder of files. The shell and pages are the same whichever one is +// active; what differs is which optional capabilities the source serves, and +// every provider-specific control in shared QML is gated on one of these +// through the ProviderCapabilities singleton the registry publishes. +// +// The catalog, search, artwork and user-item-state accessors arrive with the +// contract branch; until then those controllers still take the Jellyfin +// facade directly. +class Provider : public QObject { + Q_OBJECT + +public: + enum Capability : unsigned { + // The provider supplies its own login flow and session (Session). + Auth = 1u << 0, + // Servers can be found on the local network (Discovery, DiscoveredServers). + Discovery = 1u << 1, + // Text search and suggestions (Search). + Search = 1u << 2, + // Favourite, played and playback position live on the source (ItemState). + UserItemState = 1u << 3, + // Playback start, progress and stop are reported back to the source. + PlaybackReporting = 1u << 4, + // Intro and credit markers for the skip cards. + Segments = 1u << 5, + // Playlists, collections, rename and delete (Management). + LibraryManagement = 1u << 6, + // Group watch with a shared clock (SyncPlay). + SyncPlay = 1u << 7, + // Casting to and driving other sessions of the same source (RemoteControl). + RemoteControl = 1u << 8, + // Sign-in by code on another device (QuickConnect). + QuickConnect = 1u << 9, + // A datagram path between two Spool instances relayed by the source. + PeerRelay = 1u << 10, + }; + Q_DECLARE_FLAGS(Capabilities, Capability) + Q_FLAG(Capabilities) + + using QObject::QObject; + + virtual QString id() const = 0; + virtual QString displayName() const = 0; + virtual Capabilities capabilities() const = 0; + virtual PlaybackSource *playback() = 0; + // Registers the QML singletons only this provider's own QML reaches + // (Session, SyncPlay and the like). Shared QML never names them without + // first checking the matching capability. + virtual void registerQmlSingletons() = 0; + +signals: + void capabilitiesChanged(); +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(Provider::Capabilities) + +} // namespace JellyfinNative diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp new file mode 100644 index 00000000..e38a4bb7 --- /dev/null +++ b/src/provider/ProviderRegistry.cpp @@ -0,0 +1,68 @@ +#include "ProviderRegistry.h" + +#include + +namespace JellyfinNative { + +ProviderCapabilities::ProviderCapabilities(QObject *parent) + : QObject(parent) +{ +} + +void ProviderCapabilities::setFlags(Provider::Capabilities flags) +{ + if (m_flags == flags) + return; + m_flags = flags; + emit changed(); +} + +ProviderRegistry::ProviderRegistry(QObject *parent) + : QObject(parent) +{ +} + +void ProviderRegistry::add(Provider *provider) +{ + if (!provider || std::find(m_providers.begin(), m_providers.end(), provider) != m_providers.end()) + return; + m_providers.push_back(provider); +} + +Provider *ProviderRegistry::provider(const QString& id) const +{ + const auto it = std::find_if( + m_providers.begin(), m_providers.end(), [&id](const Provider *candidate) { return candidate->id() == id; }); + return it == m_providers.end() ? nullptr : *it; +} + +bool ProviderRegistry::setActive(const QString& id) +{ + Provider *candidate = provider(id); + if (!candidate) + return false; + setActive(candidate); + return true; +} + +void ProviderRegistry::setActive(Provider *provider) +{ + if (m_active == provider) + return; + add(provider); + QObject::disconnect(m_activeCapabilities); + m_active = provider; + if (m_active) { + m_activeCapabilities + = connect(m_active, &Provider::capabilitiesChanged, this, &ProviderRegistry::refreshCapabilities); + } + refreshCapabilities(); + emit activeChanged(); +} + +void ProviderRegistry::refreshCapabilities() +{ + m_capabilities.setFlags(m_active ? m_active->capabilities() : Provider::Capabilities {}); +} + +} // namespace JellyfinNative diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h new file mode 100644 index 00000000..9f87f1f8 --- /dev/null +++ b/src/provider/ProviderRegistry.h @@ -0,0 +1,130 @@ +#pragma once + +#include "Provider.h" + +#include +#include + +#include + +namespace JellyfinNative { + +// The active provider's capability flags as eleven booleans, registered in +// QML as the ProviderCapabilities singleton. The property names are the +// contract with shared QML: a gated control binds to one of them by name. +class ProviderCapabilities final : public QObject { + Q_OBJECT + Q_PROPERTY(bool auth READ auth NOTIFY changed) + Q_PROPERTY(bool discovery READ discovery NOTIFY changed) + Q_PROPERTY(bool search READ search NOTIFY changed) + Q_PROPERTY(bool userItemState READ userItemState NOTIFY changed) + Q_PROPERTY(bool playbackReporting READ playbackReporting NOTIFY changed) + Q_PROPERTY(bool segments READ segments NOTIFY changed) + Q_PROPERTY(bool libraryManagement READ libraryManagement NOTIFY changed) + Q_PROPERTY(bool syncPlay READ syncPlay NOTIFY changed) + Q_PROPERTY(bool remoteControl READ remoteControl NOTIFY changed) + Q_PROPERTY(bool quickConnect READ quickConnect NOTIFY changed) + Q_PROPERTY(bool peerRelay READ peerRelay NOTIFY changed) + +public: + explicit ProviderCapabilities(QObject *parent = nullptr); + + Provider::Capabilities flags() const + { + return m_flags; + } + void setFlags(Provider::Capabilities flags); + + bool auth() const + { + return m_flags.testFlag(Provider::Auth); + } + bool discovery() const + { + return m_flags.testFlag(Provider::Discovery); + } + bool search() const + { + return m_flags.testFlag(Provider::Search); + } + bool userItemState() const + { + return m_flags.testFlag(Provider::UserItemState); + } + bool playbackReporting() const + { + return m_flags.testFlag(Provider::PlaybackReporting); + } + bool segments() const + { + return m_flags.testFlag(Provider::Segments); + } + bool libraryManagement() const + { + return m_flags.testFlag(Provider::LibraryManagement); + } + bool syncPlay() const + { + return m_flags.testFlag(Provider::SyncPlay); + } + bool remoteControl() const + { + return m_flags.testFlag(Provider::RemoteControl); + } + bool quickConnect() const + { + return m_flags.testFlag(Provider::QuickConnect); + } + bool peerRelay() const + { + return m_flags.testFlag(Provider::PeerRelay); + } + +signals: + void changed(); + +private: + Provider::Capabilities m_flags; +}; + +// Every provider the build knows about, and the one the app is running on. +// Providers are added by whoever constructs them and are not owned here. +class ProviderRegistry final : public QObject { + Q_OBJECT + +public: + explicit ProviderRegistry(QObject *parent = nullptr); + + void add(Provider *provider); + Provider *provider(const QString& id) const; + const std::vector& providers() const + { + return m_providers; + } + + // Returns false when no provider carries that id; the active one is + // then unchanged. + bool setActive(const QString& id); + void setActive(Provider *provider); + Provider *active() const + { + return m_active; + } + ProviderCapabilities *capabilities() + { + return &m_capabilities; + } + +signals: + void activeChanged(); + +private: + void refreshCapabilities(); + + std::vector m_providers; + Provider *m_active = nullptr; + QMetaObject::Connection m_activeCapabilities; + ProviderCapabilities m_capabilities; +}; + +} // namespace JellyfinNative diff --git a/tests/app/ProviderRegistryTest.cpp b/tests/app/ProviderRegistryTest.cpp new file mode 100644 index 00000000..6696d528 --- /dev/null +++ b/tests/app/ProviderRegistryTest.cpp @@ -0,0 +1,157 @@ +#include "provider/ProviderRegistry.h" +#include "provider/PlaybackSource.h" +#include "provider/Provider.h" + +#include "TestMain.h" + +#include +#include +#include + +#include +#include + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +class StubProvider final : public JellyfinNative::Provider { +public: + StubProvider(QString id, Capabilities capabilities, QObject *parent = nullptr) + : Provider(parent) + , m_id(std::move(id)) + , m_capabilities(capabilities) + { + } + + QString id() const override + { + return m_id; + } + QString displayName() const override + { + return m_id; + } + Capabilities capabilities() const override + { + return m_capabilities; + } + JellyfinNative::PlaybackSource *playback() override + { + return nullptr; + } + void registerQmlSingletons() override { } + + void setCapabilities(Capabilities capabilities) + { + m_capabilities = capabilities; + emit capabilitiesChanged(); + } + +private: + QString m_id; + Capabilities m_capabilities; +}; + +// The eleven names shared QML gates on, in the order the flags are declared. +const QStringList kCapabilityNames { QStringLiteral("auth"), QStringLiteral("discovery"), QStringLiteral("search"), + QStringLiteral("userItemState"), QStringLiteral("playbackReporting"), QStringLiteral("segments"), + QStringLiteral("libraryManagement"), QStringLiteral("syncPlay"), QStringLiteral("remoteControl"), + QStringLiteral("quickConnect"), QStringLiteral("peerRelay") }; + +bool propertyValue(const QObject& object, const QString& name) +{ + const QVariant value = object.property(name.toLatin1().constData()); + require(value.isValid() && value.typeId() == QMetaType::Bool, "capability property is a bool"); + return value.toBool(); +} + +} // namespace + +JELLYFIN_TEST_MAIN("provider-registry") +{ + QCoreApplication app(argc, argv); + using JellyfinNative::Provider; + + JellyfinNative::ProviderRegistry registry; + JellyfinNative::ProviderCapabilities *capabilities = registry.capabilities(); + require(registry.active() == nullptr, "no provider is active until one is chosen"); + for (const QString& name : kCapabilityNames) + require(!propertyValue(*capabilities, name), "every capability is off with no active provider"); + + // Every flag maps to the property of the same name, and only that one. + Provider::Capabilities all; + const QList flags { Provider::Auth, Provider::Discovery, Provider::Search, + Provider::UserItemState, Provider::PlaybackReporting, Provider::Segments, Provider::LibraryManagement, + Provider::SyncPlay, Provider::RemoteControl, Provider::QuickConnect, Provider::PeerRelay }; + require(flags.size() == kCapabilityNames.size(), "one flag per capability name"); + StubProvider single(QStringLiteral("single"), {}); + registry.setActive(&single); + for (qsizetype index = 0; index < flags.size(); ++index) { + single.setCapabilities(flags.at(index)); + for (qsizetype other = 0; other < kCapabilityNames.size(); ++other) { + require(propertyValue(*capabilities, kCapabilityNames.at(other)) == (other == index), + "a flag turns on exactly the property of its own name"); + } + all |= flags.at(index); + } + + StubProvider jellyfin(QStringLiteral("jellyfin"), all); + StubProvider local(QStringLiteral("local"), Provider::Search); + registry.add(&jellyfin); + registry.add(&local); + registry.add(&jellyfin); + require(registry.providers().size() == 3, "adding a provider twice registers it once"); + require(registry.provider(QStringLiteral("local")) == &local, "providers are found by id"); + require(!registry.setActive(QStringLiteral("plex")), "an unknown id leaves the active provider alone"); + require(registry.active() == &single, "an unknown id leaves the active provider alone"); + + int activeChanged = 0; + int capabilitiesChanged = 0; + QObject::connect(®istry, &JellyfinNative::ProviderRegistry::activeChanged, &app, [&] { ++activeChanged; }); + QObject::connect( + capabilities, &JellyfinNative::ProviderCapabilities::changed, &app, [&] { ++capabilitiesChanged; }); + require(registry.setActive(QStringLiteral("jellyfin")), "a known id activates its provider"); + require(registry.active() == &jellyfin, "the named provider is active"); + require(activeChanged == 1, "activation notifies once"); + require(capabilitiesChanged == 1, "the capability change notifies once"); + for (const QString& name : kCapabilityNames) + require(propertyValue(*capabilities, name), "every capability is on for the full provider"); + + registry.setActive(&local); + require(activeChanged == 2, "switching providers notifies again"); + require(capabilitiesChanged == 2, "switching providers refreshes the flags"); + require(propertyValue(*capabilities, QStringLiteral("search")), "the local provider searches"); + require(!propertyValue(*capabilities, QStringLiteral("syncPlay")), "the local provider has no SyncPlay"); + + // A change on the previous provider no longer reaches the flags; one on + // the active provider does. + jellyfin.setCapabilities({}); + require(capabilitiesChanged == 2, "the previous provider is disconnected"); + local.setCapabilities(Provider::Search | Provider::Segments); + require(capabilitiesChanged == 3, "the active provider drives the flags"); + require(propertyValue(*capabilities, QStringLiteral("segments")), "the new flag is visible"); + local.setCapabilities(Provider::Search | Provider::Segments); + require(capabilitiesChanged == 3, "an unchanged flag set does not notify"); + + registry.setActive(nullptr); + require(registry.active() == nullptr, "the active provider can be cleared"); + for (const QString& name : kCapabilityNames) + require(!propertyValue(*capabilities, name), "clearing the active provider clears every flag"); + + // The QML singleton contract: exactly these property names, all booleans. + const QMetaObject *meta = capabilities->metaObject(); + QStringList declared; + for (int index = meta->propertyOffset(); index < meta->propertyCount(); ++index) + declared.append(QString::fromLatin1(meta->property(index).name())); + require(declared == kCapabilityNames, "ProviderCapabilities declares exactly the eleven contract names in order"); + + std::cout << "provider registry ok\n"; + return 0; +} From e8447f5b012e7ef75b8ce921166660a6f8cc0a19 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:11:46 +1000 Subject: [PATCH 009/146] refactor(app): compose the Jellyfin provider outside AppController JellyfinProvider owns the facade, LAN discovery and its cached server list, the session, QuickConnect and remote control, and builds SyncPlay, library management and the settings bridge once the app hands it the core objects they sit on. It registers its own QML singletons and keeps the session-driven lifecycle of its parts; main.cpp activates it in the ProviderRegistry, whose ProviderCapabilities instance replaces the QML placeholder singleton. The server and sign-out entry points move from App to Session, where the state they act on lives. --- CMakeLists.txt | 4 +- qml/pages/SettingsPage.qml | 4 +- qml/providers/jellyfin/LoginPage.qml | 2 +- qml/providers/jellyfin/LoginServerStep.qml | 4 +- qml/shell/AppShell.qml | 6 +- src/api/JellyfinProvider.cpp | 193 ++++++++++++++++++ src/api/JellyfinProvider.h | 131 ++++++++++++ src/app/AppController.cpp | 149 +++----------- src/app/AppController.h | 45 +--- src/app/SessionController.cpp | 41 ++++ src/app/SessionController.h | 17 ++ src/main.cpp | 49 ++--- .../ProviderCapabilities.qml | 6 +- tests/qml/provider-capabilities/qmldir | 2 +- 14 files changed, 448 insertions(+), 205 deletions(-) create mode 100644 src/api/JellyfinProvider.cpp create mode 100644 src/api/JellyfinProvider.h rename {qml/shell => tests/qml/provider-capabilities}/ProviderCapabilities.qml (87%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 393d608b..4d3f7118 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -259,6 +259,8 @@ set(SPOOL_JELLYFIN_SOURCES src/api/JellyfinApiFacade.h src/api/JellyfinSession.h src/api/JellyfinSettingsBridge.cpp + src/api/JellyfinProvider.cpp + src/api/JellyfinProvider.h src/api/JellyfinSettingsBridge.h src/api/PlaybackBandwidthPolicy.cpp src/api/PlaybackBandwidthPolicy.h @@ -443,7 +445,6 @@ endif() set_source_files_properties( qml/primitives/InputKeys.qml - qml/shell/ProviderCapabilities.qml qml/theme/Metrics.qml qml/theme/Theme.qml qml/theme/Typography.qml @@ -561,7 +562,6 @@ qt_add_qml_module(jellyfin-native qml/shell/MediaInfoOverlay.qml qml/shell/OverlayDialog.qml qml/shell/PageReadiness.js - qml/shell/ProviderCapabilities.qml qml/shell/RoutePolicy.js qml/shell/RouteStack.qml qml/shell/TlsTrustDialog.qml diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index c91fe631..d3809e6e 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -440,8 +440,8 @@ FocusScope { if (row.type === "action") { if (row.key === "action/switchUser" && shell) shell.switchUser() - else if (row.key === "action/logout") - App.logout() + else if (row.key === "action/logout" && ProviderCapabilities.auth) + Session.logout() else if (row.key === "action/manageCertificates") certificateManagerVisible = true else if (row.key === "action/clearLatencyStatistics") diff --git a/qml/providers/jellyfin/LoginPage.qml b/qml/providers/jellyfin/LoginPage.qml index 6dbcce2d..d3e8594a 100644 --- a/qml/providers/jellyfin/LoginPage.qml +++ b/qml/providers/jellyfin/LoginPage.qml @@ -68,7 +68,7 @@ FocusScope { } function enterProfile(profileId) { - App.useProfile(profileId) + Session.activateProfile(profileId) } function openAddAccount() { diff --git a/qml/providers/jellyfin/LoginServerStep.qml b/qml/providers/jellyfin/LoginServerStep.qml index 4ec1116f..f2609515 100644 --- a/qml/providers/jellyfin/LoginServerStep.qml +++ b/qml/providers/jellyfin/LoginServerStep.qml @@ -82,7 +82,7 @@ FocusScope { function commitProbedServer() { if (probeOnline) { advanceWhenOnline = false - App.rememberServer(probeServerName, probeAddress) + Session.rememberServer(probeServerName, probeAddress) root.serverChosen(probeServerName, probeAddress) return } @@ -97,7 +97,7 @@ FocusScope { function chooseDiscoveredServer(index, name, address) { if (index < 0) return - App.chooseDiscoveredServer(index) + Session.chooseDiscoveredServer(index) root.serverChosen(name, address) } diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 9e38a5de..8f939cbb 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -657,7 +657,7 @@ KeyRouter { switchUserReturnArgs = Object.assign({}, routeArgs) switchUserReturnPending = false Router.reset("login") - App.switchUser() + root.session.switchUser() navigationTarget = routeStack InputKeys.focus(routeStack) } @@ -675,12 +675,12 @@ KeyRouter { } function cancelSwitchUser() { - if (!canCancelSwitchUser) + if (!canCancelSwitchUser || !root.session) return false if (switchUserReturnPending) return true switchUserReturnPending = true - App.useProfile(switchUserReturnProfileId) + root.session.activateProfile(switchUserReturnProfileId) return true } diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp new file mode 100644 index 00000000..8fd57ef9 --- /dev/null +++ b/src/api/JellyfinProvider.cpp @@ -0,0 +1,193 @@ +#include "JellyfinProvider.h" + +#include "../app/LibraryManagementController.h" +#include "../app/QuickConnectController.h" +#include "../app/RemoteControlController.h" +#include "../app/SessionController.h" +#include "../app/SyncPlayController.h" +#include "../cache/DatabaseManager.h" +#include "../common/AsyncTask.h" +#include "../common/MetaJson.h" +#include "../discovery/DiscoveryController.h" +#include "../models/DiscoveredServerModel.h" +#include "../platform/PlatformPlaybackRuntime.h" +#include "JellyfinApiFacade.h" +#include "JellyfinSettingsBridge.h" +#if defined(JELLYFIN_NATIVE_WEBOS) +#include "../platform/webos/WebOSDeviceName.h" +#endif + +#include +#include +#include +#include + +namespace JellyfinNative { + +JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObject *parent) + : Provider(parent) + , m_database(context.database) + , m_tlsTrust(context.tlsTrust) +{ + m_api = new JellyfinApiFacade(context.network, context.tlsTrust, this); + m_api->setDeviceIdentity({}, context.deviceName, context.appVersion); +#if defined(JELLYFIN_NATIVE_WEBOS) + // webOS reports the name the owner gave the set only on request, and + // again whenever they change it. Marshal it onto the API's thread, since + // the luna callback does not belong to us. + requestWebOSDeviceName([api = QPointer(m_api)](const QString& name) { + if (!api) + return; + QMetaObject::invokeMethod( + api, + [api, name]() { + if (api) + api->setDeviceName(name); + }, + Qt::QueuedConnection); + }); +#endif + configurePlatformPlaybackCapabilities( + [api = QPointer(m_api)](const QStringList& videoCodecs, bool restrictVideoCodecs) { + if (api) + api->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); + }, + *this); + + m_discovery = new DiscoveryController(context.tlsTrust, this); + m_discoveredServers = new DiscoveredServerModel(this); + m_session = new SessionController(context.database, m_api, this); + m_session->setDiscoveredServers(m_discoveredServers); + m_quickConnect = new QuickConnectController(m_api, this); + m_remoteControl = new RemoteControlController(m_api, this); + + connect(m_discovery, &DiscoveryController::serverDiscovered, this, [this](const DiscoveredServer& server) { + m_discoveredServers->upsertServer(server); + cacheDiscoveredServers(); + }); + connect(m_session, &SessionController::serverRemembered, this, &JellyfinProvider::cacheDiscoveredServers); + connect(m_api, &JellyfinApiFacade::authenticationExpired, m_session, &SessionController::expireSession); + connect(m_quickConnect, &QuickConnectController::authenticated, this, + [this](const AuthSession& session) { m_session->acceptSession(session); }); + + // The session drives the parts that only make sense while signed in. + connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { + if (m_syncPlay) + m_syncPlay->connectSocket(); + m_remoteControl->start(); + m_discovery->stop(); + }); + connect(m_session, &SessionController::profileActivationStarted, this, [this]() { + m_quickConnect->cancel(); + leaveSessionServices(); + }); + connect(m_session, &SessionController::switchUserRequested, this, [this]() { + m_quickConnect->cancel(); + m_discovery->start(); + }); + connect(m_session, &SessionController::logoutStarted, this, [this]() { + m_quickConnect->cancel(); + leaveSessionServices(); + }); + connect(m_session, &SessionController::loggedOut, this, [this]() { + leaveSessionServices(); + resumeServerDiscovery(); + }); +} + +JellyfinProvider::~JellyfinProvider() = default; + +QString JellyfinProvider::id() const +{ + return QStringLiteral("jellyfin"); +} + +QString JellyfinProvider::displayName() const +{ + return QStringLiteral("Jellyfin"); +} + +Provider::Capabilities JellyfinProvider::capabilities() const +{ + return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | LibraryManagement | SyncPlay + | RemoteControl | QuickConnect | PeerRelay; +} + +PlaybackSource *JellyfinProvider::playback() +{ + return m_api; +} + +void JellyfinProvider::attach(const CoreServices& core) +{ + Q_ASSERT(!m_syncPlay); + m_syncPlay = new SyncPlayController(m_api, core.player, core.playQueue, m_tlsTrust, core.owner); + m_management = new LibraryManagementController(m_api, core.browse, core.owner); + m_settingsBridge = new JellyfinSettingsBridge(core.settings, m_api, core.owner); + connect(m_syncPlay, &SyncPlayController::sessionsUpdated, m_remoteControl, &RemoteControlController::applySessions); + connect(m_syncPlay, &QObject::destroyed, this, [this]() { m_syncPlay = nullptr; }); + connect(m_management, &QObject::destroyed, this, [this]() { m_management = nullptr; }); + connect(m_settingsBridge, &QObject::destroyed, this, [this]() { m_settingsBridge = nullptr; }); +} + +void JellyfinProvider::registerQmlSingletons() +{ + Q_ASSERT(m_syncPlay && m_management); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Session", m_session); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "QuickConnect", m_quickConnect); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SyncPlay", m_syncPlay); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "RemoteControl", m_remoteControl); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Management", m_management); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Discovery", m_discovery); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "DiscoveredServers", m_discoveredServers); +} + +void JellyfinProvider::resumeServerDiscovery() +{ + Async::runScoped( + this, restoreDiscoveredServersAsync(), + [this]() { + if (!m_session->authenticated()) + m_discovery->start(); + }, + [this](const std::exception_ptr& error) { + qWarning() << "discovery: cached server load failed" << exceptionMessage(error); + if (!m_session->authenticated()) + m_discovery->start(); + }, + "discovery cache"); +} + +void JellyfinProvider::shutdown() +{ + m_quickConnect->cancel(); + m_api->cancelRequests(); + m_discovery->stop(); +} + +void JellyfinProvider::cacheDiscoveredServers() +{ + QJsonArray cache; + for (const auto& entry : m_discoveredServers->servers()) + cache.push_back(metaToJson(entry)); + m_database->saveDiscoveredServers(cache); +} + +QCoro::Task JellyfinProvider::restoreDiscoveredServersAsync() +{ + const auto servers = co_await m_database->loadDiscoveredServersAsync(); + std::vector parsed; + parsed.reserve(servers.size()); + for (const auto& value : servers) + parsed.push_back(metaFromJson(value.toObject())); + m_discoveredServers->setServers(parsed, false); +} + +void JellyfinProvider::leaveSessionServices() +{ + if (m_syncPlay) + m_syncPlay->disconnectSocket(); + m_remoteControl->stop(); +} + +} // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.h b/src/api/JellyfinProvider.h new file mode 100644 index 00000000..36b5c722 --- /dev/null +++ b/src/api/JellyfinProvider.h @@ -0,0 +1,131 @@ +#pragma once + +#include "../provider/Provider.h" + +#include +#include +#include + +class QNetworkAccessManager; + +namespace JellyfinNative { + +class BrowseSessionController; +class DatabaseManager; +class DiscoveredServerModel; +class DiscoveryController; +class JellyfinApiFacade; +class JellyfinSettingsBridge; +class LibraryManagementController; +class PlayQueueController; +class PlayerController; +class QuickConnectController; +class RemoteControlController; +class SessionController; +class SettingsController; +class SyncPlayController; +class TlsTrustController; + +struct JellyfinProviderContext { + QNetworkAccessManager *network = nullptr; + TlsTrustController *tlsTrust = nullptr; + DatabaseManager *database = nullptr; + QString deviceName; + QString appVersion; +}; + +// Everything Jellyfin-specific, composed in one place: the API facade, LAN +// discovery and its cached server list, the session and its sign-in helpers, +// SyncPlay, remote control and library management. The app hands it the +// core objects those parts sit on through attach(); the provider then keeps +// the session-driven lifecycle of its own parts (discovery scans while +// nobody is signed in, the SyncPlay socket and remote-control registration +// follow the session) so the app never has to. +class JellyfinProvider final : public Provider { + Q_OBJECT + +public: + explicit JellyfinProvider(const JellyfinProviderContext& context, QObject *parent = nullptr); + ~JellyfinProvider() override; + + QString id() const override; + QString displayName() const override; + Capabilities capabilities() const override; + PlaybackSource *playback() override; + void registerQmlSingletons() override; + + // The core objects built after the provider exists. The parts created + // here are parented to `owner`, which must not outlive `player`: they + // hold it by pointer. + struct CoreServices { + QObject *owner = nullptr; + PlayerController *player = nullptr; + PlayQueueController *playQueue = nullptr; + SettingsController *settings = nullptr; + BrowseSessionController *browse = nullptr; + }; + void attach(const CoreServices& core); + + // Shows the servers seen last time and scans for more while nobody is + // signed in. The app calls it once its stored session is known; the + // provider calls it itself after a sign-out. + void resumeServerDiscovery(); + void shutdown(); + + JellyfinApiFacade *api() const + { + return m_api; + } + DiscoveryController *discovery() const + { + return m_discovery; + } + DiscoveredServerModel *discoveredServers() const + { + return m_discoveredServers; + } + SessionController *session() const + { + return m_session; + } + QuickConnectController *quickConnect() const + { + return m_quickConnect; + } + RemoteControlController *remoteControl() const + { + return m_remoteControl; + } + // Null until attach(). + SyncPlayController *syncPlay() const + { + return m_syncPlay; + } + LibraryManagementController *management() const + { + return m_management; + } + JellyfinSettingsBridge *settingsBridge() const + { + return m_settingsBridge; + } + +private: + void cacheDiscoveredServers(); + QCoro::Task restoreDiscoveredServersAsync(); + void leaveSessionServices(); + + DatabaseManager *m_database = nullptr; + TlsTrustController *m_tlsTrust = nullptr; + JellyfinApiFacade *m_api = nullptr; + DiscoveryController *m_discovery = nullptr; + DiscoveredServerModel *m_discoveredServers = nullptr; + SessionController *m_session = nullptr; + QuickConnectController *m_quickConnect = nullptr; + RemoteControlController *m_remoteControl = nullptr; + SyncPlayController *m_syncPlay = nullptr; + LibraryManagementController *m_management = nullptr; + JellyfinSettingsBridge *m_settingsBridge = nullptr; +}; + +} // namespace JellyfinNative diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 73c8eadc..d5502f17 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -3,11 +3,12 @@ #include "ArtworkService.h" #include "../api/JellyfinApiFacade.h" +#include "../api/JellyfinProvider.h" #include "../api/JellyfinSettingsBridge.h" #include "../common/AsyncTask.h" -#include "../common/MetaJson.h" #include "../common/SeriesAudioSelection.h" #include "../diagnostics/Diagnostics.h" +#include "../discovery/DiscoveryController.h" #include "../platform/PlatformPaths.h" #include "../player/PlayQueueController.h" #include "../player/PlaybackFailurePolicy.h" @@ -93,29 +94,33 @@ namespace { } -AppController::AppController(DatabaseManager *database, DiscoveryController *discovery, JellyfinApiFacade *api, - ArtworkService *artwork, PlayerController *player, TlsTrustController *tlsTrust, QObject *parent) +AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyfin, ArtworkService *artwork, + PlayerController *player, QObject *parent) : QObject(parent) , m_database(database) - , m_discovery(discovery) - , m_api(api) + , m_jellyfin(jellyfin) + , m_api(jellyfin->api()) , m_artwork(artwork) , m_player(player) + , m_remoteControl(jellyfin->remoteControl()) + , m_quickConnect(jellyfin->quickConnect()) + , m_session(jellyfin->session()) { + JellyfinApiFacade *api = m_api; m_playQueue = new PlayQueueController(api, this); - m_syncPlay = new SyncPlayController(api, player, m_playQueue, tlsTrust, this); - m_remoteControl = new RemoteControlController(api, this); - m_quickConnect = new QuickConnectController(api, this); m_settings = new SettingsController(database, player, artwork, this); - auto *settingsBridge = new JellyfinSettingsBridge(m_settings, api, this); - m_session = new SessionController(database, api, this); m_prefetch = new LibraryPrefetchController(api, artwork, this); m_browse = new BrowseSessionController(m_prefetch, this); - m_management = new LibraryManagementController(api, m_browse, this); m_home = new HomeModelController(database, api, m_prefetch, this); m_content = new ContentModelController(api, m_prefetch, this); m_search = new SearchController(api, m_prefetch, this); m_itemState = new UserItemStateController(api, m_browse, m_home, m_content, m_search, this); + // The provider's session-bound parts sit on the queue, settings and + // browse state built above, and die with this object as they always did. + m_jellyfin->attach({ this, player, m_playQueue, m_settings, m_browse }); + m_syncPlay = m_jellyfin->syncPlay(); + m_management = m_jellyfin->management(); + JellyfinSettingsBridge *settingsBridge = m_jellyfin->settingsBridge(); if (m_artwork) { m_artwork->setServerUrl(m_session->serverUrl()); connect(m_session, &SessionController::serverUrlChanged, m_artwork, @@ -124,13 +129,11 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis connect(m_playQueue, &PlayQueueController::successorPlaybackReady, this, [this]() { playQueueCurrent(false); }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); - connect(m_api, &JellyfinApiFacade::authenticationExpired, m_session, &SessionController::expireSession); connect(m_syncPlay, &SyncPlayController::errorText, this, &AppController::showToast); connect(m_remoteControl, &RemoteControlController::errorText, this, &AppController::showToast); connect(m_remoteControl, &RemoteControlController::feedbackText, this, &AppController::showToast); if (SpoolLink *link = m_remoteControl->link()) connect(link, &SpoolLink::messageReceived, this, &AppController::handleSpoolMessage); - connect(m_syncPlay, &SyncPlayController::sessionsUpdated, m_remoteControl, &RemoteControlController::applySessions); connect(m_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); connect(m_syncPlay, &SyncPlayController::remotePlayCommand, this, &AppController::handleRemotePlay); connect(m_syncPlay, &SyncPlayController::remotePlaystateCommand, this, &AppController::handleRemotePlaystate); @@ -176,10 +179,16 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis m_hasDefaultProfile = hasProfiles; emit defaultProfileChanged(); }); + connect(m_session, &SessionController::profileActivationStarted, this, [this]() { setErrorText({}); }); + connect(m_session, &SessionController::switchUserRequested, this, [this]() { + setBusy(false); + setErrorText({}); + }); + connect(m_session, &SessionController::logoutStarted, this, [this]() { + if (m_player->visible()) + m_player->stopWithReason(QStringLiteral("logout")); + }); connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { - m_syncPlay->connectSocket(); - m_remoteControl->start(); - m_discovery->stop(); if (m_artwork) m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); if (!m_hasDefaultProfile) { @@ -207,12 +216,6 @@ AppController::AppController(DatabaseManager *database, DiscoveryController *dis }); }); connect(m_session, &SessionController::loggedOut, this, &AppController::resetApplicationState); - connect(m_quickConnect, &QuickConnectController::authenticated, this, - [this](const AuthSession& session) { m_session->acceptSession(session); }); - connect(m_discovery, &DiscoveryController::serverDiscovered, this, [this](const DiscoveredServer& server) { - m_discoveredServers.upsertServer(server); - cacheDiscoveredServers(); - }); connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); // The device has just shown it cannot sustain the picture it was asked @@ -385,85 +388,12 @@ QCoro::Task AppController::initializeAsync() m_initialized = true; emit initializedChanged(); - if (!m_session->authenticated()) { - Async::runScoped( - this, applyDiscoveredServersCacheAsync(), - [this]() { - if (!m_session->authenticated()) - m_discovery->start(); - }, - [this](const std::exception_ptr& error) { - qWarning() << "discovery: cached server load failed" << exceptionMessage(error); - if (!m_session->authenticated()) - m_discovery->start(); - }, - "startup discovery cache"); - } -} - -void AppController::chooseDiscoveredServer(int index) -{ - const auto server = m_discoveredServers.serverAt(index); - if (server.address.isEmpty()) - return; - m_session->setServerName(server.name); - m_session->setServerUrl(server.address); -} - -void AppController::rememberServer(const QString& name, const QString& address) -{ - const QString normalizedAddress = address.trimmed(); - if (normalizedAddress.isEmpty()) - return; - - m_discoveredServers.upsertServer({ normalizedAddress, - name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(), normalizedAddress }); - cacheDiscoveredServers(); - m_session->setServerName(name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed()); - m_session->setServerUrl(normalizedAddress); -} - -void AppController::cacheDiscoveredServers() -{ - QJsonArray cache; - for (const auto& entry : m_discoveredServers.servers()) - cache.push_back(metaToJson(entry)); - m_database->saveDiscoveredServers(cache); -} - -void AppController::useProfile(const QString& profileId) -{ - setErrorText({}); - m_quickConnect->cancel(); - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); - m_session->activateProfile(profileId); -} - -void AppController::switchUser() -{ - qInfo() << "app: switch user requested"; - m_quickConnect->cancel(); - setBusy(false); - setErrorText({}); - m_discovery->start(); -} - -void AppController::logout() -{ - qInfo() << "app: logout requested"; - m_quickConnect->cancel(); - if (m_player->visible()) - m_player->stopWithReason(QStringLiteral("logout")); - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); - m_session->logout(); + if (!m_session->authenticated()) + m_jellyfin->resumeServerDiscovery(); } void AppController::resetApplicationState() { - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); m_remotePlaybackRequestGeneration.invalidate(); m_remotePlaybackFingerprint.clear(); m_settings->clearRemote(); @@ -485,13 +415,6 @@ void AppController::resetApplicationState() m_browse->reset(); setBusy(false); setErrorText({}); - Async::runScoped( - this, applyDiscoveredServersCacheAsync(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "discovery: cached server load failed" << exceptionMessage(error); - }, - "discovery cache"); - m_discovery->start(); } void AppController::goHome() @@ -502,7 +425,7 @@ void AppController::goHome() qInfo() << "app: go home viewKind=" << m_browse->viewKind(); m_libraryLoadGeneration.invalidate(); setBusy(false); - m_discovery->stop(); + m_jellyfin->discovery()->stop(); refreshHomeRows(); } @@ -1476,10 +1399,8 @@ void AppController::shutdown() m_shuttingDown = true; qInfo() << "app: shutdown requested"; m_player->prepareForShutdown(); - m_quickConnect->cancel(); m_prefetch->stop(); - m_api->cancelRequests(); - m_discovery->stop(); + m_jellyfin->shutdown(); m_player->teardownMpv(); } @@ -1544,16 +1465,6 @@ void AppController::showToast(const QString& message) emit toastMessage(message); } -QCoro::Task AppController::applyDiscoveredServersCacheAsync() -{ - const auto servers = co_await m_database->loadDiscoveredServersAsync(); - std::vector parsed; - parsed.reserve(servers.size()); - for (const auto& value : servers) - parsed.push_back(metaFromJson(value.toObject())); - m_discoveredServers.setServers(parsed, false); -} - void AppController::loadLibraries() { m_prefetch->stop(); @@ -1562,7 +1473,7 @@ void AppController::loadLibraries() [this](const std::vector& libraries) { m_libraries.setLibraries(libraries); setBusy(false); - m_discovery->stop(); + m_jellyfin->discovery()->stop(); refreshHomeRows(); }, [this](const std::exception_ptr& error) { diff --git a/src/app/AppController.h b/src/app/AppController.h index 240093d7..c76b0340 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -2,9 +2,7 @@ #include "../cache/DatabaseManager.h" #include "../common/RequestGeneration.h" -#include "../discovery/DiscoveryController.h" #include "../media/MediaTypes.h" -#include "../models/DiscoveredServerModel.h" #include "../models/LibraryListModel.h" #include "../models/MovieGridModel.h" #include "../player/PlayQueueController.h" @@ -13,10 +11,8 @@ #include "ContentModelController.h" #include "HomeModelController.h" #include "LibraryManagementController.h" -#include "QuickConnectController.h" #include "RemoteControlController.h" #include "SearchController.h" -#include "SessionController.h" #include "SettingsController.h" #include "SpoolRemoteProtocol.h" #include "SyncPlayController.h" @@ -35,7 +31,10 @@ namespace JellyfinNative { class ArtworkService; class JellyfinApiFacade; +class JellyfinProvider; class LibraryPrefetchController; +class QuickConnectController; +class SessionController; class UserItemStateController; class TlsTrustController; class AppController final : public QObject { @@ -51,13 +50,9 @@ class AppController final : public QObject { Q_PROPERTY(bool initialized READ initialized NOTIFY initializedChanged) public: - AppController(DatabaseManager *database, DiscoveryController *discovery, JellyfinApiFacade *api, - ArtworkService *artwork, PlayerController *player, TlsTrustController *tlsTrust, QObject *parent = nullptr); + AppController(DatabaseManager *database, JellyfinProvider *jellyfin, ArtworkService *artwork, + PlayerController *player, QObject *parent = nullptr); - DiscoveredServerModel *discoveredServers() - { - return &m_discoveredServers; - } ArtworkService *artwork() const { return m_artwork; @@ -94,14 +89,6 @@ class AppController final : public QObject { { return m_playQueue; } - SyncPlayController *syncPlay() - { - return m_syncPlay; - } - RemoteControlController *remoteControl() - { - return m_remoteControl; - } SettingsController *settings() { return m_settings; @@ -110,26 +97,9 @@ class AppController final : public QObject { { return m_initialized; } - SessionController *session() - { - return m_session; - } - QuickConnectController *quickConnect() - { - return m_quickConnect; - } - LibraryManagementController *management() - { - return m_management; - } Q_INVOKABLE void initialize(); void shutdown(); - Q_INVOKABLE void chooseDiscoveredServer(int index); - Q_INVOKABLE void rememberServer(const QString& name, const QString& address); - Q_INVOKABLE void useProfile(const QString& profileId); - Q_INVOKABLE void switchUser(); - Q_INVOKABLE void logout(); Q_INVOKABLE void goHome(); Q_INVOKABLE void openLibrary(int index); Q_INVOKABLE bool openLibraryById(const QString& libraryId); @@ -196,8 +166,6 @@ class AppController final : public QObject { void showToast(const QString& message); QCoro::Task initializeAsync(); void resetApplicationState(); - QCoro::Task applyDiscoveredServersCacheAsync(); - void cacheDiscoveredServers(); void loadLibraries(); void loadMoreCurrentItems(); void refreshHomeRows() @@ -229,7 +197,7 @@ class AppController final : public QObject { void handlePlaybackStopped(const QString& itemId, qint64 positionTicks, bool completed); DatabaseManager *m_database = nullptr; - DiscoveryController *m_discovery = nullptr; + JellyfinProvider *m_jellyfin = nullptr; JellyfinApiFacade *m_api = nullptr; ArtworkService *m_artwork = nullptr; PlayerController *m_player = nullptr; @@ -249,7 +217,6 @@ class AppController final : public QObject { quint64 m_albumQueueGeneration = 0; bool m_episodeQueuePending = false; LibraryManagementController *m_management = nullptr; - DiscoveredServerModel m_discoveredServers; LibraryListModel m_libraries; MovieItem m_activePlaybackItem; QList m_activePlaybackStreams; diff --git a/src/app/SessionController.cpp b/src/app/SessionController.cpp index 0c3760bd..729f6d13 100644 --- a/src/app/SessionController.cpp +++ b/src/app/SessionController.cpp @@ -3,6 +3,7 @@ #include "../api/JellyfinApiFacade.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" +#include "../models/DiscoveredServerModel.h" #include #include @@ -113,6 +114,43 @@ bool SessionController::initializeFromStorage(QVariantMap values, std::vectorserverAt(index); + if (server.address.isEmpty()) + return; + setServerName(server.name); + setServerUrl(server.address); +} + +void SessionController::rememberServer(const QString& name, const QString& address) +{ + const QString normalizedAddress = address.trimmed(); + if (normalizedAddress.isEmpty()) + return; + + const QString serverName = name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(); + if (m_discoveredServers) { + m_discoveredServers->upsertServer({ normalizedAddress, serverName, normalizedAddress }); + emit serverRemembered(); + } + setServerName(serverName); + setServerUrl(normalizedAddress); +} + +void SessionController::switchUser() +{ + qInfo() << "session: switch user requested"; + emit switchUserRequested(); +} + void SessionController::setServerUrl(const QString& serverUrl) { QString normalized = canonicalServerUrl(serverUrl); @@ -177,6 +215,7 @@ void SessionController::login() void SessionController::activateProfile(const QString& profileId) { + emit profileActivationStarted(); emit busyChanged(true, QStringLiteral("Opening profile…")); Async::runScoped( this, activateProfileAsync(profileId), []() {}, @@ -325,6 +364,8 @@ void SessionController::acceptSession(const AuthSession& session) void SessionController::logout() { + qInfo() << "session: logout requested"; + emit logoutStarted(); clearPassword(); if (!m_activeProfileId.isEmpty()) { const auto it = std::find_if(m_profiles.begin(), m_profiles.end(), diff --git a/src/app/SessionController.h b/src/app/SessionController.h index b7bdaea8..9c99d7cf 100644 --- a/src/app/SessionController.h +++ b/src/app/SessionController.h @@ -16,6 +16,7 @@ namespace JellyfinNative { class DatabaseManager; +class DiscoveredServerModel; class JellyfinApiFacade; class SessionController final : public QObject { @@ -46,6 +47,13 @@ class SessionController final : public QObject { QCoro::Task initializeAsync(); static QStringList localStorageKeys(); bool initializeFromStorage(QVariantMap values, std::vector profiles); + // The list the server step offers; the provider owns it and caches it. + void setDiscoveredServers(DiscoveredServerModel *servers); + Q_INVOKABLE void chooseDiscoveredServer(int index); + Q_INVOKABLE void rememberServer(const QString& name, const QString& address); + // Back to the profile picker while the session is kept, so a cancel + // returns to it; the provider scans for servers meanwhile. + Q_INVOKABLE void switchUser(); Q_INVOKABLE void setServerUrl(const QString& serverUrl); Q_INVOKABLE void setServerName(const QString& serverName); Q_INVOKABLE void setUsername(const QString& username); @@ -75,6 +83,14 @@ class SessionController final : public QObject { void accountProfilesChanged(); void profileSignInRequiredChanged(); void loggedOut(); + // A remembered or chosen server was added to the list. + void serverRemembered(); + // Lifecycle points the provider hangs its own parts on: sign-in code + // polling stops, the SyncPlay socket and remote-control registration are + // dropped before the session changes hands. + void profileActivationStarted(); + void switchUserRequested(); + void logoutStarted(); private: QCoro::Task activateProfileAsync(const QString& profileId); @@ -88,6 +104,7 @@ class SessionController final : public QObject { DatabaseManager *m_database = nullptr; JellyfinApiFacade *m_api = nullptr; + DiscoveredServerModel *m_discoveredServers = nullptr; QString m_serverUrl; QString m_serverName; QString m_username; diff --git a/src/main.cpp b/src/main.cpp index fda4e8d2..2c6bbfda 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include "api/JellyfinApiFacade.h" +#include "api/JellyfinProvider.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" #include "app/CpuTopology.h" @@ -6,7 +7,6 @@ #include "app/LocalizationManager.h" #include "app/MemoryBudget.h" #include "app/RouterController.h" -#include "app/SessionController.h" #include "app/UserItemStateController.h" #include "cache/DatabaseManager.h" #include "common/LogRotation.h" @@ -15,7 +15,6 @@ #include "diagnostics/InputLatencyMonitor.h" #include "diagnostics/RenderBenchmark.h" #include "diagnostics/SystemPerformanceMonitor.h" -#include "discovery/DiscoveryController.h" #include "media/MediaTypes.h" #include "platform/NativeAppWindow.h" #include "platform/PlatformApplicationServices.h" @@ -27,6 +26,7 @@ #include "platform/ScreenSaverInhibitor.h" #include "player/MpvVideoItem.h" #include "player/PlayerController.h" +#include "provider/ProviderRegistry.h" #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) #include "platform/UpdateController.h" #endif @@ -598,25 +598,13 @@ int main(int argc, char **argv) } JellyfinNative::TlsTrustController tlsTrust; - auto discovery = std::make_unique(&tlsTrust); - auto api = std::make_unique(networkAccessManager, &tlsTrust); - api->setDeviceIdentity({}, capabilities.deviceName, QString::fromLatin1(kAppVersion)); -#if defined(JELLYFIN_NATIVE_WEBOS) - // webOS reports the name the owner gave the set only on request, and - // again whenever they change it. Marshal it onto the API's thread, since - // the luna callback does not belong to us. - JellyfinNative::requestWebOSDeviceName([apiPointer = api.get()](const QString& name) { - QMetaObject::invokeMethod( - apiPointer, [apiPointer, name]() { apiPointer->setDeviceName(name); }, Qt::QueuedConnection); - }); -#endif - JellyfinNative::configurePlatformPlaybackCapabilities( - [guardedApi = QPointer(api.get())]( - const QStringList& videoCodecs, bool restrictVideoCodecs) { - if (guardedApi) - guardedApi->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); - }, - app); + // One provider, chosen here until the first-run picker exists. It + // outlives the player and the app controller, which hold its parts by + // pointer, so it is declared before them. + auto jellyfin = std::make_unique(JellyfinNative::JellyfinProviderContext { + networkAccessManager, &tlsTrust, &database, capabilities.deviceName, QString::fromLatin1(kAppVersion) }); + JellyfinNative::ProviderRegistry providers; + providers.setActive(jellyfin.get()); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, @@ -628,7 +616,7 @@ int main(int argc, char **argv) artworkService->setUiWidth(window.width()); auto player = std::make_unique( - &window, api.get(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); + &window, jellyfin->playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); JellyfinNative::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { @@ -649,7 +637,7 @@ int main(int argc, char **argv) updateApplicationIcon(); #endif auto controller = std::make_unique( - &database, discovery.get(), api.get(), artworkService.get(), player.get(), &tlsTrust); + &database, jellyfin.get(), artworkService.get(), player.get()); #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) // Settings own the update preference; platform installers own installation. QObject::connect(controller->settings(), &JellyfinNative::SettingsController::automaticUpdatesChanged, @@ -729,10 +717,10 @@ int main(int argc, char **argv) [](QQuickView::Status status) { logLine("view status changed: %d", static_cast(status)); }); auto localization = std::make_unique(); localization->attachToEngine(window.engine()); - if (api) { + if (JellyfinNative::JellyfinApiFacade *api = jellyfin->api()) { api->setAcceptLanguage(localization->bcp47Locale()); - QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, api.get(), - [api = api.get(), loc = localization.get()]() { api->setAcceptLanguage(loc->bcp47Locale()); }); + QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, api, + [api, loc = localization.get()]() { api->setAcceptLanguage(loc->bcp47Locale()); }); } auto router = std::make_unique(); JellyfinNative::PlatformApplicationServices platformServices(app, window, *controller, *router); @@ -766,6 +754,8 @@ int main(int argc, char **argv) platformInfo->insert(QStringLiteral("splashCoreAspect"), static_cast(SPOOL_SPLASH_CORE_ASPECT)); platformInfo->insert(QStringLiteral("splashImageUrl"), splashImageUrl(appRootPath)); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "App", controller.get()); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ProviderCapabilities", providers.capabilities()); + jellyfin->registerQmlSingletons(); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Art", artworkService.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Browse", controller->browse()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Home", controller->home()); @@ -773,17 +763,10 @@ int main(int argc, char **argv) qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ItemState", controller->itemState()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Search", controller->search()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Libraries", controller->libraries()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "DiscoveredServers", controller->discoveredServers()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Discovery", discovery.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "TlsTrust", &tlsTrust); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Session", controller->session()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "QuickConnect", controller->quickConnect()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Settings", controller->settings()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Player", controller->player()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "PlayQueue", controller->playQueue()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SyncPlay", controller->syncPlay()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "RemoteControl", controller->remoteControl()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Management", controller->management()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Router", router.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "NativeWindow", &window); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "InputLatency", &inputLatencyMonitor); diff --git a/qml/shell/ProviderCapabilities.qml b/tests/qml/provider-capabilities/ProviderCapabilities.qml similarity index 87% rename from qml/shell/ProviderCapabilities.qml rename to tests/qml/provider-capabilities/ProviderCapabilities.qml index 9aa1b923..b3734e96 100644 --- a/qml/shell/ProviderCapabilities.qml +++ b/tests/qml/provider-capabilities/ProviderCapabilities.qml @@ -6,9 +6,9 @@ import QtQuick // so a source that has no SyncPlay or remote control simply has no such // buttons, and nothing else changes. // -// Placeholder: every flag is true until the Phase 3 C++ provider registry -// replaces this file with a qmlRegisterSingletonInstance under the same name -// and the same property names. The contract is the names. +// Test stand-in for the ProviderCapabilities singleton main.cpp registers +// from the provider registry (src/provider/ProviderRegistry.h). The property +// names are the contract; ProviderRegistryTest pins the C++ side to them. QtObject { // The provider supplies its own login flow and session (Session). readonly property bool auth: true diff --git a/tests/qml/provider-capabilities/qmldir b/tests/qml/provider-capabilities/qmldir index 726bbc5e..0091baae 100644 --- a/tests/qml/provider-capabilities/qmldir +++ b/tests/qml/provider-capabilities/qmldir @@ -1 +1 @@ -singleton ProviderCapabilities 1.0 ../../../qml/shell/ProviderCapabilities.qml +singleton ProviderCapabilities 1.0 ProviderCapabilities.qml From 6e68e2417ca50e42a0e13e467acb3d3b14b188a6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:13:45 +1000 Subject: [PATCH 010/146] refactor(settings): drop the account rows when the provider has no auth The signed-in-as, switch-profile and sign-out rows are declared with withAuth() and left out of the schema model when the active provider's capabilities lack auth; SettingsController reads that from the ProviderCapabilities instance main.cpp hands it. --- src/app/SettingsController.cpp | 14 ++++++++++++-- src/app/SettingsController.h | 7 +++++++ src/app/SettingsSchema.cpp | 18 ++++++++++++++---- src/app/SettingsSchema.h | 8 +++++++- src/main.cpp | 1 + tests/app/SettingsSchemaTest.cpp | 25 +++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index a619ba18..0a7f94c2 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -5,6 +5,7 @@ #include "../platform/PlatformSettingsPolicy.h" #include "../player/PlayerController.h" #include "../player/RenderTargetProfile.h" +#include "../provider/ProviderRegistry.h" #include "ArtworkService.h" #include "SettingsSchema.h" @@ -66,8 +67,17 @@ QStringList SettingsController::systemSubtitleFonts() const QVariantList SettingsController::settingsSchema() const { - static const QVariantList schema = settingSchemaModel(); - return schema; + // Cached for the same reason as the fonts above, and because the row set + // is fixed once the provider is chosen. + if (m_schema.isEmpty()) + m_schema = settingSchemaModel(!m_capabilities || m_capabilities->auth()); + return m_schema; +} + +void SettingsController::setProviderCapabilities(const ProviderCapabilities *capabilities) +{ + m_capabilities = capabilities; + m_schema.clear(); } QVariantMap SettingsController::values() const diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index a69fd92c..09db9a1c 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -15,6 +15,7 @@ namespace JellyfinNative { class DatabaseManager; +class ProviderCapabilities; class ArtworkService; class PlayerController; struct SettingSpec; @@ -78,6 +79,10 @@ class SettingsController final : public QObject { QStringList subtitleLanguageOptions() const; QStringList systemSubtitleFonts() const; QVariantList settingsSchema() const; + // The active provider's capabilities decide which rows exist (the + // account rows need auth). Set before QML first reads the schema; the + // schema is built once, on that first read. + void setProviderCapabilities(const ProviderCapabilities *capabilities); QVariantMap values() const; Q_INVOKABLE QVariant value(const QString& key) const; bool playerControlTooltipsEnabled() const @@ -162,6 +167,8 @@ class SettingsController final : public QObject { DatabaseManager *m_database = nullptr; PlayerController *m_player = nullptr; ArtworkService *m_artwork = nullptr; + const ProviderCapabilities *m_capabilities = nullptr; + mutable QVariantList m_schema; QString m_artworkFormat = QStringLiteral("auto"); int m_artworkWebpQuality = 75; int m_artworkJpegQuality = 82; diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index fcc11675..44620489 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -281,6 +281,13 @@ SettingSpec SettingSpec::onAndroid() const return spec; } +SettingSpec SettingSpec::withAuth() const +{ + SettingSpec spec = *this; + spec.requiresAuth = true; + return spec; +} + SettingSpec SettingSpec::whenSetTo(const char *otherKey, const char *otherValue) const { SettingSpec spec = *this; @@ -547,9 +554,10 @@ const QVector& settingSpecs() .onAndroid(), #endif - pageSpec("session/account", "Account", "Signed in as", "", SettingType::ReadOnly), - pageSpec("action/switchUser", "Account", "Switch profile", "", SettingType::Action), - pageSpec("action/logout", "Account", "Sign out", "Keeps this profile on the device", SettingType::Action), + pageSpec("session/account", "Account", "Signed in as", "", SettingType::ReadOnly).withAuth(), + pageSpec("action/switchUser", "Account", "Switch profile", "", SettingType::Action).withAuth(), + pageSpec("action/logout", "Account", "Sign out", "Keeps this profile on the device", SettingType::Action) + .withAuth(), pageSpec("action/manageCertificates", "Account", "Remembered certificates", "Server certificates you chose to trust", SettingType::Action), @@ -643,11 +651,13 @@ QString serializedSettingValue(const SettingSpec& spec, const QVariant& value) return normalized.toString(); } -QVariantList settingSchemaModel() +QVariantList settingSchemaModel(bool auth) { QVariantList model; model.reserve(settingSpecs().size()); for (const SettingSpec& spec : settingSpecs()) { + if (spec.requiresAuth && !auth) + continue; QVariantMap row { { QStringLiteral("key"), QLatin1String(spec.key) }, { QStringLiteral("source"), spec.persisted ? QStringLiteral("settings") : QStringLiteral("page") }, { QStringLiteral("group"), QLatin1String(spec.group) }, diff --git a/src/app/SettingsSchema.h b/src/app/SettingsSchema.h index b3f59521..9a54bcde 100644 --- a/src/app/SettingsSchema.h +++ b/src/app/SettingsSchema.h @@ -133,6 +133,9 @@ struct SettingSpec { const char *dependsOnValue = ""; bool persisted = true; bool requiresHdrPlayback = false; + // Only meaningful with a source that has accounts: absent from the + // schema model when the active provider has no auth capability. + bool requiresAuth = false; // Declaration modifiers. Each returns a copy so specs read as one // expression: slider(...).advanced().onDesktop(). @@ -143,6 +146,7 @@ struct SettingSpec { SettingSpec onAndroid() const; SettingSpec whenSetTo(const char *otherKey, const char *otherValue) const; SettingSpec duringHdrPlayback() const; + SettingSpec withAuth() const; }; const QVector& settingSpecs(); @@ -150,6 +154,8 @@ const SettingSpec *findSettingSpec(const QString& key); QVariant settingDefaultValue(const SettingSpec& spec); QVariant normalizedSettingValue(const SettingSpec& spec, const QVariant& value); QString serializedSettingValue(const SettingSpec& spec, const QVariant& value); -QVariantList settingSchemaModel(); +// `auth` is the active provider's auth capability; rows that only make +// sense with an account are left out without it. +QVariantList settingSchemaModel(bool auth = true); } // namespace JellyfinNative diff --git a/src/main.cpp b/src/main.cpp index 2c6bbfda..58bbca9f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -638,6 +638,7 @@ int main(int argc, char **argv) #endif auto controller = std::make_unique( &database, jellyfin.get(), artworkService.get(), player.get()); + controller->settings()->setProviderCapabilities(providers.capabilities()); #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) // Settings own the update preference; platform installers own installation. QObject::connect(controller->settings(), &JellyfinNative::SettingsController::automaticUpdatesChanged, diff --git a/tests/app/SettingsSchemaTest.cpp b/tests/app/SettingsSchemaTest.cpp index cdab0c27..27159df3 100644 --- a/tests/app/SettingsSchemaTest.cpp +++ b/tests/app/SettingsSchemaTest.cpp @@ -407,6 +407,30 @@ void pageRowsShareTheSchemaContract() QStringLiteral("diagnostics controls should be hidden below Expert detail")); } +void accountRowsFollowTheAuthCapability() +{ + const QStringList accountKeys { QStringLiteral("session/account"), QStringLiteral("action/switchUser"), + QStringLiteral("action/logout") }; + for (const QString& key : accountKeys) + require(requiredSpec(key).requiresAuth, QStringLiteral("account row %1 must require auth").arg(key)); + require(!requiredSpec(QStringLiteral("action/manageCertificates")).requiresAuth, + QStringLiteral("certificate management is core and must not require auth")); + + QSet withAuth; + for (const QVariant& item : settingSchemaModel(true)) + withAuth.insert(item.toMap().value(QStringLiteral("key")).toString()); + QSet withoutAuth; + for (const QVariant& item : settingSchemaModel(false)) + withoutAuth.insert(item.toMap().value(QStringLiteral("key")).toString()); + for (const QString& key : accountKeys) { + require(withAuth.contains(key), QStringLiteral("account row %1 exists with auth").arg(key)); + require(!withoutAuth.contains(key), QStringLiteral("account row %1 must not exist without auth").arg(key)); + } + const QSet dropped = withAuth - withoutAuth; + require(dropped == stringSet(accountKeys), + QStringLiteral("only the account rows depend on auth; dropped: %1").arg(dropped.values().join(", "))); +} + void subtitleChoicesExplainTheirBehavior() { const SettingSpec& subtitleMode = requiredSpec(QStringLiteral("subtitles/mode")); @@ -565,6 +589,7 @@ JELLYFIN_TEST_MAIN("settings-schema") schemaModelExposesEverySpecOnce(); groupsAreDeclaredContiguously(); pageRowsShareTheSchemaContract(); + accountRowsFollowTheAuthCapability(); subtitleChoicesExplainTheirBehavior(); systemLanguageLabelNamesResolvedLanguage(); buttonChoicesAndLabelsExposePlayerActions(); From a535f74178c92bcf693c98caecdda362a82983df Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:15:48 +1000 Subject: [PATCH 011/146] refactor(platform): hand the platform layer hooks instead of AppController PlatformApplicationServices takes an ApplicationHooks object (player, settings, a memory-pressure callback and the two forwarded signals) and RenderBenchmark a RenderBenchmarkHooks struct, so nothing under src/platform or src/diagnostics names the composition root. main.cpp fills both in. --- src/diagnostics/RenderBenchmark.cpp | 40 ++++++++---------- src/diagnostics/RenderBenchmark.h | 26 +++++++++--- src/main.cpp | 30 +++++++++++++- src/platform/PlatformApplicationServices.h | 30 +++++++++++++- .../android/AndroidApplicationServices.cpp | 19 ++++----- .../desktop/DesktopApplicationServices.cpp | 2 +- .../webos/WebOSApplicationServices.cpp | 41 +++++++++++-------- 7 files changed, 129 insertions(+), 59 deletions(-) diff --git a/src/diagnostics/RenderBenchmark.cpp b/src/diagnostics/RenderBenchmark.cpp index 54ae5bca..7d47c070 100644 --- a/src/diagnostics/RenderBenchmark.cpp +++ b/src/diagnostics/RenderBenchmark.cpp @@ -1,8 +1,7 @@ #include "RenderBenchmark.h" -#include "../app/AppController.h" -#include "../app/ArtworkService.h" #include "../app/RouterController.h" +#include "../models/LibraryListModel.h" #include "InputLatencyMonitor.h" #include @@ -19,6 +18,8 @@ #include #include +#include + #include #include @@ -40,13 +41,13 @@ namespace { } // namespace -RenderBenchmark *RenderBenchmark::createIfRequested( - AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) +RenderBenchmark *RenderBenchmark::createIfRequested(RenderBenchmarkHooks hooks, RouterController *router, + InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) { const QString script = qEnvironmentVariable("SPOOL_BENCH").trimmed(); if (script.isEmpty()) return nullptr; - auto *benchmark = new RenderBenchmark(app, router, latency, window, parent); + auto *benchmark = new RenderBenchmark(std::move(hooks), router, latency, window, parent); // "library" walks one library's grid instead of the route set: open it, // then page down through it waiting for the artwork on each screen. That // is the part of this application that costs the most on a television and @@ -64,10 +65,10 @@ RenderBenchmark *RenderBenchmark::createIfRequested( return benchmark; } -RenderBenchmark::RenderBenchmark( - AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent) +RenderBenchmark::RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, + QQuickWindow *window, QObject *parent) : QObject(parent) - , m_app(app) + , m_hooks(std::move(hooks)) , m_router(router) , m_latency(latency) , m_window(window) @@ -112,8 +113,7 @@ RenderBenchmark::RenderBenchmark( connect(m_scrollPoll, &QTimer::timeout, this, [this] { if (!m_scrolling) return; - auto *artwork = m_app ? m_app->artwork() : nullptr; - const int outstanding = artwork ? artwork->outstandingRequests() : 0; + const int outstanding = m_hooks.outstandingArtworkRequests ? m_hooks.outstandingArtworkRequests() : 0; if (outstanding == 0 || m_scrollClock.elapsed() > m_stepTimeoutMs) finishScrollStep(); }); @@ -134,15 +134,15 @@ qint64 RenderBenchmark::budgetNs() const bool RenderBenchmark::openLibraryNamed(const QString& name) { - auto *libraries = m_app ? m_app->libraries() : nullptr; - if (!libraries) { + LibraryListModel *libraries = m_hooks.libraries; + if (!libraries || !m_hooks.openLibrary) { qWarning() << "render benchmark: no library list to search"; return false; } for (int index = 0; index < libraries->count(); ++index) { if (libraries->libraryAt(index).name.compare(name, Qt::CaseInsensitive) == 0) { qInfo() << "render benchmark: opening library" << name; - m_app->openLibrary(index); + m_hooks.openLibrary(index); return true; } } @@ -235,12 +235,8 @@ void RenderBenchmark::finishScrollStep() m_scrollGapTimer->stop(); QVariantMap sample; sample.insert(QStringLiteral("screen"), m_scrollPosition); - if (auto *artwork = m_app ? m_app->artwork() : nullptr) { - const auto totals = artwork->decodeTotals(); - sample.insert(QStringLiteral("decodeMsTotal"), static_cast(totals.decodeNs) / 1000000.0); - sample.insert(QStringLiteral("decodedPixelsTotal"), static_cast(totals.pixels)); - sample.insert(QStringLiteral("decodedImagesTotal"), totals.images); - } + if (m_hooks.artworkDecodeTotals) + sample.insert(m_hooks.artworkDecodeTotals()); sample.insert(QStringLiteral("settleMs"), static_cast(m_scrollClock.nsecsElapsed()) / 1000000.0); sample.insert(QStringLiteral("maxGapMs"), static_cast(m_scrollWorstGapNs) / 1000000.0); sample.insert(QStringLiteral("frameBudgetMs"), m_latency ? m_latency->frameBudgetMs() : 16.67); @@ -300,7 +296,7 @@ void RenderBenchmark::start() waiter->setInterval(50); const qint64 deadline = QDateTime::currentMSecsSinceEpoch() + m_stepTimeoutMs; connect(waiter, &QTimer::timeout, this, [this, waiter, deadline] { - auto *libraries = m_app ? m_app->libraries() : nullptr; + LibraryListModel *libraries = m_hooks.libraries; const bool ready = libraries && libraries->count() > 0; if (!ready && QDateTime::currentMSecsSinceEpoch() < deadline) return; @@ -344,8 +340,8 @@ void RenderBenchmark::step() return; } - if (m_forceCold) - m_app->onMemoryPressure(QStringLiteral("critical")); + if (m_forceCold && m_hooks.forceColdCaches) + m_hooks.forceColdCaches(); m_awaitingSample = true; m_stepTimer.start(); diff --git a/src/diagnostics/RenderBenchmark.h b/src/diagnostics/RenderBenchmark.h index 1f347b32..424d1fa7 100644 --- a/src/diagnostics/RenderBenchmark.h +++ b/src/diagnostics/RenderBenchmark.h @@ -7,14 +7,30 @@ #include #include #include +#include + +#include class QQuickWindow; class QQuickItem; namespace JellyfinNative { class InputLatencyMonitor; +class LibraryListModel; class RouterController; -class AppController; + +// The handful of things the benchmark drives in the app, handed in so the +// diagnostics layer never names the composition root. +struct RenderBenchmarkHooks { + LibraryListModel *libraries = nullptr; + std::function openLibrary; + std::function outstandingArtworkRequests; + // decodeMsTotal, decodedPixelsTotal and decodedImagesTotal, as the + // artwork service counts them. + std::function artworkDecodeTotals; + // Sheds every cache the app can, so a walk measures cold pages. + std::function forceColdCaches; +}; // Walks the app through a scripted set of route switches and writes down what // each one cost, so "does a page still appear in one frame" is a number in CI @@ -32,14 +48,14 @@ class RenderBenchmark final : public QObject { public: // Returns nullptr when SPOOL_BENCH is unset, which is every ordinary run. - static RenderBenchmark *createIfRequested(AppController *app, RouterController *router, + static RenderBenchmark *createIfRequested(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, QObject *parent); void start(); private: - RenderBenchmark(AppController *app, RouterController *router, InputLatencyMonitor *latency, QQuickWindow *window, - QObject *parent); + RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *router, InputLatencyMonitor *latency, + QQuickWindow *window, QObject *parent); void step(); void recordSample(); @@ -64,7 +80,7 @@ class RenderBenchmark final : public QObject { void finishIdleProbe(); void finish(); - AppController *m_app = nullptr; + RenderBenchmarkHooks m_hooks; RouterController *m_router = nullptr; InputLatencyMonitor *m_latency = nullptr; // Offscreen, nothing asks for a frame on its own: no compositor is diff --git a/src/main.cpp b/src/main.cpp index 58bbca9f..1c6465e2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,7 @@ #include "api/JellyfinProvider.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" +#include "app/ArtworkService.h" #include "app/CpuTopology.h" #include "app/GraphicsStartup.h" #include "app/LocalizationManager.h" @@ -77,6 +78,7 @@ #include #include #include +#include #ifdef Q_OS_WIN #include #endif @@ -724,7 +726,18 @@ int main(int argc, char **argv) [api, loc = localization.get()]() { api->setAcceptLanguage(loc->bcp47Locale()); }); } auto router = std::make_unique(); - JellyfinNative::PlatformApplicationServices platformServices(app, window, *controller, *router); + JellyfinNative::ApplicationHooks applicationHooks; + applicationHooks.player = player.get(); + applicationHooks.settings = controller->settings(); + applicationHooks.memoryPressure + = [controller = controller.get()](const QString& level) { controller->onMemoryPressure(level); }; + QObject::connect(controller.get(), &JellyfinNative::AppController::aggressiveMemoryPressure, &applicationHooks, + &JellyfinNative::ApplicationHooks::aggressiveMemoryPressure); + QObject::connect(controller.get(), &JellyfinNative::AppController::diagnosticsReportSaved, &applicationHooks, + &JellyfinNative::ApplicationHooks::diagnosticsReportSaved); + QObject::connect(&applicationHooks, &JellyfinNative::ApplicationHooks::toastRequested, controller.get(), + &JellyfinNative::AppController::toastMessage); + JellyfinNative::PlatformApplicationServices platformServices(app, window, applicationHooks, *router); platformServices.start(); QQmlPropertyMap *platformInfo = QQmlPropertyMap::create(&app); platformInfo->insert(QStringLiteral("isTV"), capabilities.isTV); @@ -837,8 +850,21 @@ int main(int argc, char **argv) // as it always does and is then walked through a set of route switches // with what each one cost written out, so page-switch cost is a number in // CI rather than an impression. + JellyfinNative::RenderBenchmarkHooks benchmarkHooks; + benchmarkHooks.libraries = controller->libraries(); + benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; + benchmarkHooks.outstandingArtworkRequests + = [artwork = artworkService.get()] { return artwork->outstandingRequests(); }; + benchmarkHooks.artworkDecodeTotals = [artwork = artworkService.get()] { + const auto totals = artwork->decodeTotals(); + return QVariantMap { { QStringLiteral("decodeMsTotal"), static_cast(totals.decodeNs) / 1000000.0 }, + { QStringLiteral("decodedPixelsTotal"), static_cast(totals.pixels) }, + { QStringLiteral("decodedImagesTotal"), totals.images } }; + }; + benchmarkHooks.forceColdCaches + = [controller = controller.get()] { controller->onMemoryPressure(QStringLiteral("critical")); }; if (auto *benchmark = JellyfinNative::RenderBenchmark::createIfRequested( - controller.get(), router.get(), &inputLatencyMonitor, &window, &app)) { + std::move(benchmarkHooks), router.get(), &inputLatencyMonitor, &window, &app)) { if (controller->initialized()) { benchmark->start(); } else { diff --git a/src/platform/PlatformApplicationServices.h b/src/platform/PlatformApplicationServices.h index 63698d9c..1452470f 100644 --- a/src/platform/PlatformApplicationServices.h +++ b/src/platform/PlatformApplicationServices.h @@ -1,19 +1,45 @@ #pragma once +#include +#include + +#include #include class QGuiApplication; namespace JellyfinNative { -class AppController; class NativeAppWindow; +class PlayerController; class RouterController; +class SettingsController; + +// What the platform layer needs from the application above it, handed down +// at startup so nothing under src/platform names the composition root. The +// signals are forwarded from the app; the callback runs on the GUI thread. +class ApplicationHooks final : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + PlayerController *player = nullptr; + SettingsController *settings = nullptr; + // A platform memory warning at the given level ("low" or "critical"). + std::function memoryPressure; + +signals: + // The app has shed what it can; the window may drop its resources too. + void aggressiveMemoryPressure(); + void diagnosticsReportSaved(const QString& path); + void toastRequested(const QString& message); +}; class PlatformApplicationServices final { public: PlatformApplicationServices( - QGuiApplication& application, NativeAppWindow& window, AppController& controller, RouterController& router); + QGuiApplication& application, NativeAppWindow& window, ApplicationHooks& hooks, RouterController& router); ~PlatformApplicationServices(); PlatformApplicationServices(const PlatformApplicationServices&) = delete; diff --git a/src/platform/android/AndroidApplicationServices.cpp b/src/platform/android/AndroidApplicationServices.cpp index c812a296..bd402118 100644 --- a/src/platform/android/AndroidApplicationServices.cpp +++ b/src/platform/android/AndroidApplicationServices.cpp @@ -2,7 +2,6 @@ #include "AndroidLocalMediaSession.h" -#include "app/AppController.h" #include "player/PlayerController.h" #include @@ -11,21 +10,21 @@ namespace JellyfinNative { struct PlatformApplicationServices::PlatformData { - explicit PlatformData(AppController& controller) - : controller(&controller) - , player(controller.player()) - , mediaSession(controller) + explicit PlatformData(ApplicationHooks& applicationHooks) + : hooks(&applicationHooks) + , player(applicationHooks.player) + , mediaSession(applicationHooks) { } - AppController *controller = nullptr; + ApplicationHooks *hooks = nullptr; PlayerController *player = nullptr; AndroidLocalMediaSession mediaSession; }; PlatformApplicationServices::PlatformApplicationServices( - QGuiApplication&, NativeAppWindow&, AppController& controller, RouterController&) - : m_platform(std::make_unique(controller)) + QGuiApplication&, NativeAppWindow&, ApplicationHooks& hooks, RouterController&) + : m_platform(std::make_unique(hooks)) { } @@ -51,14 +50,14 @@ void PlatformApplicationServices::start() }); QObject::connect( - platform->controller, &AppController::diagnosticsReportSaved, qGuiApp, [platform](const QString& reportPath) { + platform->hooks, &ApplicationHooks::diagnosticsReportSaved, qGuiApp, [platform](const QString& reportPath) { const QJniObject context = QNativeInterface::QAndroidApplication::context(); const QJniObject path = QJniObject::fromString(reportPath); const jboolean opened = QJniObject::callStaticMethod("com/sachk/spool/AndroidUpdateBridge", "shareDiagnostics", "(Landroid/content/Context;Ljava/lang/String;)Z", context.object(), path.object()); if (!opened) - emit platform->controller->toastMessage(QStringLiteral("Could not open Android’s share menu.")); + emit platform->hooks->toastRequested(QStringLiteral("Could not open Android’s share menu.")); }); } diff --git a/src/platform/desktop/DesktopApplicationServices.cpp b/src/platform/desktop/DesktopApplicationServices.cpp index 2383ec8f..a480a153 100644 --- a/src/platform/desktop/DesktopApplicationServices.cpp +++ b/src/platform/desktop/DesktopApplicationServices.cpp @@ -5,7 +5,7 @@ namespace JellyfinNative { struct PlatformApplicationServices::PlatformData { }; PlatformApplicationServices::PlatformApplicationServices( - QGuiApplication&, NativeAppWindow&, AppController&, RouterController&) + QGuiApplication&, NativeAppWindow&, ApplicationHooks&, RouterController&) : m_platform(std::make_unique()) { } diff --git a/src/platform/webos/WebOSApplicationServices.cpp b/src/platform/webos/WebOSApplicationServices.cpp index df6b1dc2..9e8caac6 100644 --- a/src/platform/webos/WebOSApplicationServices.cpp +++ b/src/platform/webos/WebOSApplicationServices.cpp @@ -1,6 +1,5 @@ #include "platform/PlatformApplicationServices.h" -#include "app/AppController.h" #include "app/RouterController.h" #include "app/SettingsController.h" #include "platform/NativeAppWindow.h" @@ -26,11 +25,11 @@ extern "C" { namespace JellyfinNative { struct PlatformApplicationServices::PlatformData { - PlatformData(QGuiApplication& guiApplication, NativeAppWindow& nativeWindow, AppController& appController, + PlatformData(QGuiApplication& guiApplication, NativeAppWindow& nativeWindow, ApplicationHooks& applicationHooks, RouterController& routerController) : application(&guiApplication) , window(&nativeWindow) - , controller(&appController) + , hooks(&applicationHooks) , router(&routerController) { backgroundTrimTimer.setSingleShot(true); @@ -108,7 +107,7 @@ struct PlatformApplicationServices::PlatformData { auto *platform = fromCallbackContext(data); const QByteArray payload(message && LSMessageGetPayload(message) ? LSMessageGetPayload(message) : ""); const QJsonDocument document = QJsonDocument::fromJson(payload); - if (!platform || !platform->controller || !document.isObject()) + if (!platform || !platform->hooks || !document.isObject()) return true; const QJsonObject object = document.object(); if (!object.value(QStringLiteral("returnValue")).toBool(true) @@ -120,7 +119,11 @@ struct PlatformApplicationServices::PlatformData { const QString level = object.value(QStringLiteral("level")).toString(); if (!level.isEmpty()) { QMetaObject::invokeMethod( - platform->controller, [platform, level] { platform->controller->onMemoryPressure(level); }, + platform->hooks, + [platform, level] { + if (platform->hooks->memoryPressure) + platform->hooks->memoryPressure(level); + }, Qt::QueuedConnection); } return true; @@ -143,7 +146,7 @@ struct PlatformApplicationServices::PlatformData { QGuiApplication *application = nullptr; NativeAppWindow *window = nullptr; - AppController *controller = nullptr; + ApplicationHooks *hooks = nullptr; RouterController *router = nullptr; WebOSAudioRoute audioRoute; QTimer backgroundTrimTimer; @@ -155,8 +158,8 @@ struct PlatformApplicationServices::PlatformData { }; PlatformApplicationServices::PlatformApplicationServices( - QGuiApplication& application, NativeAppWindow& window, AppController& controller, RouterController& router) - : m_platform(std::make_unique(application, window, controller, router)) + QGuiApplication& application, NativeAppWindow& window, ApplicationHooks& hooks, RouterController& router) + : m_platform(std::make_unique(application, window, hooks, router)) { } @@ -168,17 +171,21 @@ void PlatformApplicationServices::start() return; m_platform->started = true; - QObject::connect(&m_platform->audioRoute, &WebOSAudioRoute::routeChanged, m_platform->controller->settings(), - [settings = m_platform->controller->settings()](const QString& output, int displayLatencyMs, - int outputLatencyMs) { settings->updateAudioOutputRoute(output, displayLatencyMs, outputLatencyMs); }); + QObject::connect(&m_platform->audioRoute, &WebOSAudioRoute::routeChanged, m_platform->hooks->settings, + [settings = m_platform->hooks->settings](const QString& output, int displayLatencyMs, int outputLatencyMs) { + settings->updateAudioOutputRoute(output, displayLatencyMs, outputLatencyMs); + }); QObject::connect( - m_platform->controller, &AppController::aggressiveMemoryPressure, m_platform->window, + m_platform->hooks, &ApplicationHooks::aggressiveMemoryPressure, m_platform->window, [window = m_platform->window] { window->releaseResources(); }, Qt::QueuedConnection); - QObject::connect(&m_platform->backgroundTrimTimer, &QTimer::timeout, m_platform->controller, - [controller = m_platform->controller] { controller->onMemoryPressure(QStringLiteral("critical")); }); + QObject::connect( + &m_platform->backgroundTrimTimer, &QTimer::timeout, m_platform->hooks, [hooks = m_platform->hooks] { + if (hooks->memoryPressure) + hooks->memoryPressure(QStringLiteral("critical")); + }); QObject::connect(m_platform->application, &QGuiApplication::applicationStateChanged, m_platform->application, [platform = m_platform.get()](Qt::ApplicationState state) { - PlayerController *player = platform->controller->player(); + PlayerController *player = platform->hooks->player; if (state == Qt::ApplicationHidden || state == Qt::ApplicationSuspended) player->teardownForBackground(); else if (state == Qt::ApplicationInactive) @@ -195,7 +202,7 @@ void PlatformApplicationServices::start() }); QObject::connect(m_platform->window, &NativeAppWindow::platformSurfaceStateChanged, m_platform->application, [platform = m_platform.get()](int state) { - PlayerController *player = platform->controller->player(); + PlayerController *player = platform->hooks->player; if (state == WL_WEBOS_SHELL_SURFACE_STATE_MINIMIZED) { if (player->sessionActive() && !player->paused()) { qInfo() << "webOS shell minimized: pausing playback"; @@ -211,7 +218,7 @@ void PlatformApplicationServices::start() }); QObject::connect(m_platform->window, &NativeAppWindow::platformSurfaceExposed, &m_platform->backgroundTrimTimer, [platform = m_platform.get()](bool exposed) { - PlayerController *player = platform->controller->player(); + PlayerController *player = platform->hooks->player; if (exposed) { platform->backgroundTrimTimer.stop(); player->resyncForForeground(); From 08cee81f7382da807f2adb2d80027ff0d60c8811 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:15:48 +1000 Subject: [PATCH 012/146] docs: describe the provider registry and JellyfinProvider in the module seam --- AGENTS.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3e5e4e1..2dea1d4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,15 +40,32 @@ library; the grouping exists so the split stays mechanical. `src/cache` and `src/diagnostics`) never includes `src/api/` or `src/discovery/`. `tools/check-module-seam.sh` enforces that and runs as the `module-seam` ctest; `--strict` also treats every file still listed in - `SPOOL_JELLYFIN_SOURCES` as provider and currently reports the - composition-root leaks left for the provider registry work - (`PlatformApplicationServices`, `RenderBenchmark` and `DatabaseManager` - reach `AppController`, `ArtworkService` or `AccountProfile`). + `SPOOL_JELLYFIN_SOURCES` as provider and currently reports one leftover + (`DatabaseManager` reaches `AccountProfile`). +- A media source is a `Provider` (`src/provider/Provider.h`): an id, a + display name, a set of capability flags and a `PlaybackSource`. + `ProviderRegistry` holds the active one and publishes its flags as the + `ProviderCapabilities` QML singleton (eleven booleans: auth, discovery, + search, userItemState, playbackReporting, segments, libraryManagement, + syncPlay, remoteControl, quickConnect, peerRelay). Shared QML gates every + provider-specific control on one of those names; the provider registers + the singletons only its own QML reaches (`Session`, `SyncPlay`, + `RemoteControl`, `Management`, `QuickConnect`, `Discovery`, + `DiscoveredServers`) from `registerQmlSingletons()`. +- `JellyfinProvider` (`src/api`) composes everything Jellyfin-specific: the + facade, discovery and its cached server list, the session, QuickConnect, + remote control, and after `attach()` SyncPlay, library management and the + settings bridge. It owns the session-driven lifecycle of those parts; + `AppController` is the composition root above it and constructs none of + them. Nothing under `src/platform` or `src/diagnostics` includes + `AppController`: the platform layer takes `ApplicationHooks` and the render + benchmark `RenderBenchmarkHooks`, both filled in by `main.cpp`. - The player talks to its media source only through `src/provider/PlaybackSource.h`; `JellyfinApiFacade` implements it. `SettingsController` emits the preferences a source needs and takes the account's remote settings back through `applyRemote*()`; - `JellyfinSettingsBridge` in `src/api` is the Jellyfin side of that. + `JellyfinSettingsBridge` in `src/api` is the Jellyfin side of that. Its + schema drops the account rows when the active provider has no `auth`. `configurePlatformPlaybackCapabilities()` takes an applier callback, not the facade. - `qml/primitives` and `qml/theme` reach exactly two singletons, `Art.url` and From 5e5203e38bcad74e48f54d54ea7a4a4afb209fd1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:03:50 +1000 Subject: [PATCH 013/146] refactor(provider): put the catalog, search, item state and artwork behind interfaces The content, home, search, prefetch and user-item-state controllers took the Jellyfin facade and used a dozen of its methods. They now take Catalog, SearchSource and UserItemStateSink, plain abstract classes that carry exactly that surface, and ArtworkService asks an ArtworkSource for image URLs instead of building Jellyfin's Items/{id}/Images path itself. The facade implements all four beside PlaybackSource; one signedIn() answers every interface's readiness question, and libraryScopeKey() replaces the home controller's hand-rolled server/user cache key. Tests render artwork requests through a recording source so they check what the service asks for; the Jellyfin URL shape stays covered by the facade URL test. --- src/api/JellyfinApiFacade.cpp | 28 +++++++++++++++ src/api/JellyfinApiFacade.h | 44 +++++++++++++++-------- src/app/AppController.cpp | 5 --- src/app/ArtworkService.cpp | 49 ++++++++++++-------------- src/app/ArtworkService.h | 6 ++-- src/app/ContentModelController.cpp | 12 +++---- src/app/ContentModelController.h | 6 ++-- src/app/HomeModelController.cpp | 14 +++----- src/app/HomeModelController.h | 8 ++--- src/app/LibraryPrefetchController.cpp | 10 +++--- src/app/LibraryPrefetchController.h | 6 ++-- src/app/SearchController.cpp | 7 ++-- src/app/SearchController.h | 6 ++-- src/app/UserItemStateController.cpp | 13 ++++--- src/app/UserItemStateController.h | 7 ++-- src/main.cpp | 1 + src/provider/ArtworkSource.h | 36 +++++++++++++++++++ src/provider/Catalog.h | 43 ++++++++++++++++++++++ src/provider/SearchSource.h | 25 +++++++++++++ src/provider/UserItemStateSink.h | 22 ++++++++++++ tests/RecordingArtworkSource.h | 37 +++++++++++++++++++ tests/api/JellyfinApiFacadeUrlTest.cpp | 2 +- tests/app/ArtworkUrlTest.cpp | 6 +++- tests/app/SettingsControllerTest.cpp | 4 ++- 24 files changed, 295 insertions(+), 102 deletions(-) create mode 100644 src/provider/ArtworkSource.h create mode 100644 src/provider/Catalog.h create mode 100644 src/provider/SearchSource.h create mode 100644 src/provider/UserItemStateSink.h create mode 100644 tests/RecordingArtworkSource.h diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp index a4b6b2f3..06e6ad7b 100644 --- a/src/api/JellyfinApiFacade.cpp +++ b/src/api/JellyfinApiFacade.cpp @@ -468,6 +468,34 @@ bool JellyfinApiFacade::signedIn() const return !m_session.accessToken.isEmpty(); } +QString JellyfinApiFacade::libraryScopeKey() const +{ + const QString userKey = m_session.userId.isEmpty() ? m_session.userName : m_session.userId; + const QString serverKey = m_session.serverId.isEmpty() ? serverUrl() : m_session.serverId; + return userKey.isEmpty() || serverKey.isEmpty() ? QString() : QStringLiteral("%1/%2").arg(serverKey, userKey); +} + +QString JellyfinApiFacade::imageUrl(const ArtworkSource::ImageRequest& request) const +{ + const QString origin = serverUrl(); + if (origin.isEmpty() || request.itemId.isEmpty() || request.tag.isEmpty() || request.imageType.isEmpty()) + return {}; + QUrl url = serverUrlWithPath( + origin, { QStringLiteral("Items"), request.itemId, QStringLiteral("Images"), request.imageType }); + QUrlQuery query; + if (request.fillWidth > 0 && request.fillHeight > 0) { + query.addQueryItem(QStringLiteral("fillWidth"), QString::number(request.fillWidth)); + query.addQueryItem(QStringLiteral("fillHeight"), QString::number(request.fillHeight)); + } else { + query.addQueryItem(QStringLiteral("maxWidth"), QString::number(request.maxWidth)); + } + query.addQueryItem(QStringLiteral("quality"), QString::number(request.quality)); + query.addQueryItem(QStringLiteral("format"), request.format); + query.addQueryItem(QStringLiteral("tag"), request.tag); + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); +} + QCoro::Task> JellyfinApiFacade::fetchSeriesEpisodes(QString seriesId) { return fetchEpisodes(std::move(seriesId)); diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h index 7a3c88bc..29af3602 100644 --- a/src/api/JellyfinApiFacade.h +++ b/src/api/JellyfinApiFacade.h @@ -1,7 +1,11 @@ #pragma once #include "../media/MediaTypes.h" +#include "../provider/ArtworkSource.h" +#include "../provider/Catalog.h" #include "../provider/PlaybackSource.h" +#include "../provider/SearchSource.h" +#include "../provider/UserItemStateSink.h" #include "HttpRequestPolicy.h" #include "JellyfinSession.h" #include "PlaybackBandwidthPolicy.h" @@ -26,7 +30,13 @@ namespace JellyfinNative { class TlsTrustController; -class JellyfinApiFacade final : public PlaybackSource { +// PlaybackSource is the QObject base; the others are plain interfaces the +// source-agnostic controllers talk through. +class JellyfinApiFacade final : public PlaybackSource, + public Catalog, + public SearchSource, + public UserItemStateSink, + public ArtworkSource { Q_OBJECT public: @@ -65,7 +75,11 @@ class JellyfinApiFacade final : public PlaybackSource { // every media request, and the server URL owns the TLS trust decision. QByteArray mediaRequestHeaders() const override; QUrl mediaOrigin() const override; + // Catalog, SearchSource, UserItemStateSink and PlaybackSource all ask + // this; one session answers for every one of them. bool signedIn() const override; + QString libraryScopeKey() const override; + QString imageUrl(const ArtworkSource::ImageRequest& request) const override; QCoro::Task> fetchSeriesEpisodes(QString seriesId) override; QCoro::Task refreshPlaybackNetworkState(); @@ -108,19 +122,19 @@ class JellyfinApiFacade final : public PlaybackSource { QCoro::Task fetchCultures(); QCoro::Task> fetchLibraries(); QCoro::Task fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}); + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}); - QCoro::Task fetchItemDetails(QString itemId); + QCoro::Task fetchItemDetails(QString itemId) override; QCoro::Task> fetchItemsByIds(QStringList itemIds); - QCoro::Task> fetchSeasons(QString seriesId); - QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}); - QCoro::Task> fetchResumeItems(int limit = 24); - QCoro::Task> fetchNextUpEpisodes(int limit = 24); - QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24); - QCoro::Task> searchItems(QString searchTerm, int limit = 80); - QCoro::Task> fetchSearchSuggestions(int limit = 20); - QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24); - QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000); + QCoro::Task> fetchSeasons(QString seriesId) override; + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; + QCoro::Task> fetchResumeItems(int limit = 24) override; + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; QCoro::Task> fetchManagementTargets(QString itemType); QCoro::Task createPlaylist(QString name, QStringList itemIds = {}); QCoro::Task addPlaylistItems(QString playlistId, QStringList itemIds, int position = -1); @@ -132,9 +146,9 @@ class JellyfinApiFacade final : public PlaybackSource { QCoro::Task removeCollectionItems(QString collectionId, QStringList itemIds); QCoro::Task renameItem(QString itemId, QString name); QCoro::Task deleteItem(QString itemId); - QCoro::Task setItemFavorite(QString itemId, bool favorite); - QCoro::Task setItemPlayed(QString itemId, bool played); - QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks); + QCoro::Task setItemFavorite(QString itemId, bool favorite) override; + QCoro::Task setItemPlayed(QString itemId, bool played) override; + QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; QCoro::Task> fetchMediaSegments(QString itemId); QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override; diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index d5502f17..a4a251b6 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -121,11 +121,6 @@ AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyf m_syncPlay = m_jellyfin->syncPlay(); m_management = m_jellyfin->management(); JellyfinSettingsBridge *settingsBridge = m_jellyfin->settingsBridge(); - if (m_artwork) { - m_artwork->setServerUrl(m_session->serverUrl()); - connect(m_session, &SessionController::serverUrlChanged, m_artwork, - [this]() { m_artwork->setServerUrl(m_session->serverUrl()); }); - } connect(m_playQueue, &PlayQueueController::successorPlaybackReady, this, [this]() { playQueueCurrent(false); }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index b414cbc6..1a2a9937 100644 --- a/src/app/ArtworkService.cpp +++ b/src/app/ArtworkService.cpp @@ -483,11 +483,9 @@ QString ArtworkService::itemUrl(const MovieItem& item, bool landscape, int width return movieUrl(item, landscape ? QStringLiteral("landscape") : QStringLiteral("poster"), width); } -void ArtworkService::setServerUrl(QString serverUrl) +void ArtworkService::setSource(const ArtworkSource *source) { - while (serverUrl.endsWith(QLatin1Char('/'))) - serverUrl.chop(1); - m_serverUrl = std::move(serverUrl); + m_source = source; } void ArtworkService::setUiWidth(int width) @@ -596,36 +594,33 @@ QString ArtworkService::movieUrl(const MovieItem& item, const QString& kind, int QString ArtworkService::buildLosslessUrl( const QString& itemId, const QString& tag, const QString& imageType, int maxWidth) const { - if (m_serverUrl.isEmpty() || itemId.isEmpty() || tag.isEmpty() || imageType.isEmpty()) + if (!m_source) return {}; - QUrl url = serverUrlWithPath(m_serverUrl, { QStringLiteral("Items"), itemId, QStringLiteral("Images"), imageType }); - QUrlQuery query; - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(maxWidth)); - query.addQueryItem(QStringLiteral("quality"), QStringLiteral("90")); - query.addQueryItem(QStringLiteral("format"), QStringLiteral("png")); - query.addQueryItem(QStringLiteral("tag"), tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); + ArtworkSource::ImageRequest request; + request.itemId = itemId; + request.tag = tag; + request.imageType = imageType; + request.maxWidth = maxWidth; + request.format = QStringLiteral("png"); + request.quality = 90; + return m_source->imageUrl(request); } QString ArtworkService::buildUrl(const QString& itemId, const QString& tag, const QString& imageType, int maxWidth, int qualityOffset, int fillWidth, int fillHeight) const { - if (m_serverUrl.isEmpty() || itemId.isEmpty() || tag.isEmpty() || imageType.isEmpty()) + if (!m_source) return {}; - QUrl url = serverUrlWithPath(m_serverUrl, { QStringLiteral("Items"), itemId, QStringLiteral("Images"), imageType }); - QUrlQuery query; - if (fillWidth > 0 && fillHeight > 0) { - query.addQueryItem(QStringLiteral("fillWidth"), QString::number(fillWidth)); - query.addQueryItem(QStringLiteral("fillHeight"), QString::number(fillHeight)); - } else { - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(maxWidth)); - } - query.addQueryItem(QStringLiteral("quality"), QString::number(qualityForOffset(qualityOffset))); - query.addQueryItem(QStringLiteral("format"), artworkFormat()); - query.addQueryItem(QStringLiteral("tag"), tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); + ArtworkSource::ImageRequest request; + request.itemId = itemId; + request.tag = tag; + request.imageType = imageType; + request.maxWidth = maxWidth; + request.fillWidth = fillWidth; + request.fillHeight = fillHeight; + request.format = artworkFormat(); + request.quality = qualityForOffset(qualityOffset); + return m_source->imageUrl(request); } QQuickImageResponse *ArtworkService::requestImageResponse(const QString& id, const QSize& requestedSize) diff --git a/src/app/ArtworkService.h b/src/app/ArtworkService.h index 8816d6a7..10818c85 100644 --- a/src/app/ArtworkService.h +++ b/src/app/ArtworkService.h @@ -1,6 +1,7 @@ #pragma once #include "../media/MediaTypes.h" +#include "../provider/ArtworkSource.h" #include "ArtworkPrefetcher.h" #include @@ -52,7 +53,8 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { Q_INVOKABLE QString url(const QVariant& item, const QString& kind, int width = 0) const; QString itemUrl(const MovieItem& item, bool landscape, int width = 0) const override; - void setServerUrl(QString serverUrl); + // Where image URLs come from. Unset, every request resolves to no URL. + void setSource(const ArtworkSource *source); void setUiWidth(int width); // Which codec to ask the server for, and how hard it should compress. // Format is "webp" or "jpeg"; anything else falls back to the platform @@ -122,7 +124,7 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { QString artworkFormat() const; QString m_cacheDirectory; - QString m_serverUrl; + const ArtworkSource *m_source = nullptr; QString m_artworkFormat; int m_webpQuality = 75; int m_jpegQuality = 82; diff --git a/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index a12813ae..ada33cd1 100644 --- a/src/app/ContentModelController.cpp +++ b/src/app/ContentModelController.cpp @@ -1,6 +1,5 @@ #include "ContentModelController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "LibraryPrefetchController.h" @@ -13,10 +12,9 @@ namespace JellyfinNative { -ContentModelController::ContentModelController( - JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) +ContentModelController::ContentModelController(Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(catalog) , m_prefetch(prefetch) { } @@ -52,7 +50,7 @@ void ContentModelController::loadDetailRows( m_detailSeasonOptions.clear(); m_detailSimilarItems.clear(); - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { emit detailRowsChanged(); return; } @@ -169,7 +167,7 @@ void ContentModelController::loadItemDetail(const QString& itemId) const RequestGeneration::Token generation = m_detailItemGeneration.next(); m_detailItem = {}; - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) { emit detailItemChanged(); return; } @@ -193,7 +191,7 @@ void ContentModelController::loadPersonItems(const QString& personId) { const RequestGeneration::Token generation = m_personItemsGeneration.next(); clearPersonItems(); - if (personId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) { + if (personId.isEmpty() || !m_api || !m_api->signedIn()) { m_personItemsBusy = false; emit personItemsChanged(); return; diff --git a/src/app/ContentModelController.h b/src/app/ContentModelController.h index 34c145c8..4a3e6ae6 100644 --- a/src/app/ContentModelController.h +++ b/src/app/ContentModelController.h @@ -2,6 +2,7 @@ #include "../common/RequestGeneration.h" #include "../models/MovieGridModel.h" +#include "../provider/Catalog.h" #include #include @@ -13,7 +14,6 @@ namespace JellyfinNative { -class JellyfinApiFacade; class LibraryPrefetchController; class ContentModelController final : public QObject { @@ -28,7 +28,7 @@ class ContentModelController final : public QObject { Q_PROPERTY(bool personItemsBusy READ personItemsBusy NOTIFY personItemsChanged) public: - ContentModelController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent = nullptr); + ContentModelController(Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent = nullptr); MovieGridModel *detailSeasons() { @@ -93,7 +93,7 @@ class ContentModelController final : public QObject { void setPersonCredits(PersonCredits credits); void clearPersonItems(); - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_detailSeasons; MovieGridModel m_detailSeasonOptions; diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index f3cfddbd..a0c527fb 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -1,6 +1,5 @@ #include "HomeModelController.h" -#include "../api/JellyfinApiFacade.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" #include "../common/MetaJson.h" @@ -163,10 +162,10 @@ namespace { } // namespace HomeModelController::HomeModelController( - DatabaseManager *database, JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) + DatabaseManager *database, Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) , m_database(database) - , m_api(api) + , m_api(catalog) , m_prefetch(prefetch) { } @@ -242,12 +241,7 @@ QCoro::Task HomeModelController::loadCachedPayloadAsync() QString HomeModelController::payloadCacheKey() const { - if (!m_api) - return {}; - const AuthSession session = m_api->session(); - const QString userKey = session.userId.isEmpty() ? session.userName : session.userId; - const QString serverKey = session.serverId.isEmpty() ? m_api->serverUrl() : session.serverId; - return userKey.isEmpty() || serverKey.isEmpty() ? QString() : QStringLiteral("%1/%2").arg(serverKey, userKey); + return m_api ? m_api->libraryScopeKey() : QString(); } void HomeModelController::saveCachedPayload(const QJsonObject& payload) @@ -259,7 +253,7 @@ void HomeModelController::saveCachedPayload(const QJsonObject& payload) void HomeModelController::refresh(const std::vector& libraries) { - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; if (libraries.empty()) return; diff --git a/src/app/HomeModelController.h b/src/app/HomeModelController.h index 47bf0e16..3707e276 100644 --- a/src/app/HomeModelController.h +++ b/src/app/HomeModelController.h @@ -3,6 +3,7 @@ #include "../common/RequestGeneration.h" #include "../media/MediaTypes.h" #include "../models/MovieGridModel.h" +#include "../provider/Catalog.h" #include #include @@ -16,7 +17,6 @@ namespace JellyfinNative { class DatabaseManager; -class JellyfinApiFacade; class LibraryPrefetchController; class HomeModelController final : public QObject { @@ -27,8 +27,8 @@ class HomeModelController final : public QObject { Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) public: - HomeModelController(DatabaseManager *database, JellyfinApiFacade *api, LibraryPrefetchController *prefetch, - QObject *parent = nullptr); + HomeModelController( + DatabaseManager *database, Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent = nullptr); MovieGridModel *resumeItems() { @@ -79,7 +79,7 @@ class HomeModelController final : public QObject { void saveCachedPayload(const QJsonObject& payload); DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_resumeItems; MovieGridModel m_nextUpItems; diff --git a/src/app/LibraryPrefetchController.cpp b/src/app/LibraryPrefetchController.cpp index a941d72e..a28581b7 100644 --- a/src/app/LibraryPrefetchController.cpp +++ b/src/app/LibraryPrefetchController.cpp @@ -1,6 +1,5 @@ #include "LibraryPrefetchController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "ArtworkPrefetcher.h" #include "LibraryQuery.h" @@ -20,10 +19,9 @@ namespace { } // namespace -LibraryPrefetchController::LibraryPrefetchController( - JellyfinApiFacade *api, ArtworkPrefetcher *artwork, QObject *parent) +LibraryPrefetchController::LibraryPrefetchController(Catalog *catalog, ArtworkPrefetcher *artwork, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(catalog) , m_artwork(artwork) { m_timer.setSingleShot(true); @@ -42,7 +40,7 @@ void LibraryPrefetchController::stop() void LibraryPrefetchController::schedule(const std::vector& libraries, const QStringList& recentLibraryIds) { stop(); - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; std::vector selected; @@ -147,7 +145,7 @@ void LibraryPrefetchController::prefetchPosters( void LibraryPrefetchController::startNext() { - if (m_active || !m_api || m_api->session().accessToken.isEmpty()) + if (m_active || !m_api || !m_api->signedIn()) return; if (m_index < 0 || m_index >= static_cast(m_queue.size())) return; diff --git a/src/app/LibraryPrefetchController.h b/src/app/LibraryPrefetchController.h index bde785b9..973296fa 100644 --- a/src/app/LibraryPrefetchController.h +++ b/src/app/LibraryPrefetchController.h @@ -2,6 +2,7 @@ #include "../common/RequestGeneration.h" #include "../media/MediaTypes.h" +#include "../provider/Catalog.h" #include #include @@ -15,7 +16,6 @@ namespace JellyfinNative { class ArtworkPrefetcher; -class JellyfinApiFacade; class LibraryPrefetchController final : public QObject { Q_OBJECT @@ -23,7 +23,7 @@ class LibraryPrefetchController final : public QObject { public: enum class ImageKind { Poster, Landscape }; - LibraryPrefetchController(JellyfinApiFacade *api, ArtworkPrefetcher *artwork = nullptr, QObject *parent = nullptr); + LibraryPrefetchController(Catalog *catalog, ArtworkPrefetcher *artwork = nullptr, QObject *parent = nullptr); void stop(); void schedule(const std::vector& libraries, const QStringList& recentLibraryIds); @@ -43,7 +43,7 @@ class LibraryPrefetchController final : public QObject { void startNext(); - JellyfinApiFacade *m_api = nullptr; + Catalog *m_api = nullptr; ArtworkPrefetcher *m_artwork = nullptr; QTimer m_timer; RequestGeneration m_generation; diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index 495d7df6..8ba046bc 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -1,6 +1,5 @@ #include "SearchController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "LibraryPrefetchController.h" @@ -14,9 +13,9 @@ namespace { constexpr int kSearchDebounceMs = 260; } -SearchController::SearchController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent) +SearchController::SearchController(SearchSource *source, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(source) , m_prefetch(prefetch) { m_debounceTimer.setSingleShot(true); @@ -199,7 +198,7 @@ void SearchController::setResults(std::vector items) bool SearchController::authenticated() const { - return m_api && !m_api->session().accessToken.isEmpty(); + return m_api && m_api->signedIn(); } void SearchController::setBusy(bool busy) diff --git a/src/app/SearchController.h b/src/app/SearchController.h index 2bd353de..a86a5e34 100644 --- a/src/app/SearchController.h +++ b/src/app/SearchController.h @@ -2,6 +2,7 @@ #include "../common/RequestGeneration.h" #include "../models/MovieGridModel.h" +#include "../provider/SearchSource.h" #include #include @@ -10,7 +11,6 @@ #include namespace JellyfinNative { -class JellyfinApiFacade; class LibraryPrefetchController; class SearchController final : public QObject { @@ -26,7 +26,7 @@ class SearchController final : public QObject { Q_PROPERTY(bool suggestionsBusy READ suggestionsBusy NOTIFY suggestionsChanged) public: - SearchController(JellyfinApiFacade *api, LibraryPrefetchController *prefetch, QObject *parent = nullptr); + SearchController(SearchSource *source, LibraryPrefetchController *prefetch, QObject *parent = nullptr); QString query() const { @@ -90,7 +90,7 @@ class SearchController final : public QObject { void clearResults(); void setResults(std::vector items); - JellyfinApiFacade *m_api = nullptr; + SearchSource *m_api = nullptr; LibraryPrefetchController *m_prefetch = nullptr; MovieGridModel m_movieResults; MovieGridModel m_seriesResults; diff --git a/src/app/UserItemStateController.cpp b/src/app/UserItemStateController.cpp index 7cc64762..0ba6d71b 100644 --- a/src/app/UserItemStateController.cpp +++ b/src/app/UserItemStateController.cpp @@ -1,6 +1,5 @@ #include "UserItemStateController.h" -#include "../api/JellyfinApiFacade.h" #include "../common/AsyncTask.h" #include "BrowseSessionController.h" @@ -10,10 +9,10 @@ namespace JellyfinNative { -UserItemStateController::UserItemStateController(JellyfinApiFacade *api, BrowseSessionController *currentItems, +UserItemStateController::UserItemStateController(UserItemStateSink *sink, BrowseSessionController *currentItems, HomeModelController *home, ContentModelController *content, SearchController *search, QObject *parent) : QObject(parent) - , m_api(api) + , m_api(sink) , m_browse(currentItems) , m_home(home) , m_content(content) @@ -78,7 +77,7 @@ void UserItemStateController::recordPlaybackStopped( return; } applyPlayed(itemId, true); - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_api || !m_api->signedIn()) return; Async::runScoped( this, m_api->setItemPlayed(itemId, true), []() {}, @@ -87,7 +86,7 @@ void UserItemStateController::recordPlaybackStopped( void UserItemStateController::setFavorite(const QString& itemId, bool favorite) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyFavorite(itemId, favorite); Async::runScoped( @@ -100,7 +99,7 @@ void UserItemStateController::setFavorite(const QString& itemId, bool favorite) void UserItemStateController::setPlayed(const QString& itemId, bool played) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyPlayed(itemId, played); Async::runScoped( @@ -113,7 +112,7 @@ void UserItemStateController::setPlayed(const QString& itemId, bool played) void UserItemStateController::clearProgress(const QString& itemId) { - if (itemId.isEmpty() || !m_api || m_api->session().accessToken.isEmpty()) + if (itemId.isEmpty() || !m_api || !m_api->signedIn()) return; applyResumeTicks(itemId, 0); applyPlayed(itemId, false); diff --git a/src/app/UserItemStateController.h b/src/app/UserItemStateController.h index 332d41f7..a3215ac5 100644 --- a/src/app/UserItemStateController.h +++ b/src/app/UserItemStateController.h @@ -1,5 +1,7 @@ #pragma once +#include "../provider/UserItemStateSink.h" + #include #include @@ -9,14 +11,13 @@ class ContentModelController; class BrowseSessionController; class HomeModelController; class SearchController; -class JellyfinApiFacade; struct MovieItem; class UserItemStateController final : public QObject { Q_OBJECT public: - UserItemStateController(JellyfinApiFacade *api, BrowseSessionController *currentItems, HomeModelController *home, + UserItemStateController(UserItemStateSink *sink, BrowseSessionController *currentItems, HomeModelController *home, ContentModelController *content, SearchController *search, QObject *parent = nullptr); void applyResumeTicks(const QString& itemId, qint64 positionTicks); @@ -33,7 +34,7 @@ class UserItemStateController final : public QObject { void errorOccurred(const QString& message); private: - JellyfinApiFacade *m_api = nullptr; + UserItemStateSink *m_api = nullptr; BrowseSessionController *m_browse = nullptr; HomeModelController *m_home = nullptr; ContentModelController *m_content = nullptr; diff --git a/src/main.cpp b/src/main.cpp index 1c6465e2..aa9feec6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -616,6 +616,7 @@ int main(int argc, char **argv) qmlImageCachePath + QStringLiteral("/artwork"), memoryBudget.qmlImageDiskCacheBytes, memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, &tlsTrust); artworkService->setUiWidth(window.width()); + artworkService->setSource(api.get()); auto player = std::make_unique( &window, jellyfin->playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); diff --git a/src/provider/ArtworkSource.h b/src/provider/ArtworkSource.h new file mode 100644 index 00000000..86327366 --- /dev/null +++ b/src/provider/ArtworkSource.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +namespace JellyfinNative { + +// Turns an image the model names (an item, an image slot and the tag the +// source stamped on it) into a URL the artwork pipeline can fetch. Which +// slot, how large, and how hard to compress are decided by ArtworkService; +// only the URL shape belongs to the source. Plain abstract class, see +// Catalog. +class ArtworkSource { +public: + struct ImageRequest { + QString itemId; + QString tag; + // Primary, Backdrop, Logo, Banner or Thumb: the slot names the media + // model's tag fields already use. + QString imageType; + // Either a width bound or an exact fill box; the fill box wins when + // both dimensions are set. + int maxWidth = 0; + int fillWidth = 0; + int fillHeight = 0; + // webp, jpeg or png, and the encoder quality to ask for. + QString format; + int quality = 0; + }; + + virtual ~ArtworkSource() = default; + + // Empty when the request cannot be served (no item, no tag, no origin). + virtual QString imageUrl(const ImageRequest& request) const = 0; +}; + +} // namespace JellyfinNative diff --git a/src/provider/Catalog.h b/src/provider/Catalog.h new file mode 100644 index 00000000..3a3bd73e --- /dev/null +++ b/src/provider/Catalog.h @@ -0,0 +1,43 @@ +#pragma once + +#include "../media/MediaTypes.h" + +#include + +#include +#include + +#include + +namespace JellyfinNative { + +// The browse and lookup half of a media source: what the content, home and +// prefetch controllers ask for today, and nothing more. It is a plain +// abstract class rather than a QObject because the Jellyfin facade already +// derives from PlaybackSource (a QObject) and moc allows only one; a source +// implements this beside that, and QObject-ness is never needed here. +class Catalog { +public: + virtual ~Catalog() = default; + + // Whether lookups can be made right now; a source that needs a session + // reports false until it has one. + virtual bool signedIn() const = 0; + // Names the account and library this catalog serves so cached payloads + // for one never surface under another. Empty while nothing is served. + virtual QString libraryScopeKey() const = 0; + + virtual QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) + = 0; + virtual QCoro::Task fetchItemDetails(QString itemId) = 0; + virtual QCoro::Task> fetchSeasons(QString seriesId) = 0; + virtual QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) = 0; + virtual QCoro::Task> fetchResumeItems(int limit = 24) = 0; + virtual QCoro::Task> fetchNextUpEpisodes(int limit = 24) = 0; + virtual QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) = 0; + virtual QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) = 0; + virtual QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) = 0; +}; + +} // namespace JellyfinNative diff --git a/src/provider/SearchSource.h b/src/provider/SearchSource.h new file mode 100644 index 00000000..761f0b82 --- /dev/null +++ b/src/provider/SearchSource.h @@ -0,0 +1,25 @@ +#pragma once + +#include "../media/MediaTypes.h" + +#include + +#include + +#include + +namespace JellyfinNative { + +// Text search over a media source. Plain abstract class for the same reason +// as Catalog: the implementing facade is already a QObject through +// PlaybackSource. +class SearchSource { +public: + virtual ~SearchSource() = default; + + virtual bool signedIn() const = 0; + virtual QCoro::Task> searchItems(QString searchTerm, int limit = 80) = 0; + virtual QCoro::Task> fetchSearchSuggestions(int limit = 20) = 0; +}; + +} // namespace JellyfinNative diff --git a/src/provider/UserItemStateSink.h b/src/provider/UserItemStateSink.h new file mode 100644 index 00000000..e66c03b1 --- /dev/null +++ b/src/provider/UserItemStateSink.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +namespace JellyfinNative { + +// Where per-user item state is written back: played, favourite and resume +// position. Plain abstract class, see Catalog. A source that keeps no such +// state completes each call without doing anything. +class UserItemStateSink { +public: + virtual ~UserItemStateSink() = default; + + virtual bool signedIn() const = 0; + virtual QCoro::Task setItemFavorite(QString itemId, bool favorite) = 0; + virtual QCoro::Task setItemPlayed(QString itemId, bool played) = 0; + virtual QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) = 0; +}; + +} // namespace JellyfinNative diff --git a/tests/RecordingArtworkSource.h b/tests/RecordingArtworkSource.h new file mode 100644 index 00000000..672a636a --- /dev/null +++ b/tests/RecordingArtworkSource.h @@ -0,0 +1,37 @@ +#pragma once + +#include "provider/ArtworkSource.h" + +#include +#include +#include + +namespace JellyfinNative::Testing { + +// Renders each request as a query string so a test can read back what the +// artwork service asked for; the URL shape a real source produces is that +// source's own test. +class RecordingArtworkSource final : public ArtworkSource { +public: + QString imageUrl(const ImageRequest& request) const override + { + if (request.itemId.isEmpty() || request.tag.isEmpty() || request.imageType.isEmpty()) + return {}; + QUrlQuery query; + query.addQueryItem(QStringLiteral("type"), request.imageType); + if (request.fillWidth > 0 && request.fillHeight > 0) { + query.addQueryItem(QStringLiteral("fillWidth"), QString::number(request.fillWidth)); + query.addQueryItem(QStringLiteral("fillHeight"), QString::number(request.fillHeight)); + } else { + query.addQueryItem(QStringLiteral("maxWidth"), QString::number(request.maxWidth)); + } + query.addQueryItem(QStringLiteral("quality"), QString::number(request.quality)); + query.addQueryItem(QStringLiteral("format"), request.format); + query.addQueryItem(QStringLiteral("tag"), request.tag); + QUrl url(QStringLiteral("https://example.test/") + request.itemId); + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); + } +}; + +} // namespace JellyfinNative::Testing diff --git a/tests/api/JellyfinApiFacadeUrlTest.cpp b/tests/api/JellyfinApiFacadeUrlTest.cpp index 5762bafc..0f7ed520 100644 --- a/tests/api/JellyfinApiFacadeUrlTest.cpp +++ b/tests/api/JellyfinApiFacadeUrlTest.cpp @@ -105,7 +105,7 @@ JELLYFIN_TEST_MAIN("jellyfin-api-facade-url") QTemporaryDir cacheDirectory; require(cacheDirectory.isValid(), "artwork test cache should be available"); ArtworkService artwork(cacheDirectory.path(), 1024 * 1024, 1024 * 1024, 1, &tlsTrust); - artwork.setServerUrl(QStringLiteral("https://media.example.test/jellyfin/root/")); + artwork.setSource(&api); MovieItem imageItem; imageItem.id = QStringLiteral("folder/item 1"); imageItem.thumbTag = QStringLiteral("tag/one two"); diff --git a/tests/app/ArtworkUrlTest.cpp b/tests/app/ArtworkUrlTest.cpp index 63c2a6d7..cb91adc7 100644 --- a/tests/app/ArtworkUrlTest.cpp +++ b/tests/app/ArtworkUrlTest.cpp @@ -1,6 +1,7 @@ #include "app/ArtworkService.h" #include "platform/PlatformSettingsPolicy.h" +#include "RecordingArtworkSource.h" #include "TestMain.h" #include @@ -9,6 +10,7 @@ #include #include +#include using namespace JellyfinNative; @@ -41,10 +43,12 @@ QString parameter(const QString& url, const QString& name) return QUrlQuery(QUrl(url).query()).queryItemValue(name); } +JellyfinNative::Testing::RecordingArtworkSource g_source; + std::unique_ptr service() { auto artwork = std::make_unique(QString(), 0, 1024, 1, nullptr); - artwork->setServerUrl(QStringLiteral("https://example.test")); + artwork->setSource(&g_source); return artwork; } diff --git a/tests/app/SettingsControllerTest.cpp b/tests/app/SettingsControllerTest.cpp index 435647f9..76e66b2c 100644 --- a/tests/app/SettingsControllerTest.cpp +++ b/tests/app/SettingsControllerTest.cpp @@ -4,6 +4,7 @@ #include "cache/DatabaseManager.h" #include "platform/PlatformSettingsPolicy.h" +#include "RecordingArtworkSource.h" #include "TestMain.h" #include @@ -41,8 +42,9 @@ JELLYFIN_TEST_MAIN("settings-controller") require(database.initialize(directory.filePath(QStringLiteral("settings.sqlite"))), "settings database did not initialize"); + JellyfinNative::Testing::RecordingArtworkSource artworkSource; ArtworkService artwork(QString(), 0, 1024, 1, nullptr); - artwork.setServerUrl(QStringLiteral("https://example.test")); + artwork.setSource(&artworkSource); MovieItem poster; poster.id = QStringLiteral("item1"); poster.posterTag = QStringLiteral("tag1"); From 650808d97d7a3dffd5b3269c1be59ebc1c777c25 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:03:59 +1000 Subject: [PATCH 014/146] build: move the source-agnostic controllers and artwork into core With the catalog, search, item-state and artwork surfaces behind provider interfaces, the content, browse, home, search, prefetch and user-item-state controllers, the artwork service and its image provider and prefetcher, and the saved-account row no longer include anything from the Jellyfin side. Move them from SPOOL_JELLYFIN_SOURCES to SPOOL_SHELL_SOURCES and list the four new interface headers with them. AccountProfile is a generic saved-account row (server, user, token, avatar, timestamps) that core's profiles table already persists, so it goes with core rather than staying provider-shaped by name alone. --- CMakeLists.txt | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d3f7118..d40f7209 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -199,6 +199,25 @@ set(SPOOL_PLAYER_SOURCES # Application plumbing that does not name a media backend. set(SPOOL_SHELL_SOURCES + src/app/AccountProfile.cpp + src/app/AccountProfile.h + src/app/ArtworkImageProvider.cpp + src/app/ArtworkImageProvider.h + src/app/ArtworkPrefetcher.h + src/app/ArtworkService.cpp + src/app/ArtworkService.h + src/app/BrowseSessionController.cpp + src/app/BrowseSessionController.h + src/app/ContentModelController.cpp + src/app/ContentModelController.h + src/app/HomeModelController.cpp + src/app/HomeModelController.h + src/app/LibraryPrefetchController.cpp + src/app/LibraryPrefetchController.h + src/app/SearchController.cpp + src/app/SearchController.h + src/app/UserItemStateController.cpp + src/app/UserItemStateController.h src/app/CpuTopology.cpp src/app/CpuTopology.h src/app/LocalizationManager.cpp @@ -235,10 +254,14 @@ set(SPOOL_SHELL_SOURCES src/models/LibraryListModel.h src/models/MovieGridModel.cpp src/models/MovieGridModel.h + src/provider/ArtworkSource.h + src/provider/Catalog.h src/provider/PlaybackSource.h src/provider/Provider.h src/provider/ProviderRegistry.cpp src/provider/ProviderRegistry.h + src/provider/SearchSource.h + src/provider/UserItemStateSink.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp @@ -266,41 +289,22 @@ set(SPOOL_JELLYFIN_SOURCES src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp src/api/PlaybackNegotiation.h - src/app/AccountProfile.cpp - src/app/AccountProfile.h src/app/AppController.cpp src/app/AppController.h - src/app/ArtworkImageProvider.cpp - src/app/ArtworkImageProvider.h - src/app/ArtworkPrefetcher.h - src/app/ArtworkService.cpp - src/app/ArtworkService.h - src/app/BrowseSessionController.cpp - src/app/BrowseSessionController.h - src/app/ContentModelController.cpp - src/app/ContentModelController.h - src/app/HomeModelController.cpp - src/app/HomeModelController.h src/app/LibraryManagementController.cpp src/app/LibraryManagementController.h - src/app/LibraryPrefetchController.cpp - src/app/LibraryPrefetchController.h src/app/QuickConnectController.cpp src/app/QuickConnectController.h src/app/RemoteControlController.cpp src/app/RemoteControlController.h src/app/SpoolLink.cpp src/app/SpoolLink.h - src/app/SearchController.cpp - src/app/SearchController.h src/app/SessionController.cpp src/app/SessionController.h src/app/SyncPlayClock.cpp src/app/SyncPlayClock.h src/app/SyncPlayController.cpp src/app/SyncPlayController.h - src/app/UserItemStateController.cpp - src/app/UserItemStateController.h src/discovery/DiscoveredServer.h src/discovery/DiscoveryController.cpp src/discovery/DiscoveryController.h From ff7ca39944404d832980045b997f6a78bd77af2e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:40:40 +1000 Subject: [PATCH 015/146] refactor(provider): finish the Provider surface and free AppController of Jellyfin Provider now hands out every interface the app consumes: catalog(), artwork(), search(), itemState(), plus the new StreamQualityControl, GroupPlayback and RemotePlayback, and carries the session-shaped hooks and signals (ready, restoreFromStorage, setDeviceId, setLocale, handleUnauthorized; sessionStarted/Ended, busyChanged, errorOccurred, toastRequested, contentChanged) the app used to take from the Jellyfin session and its controllers directly. Catalog gains fetchLibraries, fetchLibraryFilterOptions and fetchItemsByIds; PlaybackSource gains resolvePlayback (was negotiatePlayback) and fetchMediaSegments. The transcoding ceiling moves behind StreamQualityControl with its own streamQuality capability, and the player overlay only offers the quality menu when it is set. SyncPlayController implements GroupPlayback, RemoteControlController implements RemotePlayback, and JellyfinProvider relays session and remote-command signals, sets the artwork token, runs the deferred post-login work and publishes playback activity itself. AppController takes a Provider, includes nothing from the Jellyfin side, guards every group and remote path against a provider that has neither, and moves into the core source group. --- CMakeLists.txt | 14 +- qml/pages/PlayerOverlayPage.qml | 9 +- src/api/JellyfinApiFacade.cpp | 25 +- src/api/JellyfinApiFacade.h | 26 +- src/api/JellyfinProvider.cpp | 168 +++++++- src/api/JellyfinProvider.h | 51 ++- src/app/AppController.cpp | 374 +++++++++--------- src/app/AppController.h | 45 ++- src/app/RemoteControlController.cpp | 7 +- src/app/RemoteControlController.h | 9 +- src/app/SyncPlayController.cpp | 7 +- src/app/SyncPlayController.h | 35 +- src/main.cpp | 27 +- src/provider/Catalog.h | 8 + src/provider/GroupPlayback.h | 49 +++ src/provider/PlaybackSource.h | 8 + src/provider/Provider.h | 118 +++++- src/provider/ProviderRegistry.h | 7 +- src/provider/RemotePlayback.h | 42 ++ src/provider/StreamQualityControl.h | 36 ++ tests/app/ProviderRegistryTest.cpp | 23 +- .../ProviderCapabilities.qml | 2 + tests/qml/tst_ProviderGating.qml | 2 +- 23 files changed, 787 insertions(+), 305 deletions(-) create mode 100644 src/provider/GroupPlayback.h create mode 100644 src/provider/RemotePlayback.h create mode 100644 src/provider/StreamQualityControl.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d40f7209..5c5efb08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,10 +137,9 @@ jellyfin_resolve_platform_dependencies() # now; keeping them apart here is what makes the split mechanical later. # # The rule that matters: nothing in the platform, player or shell groups may -# include src/api/ or src/discovery/. Four places still break it and are the -# work a split has to do first — PlayerController, PlaybackReporter, -# PlayQueueController and SettingsController each take a JellyfinApiFacade, -# and configurePlatformPlaybackCapabilities() is handed one. +# include src/api/ or src/discovery/. Core reaches a media source only +# through the interfaces in src/provider/; +# tools/check-module-seam.sh enforces it as the module-seam test. # Per-OS abstractions. No backend and no UI knowledge. set(SPOOL_PLATFORM_SOURCES @@ -238,6 +237,8 @@ set(SPOOL_SHELL_SOURCES src/app/SpoolRemoteProtocol.h src/app/UpdateManifest.cpp src/app/UpdateManifest.h + src/app/AppController.cpp + src/app/AppController.h src/cache/DatabaseManager.cpp src/cache/DatabaseManager.h src/common/AsyncTask.h @@ -256,11 +257,14 @@ set(SPOOL_SHELL_SOURCES src/models/MovieGridModel.h src/provider/ArtworkSource.h src/provider/Catalog.h + src/provider/GroupPlayback.h src/provider/PlaybackSource.h src/provider/Provider.h src/provider/ProviderRegistry.cpp src/provider/ProviderRegistry.h + src/provider/RemotePlayback.h src/provider/SearchSource.h + src/provider/StreamQualityControl.h src/provider/UserItemStateSink.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h @@ -289,8 +293,6 @@ set(SPOOL_JELLYFIN_SOURCES src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp src/api/PlaybackNegotiation.h - src/app/AppController.cpp - src/app/AppController.h src/app/LibraryManagementController.cpp src/app/LibraryManagementController.h src/app/QuickConnectController.cpp diff --git a/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index d9b286ae..85a56d19 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -147,12 +147,13 @@ FocusScope { { "action": "speed", "label": "Playback speed" - }, - { - "action": "quality", - "label": "Quality" } ] + if (ProviderCapabilities.streamQuality) + values.push({ + "action": "quality", + "label": "Quality" + }) if (!audioOnly) values.push({ "action": "subtitleSettings", diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp index 06e6ad7b..d7810935 100644 --- a/src/api/JellyfinApiFacade.cpp +++ b/src/api/JellyfinApiFacade.cpp @@ -1898,7 +1898,7 @@ QCoro::Task JellyfinApiFacade::syncPlaySetPlaylistItem(QString playlistIte co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetPlaylistItem"), QJsonDocument(body)); } -QCoro::Task JellyfinApiFacade::negotiatePlayback(MovieItem movie, bool forceTranscode) +QCoro::Task JellyfinApiFacade::resolvePlayback(MovieItem movie, bool forceTranscode) { Diagnostics::Task task(QStringLiteral("api_negotiate_playback"), { { QStringLiteral("itemId"), movie.id }, { QStringLiteral("title"), movie.title } }); @@ -2280,4 +2280,27 @@ PlaybackSession JellyfinApiFacade::buildPlaybackSession( }; } +void JellyfinApiFacade::setOverride(qint64 bitrate, int height) +{ + setSessionBitrateOverride(bitrate); + // The rung's resolution travels with its bitrate. Without this the server + // was told a ceiling in megabits and nothing about size, so it re-encoded + // at the source resolution and "480p" only ever meant "fewer bits". + setSessionHeightOverride(height); +} + +QString JellyfinApiFacade::autoDescription() const +{ + return PlaybackBandwidthPolicy::describeAuto( + streamingBitrateSource(), maxStreamingBitrate(), playbackParallelRequests()); +} + +std::vector JellyfinApiFacade::ladder(qint64 sourceBitrate) const +{ + std::vector rungs; + for (const PlaybackBandwidthPolicy::QualityOption& rung : PlaybackBandwidthPolicy::qualityLadder(sourceBitrate)) + rungs.push_back({ rung.label, rung.bitrate, rung.height }); + return rungs; +} + } // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h index 29af3602..7179149b 100644 --- a/src/api/JellyfinApiFacade.h +++ b/src/api/JellyfinApiFacade.h @@ -5,6 +5,7 @@ #include "../provider/Catalog.h" #include "../provider/PlaybackSource.h" #include "../provider/SearchSource.h" +#include "../provider/StreamQualityControl.h" #include "../provider/UserItemStateSink.h" #include "HttpRequestPolicy.h" #include "JellyfinSession.h" @@ -36,7 +37,8 @@ class JellyfinApiFacade final : public PlaybackSource, public Catalog, public SearchSource, public UserItemStateSink, - public ArtworkSource { + public ArtworkSource, + public StreamQualityControl { Q_OBJECT public: @@ -120,12 +122,12 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task updateUserConfiguration(QJsonObject configuration); QCoro::Task fetchCurrentUserPolicy(); QCoro::Task fetchCultures(); - QCoro::Task> fetchLibraries(); + QCoro::Task> fetchLibraries() override; QCoro::Task fetchBrowsePage( BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; - QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}); + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; QCoro::Task fetchItemDetails(QString itemId) override; - QCoro::Task> fetchItemsByIds(QStringList itemIds); + QCoro::Task> fetchItemsByIds(QStringList itemIds) override; QCoro::Task> fetchSeasons(QString seriesId) override; QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; QCoro::Task> fetchResumeItems(int limit = 24) override; @@ -149,10 +151,22 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task setItemFavorite(QString itemId, bool favorite) override; QCoro::Task setItemPlayed(QString itemId, bool played) override; QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; - QCoro::Task> fetchMediaSegments(QString itemId); + QCoro::Task> fetchMediaSegments(QString itemId) override; QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override; - QCoro::Task negotiatePlayback(MovieItem movie, bool forceTranscode = false); + QCoro::Task resolvePlayback(MovieItem movie, bool forceTranscode = false) override; + // StreamQualityControl: the session ceiling and the ladder under it. + qint64 bitrateOverride() const override + { + return sessionBitrateOverride(); + } + int heightOverride() const override + { + return sessionHeightOverride(); + } + void setOverride(qint64 bitrate, int height) override; + QString autoDescription() const override; + std::vector ladder(qint64 sourceBitrate) const override; QCoro::Task fetchControllableSessions(); QCoro::Task sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp index 8fd57ef9..dca38c8a 100644 --- a/src/api/JellyfinProvider.cpp +++ b/src/api/JellyfinProvider.cpp @@ -1,9 +1,11 @@ #include "JellyfinProvider.h" +#include "../app/ArtworkService.h" #include "../app/LibraryManagementController.h" #include "../app/QuickConnectController.h" #include "../app/RemoteControlController.h" #include "../app/SessionController.h" +#include "../app/SettingsController.h" #include "../app/SyncPlayController.h" #include "../cache/DatabaseManager.h" #include "../common/AsyncTask.h" @@ -11,6 +13,7 @@ #include "../discovery/DiscoveryController.h" #include "../models/DiscoveredServerModel.h" #include "../platform/PlatformPlaybackRuntime.h" +#include "../player/PlayerController.h" #include "JellyfinApiFacade.h" #include "JellyfinSettingsBridge.h" #if defined(JELLYFIN_NATIVE_WEBOS) @@ -21,6 +24,7 @@ #include #include #include +#include namespace JellyfinNative { @@ -70,12 +74,48 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje connect(m_quickConnect, &QuickConnectController::authenticated, this, [this](const AuthSession& session) { m_session->acceptSession(session); }); + // What the app shows for the sign-in parts: one busy flag, one error + // line, toasts for the rest. + connect(m_session, &SessionController::busyChanged, this, &Provider::busyChanged); + connect(m_session, &SessionController::errorOccurred, this, &Provider::errorOccurred); + connect(m_quickConnect, &QuickConnectController::busyChanged, this, &Provider::busyChanged); + connect(m_quickConnect, &QuickConnectController::errorOccurred, this, &Provider::errorOccurred); + connect(m_remoteControl, &RemoteControlController::errorText, this, &Provider::toastRequested); + connect(m_remoteControl, &RemoteControlController::feedbackText, this, &Provider::toastRequested); + connect(m_session, &SessionController::accountProfilesChanged, this, + [this]() { setHasDefaultProfile(!m_session->accountProfiles().isEmpty()); }); + connect(m_session, &SessionController::profileActivationStarted, this, &Provider::activationStarted); + connect(m_session, &SessionController::switchUserRequested, this, &Provider::switchUserRequested); + connect(m_session, &SessionController::logoutStarted, this, &Provider::signOutStarted); + // The session drives the parts that only make sense while signed in. connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { + if (m_artwork) + m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); if (m_syncPlay) m_syncPlay->connectSocket(); m_remoteControl->start(); m_discovery->stop(); + setHasDefaultProfile(true); + emit sessionStarted(); + + // The home route is the only launch-critical server work. Subtitle + // metadata and bandwidth probing are useful, but starting them beside + // the initial home requests competes for the TV's limited network and + // JSON-processing budget. Load them after the first interaction window; + // opening Settings sooner triggers the same idempotent load directly. + const QString sessionToken = m_api->session().accessToken; + QTimer::singleShot(5000, this, [this, sessionToken]() { + if (!m_session->authenticated() || m_api->session().accessToken != sessionToken) + return; + if (m_settings) + m_settings->loadRemote(); + Async::runScoped( + this, m_api->refreshPlaybackNetworkState(), []() {}, + [](const std::exception_ptr& error) { + qWarning() << "playback bandwidth: route measurement failed" << exceptionMessage(error); + }); + }); }); connect(m_session, &SessionController::profileActivationStarted, this, [this]() { m_quickConnect->cancel(); @@ -91,6 +131,11 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje }); connect(m_session, &SessionController::loggedOut, this, [this]() { leaveSessionServices(); + if (m_artwork) + m_artwork->setAuthorizationHeader({}); + if (m_management) + m_management->clear(); + emit sessionEnded(); resumeServerDiscovery(); }); } @@ -110,7 +155,7 @@ QString JellyfinProvider::displayName() const Provider::Capabilities JellyfinProvider::capabilities() const { return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | LibraryManagement | SyncPlay - | RemoteControl | QuickConnect | PeerRelay; + | RemoteControl | QuickConnect | PeerRelay | StreamQuality; } PlaybackSource *JellyfinProvider::playback() @@ -118,9 +163,46 @@ PlaybackSource *JellyfinProvider::playback() return m_api; } +Catalog *JellyfinProvider::catalog() +{ + return m_api; +} + +ArtworkSource *JellyfinProvider::artwork() +{ + return m_api; +} + +SearchSource *JellyfinProvider::search() +{ + return m_api; +} + +UserItemStateSink *JellyfinProvider::itemState() +{ + return m_api; +} + +StreamQualityControl *JellyfinProvider::streamQuality() +{ + return m_api; +} + +GroupPlayback *JellyfinProvider::groupPlayback() +{ + return m_syncPlay; +} + +RemotePlayback *JellyfinProvider::remotePlayback() +{ + return m_remoteControl; +} + void JellyfinProvider::attach(const CoreServices& core) { Q_ASSERT(!m_syncPlay); + m_settings = core.settings; + m_artwork = core.artwork; m_syncPlay = new SyncPlayController(m_api, core.player, core.playQueue, m_tlsTrust, core.owner); m_management = new LibraryManagementController(m_api, core.browse, core.owner); m_settingsBridge = new JellyfinSettingsBridge(core.settings, m_api, core.owner); @@ -128,6 +210,34 @@ void JellyfinProvider::attach(const CoreServices& core) connect(m_syncPlay, &QObject::destroyed, this, [this]() { m_syncPlay = nullptr; }); connect(m_management, &QObject::destroyed, this, [this]() { m_management = nullptr; }); connect(m_settingsBridge, &QObject::destroyed, this, [this]() { m_settingsBridge = nullptr; }); + + connect(m_syncPlay, &SyncPlayController::errorText, this, &Provider::toastRequested); + connect(m_management, &LibraryManagementController::errorOccurred, this, &Provider::toastRequested); + connect(m_management, &LibraryManagementController::operationSucceeded, this, &Provider::toastRequested); + connect(m_management, &LibraryManagementController::refreshRequested, this, &Provider::contentChanged); + connect(m_settingsBridge, &JellyfinSettingsBridge::errorOccurred, this, &Provider::toastRequested); + + // Commands other clients send arrive over the SyncPlay socket; they + // reach the app only while this device is set to accept them. + const auto forward = [this](void (RemotePlayback::*signal)(const QJsonObject&)) { + return [this, signal](const QJsonObject& data) { + if (m_api->remoteControlTargetEnabled()) + emit(m_remoteControl->*signal)(data); + }; + }; + connect(m_syncPlay, &SyncPlayController::remotePlayCommand, m_remoteControl, + forward(&RemotePlayback::playCommandReceived)); + connect(m_syncPlay, &SyncPlayController::remotePlaystateCommand, m_remoteControl, + forward(&RemotePlayback::playstateCommandReceived)); + connect(m_syncPlay, &SyncPlayController::remoteGeneralCommand, m_remoteControl, + forward(&RemotePlayback::generalCommandReceived)); + if (SpoolLink *link = m_remoteControl->link()) + connect(link, &SpoolLink::messageReceived, m_remoteControl, &RemotePlayback::peerMessageReceived); + + // Keep the bandwidth probe off the wire while a stream is running; it + // resumes on its own once the session ends. + connect(core.player, &PlayerController::sessionActiveChanged, this, + [this, player = core.player]() { m_api->setPlaybackActive(player->sessionActive()); }); } void JellyfinProvider::registerQmlSingletons() @@ -142,6 +252,47 @@ void JellyfinProvider::registerQmlSingletons() qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "DiscoveredServers", m_discoveredServers); } +void JellyfinProvider::shutdown() +{ + m_quickConnect->cancel(); + m_api->cancelRequests(); + m_discovery->stop(); +} + +bool JellyfinProvider::ready() const +{ + return m_session->authenticated(); +} + +QStringList JellyfinProvider::startupStorageKeys() const +{ + return SessionController::localStorageKeys(); +} + +bool JellyfinProvider::restoreFromStorage(QVariantMap values, std::vector profiles) +{ + const bool hasDefaultProfile = m_session->initializeFromStorage(std::move(values), std::move(profiles)); + setHasDefaultProfile(hasDefaultProfile); + if (!m_session->authenticated()) + resumeServerDiscovery(); + return hasDefaultProfile; +} + +void JellyfinProvider::setDeviceId(const QString& deviceId) +{ + m_api->setDeviceId(deviceId); +} + +void JellyfinProvider::setLocale(const QString& bcp47) +{ + m_api->setAcceptLanguage(bcp47); +} + +bool JellyfinProvider::handleUnauthorized(const std::exception_ptr& error) +{ + return m_session->handleUnauthorized(error); +} + void JellyfinProvider::resumeServerDiscovery() { Async::runScoped( @@ -158,13 +309,6 @@ void JellyfinProvider::resumeServerDiscovery() "discovery cache"); } -void JellyfinProvider::shutdown() -{ - m_quickConnect->cancel(); - m_api->cancelRequests(); - m_discovery->stop(); -} - void JellyfinProvider::cacheDiscoveredServers() { QJsonArray cache; @@ -190,4 +334,12 @@ void JellyfinProvider::leaveSessionServices() m_remoteControl->stop(); } +void JellyfinProvider::setHasDefaultProfile(bool hasDefaultProfile) +{ + if (m_hasDefaultProfile == hasDefaultProfile) + return; + m_hasDefaultProfile = hasDefaultProfile; + emit hasDefaultProfileChanged(); +} + } // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.h b/src/api/JellyfinProvider.h index 36b5c722..41bac9e6 100644 --- a/src/api/JellyfinProvider.h +++ b/src/api/JellyfinProvider.h @@ -10,15 +10,13 @@ class QNetworkAccessManager; namespace JellyfinNative { -class BrowseSessionController; +class ArtworkService; class DatabaseManager; class DiscoveredServerModel; class DiscoveryController; class JellyfinApiFacade; class JellyfinSettingsBridge; class LibraryManagementController; -class PlayQueueController; -class PlayerController; class QuickConnectController; class RemoteControlController; class SessionController; @@ -40,7 +38,8 @@ struct JellyfinProviderContext { // core objects those parts sit on through attach(); the provider then keeps // the session-driven lifecycle of its own parts (discovery scans while // nobody is signed in, the SyncPlay socket and remote-control registration -// follow the session) so the app never has to. +// follow the session, artwork carries the session's token) so the app never +// has to. The app sees it only as a Provider. class JellyfinProvider final : public Provider { Q_OBJECT @@ -52,25 +51,28 @@ class JellyfinProvider final : public Provider { QString displayName() const override; Capabilities capabilities() const override; PlaybackSource *playback() override; - void registerQmlSingletons() override; + Catalog *catalog() override; + ArtworkSource *artwork() override; + SearchSource *search() override; + UserItemStateSink *itemState() override; + StreamQualityControl *streamQuality() override; + GroupPlayback *groupPlayback() override; + RemotePlayback *remotePlayback() override; - // The core objects built after the provider exists. The parts created - // here are parented to `owner`, which must not outlive `player`: they - // hold it by pointer. - struct CoreServices { - QObject *owner = nullptr; - PlayerController *player = nullptr; - PlayQueueController *playQueue = nullptr; - SettingsController *settings = nullptr; - BrowseSessionController *browse = nullptr; - }; - void attach(const CoreServices& core); + void attach(const CoreServices& core) override; + void registerQmlSingletons() override; + void shutdown() override; - // Shows the servers seen last time and scans for more while nobody is - // signed in. The app calls it once its stored session is known; the - // provider calls it itself after a sign-out. - void resumeServerDiscovery(); - void shutdown(); + bool ready() const override; + bool hasDefaultProfile() const override + { + return m_hasDefaultProfile; + } + QStringList startupStorageKeys() const override; + bool restoreFromStorage(QVariantMap values, std::vector profiles) override; + void setDeviceId(const QString& deviceId) override; + void setLocale(const QString& bcp47) override; + bool handleUnauthorized(const std::exception_ptr& error) override; JellyfinApiFacade *api() const { @@ -111,9 +113,13 @@ class JellyfinProvider final : public Provider { } private: + // Shows the servers seen last time and scans for more while nobody is + // signed in. Runs after the stored session is known and after a sign-out. + void resumeServerDiscovery(); void cacheDiscoveredServers(); QCoro::Task restoreDiscoveredServersAsync(); void leaveSessionServices(); + void setHasDefaultProfile(bool hasDefaultProfile); DatabaseManager *m_database = nullptr; TlsTrustController *m_tlsTrust = nullptr; @@ -126,6 +132,9 @@ class JellyfinProvider final : public Provider { SyncPlayController *m_syncPlay = nullptr; LibraryManagementController *m_management = nullptr; JellyfinSettingsBridge *m_settingsBridge = nullptr; + SettingsController *m_settings = nullptr; + ArtworkService *m_artwork = nullptr; + bool m_hasDefaultProfile = false; }; } // namespace JellyfinNative diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index a4a251b6..f429393a 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -2,27 +2,26 @@ #include "ArtworkService.h" -#include "../api/JellyfinApiFacade.h" -#include "../api/JellyfinProvider.h" -#include "../api/JellyfinSettingsBridge.h" #include "../common/AsyncTask.h" #include "../common/SeriesAudioSelection.h" #include "../diagnostics/Diagnostics.h" -#include "../discovery/DiscoveryController.h" #include "../platform/PlatformPaths.h" #include "../player/PlayQueueController.h" #include "../player/PlaybackFailurePolicy.h" #include "../player/PlayerController.h" +#include "../provider/Catalog.h" +#include "../provider/GroupPlayback.h" +#include "../provider/PlaybackSource.h" +#include "../provider/Provider.h" +#include "../provider/RemotePlayback.h" +#include "../provider/StreamQualityControl.h" #include "BrowseSessionController.h" #include "ContentModelController.h" #include "HomeModelController.h" #include "LibraryPrefetchController.h" #include "LibraryQuery.h" -#include "QuickConnectController.h" #include "SearchController.h" -#include "SessionController.h" #include "SettingsController.h" -#include "SpoolLink.h" #include "SpoolRemoteProtocol.h" #include "UserItemStateController.h" @@ -55,12 +54,13 @@ namespace { // Reopening a library within this window shows the cached page as-is; // a server refresh so soon after the last one only causes delegate churn. constexpr qint64 kFreshLibraryCacheMs = 30000; - constexpr auto kSeriesTrackSelectionNamespace = "series-track-selection-v2"; + // v3: keyed by the catalog's scope rather than a Jellyfin server and user. + constexpr auto kSeriesTrackSelectionNamespace = "series-track-selection-v3"; constexpr auto kRememberSeriesAudioTrackKey = "playback/rememberSeriesAudioTrack"; - QString seriesTrackSelectionKey(const AuthSession& session, const QString& seriesId) + QString seriesTrackSelectionKey(const QString& libraryScopeKey, const QString& seriesId) { - return session.serverId + QLatin1Char('/') + session.userId + QLatin1Char('/') + seriesId; + return libraryScopeKey + QLatin1Char('/') + seriesId; } QByteArray encodeTrackSelection(const SeriesAudioPreference& audioPreference, int subtitleStreamIndex) @@ -94,123 +94,87 @@ namespace { } -AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyfin, ArtworkService *artwork, - PlayerController *player, QObject *parent) +AppController::AppController( + DatabaseManager *database, Provider *provider, ArtworkService *artwork, PlayerController *player, QObject *parent) : QObject(parent) , m_database(database) - , m_jellyfin(jellyfin) - , m_api(jellyfin->api()) + , m_provider(provider) + , m_catalog(provider->catalog()) + , m_playback(provider->playback()) + , m_quality(provider->streamQuality()) , m_artwork(artwork) , m_player(player) - , m_remoteControl(jellyfin->remoteControl()) - , m_quickConnect(jellyfin->quickConnect()) - , m_session(jellyfin->session()) { - JellyfinApiFacade *api = m_api; - m_playQueue = new PlayQueueController(api, this); + m_playQueue = new PlayQueueController(m_playback, this); m_settings = new SettingsController(database, player, artwork, this); - m_prefetch = new LibraryPrefetchController(api, artwork, this); + m_prefetch = new LibraryPrefetchController(m_catalog, artwork, this); m_browse = new BrowseSessionController(m_prefetch, this); - m_home = new HomeModelController(database, api, m_prefetch, this); - m_content = new ContentModelController(api, m_prefetch, this); - m_search = new SearchController(api, m_prefetch, this); - m_itemState = new UserItemStateController(api, m_browse, m_home, m_content, m_search, this); + m_home = new HomeModelController(database, m_catalog, m_prefetch, this); + m_content = new ContentModelController(m_catalog, m_prefetch, this); + m_search = new SearchController(provider->search(), m_prefetch, this); + m_itemState = new UserItemStateController(provider->itemState(), m_browse, m_home, m_content, m_search, this); // The provider's session-bound parts sit on the queue, settings and // browse state built above, and die with this object as they always did. - m_jellyfin->attach({ this, player, m_playQueue, m_settings, m_browse }); - m_syncPlay = m_jellyfin->syncPlay(); - m_management = m_jellyfin->management(); - JellyfinSettingsBridge *settingsBridge = m_jellyfin->settingsBridge(); + m_provider->attach({ this, player, m_playQueue, m_settings, m_browse, artwork }); + m_group = m_provider->groupPlayback(); + m_remote = m_provider->remotePlayback(); connect(m_playQueue, &PlayQueueController::successorPlaybackReady, this, [this]() { playQueueCurrent(false); }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); - connect(m_syncPlay, &SyncPlayController::errorText, this, &AppController::showToast); - connect(m_remoteControl, &RemoteControlController::errorText, this, &AppController::showToast); - connect(m_remoteControl, &RemoteControlController::feedbackText, this, &AppController::showToast); - if (SpoolLink *link = m_remoteControl->link()) - connect(link, &SpoolLink::messageReceived, this, &AppController::handleSpoolMessage); connect(m_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); - connect(m_syncPlay, &SyncPlayController::remotePlayCommand, this, &AppController::handleRemotePlay); - connect(m_syncPlay, &SyncPlayController::remotePlaystateCommand, this, &AppController::handleRemotePlaystate); - connect(m_syncPlay, &SyncPlayController::remoteGeneralCommand, this, &AppController::handleRemoteGeneralCommand); - connect(m_syncPlay, &SyncPlayController::queuePlaybackRequested, this, [this](qint64 positionTicks) { - MovieItem item = m_playQueue->currentItem(); - if (item.id.isEmpty()) - return; - item.resumeTicks = std::max(0, positionTicks); - if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) - m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); - m_activePlaybackItem = item; - setBusy(true, QStringLiteral("Joining SyncPlay playback…")); - Async::runScoped( - this, startPlayback(item, true), []() {}, - [this](const std::exception_ptr& error) { - setBusy(false); - showToast(exceptionMessage(error)); - }, - "SyncPlay playback startup"); - }); + if (m_remote) { + connect(m_remote, &RemotePlayback::peerMessageReceived, this, &AppController::handleSpoolMessage); + connect(m_remote, &RemotePlayback::playCommandReceived, this, &AppController::handleRemotePlay); + connect(m_remote, &RemotePlayback::playstateCommandReceived, this, &AppController::handleRemotePlaystate); + connect(m_remote, &RemotePlayback::generalCommandReceived, this, &AppController::handleRemoteGeneralCommand); + } + if (m_group) { + connect(m_group, &GroupPlayback::queuePlaybackRequested, this, [this](qint64 positionTicks) { + MovieItem item = m_playQueue->currentItem(); + if (item.id.isEmpty()) + return; + item.resumeTicks = std::max(0, positionTicks); + if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) + m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); + m_activePlaybackItem = item; + setBusy(true, QStringLiteral("Joining SyncPlay playback…")); + Async::runScoped( + this, startPlayback(item, true), []() {}, + [this](const std::exception_ptr& error) { + setBusy(false); + showToast(exceptionMessage(error)); + }, + "SyncPlay playback startup"); + }); + } connect(m_content, &ContentModelController::errorOccurred, this, &AppController::showToast); connect(m_search, &SearchController::errorOccurred, this, &AppController::showToast); connect(m_itemState, &UserItemStateController::errorOccurred, this, &AppController::setErrorText); - connect(m_management, &LibraryManagementController::errorOccurred, this, &AppController::showToast); - connect(m_management, &LibraryManagementController::operationSucceeded, this, &AppController::showToast); - connect(m_management, &LibraryManagementController::refreshRequested, this, [this](const QString& changedItemId) { + connect(m_settings, &SettingsController::errorOccurred, this, &AppController::showToast); + connect(m_provider, &Provider::toastRequested, this, &AppController::showToast); + connect(m_provider, &Provider::busyChanged, this, &AppController::setBusy); + connect(m_provider, &Provider::errorOccurred, this, &AppController::setErrorText); + connect(m_provider, &Provider::contentChanged, this, [this](const QString& changedItemId) { if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) goHome(); else beginBrowse(); }); - connect(m_quickConnect, &QuickConnectController::busyChanged, this, &AppController::setBusy); - connect(m_quickConnect, &QuickConnectController::errorOccurred, this, &AppController::setErrorText); - connect(m_settings, &SettingsController::errorOccurred, this, &AppController::showToast); - connect(settingsBridge, &JellyfinSettingsBridge::errorOccurred, this, &AppController::showToast); - connect(m_session, &SessionController::busyChanged, this, &AppController::setBusy); - connect(m_session, &SessionController::errorOccurred, this, &AppController::setErrorText); - connect(m_session, &SessionController::accountProfilesChanged, this, [this]() { - const bool hasProfiles = !m_session->accountProfiles().isEmpty(); - if (m_hasDefaultProfile == hasProfiles) - return; - m_hasDefaultProfile = hasProfiles; - emit defaultProfileChanged(); - }); - connect(m_session, &SessionController::profileActivationStarted, this, [this]() { setErrorText({}); }); - connect(m_session, &SessionController::switchUserRequested, this, [this]() { + connect(m_provider, &Provider::hasDefaultProfileChanged, this, &AppController::defaultProfileChanged); + connect(m_provider, &Provider::activationStarted, this, [this]() { setErrorText({}); }); + connect(m_provider, &Provider::switchUserRequested, this, [this]() { setBusy(false); setErrorText({}); }); - connect(m_session, &SessionController::logoutStarted, this, [this]() { + connect(m_provider, &Provider::signOutStarted, this, [this]() { if (m_player->visible()) m_player->stopWithReason(QStringLiteral("logout")); }); - connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { - if (m_artwork) - m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); - if (!m_hasDefaultProfile) { - m_hasDefaultProfile = true; - emit defaultProfileChanged(); - } + connect(m_provider, &Provider::sessionStarted, this, [this]() { m_home->loadCachedPayload(); loadLibraries(); - - // The home route is the only launch-critical server work. Subtitle - // metadata and bandwidth probing are useful, but starting them beside - // the initial home requests competes for the TV's limited network and - // JSON-processing budget. Load them after the first interaction window; - // opening Settings sooner triggers the same idempotent load directly. - const QString sessionToken = m_api->session().accessToken; - QTimer::singleShot(5000, this, [this, sessionToken]() { - if (!m_session->authenticated() || m_api->session().accessToken != sessionToken) - return; - m_settings->loadRemote(); - Async::runScoped( - this, m_api->refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: route measurement failed" << exceptionMessage(error); - }); - }); }); - connect(m_session, &SessionController::loggedOut, this, &AppController::resetApplicationState); + connect(m_provider, &Provider::sessionEnded, this, &AppController::resetApplicationState); connect(m_player, &PlayerController::playbackStopped, this, &AppController::handlePlaybackStopped); // The device has just shown it cannot sustain the picture it was asked @@ -249,8 +213,6 @@ AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyf if (m_player->visible()) setPlaybackTransition(false); }); - connect(m_player, &PlayerController::sessionActiveChanged, this, - [this]() { m_api->setPlaybackActive(m_player->sessionActive()); }); connect(m_player, &PlayerController::streamSelectionChanged, this, [this](int audioStreamIndex, int subtitleStreamIndex) { // Kept so a quality change can renegotiate onto the same tracks. @@ -263,13 +225,13 @@ AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyf ? seriesAudioPreferenceForSelection(m_activePlaybackStreams, audioStreamIndex) : SeriesAudioPreference {}; m_database->saveCacheEntry(QString::fromLatin1(kSeriesTrackSelectionNamespace), - seriesTrackSelectionKey(m_api->session(), m_activePlaybackItem.seriesId), + seriesTrackSelectionKey(m_catalog->libraryScopeKey(), m_activePlaybackItem.seriesId), encodeTrackSelection(audioPreference, subtitleStreamIndex)); }); connect(m_player, &PlayerController::playbackLoadFailed, this, [this](const QString& itemId, qint64 positionTicks, const QString&, bool retryableCodecFailure, int audioStreamIndex, int subtitleStreamIndex) { - const bool syncPlayActive = m_syncPlay && m_syncPlay->enabled(); + const bool syncPlayActive = inSyncPlayGroup(); if (itemId.isEmpty() || itemId != m_activePlaybackItem.id) return; // A quality the server cannot deliver should cost the viewer the @@ -279,8 +241,8 @@ AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyf const int restoredHeight = m_qualityFallbackHeight; m_qualityFallbackBitrate = -1; m_qualityFallbackHeight = 0; - m_api->setSessionBitrateOverride(restoredBitrate); - m_api->setSessionHeightOverride(restoredHeight); + if (m_quality) + m_quality->setOverride(restoredBitrate, restoredHeight); emit streamingQualityChanged(); const MovieItem resumeItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); setBusy(true, QStringLiteral("Restoring the previous quality…")); @@ -295,7 +257,7 @@ AppController::AppController(DatabaseManager *database, JellyfinProvider *jellyf return; } if (retryableCodecFailure && syncPlayActive) { - m_syncPlay->leaveGroup(); + m_group->leaveGroup(); showToast(QStringLiteral("This stream is not directly compatible. Leaving SyncPlay; start it again to " "use server transcoding.")); return; @@ -348,7 +310,7 @@ QCoro::Task AppController::initializeAsync() { Diagnostics::Task task(QStringLiteral("app_initialize")); QStringList startupKeys = SettingsController::localSettingKeys(); - startupKeys.append(SessionController::localStorageKeys()); + startupKeys.append(m_provider->startupStorageKeys()); StartupState startupState = co_await m_database->loadStartupStateAsync(startupKeys); QString deviceId = std::move(startupState.deviceId); @@ -371,20 +333,18 @@ QCoro::Task AppController::initializeAsync() deviceId += QStringLiteral("-%1").arg(instanceSlot + 1); qInfo() << "app: another instance holds the stored device identity; running as instance" << instanceSlot + 1; } - m_api->setDeviceId(deviceId); + m_provider->setDeviceId(deviceId); m_settings->applyLocalValues(startupState.values); - const bool hasDefaultProfile - = m_session->initializeFromStorage(std::move(startupState.values), std::move(startupState.profiles)); - if (m_hasDefaultProfile != hasDefaultProfile) { - m_hasDefaultProfile = hasDefaultProfile; - emit defaultProfileChanged(); - } + m_provider->restoreFromStorage(std::move(startupState.values), std::move(startupState.profiles)); m_initialized = true; emit initializedChanged(); - if (!m_session->authenticated()) - m_jellyfin->resumeServerDiscovery(); +} + +bool AppController::hasDefaultProfile() const +{ + return m_provider->hasDefaultProfile(); } void AppController::resetApplicationState() @@ -405,7 +365,6 @@ void AppController::resetApplicationState() m_content->reset(); m_search->reset(); m_activePlaybackItem = {}; - m_management->clear(); m_libraryLoadGeneration.invalidate(); m_browse->reset(); setBusy(false); @@ -414,13 +373,12 @@ void AppController::resetApplicationState() void AppController::goHome() { - if (!m_session->authenticated()) + if (!m_provider->ready()) return; qInfo() << "app: go home viewKind=" << m_browse->viewKind(); m_libraryLoadGeneration.invalidate(); setBusy(false); - m_jellyfin->discovery()->stop(); refreshHomeRows(); } @@ -491,7 +449,7 @@ void AppController::playFromModel(QObject *model, int index, bool fromStart) } // Opening an item from the queue is still a play request, and it must // land wherever every other one does. - if (m_remoteControl->targetSelected()) { + if (remoteTargetSelected()) { const MovieItem item = m_playQueue->itemAt(index); if (!item.id.isEmpty()) { playQueuedItem(item, fromStart); @@ -551,7 +509,7 @@ bool AppController::modelIsOrderedList(MovieGridModel *model) const void AppController::playAlbumFrom(const MovieItem& track, bool fromStart) { - if (!m_api || track.albumId.isEmpty()) { + if (track.albumId.isEmpty()) { playQueuedItem(track, fromStart); return; } @@ -561,7 +519,7 @@ void AppController::playAlbumFrom(const MovieItem& track, bool fromStart) // The same descriptor ContentModelController uses for album children, so // there is one definition of what an album contains. Async::runScoped( - this, m_api->fetchBrowsePage(BrowseDescriptor::folderChildren(track.albumId), 0, 200, {}), + this, m_catalog->fetchBrowsePage(BrowseDescriptor::folderChildren(track.albumId), 0, 200, {}), [this, generation, track, fromStart](const PagedMovieItems& page) { if (generation != m_albumQueueGeneration) return; @@ -600,8 +558,8 @@ void AppController::playQueueNext() playEpisodeWithContext(m_playQueue->currentItem(), 1, true); return; } - if (m_syncPlay && m_syncPlay->enabled()) { - m_syncPlay->requestNextItem(); + if (inSyncPlayGroup()) { + m_group->requestNextItem(); return; } if (!m_playQueue->next()) @@ -615,8 +573,8 @@ void AppController::playQueuePrevious() playEpisodeWithContext(m_playQueue->currentItem(), -1, true); return; } - if (m_syncPlay && m_syncPlay->enabled()) { - m_syncPlay->requestPreviousItem(); + if (inSyncPlayGroup()) { + m_group->requestPreviousItem(); return; } if (!m_playQueue->previous()) @@ -633,10 +591,10 @@ void AppController::playQueueItem(int index) if (inSyncPlayGroup()) { // Jumping the group, not just this client. - m_syncPlay->requestPlayItem(queuePlaylistItemId(index)); + m_group->requestPlayItem(queuePlaylistItemId(index)); return; } - if (m_remoteControl->targetSelected()) { + if (remoteTargetSelected()) { const MovieItem item = m_playQueue->itemAt(index); if (!item.id.isEmpty()) { playQueuedItem(item, false); @@ -655,7 +613,26 @@ bool AppController::queueEditable() const bool AppController::inSyncPlayGroup() const { - return m_syncPlay && m_syncPlay->enabled(); + return m_group && m_group->enabled(); +} + +bool AppController::remoteTargetSelected() const +{ + return m_remote && m_remote->targetSelected(); +} + +bool AppController::playRemotely( + const std::vector& items, int startIndex, const QString& command, bool fromStart) +{ + return m_remote && m_remote->playItems(items, startIndex, command, fromStart); +} + +void AppController::groupOrLocalTogglePause() +{ + if (inSyncPlayGroup()) + m_group->requestTogglePause(); + else + m_player->togglePause(); } QString AppController::queuePlaylistItemId(int index) const @@ -677,7 +654,7 @@ void AppController::commitQueueMove(int from, int to) if (from == to || !inSyncPlayGroup()) return; // The preview already left the row at `to`, so that is the entry to publish. - m_syncPlay->requestMoveItem(queuePlaylistItemId(to), to); + m_group->requestMoveItem(queuePlaylistItemId(to), to); } bool AppController::previewQueueMoveRange(int from, int count, int to) @@ -692,7 +669,7 @@ void AppController::commitQueueMoveRange(int from, int count, int to) // The preview has already laid the block down at `to`, so publish the rows // where they now sit, top down, which is the order the group will apply. for (int offset = 0; offset < count; ++offset) - m_syncPlay->requestMoveItem(queuePlaylistItemId(to + offset), to + offset); + m_group->requestMoveItem(queuePlaylistItemId(to + offset), to + offset); } void AppController::removeQueueItem(int index) @@ -700,7 +677,7 @@ void AppController::removeQueueItem(int index) if (inSyncPlayGroup()) { // No local edit: a removal is a single action with nothing to animate, // so let the group's broadcast be the one thing that changes the queue. - m_syncPlay->requestRemoveItems({ queuePlaylistItemId(index) }); + m_group->requestRemoveItems({ queuePlaylistItemId(index) }); return; } @@ -722,7 +699,7 @@ void AppController::removeQueueItem(int index) void AppController::playNextFromItem(const MovieItem& item) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayNext"), false)) + if (playRemotely({ item }, 0, QStringLiteral("PlayNext"), false)) return; if (enqueueForGroup(item, true)) return; @@ -732,7 +709,7 @@ void AppController::playNextFromItem(const MovieItem& item) void AppController::addToQueueFromItem(const MovieItem& item) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayLast"), false)) + if (playRemotely({ item }, 0, QStringLiteral("PlayLast"), false)) return; if (enqueueForGroup(item, false)) return; @@ -748,7 +725,7 @@ bool AppController::enqueueForGroup(const MovieItem& item, bool queueNext) setErrorText(QStringLiteral("This item cannot be queued.")); return true; } - m_syncPlay->requestQueueItems({ item.id }, queueNext); + m_group->requestQueueItems({ item.id }, queueNext); return true; } @@ -756,7 +733,7 @@ void AppController::loadMoreCurrentItems() { if (m_browse->loadingMore() || !m_browse->hasMore()) return; - if (!m_api || m_api->session().accessToken.isEmpty()) + if (!m_catalog->signedIn()) return; const BrowseDescriptor descriptor = m_browse->descriptor(); if (!descriptor.isValid()) @@ -776,13 +753,13 @@ void AppController::loadMoreCurrentItems() showToast(exceptionMessage(error)); }; - Async::runLatest(this, m_api->fetchBrowsePage(descriptor, startIndex, kLibraryPageSize, query), + Async::runLatest(this, m_catalog->fetchBrowsePage(descriptor, startIndex, kLibraryPageSize, query), m_libraryLoadGeneration, loadGeneration, onDone, onError); } void AppController::playQueuedItems(const std::vector& items, int startIndex, bool fromStart) { - if (m_remoteControl->playItems(items, startIndex, QStringLiteral("PlayNow"), fromStart)) + if (playRemotely(items, startIndex, QStringLiteral("PlayNow"), fromStart)) return; if (!m_playQueue->playNow(items, startIndex)) { showToast(QStringLiteral("This item cannot be queued.")); @@ -795,8 +772,8 @@ void AppController::playModel(MovieGridModel *model, bool shuffled) { if (!model) return; - if (shuffled && m_remoteControl->targetSelected()) { - m_remoteControl->playItems(model->movies(), 0, QStringLiteral("PlayShuffle"), false); + if (shuffled && remoteTargetSelected()) { + playRemotely(model->movies(), 0, QStringLiteral("PlayShuffle"), false); return; } const std::vector& items = model->movies(); @@ -813,12 +790,12 @@ void AppController::playModel(MovieGridModel *model, bool shuffled) void AppController::queueEpisodicContainer(const QString& seriesId, const QString& seasonId, bool next) { - if (!m_api || seriesId.isEmpty()) + if (seriesId.isEmpty()) return; Async::runScoped( - this, m_api->fetchEpisodes(seriesId, seasonId), + this, m_catalog->fetchEpisodes(seriesId, seasonId), [this, next](const std::vector& episodes) { - if (m_remoteControl->playItems(episodes, next ? 0 : static_cast(episodes.size()) - 1, + if (playRemotely(episodes, next ? 0 : static_cast(episodes.size()) - 1, next ? QStringLiteral("PlayNext") : QStringLiteral("PlayLast"), false)) return; if (episodes.empty()) { @@ -836,7 +813,7 @@ void AppController::queueEpisodicContainer(const QString& seriesId, const QStrin void AppController::playEpisodicContainer(const QString& seriesId, const QString& seasonId) { - if (!m_api || seriesId.isEmpty()) + if (seriesId.isEmpty()) return; if (m_episodeQueuePending) return; @@ -847,7 +824,7 @@ void AppController::playEpisodicContainer(const QString& seriesId, const QString seasonId.isEmpty() ? QStringLiteral("Finding the next episode…") : QStringLiteral("Finding the next episode in this season…")); Async::runScoped( - this, m_api->fetchEpisodes(seriesId, seasonId), + this, m_catalog->fetchEpisodes(seriesId, seasonId), [this, generation](const std::vector& episodes) { if (generation != m_episodeQueueGeneration) return; @@ -885,7 +862,7 @@ void AppController::cancelEpisodicPlaybackSelection() void AppController::playQueuedItem(const MovieItem& item, bool fromStart) { - if (m_remoteControl->playItems({ item }, 0, QStringLiteral("PlayNow"), fromStart)) + if (playRemotely({ item }, 0, QStringLiteral("PlayNow"), fromStart)) return; if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) { playEpisodeWithContext(item, 0, fromStart); @@ -900,7 +877,7 @@ void AppController::playQueuedItem(const MovieItem& item, bool fromStart) void AppController::playEpisodeWithContext(const MovieItem& episode, int direction, bool fromStart) { - if (!m_api || episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty()) { + if (episode.itemType != QStringLiteral("Episode") || episode.seriesId.isEmpty()) { if (direction == 0 && m_playQueue->playNow(episode)) startQueuedPlayback(fromStart); return; @@ -910,7 +887,7 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi m_episodeQueuePending = true; setBusy(true, direction == 0 ? QStringLiteral("Loading episode queue…") : QStringLiteral("Finding episode…")); Async::runScoped( - this, m_api->fetchEpisodes(episode.seriesId), + this, m_catalog->fetchEpisodes(episode.seriesId), [this, generation, episode, direction, fromStart](const std::vector& episodes) { if (generation != m_episodeQueueGeneration) return; @@ -966,7 +943,7 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi void AppController::startQueuedPlayback(bool fromStart) { - if (!m_syncPlay || !m_syncPlay->enabled()) { + if (!inSyncPlayGroup()) { playQueueCurrent(fromStart); return; } @@ -985,13 +962,13 @@ void AppController::startQueuedPlayback(bool fromStart) // Arm this before SetNewQueue: the websocket PlayQueue update can arrive // before or after the HTTP response. Waiting for the response allowed an // old loaded session to consume the pending unpause request. - m_syncPlay->requestUnpauseWhenReady(); + m_group->requestUnpauseWhenReady(); Async::runScoped( - this, m_api->syncPlaySetNewQueue(itemIds, m_playQueue->currentIndex(), startPositionTicks), []() {}, + this, m_group->publishQueue(itemIds, m_playQueue->currentIndex(), startPositionTicks), []() {}, [this, generation](const std::exception_ptr& error) { if (!m_syncPlayQueueRequestGeneration.isCurrent(generation)) return; - m_syncPlay->cancelPendingUnpause(); + m_group->cancelPendingUnpause(); setBusy(false); showToast(exceptionMessage(error)); }, @@ -1006,10 +983,10 @@ void AppController::playQueueCurrent(bool fromStart) if (fromStart || !isMeaningfulResumePosition(item.resumeTicks, item.runtimeTicks)) item.resumeTicks = 0; m_activePlaybackItem = item; - if (m_api && item.itemType == QStringLiteral("Movie") && item.people.isEmpty()) { + if (item.itemType == QStringLiteral("Movie") && item.people.isEmpty()) { const QString itemId = item.id; Async::runScoped( - this, m_api->fetchItemDetails(itemId), + this, m_catalog->fetchItemDetails(itemId), [this, itemId](const MovieItem& details) { m_playQueue->updatePeople(itemId, details.people); }, [itemId](const std::exception_ptr& error) { qInfo() << "app: movie credits unavailable for" << itemId << ":" << exceptionMessage(error); @@ -1028,8 +1005,6 @@ void AppController::playQueueCurrent(bool fromStart) void AppController::handleRemotePlay(const QJsonObject& data) { - if (!m_api->remoteControlTargetEnabled()) - return; QStringList itemIds; for (const QJsonValue& value : data.value(QStringLiteral("ItemIds")).toArray()) { const QString id = value.toString(); @@ -1055,7 +1030,7 @@ void AppController::handleRemotePlay(const QJsonObject& data) setBusy(true, QStringLiteral("Starting remote playback…")); qInfo() << "remote: play" << command << itemIds.size() << "items, index" << requestedIndex; Async::runScoped( - this, m_api->fetchItemsByIds(itemIds), + this, m_catalog->fetchItemsByIds(itemIds), [this, generation, fingerprint, itemIds, command, requestedIndex, startTicks]( const std::vector& fetched) { if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) @@ -1128,7 +1103,8 @@ void AppController::handleSpoolMessage(const SpoolRemoteProtocol::Message& messa } if (const auto join = SpoolRemoteProtocol::syncPlayJoin(message)) { qInfo() << "remote: joining SyncPlay group" << join->groupId << "on request"; - m_syncPlay->joinGroup(join->groupId); + if (m_group) + m_group->joinGroup(join->groupId); return; } qInfo() << "remote: ignoring unknown Spool command" << message.command; @@ -1136,29 +1112,36 @@ void AppController::handleSpoolMessage(const SpoolRemoteProtocol::Message& messa void AppController::handleRemotePlaystate(const QJsonObject& data) { - if (!m_api->remoteControlTargetEnabled()) - return; const QString command = data.value(QStringLiteral("Command")).toString(); qInfo() << "remote: playstate" << command; if (command == QStringLiteral("Stop")) { m_player->stopWithReason(QStringLiteral("remote-stop")); } else if (command == QStringLiteral("Pause")) { if (!m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); + groupOrLocalTogglePause(); } else if (command == QStringLiteral("Unpause")) { if (m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); + groupOrLocalTogglePause(); } else if (command == QStringLiteral("PlayPause")) { - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); + groupOrLocalTogglePause(); } else if (command == QStringLiteral("Seek")) { const double seconds = static_cast(data.value(QStringLiteral("SeekPositionTicks")).toVariant().toLongLong()) / 10'000'000.0; - m_syncPlay->enabled() ? m_syncPlay->requestSeek(seconds) : m_player->seek(seconds); + if (inSyncPlayGroup()) + m_group->requestSeek(seconds); + else + m_player->seek(seconds); } else if (command == QStringLiteral("Rewind")) { - m_syncPlay->enabled() ? m_syncPlay->requestRelativeSeek(-10.0) : m_player->seekBack(); + if (inSyncPlayGroup()) + m_group->requestRelativeSeek(-10.0); + else + m_player->seekBack(); } else if (command == QStringLiteral("FastForward")) { - m_syncPlay->enabled() ? m_syncPlay->requestRelativeSeek(10.0) : m_player->seekForward(); + if (inSyncPlayGroup()) + m_group->requestRelativeSeek(10.0); + else + m_player->seekForward(); } else if (command == QStringLiteral("NextTrack")) { playQueueNext(); } else if (command == QStringLiteral("PreviousTrack")) { @@ -1168,8 +1151,6 @@ void AppController::handleRemotePlaystate(const QJsonObject& data) void AppController::handleRemoteGeneralCommand(const QJsonObject& data) { - if (!m_api->remoteControlTargetEnabled()) - return; const QString command = data.value(QStringLiteral("Name")).toString(); const QJsonObject arguments = data.value(QStringLiteral("Arguments")).toObject(); const auto argumentInt = [&arguments](const QString& key, int fallback = 0) { @@ -1182,8 +1163,7 @@ void AppController::handleRemoteGeneralCommand(const QJsonObject& data) if (const auto message = SpoolRemoteProtocol::decode(command, arguments)) { // Link signalling never reaches the application; everything else is // handled the same whether it arrived here or over the direct path. - SpoolLink *link = m_remoteControl->link(); - if (!link || !link->handleServerMessage(*message)) + if (!m_remote || !m_remote->handlePeerSignalling(*message)) handleSpoolMessage(*message); } else if (command == QStringLiteral("SetVolume")) { m_player->setVolume(argumentInt(QStringLiteral("Volume"), m_player->volume())); @@ -1237,10 +1217,10 @@ void AppController::handleRemoteGeneralCommand(const QJsonObject& data) } } else if (command == QStringLiteral("Play") || command == QStringLiteral("Unpause")) { if (m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); + groupOrLocalTogglePause(); } else if (command == QStringLiteral("Pause")) { if (!m_player->paused()) - m_syncPlay->enabled() ? m_syncPlay->requestTogglePause() : m_player->togglePause(); + groupOrLocalTogglePause(); } else if (command == QStringLiteral("Stop")) { m_player->stopWithReason(QStringLiteral("remote-stop")); } else if (command == QStringLiteral("PlayNext")) { @@ -1295,13 +1275,13 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau if (!forceTranscode) m_codecFallbackAttempted = false; - PlaybackSession session = co_await m_api->negotiatePlayback(playItem, forceTranscode); + PlaybackSession session = co_await m_playback->resolvePlayback(playItem, forceTranscode); if (!m_playbackLoadGeneration.isCurrent(generation)) co_return; if (!forceTranscode && playItem.itemType == QStringLiteral("Episode") && !playItem.seriesId.isEmpty()) { const QByteArray storedSelection = co_await m_database->loadCacheEntryAsync(QString::fromLatin1(kSeriesTrackSelectionNamespace), - seriesTrackSelectionKey(m_api->session(), playItem.seriesId)); + seriesTrackSelectionKey(m_catalog->libraryScopeKey(), playItem.seriesId)); if (!m_playbackLoadGeneration.isCurrent(generation)) co_return; SeriesAudioPreference storedAudioPreference; @@ -1357,7 +1337,7 @@ QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPau const QString itemId = playItem.id; Async::runScoped( - this, m_api->fetchMediaSegments(itemId), + this, m_playback->fetchMediaSegments(itemId), [this, itemId](const std::vector& segments) { if (m_player && !segments.empty()) m_player->setMediaSegments(itemId, segments); @@ -1395,7 +1375,7 @@ void AppController::shutdown() qInfo() << "app: shutdown requested"; m_player->prepareForShutdown(); m_prefetch->stop(); - m_jellyfin->shutdown(); + m_provider->shutdown(); m_player->teardownMpv(); } @@ -1464,27 +1444,27 @@ void AppController::loadLibraries() { m_prefetch->stop(); Async::runScoped( - this, m_api->fetchLibraries(), + this, m_catalog->fetchLibraries(), [this](const std::vector& libraries) { m_libraries.setLibraries(libraries); setBusy(false); - m_jellyfin->discovery()->stop(); refreshHomeRows(); }, [this](const std::exception_ptr& error) { setBusy(false); - if (!m_session->handleUnauthorized(error)) + if (!m_provider->handleUnauthorized(error)) setErrorText(exceptionMessage(error)); }); } void AppController::loadLibraryFilterOptions(RequestGeneration::Token generation, const LibraryItem& library) { - if (!m_api || m_api->session().accessToken.isEmpty() || library.id.isEmpty()) + if (!m_catalog->signedIn() || library.id.isEmpty()) return; Async::runLatest( - this, m_api->fetchLibraryFilterOptions(library.id, library.collectionType), m_libraryLoadGeneration, generation, + this, m_catalog->fetchLibraryFilterOptions(library.id, library.collectionType), m_libraryLoadGeneration, + generation, [this, library](const QVariantMap& options) { if (library.id != m_browse->libraryId()) return; @@ -1511,7 +1491,7 @@ void AppController::showCurrentItemsPage(const PagedMovieItems& page, const QStr RequestGeneration::Token AppController::beginBrowse(bool useWarmCache) { const BrowseDescriptor descriptor = m_browse->descriptor(); - if (!descriptor.isValid() || !m_api || m_api->session().accessToken.isEmpty()) + if (!descriptor.isValid() || !m_catalog->signedIn()) return 0; const RequestGeneration::Token generation = m_libraryLoadGeneration.next(); @@ -1547,7 +1527,7 @@ RequestGeneration::Token AppController::beginBrowse(bool useWarmCache) } Async::runLatest( - this, m_api->fetchBrowsePage(descriptor, 0, kLibraryPageSize, query), m_libraryLoadGeneration, generation, + this, m_catalog->fetchBrowsePage(descriptor, 0, kLibraryPageSize, query), m_libraryLoadGeneration, generation, [this, cacheKey](const PagedMovieItems& page) { showCurrentItemsPage(page, cacheKey, false); }, [this](const std::exception_ptr& error) { m_browse->setLoadingMore(false); @@ -1567,13 +1547,13 @@ void AppController::openNamedCollection(const QString& kind, const QString& valu QVariantList AppController::streamingQualityOptions() const { - const qint64 override = m_api->sessionBitrateOverride(); + if (!m_quality) + return {}; + const qint64 override = m_quality->bitrateOverride(); QVariantList options; options.push_back(QVariantMap { { QStringLiteral("label"), QStringLiteral("Auto") }, - { QStringLiteral("detail"), - PlaybackBandwidthPolicy::describeAuto( - m_api->streamingBitrateSource(), m_api->maxStreamingBitrate(), m_api->playbackParallelRequests()) }, + { QStringLiteral("detail"), m_quality->autoDescription() }, { QStringLiteral("bitrate"), 0 }, { QStringLiteral("selected"), override <= 0 }, }); @@ -1582,7 +1562,7 @@ QVariantList AppController::streamingQualityOptions() const for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) sourceBitrate = std::max(sourceBitrate, source.bitRate); - for (const PlaybackBandwidthPolicy::QualityOption& rung : PlaybackBandwidthPolicy::qualityLadder(sourceBitrate)) { + for (const StreamQualityControl::Rung& rung : m_quality->ladder(sourceBitrate)) { options.push_back(QVariantMap { { QStringLiteral("label"), rung.label }, { QStringLiteral("detail"), QString() }, @@ -1596,20 +1576,18 @@ QVariantList AppController::streamingQualityOptions() const void AppController::selectStreamingQuality(qint64 bitrate, int height) { - const qint64 previousBitrate = m_api->sessionBitrateOverride(); - const int previousHeight = m_api->sessionHeightOverride(); + if (!m_quality) + return; + const qint64 previousBitrate = m_quality->bitrateOverride(); + const int previousHeight = m_quality->heightOverride(); if (previousBitrate == bitrate && previousHeight == height) return; - m_api->setSessionBitrateOverride(bitrate); - // The rung's resolution travels with its bitrate. Without this the server - // was told a ceiling in megabits and nothing about size, so it re-encoded - // at the source resolution and "480p" only ever meant "fewer bits". - m_api->setSessionHeightOverride(height); + m_quality->setOverride(bitrate, height); emit streamingQualityChanged(); - // The server chose direct play or transcoding against the ceiling it was - // given when the stream was negotiated, so a new ceiling only takes effect - // through a fresh negotiation. Resume where the viewer was, keeping the + // The source chose direct play or transcoding against the ceiling it was + // given when the stream was resolved, so a new ceiling only takes effect + // through a fresh resolution. Resume where the viewer was, keeping the // audio and subtitle tracks they had selected. if (!m_player->sessionActive() || m_activePlaybackItem.id.isEmpty()) return; @@ -1619,7 +1597,7 @@ void AppController::selectStreamingQuality(qint64 bitrate, int height) const int subtitleStreamIndex = m_activeSubtitleStreamIndex; m_qualityFallbackBitrate = previousBitrate; m_qualityFallbackHeight = previousHeight; - // Renegotiating is a network round trip. Leaving the old core up for it + // Resolving again is a network round trip. Leaving the old core up for it // keeps the picture on screen until the replacement is ready to start; // play() still tears it down synchronously before the new one begins. setBusy(true, QStringLiteral("Changing quality…")); @@ -1637,7 +1615,7 @@ void AppController::handlePlaybackStopped(const QString& itemId, qint64 position qInfo() << "app: playback stopped" << itemId << positionTicks << completed; m_itemState->recordPlaybackStopped(m_activePlaybackItem, itemId, positionTicks, completed); m_playQueue->updateResumeTicks(itemId, completed ? 0 : positionTicks); - if (!completed || m_activePlaybackItem.id != itemId || (m_syncPlay && m_syncPlay->enabled())) { + if (!completed || m_activePlaybackItem.id != itemId || inSyncPlayGroup()) { setPlaybackTransition(false); return; } diff --git a/src/app/AppController.h b/src/app/AppController.h index c76b0340..8b1dc57d 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -10,14 +10,12 @@ #include "BrowseSessionController.h" #include "ContentModelController.h" #include "HomeModelController.h" -#include "LibraryManagementController.h" -#include "RemoteControlController.h" #include "SearchController.h" #include "SettingsController.h" #include "SpoolRemoteProtocol.h" -#include "SyncPlayController.h" #include +#include #include #include #include @@ -30,13 +28,19 @@ namespace JellyfinNative { class ArtworkService; -class JellyfinApiFacade; -class JellyfinProvider; +class Catalog; +class GroupPlayback; class LibraryPrefetchController; -class QuickConnectController; -class SessionController; +class PlaybackSource; +class Provider; +class RemotePlayback; +class StreamQualityControl; class UserItemStateController; class TlsTrustController; +// The composition root: it owns the source-agnostic controllers, wires them +// to the player and the queue, and talks to the media source only through +// the Provider it was handed and the interfaces that provider serves. It +// knows nothing about which provider that is. class AppController final : public QObject { Q_OBJECT Q_PROPERTY(bool busy MEMBER m_busy NOTIFY busyChanged) @@ -46,12 +50,12 @@ class AppController final : public QObject { Q_PROPERTY(bool playbackTransition MEMBER m_playbackTransition NOTIFY playbackTransitionChanged) Q_PROPERTY(QString busyText MEMBER m_busyText NOTIFY busyChanged) Q_PROPERTY(QString errorText MEMBER m_errorText NOTIFY errorTextChanged) - Q_PROPERTY(bool hasDefaultProfile MEMBER m_hasDefaultProfile NOTIFY defaultProfileChanged) + Q_PROPERTY(bool hasDefaultProfile READ hasDefaultProfile NOTIFY defaultProfileChanged) Q_PROPERTY(bool initialized READ initialized NOTIFY initializedChanged) public: - AppController(DatabaseManager *database, JellyfinProvider *jellyfin, ArtworkService *artwork, - PlayerController *player, QObject *parent = nullptr); + AppController(DatabaseManager *database, Provider *provider, ArtworkService *artwork, PlayerController *player, + QObject *parent = nullptr); ArtworkService *artwork() const { @@ -97,6 +101,7 @@ class AppController final : public QObject { { return m_initialized; } + bool hasDefaultProfile() const; Q_INVOKABLE void initialize(); void shutdown(); @@ -181,6 +186,11 @@ class AppController final : public QObject { bool modelIsOrderedList(MovieGridModel *model) const; void playAlbumFrom(const MovieItem& track, bool fromStart); bool inSyncPlayGroup() const; + bool remoteTargetSelected() const; + // Hands the items to the remote target when one is selected; false + // means play them here. + bool playRemotely(const std::vector& items, int startIndex, const QString& command, bool fromStart); + void groupOrLocalTogglePause(); void setPlaybackTransition(bool transition); QString queuePlaylistItemId(int index) const; bool enqueueForGroup(const MovieItem& item, bool queueNext); @@ -197,26 +207,26 @@ class AppController final : public QObject { void handlePlaybackStopped(const QString& itemId, qint64 positionTicks, bool completed); DatabaseManager *m_database = nullptr; - JellyfinProvider *m_jellyfin = nullptr; - JellyfinApiFacade *m_api = nullptr; + Provider *m_provider = nullptr; + Catalog *m_catalog = nullptr; + PlaybackSource *m_playback = nullptr; + // Null when the provider lacks the matching capability. + StreamQualityControl *m_quality = nullptr; + GroupPlayback *m_group = nullptr; + RemotePlayback *m_remote = nullptr; ArtworkService *m_artwork = nullptr; PlayerController *m_player = nullptr; - SyncPlayController *m_syncPlay = nullptr; - RemoteControlController *m_remoteControl = nullptr; PlayQueueController *m_playQueue = nullptr; ContentModelController *m_content = nullptr; BrowseSessionController *m_browse = nullptr; HomeModelController *m_home = nullptr; - QuickConnectController *m_quickConnect = nullptr; SettingsController *m_settings = nullptr; - SessionController *m_session = nullptr; LibraryPrefetchController *m_prefetch = nullptr; UserItemStateController *m_itemState = nullptr; SearchController *m_search = nullptr; quint64 m_episodeQueueGeneration = 0; quint64 m_albumQueueGeneration = 0; bool m_episodeQueuePending = false; - LibraryManagementController *m_management = nullptr; LibraryListModel m_libraries; MovieItem m_activePlaybackItem; QList m_activePlaybackStreams; @@ -225,7 +235,6 @@ class AppController final : public QObject { bool m_busy = false; bool m_playbackTransition = false; quint64 m_playbackTransitionGeneration = 0; - bool m_hasDefaultProfile = false; bool m_initialized = false; QString m_busyText; QString m_errorText; diff --git a/src/app/RemoteControlController.cpp b/src/app/RemoteControlController.cpp index 683378ef..eddc669c 100644 --- a/src/app/RemoteControlController.cpp +++ b/src/app/RemoteControlController.cpp @@ -55,7 +55,7 @@ namespace { } // namespace RemoteControlController::RemoteControlController(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) + : RemotePlayback(parent) , m_api(api) , m_mediaSession(createPlatformRemoteMediaSession(this)) { @@ -959,4 +959,9 @@ void RemoteControlController::reportCommandError(const QString& action, const st emit errorText(QStringLiteral("Could not %1: %2").arg(action, exceptionMessage(error))); } +bool RemoteControlController::handlePeerSignalling(const SpoolRemoteProtocol::Message& message) +{ + return m_link && m_link->handleServerMessage(message); +} + } // namespace JellyfinNative diff --git a/src/app/RemoteControlController.h b/src/app/RemoteControlController.h index 4127312f..7e152951 100644 --- a/src/app/RemoteControlController.h +++ b/src/app/RemoteControlController.h @@ -3,6 +3,7 @@ #include "../common/RequestGeneration.h" #include "../media/MediaTypes.h" #include "../player/PlaybackTimeline.h" +#include "../provider/RemotePlayback.h" #include "SpoolLink.h" #include @@ -23,7 +24,7 @@ namespace JellyfinNative { class JellyfinApiFacade; class PlatformRemoteMediaSession; -class RemoteControlController final : public QObject { +class RemoteControlController final : public RemotePlayback { Q_OBJECT Q_PROPERTY(QVariantList targets READ targets NOTIFY targetsChanged) Q_PROPERTY(QString selectedSessionId READ selectedSessionId NOTIFY targetChanged) @@ -67,7 +68,7 @@ class RemoteControlController final : public QObject { { return m_selectedTargetDetail; } - bool targetSelected() const + bool targetSelected() const override { return !m_selectedSessionId.isEmpty(); } @@ -139,7 +140,9 @@ class RemoteControlController final : public QObject { void start(); void stop(); void applySessions(const QJsonArray& sessions); - bool playItems(const std::vector& items, int startIndex, const QString& command, bool fromStart); + bool playItems( + const std::vector& items, int startIndex, const QString& command, bool fromStart) override; + bool handlePeerSignalling(const SpoolRemoteProtocol::Message& message) override; Q_INVOKABLE void refreshTargets(); Q_INVOKABLE void selectTarget(const QString& sessionId); diff --git a/src/app/SyncPlayController.cpp b/src/app/SyncPlayController.cpp index c6e0bfa9..e0320332 100644 --- a/src/app/SyncPlayController.cpp +++ b/src/app/SyncPlayController.cpp @@ -105,7 +105,7 @@ SyncCorrection SyncPlayDriftPolicy::evaluate(double diffMs) SyncPlayController::SyncPlayController(JellyfinApiFacade *api, PlayerController *player, PlayQueueController *playQueue, TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) + : GroupPlayback(parent) , m_api(api) , m_player(player) , m_playQueue(playQueue) @@ -1126,4 +1126,9 @@ QUrl SyncPlayController::socketUrl() const return url; } +QCoro::Task SyncPlayController::publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) +{ + co_await m_api->syncPlaySetNewQueue(std::move(itemIds), playingIndex, startPositionTicks); +} + } // namespace JellyfinNative diff --git a/src/app/SyncPlayController.h b/src/app/SyncPlayController.h index 7340baad..70a37498 100644 --- a/src/app/SyncPlayController.h +++ b/src/app/SyncPlayController.h @@ -1,7 +1,10 @@ #pragma once +#include "../provider/GroupPlayback.h" #include "SyncPlayClock.h" +#include + #include #include #include @@ -84,7 +87,7 @@ class SyncPlayDriftPolicy final { static SyncCorrection evaluate(double diffMs); }; -class SyncPlayController final : public QObject { +class SyncPlayController final : public GroupPlayback { Q_OBJECT Q_PROPERTY(QString currentGroupId READ currentGroupId NOTIFY groupChanged) Q_PROPERTY(QString currentGroupName READ currentGroupName NOTIFY groupChanged) @@ -119,7 +122,7 @@ class SyncPlayController final : public QObject { { return m_groups; } - bool enabled() const + bool enabled() const override { return !m_groupId.isEmpty(); } @@ -173,21 +176,22 @@ class SyncPlayController final : public QObject { Q_INVOKABLE void connectSocket(); Q_INVOKABLE void disconnectSocket(); Q_INVOKABLE void createGroup(const QString& name); - Q_INVOKABLE void joinGroup(const QString& groupId); - Q_INVOKABLE void leaveGroup(); - Q_INVOKABLE void requestTogglePause(); - Q_INVOKABLE void requestSeek(double positionSeconds); - Q_INVOKABLE void requestRelativeSeek(double deltaSeconds); - Q_INVOKABLE void requestNextItem(); - Q_INVOKABLE void requestPreviousItem(); + Q_INVOKABLE void joinGroup(const QString& groupId) override; + Q_INVOKABLE void leaveGroup() override; + Q_INVOKABLE void requestTogglePause() override; + Q_INVOKABLE void requestSeek(double positionSeconds) override; + Q_INVOKABLE void requestRelativeSeek(double deltaSeconds) override; + Q_INVOKABLE void requestNextItem() override; + Q_INVOKABLE void requestPreviousItem() override; // Inside a group the queue belongs to the server. These publish an intent // and the resulting PlayQueue broadcast is what actually changes the queue. - void requestMoveItem(const QString& playlistItemId, int newIndex); - void requestRemoveItems(const QStringList& playlistItemIds); - void requestQueueItems(const QStringList& itemIds, bool queueNext); - void requestPlayItem(const QString& playlistItemId); - void requestUnpauseWhenReady(); - void cancelPendingUnpause(); + void requestMoveItem(const QString& playlistItemId, int newIndex) override; + void requestRemoveItems(const QStringList& playlistItemIds) override; + void requestQueueItems(const QStringList& itemIds, bool queueNext) override; + void requestPlayItem(const QString& playlistItemId) override; + void requestUnpauseWhenReady() override; + void cancelPendingUnpause() override; + QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) override; signals: void groupsChanged(); @@ -199,7 +203,6 @@ class SyncPlayController final : public QObject { void remotePlaystateCommand(const QJsonObject& data); void remoteGeneralCommand(const QJsonObject& data); void sessionsUpdated(const QJsonArray& sessions); - void queuePlaybackRequested(qint64 positionTicks); private: void handleSocketTextMessage(const QString& message); diff --git a/src/main.cpp b/src/main.cpp index aa9feec6..35a0fb4f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,3 @@ -#include "api/JellyfinApiFacade.h" #include "api/JellyfinProvider.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" @@ -27,6 +26,7 @@ #include "platform/ScreenSaverInhibitor.h" #include "player/MpvVideoItem.h" #include "player/PlayerController.h" +#include "provider/Provider.h" #include "provider/ProviderRegistry.h" #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) #include "platform/UpdateController.h" @@ -602,11 +602,14 @@ int main(int argc, char **argv) JellyfinNative::TlsTrustController tlsTrust; // One provider, chosen here until the first-run picker exists. It // outlives the player and the app controller, which hold its parts by - // pointer, so it is declared before them. + // pointer, so it is declared before them. The app only ever sees it as + // a Provider. + JellyfinNative::ProviderRegistry providers; auto jellyfin = std::make_unique(JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, capabilities.deviceName, QString::fromLatin1(kAppVersion) }); - JellyfinNative::ProviderRegistry providers; + providers.add(jellyfin.get()); providers.setActive(jellyfin.get()); + JellyfinNative::Provider *provider = providers.active(); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, @@ -616,10 +619,10 @@ int main(int argc, char **argv) qmlImageCachePath + QStringLiteral("/artwork"), memoryBudget.qmlImageDiskCacheBytes, memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, &tlsTrust); artworkService->setUiWidth(window.width()); - artworkService->setSource(api.get()); + artworkService->setSource(provider->artwork()); auto player = std::make_unique( - &window, jellyfin->playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); + &window, provider->playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); JellyfinNative::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { @@ -639,8 +642,8 @@ int main(int argc, char **argv) player.get(), &JellyfinNative::PlayerController::sessionActiveChanged, &app, updateApplicationIcon); updateApplicationIcon(); #endif - auto controller = std::make_unique( - &database, jellyfin.get(), artworkService.get(), player.get()); + auto controller + = std::make_unique(&database, provider, artworkService.get(), player.get()); controller->settings()->setProviderCapabilities(providers.capabilities()); #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) // Settings own the update preference; platform installers own installation. @@ -721,11 +724,9 @@ int main(int argc, char **argv) [](QQuickView::Status status) { logLine("view status changed: %d", static_cast(status)); }); auto localization = std::make_unique(); localization->attachToEngine(window.engine()); - if (JellyfinNative::JellyfinApiFacade *api = jellyfin->api()) { - api->setAcceptLanguage(localization->bcp47Locale()); - QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, api, - [api, loc = localization.get()]() { api->setAcceptLanguage(loc->bcp47Locale()); }); - } + provider->setLocale(localization->bcp47Locale()); + QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, provider, + [provider, loc = localization.get()]() { provider->setLocale(loc->bcp47Locale()); }); auto router = std::make_unique(); JellyfinNative::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); @@ -770,7 +771,7 @@ int main(int argc, char **argv) platformInfo->insert(QStringLiteral("splashImageUrl"), splashImageUrl(appRootPath)); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "App", controller.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ProviderCapabilities", providers.capabilities()); - jellyfin->registerQmlSingletons(); + provider->registerQmlSingletons(); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Art", artworkService.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Browse", controller->browse()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Home", controller->home()); diff --git a/src/provider/Catalog.h b/src/provider/Catalog.h index 3a3bd73e..28a79e53 100644 --- a/src/provider/Catalog.h +++ b/src/provider/Catalog.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -38,6 +39,13 @@ class Catalog { virtual QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) = 0; virtual QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) = 0; virtual QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) = 0; + + // The top-level libraries the home page and the rail are built from. + virtual QCoro::Task> fetchLibraries() = 0; + // The genres, years and the like a library can be filtered by; empty + // when the source offers no filtering. + virtual QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) = 0; + virtual QCoro::Task> fetchItemsByIds(QStringList itemIds) = 0; }; } // namespace JellyfinNative diff --git a/src/provider/GroupPlayback.h b/src/provider/GroupPlayback.h new file mode 100644 index 00000000..976c5c4f --- /dev/null +++ b/src/provider/GroupPlayback.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include +#include + +namespace JellyfinNative { + +// Watching together: while a group is joined, the queue and the transport +// belong to the group, and the app hands every local action here instead of +// acting on the player. This is the surface the app drives; how the group is +// created, joined and shown is the provider's own QML. +class GroupPlayback : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + // Whether a group is joined, and therefore whether the app must route + // playback actions through it. + virtual bool enabled() const = 0; + + virtual void requestTogglePause() = 0; + virtual void requestSeek(double positionSeconds) = 0; + virtual void requestRelativeSeek(double deltaSeconds) = 0; + virtual void requestNextItem() = 0; + virtual void requestPreviousItem() = 0; + virtual void requestPlayItem(const QString& playlistItemId) = 0; + virtual void requestMoveItem(const QString& playlistItemId, int newIndex) = 0; + virtual void requestRemoveItems(const QStringList& playlistItemIds) = 0; + virtual void requestQueueItems(const QStringList& itemIds, bool queueNext) = 0; + // Replaces the group's queue with the local one and asks it to start at + // `playingIndex`. Arm requestUnpauseWhenReady() before calling: the + // group's own queue update can land before or after the reply. + virtual QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) = 0; + virtual void requestUnpauseWhenReady() = 0; + virtual void cancelPendingUnpause() = 0; + virtual void joinGroup(const QString& groupId) = 0; + virtual void leaveGroup() = 0; + +signals: + // The group wants this client to start what its queue points at, from + // this position. + void queuePlaybackRequested(qint64 positionTicks); +}; + +} // namespace JellyfinNative diff --git a/src/provider/PlaybackSource.h b/src/provider/PlaybackSource.h index 7e2ed495..9a151135 100644 --- a/src/provider/PlaybackSource.h +++ b/src/provider/PlaybackSource.h @@ -35,6 +35,14 @@ class PlaybackSource : public QObject { virtual bool signedIn() const = 0; virtual QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const = 0; + // Turns an item into something mpv can open: the URL, its streams, and + // where to start. `forceTranscode` asks a source that can re-encode to + // do so after a direct stream failed to decode; a source that cannot + // ignores it. + virtual QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) = 0; + // Intro and credit markers; empty for a source without them. + virtual QCoro::Task> fetchMediaSegments(QString itemId) = 0; + // Every episode of a series in play order, so the queue can continue // past the one the viewer started. virtual QCoro::Task> fetchSeriesEpisodes(QString seriesId) = 0; diff --git a/src/provider/Provider.h b/src/provider/Provider.h index bcad112b..d573571b 100644 --- a/src/provider/Provider.h +++ b/src/provider/Provider.h @@ -1,12 +1,31 @@ #pragma once +#include "../app/AccountProfile.h" + #include #include #include +#include +#include + +#include +#include namespace JellyfinNative { +class ArtworkService; +class ArtworkSource; +class BrowseSessionController; +class Catalog; +class GroupPlayback; +class PlayQueueController; class PlaybackSource; +class PlayerController; +class RemotePlayback; +class SearchSource; +class SettingsController; +class StreamQualityControl; +class UserItemStateSink; // A source of media the app can sit on: a Jellyfin server, a debrid account, // a folder of files. The shell and pages are the same whichever one is @@ -14,9 +33,10 @@ class PlaybackSource; // every provider-specific control in shared QML is gated on one of these // through the ProviderCapabilities singleton the registry publishes. // -// The catalog, search, artwork and user-item-state accessors arrive with the -// contract branch; until then those controllers still take the Jellyfin -// facade directly. +// The app talks to a provider only through the interfaces returned here and +// the signals below. A provider that needs nothing from the session-shaped +// hooks (no login, no server) leaves the defaults in place: it is ready from +// the start and never signs out. class Provider : public QObject { Q_OBJECT @@ -44,23 +64,115 @@ class Provider : public QObject { QuickConnect = 1u << 9, // A datagram path between two Spool instances relayed by the source. PeerRelay = 1u << 10, + // The source can be asked for a lower bitrate or resolution than it + // holds, so the player offers a quality ladder. + StreamQuality = 1u << 11, }; Q_DECLARE_FLAGS(Capabilities, Capability) Q_FLAG(Capabilities) + // The core objects a provider's session-bound parts sit on. Anything the + // provider creates from these is parented to `owner`, which must not + // outlive `player`. + struct CoreServices { + QObject *owner = nullptr; + PlayerController *player = nullptr; + PlayQueueController *playQueue = nullptr; + SettingsController *settings = nullptr; + BrowseSessionController *browse = nullptr; + ArtworkService *artwork = nullptr; + }; + using QObject::QObject; virtual QString id() const = 0; virtual QString displayName() const = 0; virtual Capabilities capabilities() const = 0; + + // Every provider serves these three. virtual PlaybackSource *playback() = 0; + virtual Catalog *catalog() = 0; + virtual ArtworkSource *artwork() = 0; + // Null unless the matching capability is set. + virtual SearchSource *search() + { + return nullptr; + } + virtual UserItemStateSink *itemState() + { + return nullptr; + } + virtual StreamQualityControl *streamQuality() + { + return nullptr; + } + virtual GroupPlayback *groupPlayback() + { + return nullptr; + } + virtual RemotePlayback *remotePlayback() + { + return nullptr; + } + + // Called once the core objects exist, before anything is played. + virtual void attach(const CoreServices&) { } // Registers the QML singletons only this provider's own QML reaches // (Session, SyncPlay and the like). Shared QML never names them without // first checking the matching capability. virtual void registerQmlSingletons() = 0; + virtual void shutdown() { } + + // Whether the catalog can be used right now. A source that needs a + // session reports false until it has one and emits sessionStarted() when + // it does; a source that needs none is ready from construction. + virtual bool ready() const = 0; + // Whether a stored account will sign in without the viewer's help. The + // shell holds its first route until this settles. + virtual bool hasDefaultProfile() const + { + return true; + } + // The local settings keys the provider wants read before the first + // frame, handed back through restoreFromStorage() with the saved + // accounts. Returns what hasDefaultProfile() will report. + virtual QStringList startupStorageKeys() const + { + return {}; + } + virtual bool restoreFromStorage(QVariantMap, std::vector) + { + return true; + } + // The identity this instance presents to a server, unique per running + // instance on the same machine. + virtual void setDeviceId(const QString&) { } + // The viewer's BCP 47 locale, for sources that localise their strings. + virtual void setLocale(const QString&) { } + // True when the error meant the session is gone and the provider has + // already started signing out, so the caller need not report it. + virtual bool handleUnauthorized(const std::exception_ptr&) + { + return false; + } signals: void capabilitiesChanged(); + // ready() flipped: the catalog became usable, or stopped being so. + void sessionStarted(); + void sessionEnded(); + // The viewer is opening a stored account, switching accounts, or + // signing out; the app clears what it was showing for the last one. + void activationStarted(); + void switchUserRequested(); + void signOutStarted(); + void hasDefaultProfileChanged(); + void busyChanged(bool busy, const QString& text); + void errorOccurred(const QString& message); + void toastRequested(const QString& message); + // Something the provider did changed the library; an empty id means + // "whatever is on screen". + void contentChanged(const QString& changedItemId); }; Q_DECLARE_OPERATORS_FOR_FLAGS(Provider::Capabilities) diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 9f87f1f8..477373df 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -9,7 +9,7 @@ namespace JellyfinNative { -// The active provider's capability flags as eleven booleans, registered in +// The active provider's capability flags as twelve booleans, registered in // QML as the ProviderCapabilities singleton. The property names are the // contract with shared QML: a gated control binds to one of them by name. class ProviderCapabilities final : public QObject { @@ -25,6 +25,7 @@ class ProviderCapabilities final : public QObject { Q_PROPERTY(bool remoteControl READ remoteControl NOTIFY changed) Q_PROPERTY(bool quickConnect READ quickConnect NOTIFY changed) Q_PROPERTY(bool peerRelay READ peerRelay NOTIFY changed) + Q_PROPERTY(bool streamQuality READ streamQuality NOTIFY changed) public: explicit ProviderCapabilities(QObject *parent = nullptr); @@ -79,6 +80,10 @@ class ProviderCapabilities final : public QObject { { return m_flags.testFlag(Provider::PeerRelay); } + bool streamQuality() const + { + return m_flags.testFlag(Provider::StreamQuality); + } signals: void changed(); diff --git a/src/provider/RemotePlayback.h b/src/provider/RemotePlayback.h new file mode 100644 index 00000000..6a916337 --- /dev/null +++ b/src/provider/RemotePlayback.h @@ -0,0 +1,42 @@ +#pragma once + +#include "../app/SpoolRemoteProtocol.h" +#include "../media/MediaTypes.h" + +#include +#include +#include + +#include + +namespace JellyfinNative { + +// Playing on another client of the same source, and being played on by one. +// Outgoing: while a target is selected, every play request goes there +// instead of the local queue. Incoming: commands other clients send arrive +// as the signals below, in the source's own command envelope, already +// filtered by whether this device accepts them. +class RemotePlayback : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + virtual bool targetSelected() const = 0; + // Sends the items to the selected target. False when no target is + // selected, so the caller plays them locally. + virtual bool playItems(const std::vector& items, int startIndex, const QString& command, bool fromStart) + = 0; + // A Spool-to-Spool message that arrived through the source. True when it + // was the peer link's own signalling and has been consumed. + virtual bool handlePeerSignalling(const SpoolRemoteProtocol::Message& message) = 0; + +signals: + void playCommandReceived(const QJsonObject& data); + void playstateCommandReceived(const QJsonObject& data); + void generalCommandReceived(const QJsonObject& data); + // A Spool-to-Spool message that arrived over the direct peer link. + void peerMessageReceived(const JellyfinNative::SpoolRemoteProtocol::Message& message); +}; + +} // namespace JellyfinNative diff --git a/src/provider/StreamQualityControl.h b/src/provider/StreamQualityControl.h new file mode 100644 index 00000000..f6dff98b --- /dev/null +++ b/src/provider/StreamQualityControl.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include + +namespace JellyfinNative { + +// A source that can deliver a stream below its stored quality, so the player +// can offer a ladder and remember a ceiling for the session. Plain abstract +// class, see Catalog. A source that always plays the file as stored has no +// StreamQuality capability and no object here. +class StreamQualityControl { +public: + struct Rung { + QString label; + qint64 bitrate = 0; + int height = 0; + }; + + virtual ~StreamQualityControl() = default; + + // The ceiling the viewer picked for this session; zero means Auto. + virtual qint64 bitrateOverride() const = 0; + virtual int heightOverride() const = 0; + virtual void setOverride(qint64 bitrate, int height) = 0; + // The line under "Auto": what the automatic ceiling currently is and + // where it came from. + virtual QString autoDescription() const = 0; + // The ceilings worth offering below a stream of this bitrate, coarsest + // first. + virtual std::vector ladder(qint64 sourceBitrate) const = 0; +}; + +} // namespace JellyfinNative diff --git a/tests/app/ProviderRegistryTest.cpp b/tests/app/ProviderRegistryTest.cpp index 6696d528..4e28479e 100644 --- a/tests/app/ProviderRegistryTest.cpp +++ b/tests/app/ProviderRegistryTest.cpp @@ -1,4 +1,6 @@ #include "provider/ProviderRegistry.h" +#include "provider/ArtworkSource.h" +#include "provider/Catalog.h" #include "provider/PlaybackSource.h" #include "provider/Provider.h" @@ -46,7 +48,19 @@ class StubProvider final : public JellyfinNative::Provider { { return nullptr; } + JellyfinNative::Catalog *catalog() override + { + return nullptr; + } + JellyfinNative::ArtworkSource *artwork() override + { + return nullptr; + } void registerQmlSingletons() override { } + bool ready() const override + { + return true; + } void setCapabilities(Capabilities capabilities) { @@ -59,11 +73,11 @@ class StubProvider final : public JellyfinNative::Provider { Capabilities m_capabilities; }; -// The eleven names shared QML gates on, in the order the flags are declared. +// The twelve names shared QML gates on, in the order the flags are declared. const QStringList kCapabilityNames { QStringLiteral("auth"), QStringLiteral("discovery"), QStringLiteral("search"), QStringLiteral("userItemState"), QStringLiteral("playbackReporting"), QStringLiteral("segments"), QStringLiteral("libraryManagement"), QStringLiteral("syncPlay"), QStringLiteral("remoteControl"), - QStringLiteral("quickConnect"), QStringLiteral("peerRelay") }; + QStringLiteral("quickConnect"), QStringLiteral("peerRelay"), QStringLiteral("streamQuality") }; bool propertyValue(const QObject& object, const QString& name) { @@ -89,7 +103,8 @@ JELLYFIN_TEST_MAIN("provider-registry") Provider::Capabilities all; const QList flags { Provider::Auth, Provider::Discovery, Provider::Search, Provider::UserItemState, Provider::PlaybackReporting, Provider::Segments, Provider::LibraryManagement, - Provider::SyncPlay, Provider::RemoteControl, Provider::QuickConnect, Provider::PeerRelay }; + Provider::SyncPlay, Provider::RemoteControl, Provider::QuickConnect, Provider::PeerRelay, + Provider::StreamQuality }; require(flags.size() == kCapabilityNames.size(), "one flag per capability name"); StubProvider single(QStringLiteral("single"), {}); registry.setActive(&single); @@ -150,7 +165,7 @@ JELLYFIN_TEST_MAIN("provider-registry") QStringList declared; for (int index = meta->propertyOffset(); index < meta->propertyCount(); ++index) declared.append(QString::fromLatin1(meta->property(index).name())); - require(declared == kCapabilityNames, "ProviderCapabilities declares exactly the eleven contract names in order"); + require(declared == kCapabilityNames, "ProviderCapabilities declares exactly the twelve contract names in order"); std::cout << "provider registry ok\n"; return 0; diff --git a/tests/qml/provider-capabilities/ProviderCapabilities.qml b/tests/qml/provider-capabilities/ProviderCapabilities.qml index b3734e96..20d5cfa0 100644 --- a/tests/qml/provider-capabilities/ProviderCapabilities.qml +++ b/tests/qml/provider-capabilities/ProviderCapabilities.qml @@ -32,4 +32,6 @@ QtObject { readonly property bool quickConnect: true // A datagram path between two Spool instances relayed by the source. readonly property bool peerRelay: true + // The source can deliver a lower bitrate or resolution on request. + readonly property bool streamQuality: true } diff --git a/tests/qml/tst_ProviderGating.qml b/tests/qml/tst_ProviderGating.qml index 6f2d424f..68769f4d 100644 --- a/tests/qml/tst_ProviderGating.qml +++ b/tests/qml/tst_ProviderGating.qml @@ -10,7 +10,7 @@ TestCase { name: "ProviderGating" readonly property var capabilityNames: ["auth", "discovery", "search", "userItemState", "playbackReporting", - "segments", "libraryManagement", "syncPlay", "remoteControl", "quickConnect", "peerRelay"] + "segments", "libraryManagement", "syncPlay", "remoteControl", "quickConnect", "peerRelay", "streamQuality"] function test_capabilityNamesAreBooleans() { for (let index = 0; index < capabilityNames.length; ++index) { From 6933242257993be5da2a98be23bf06102a83b1ed Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:40:58 +1000 Subject: [PATCH 016/146] feat(provider): add a folder-of-files provider and choose the provider at start-up LocalProvider (src/providers/local) serves every media file under one directory as a single library: browse pages, details, latest, search, in-memory favourite/played/resume state, and a playback session whose URL is the file itself. It has no sign-in, no artwork, no segments and no reporting, and announces its session from restoreFromStorage() since there is nothing to wait for. It is the second implementation of the provider contract and stays as its permanent fixture (local-provider ctest against tests/media/fixtures). main.cpp registers both providers and activates one from --provider or SPOOL_PROVIDER (default jellyfin); the local one takes --library-root or SPOOL_LOCAL_LIBRARY. With nothing passed the Jellyfin path is unchanged. The router now starts on home when the active provider has no auth, so the provider's login page is never built without its singletons. The seam check treats src/providers as provider code. --- CMakeLists.txt | 17 +- src/app/RouterController.cpp | 6 + src/app/RouterController.h | 3 + src/main.cpp | 45 +++- src/providers/local/LocalProvider.cpp | 369 ++++++++++++++++++++++++++ src/providers/local/LocalProvider.h | 134 ++++++++++ tests/providers/LocalProviderTest.cpp | 146 ++++++++++ tools/check-module-seam.sh | 2 +- 8 files changed, 712 insertions(+), 10 deletions(-) create mode 100644 src/providers/local/LocalProvider.cpp create mode 100644 src/providers/local/LocalProvider.h create mode 100644 tests/providers/LocalProviderTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c5efb08..9bbb7148 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,8 +137,8 @@ jellyfin_resolve_platform_dependencies() # now; keeping them apart here is what makes the split mechanical later. # # The rule that matters: nothing in the platform, player or shell groups may -# include src/api/ or src/discovery/. Core reaches a media source only -# through the interfaces in src/provider/; +# include src/api/, src/discovery/ or src/providers/. Core reaches a media +# source only through the interfaces in src/provider/; # tools/check-module-seam.sh enforces it as the module-seam test. # Per-OS abstractions. No backend and no UI knowledge. @@ -314,11 +314,19 @@ set(SPOOL_JELLYFIN_SOURCES src/models/DiscoveredServerModel.h ) +# The folder-of-files provider: the second implementation of the provider +# contract, kept as its permanent fixture. Nothing in core may include it. +set(SPOOL_LOCAL_SOURCES + src/providers/local/LocalProvider.cpp + src/providers/local/LocalProvider.h +) + set(JELLYFIN_CORE_SOURCES ${SPOOL_PLATFORM_SOURCES} ${SPOOL_PLAYER_SOURCES} ${SPOOL_SHELL_SOURCES} ${SPOOL_JELLYFIN_SOURCES} + ${SPOOL_LOCAL_SOURCES} ) add_library(jellyfin-core STATIC ${JELLYFIN_CORE_SOURCES}) @@ -750,7 +758,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # by add_test(). One executable per test used to cost more to build on # Windows than the application itself, because every one of them repeated a # whole-program-optimized link against all of jellyfin-core. - set(JELLYFIN_TEST_GROUPS api app cache common diagnostics discovery platform player) + set(JELLYFIN_TEST_GROUPS api app cache common diagnostics discovery platform player providers) set(JELLYFIN_TESTS_api http-request-policy tests/api/HttpRequestPolicyTest.cpp @@ -804,6 +812,9 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) macos-titlebar tests/platform/MacOSTitlebarTest.mm ) endif() + set(JELLYFIN_TESTS_providers + local-provider tests/providers/LocalProviderTest.cpp + ) set(JELLYFIN_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp playback-track-parser tests/player/PlaybackTrackParserTest.cpp diff --git a/src/app/RouterController.cpp b/src/app/RouterController.cpp index 453c7b68..83bf3664 100644 --- a/src/app/RouterController.cpp +++ b/src/app/RouterController.cpp @@ -59,6 +59,12 @@ RouterController::RouterController(QObject *parent) { } +RouterController::RouterController(const QString& startRoute, QObject *parent) + : QObject(parent) + , m_route(startRoute) +{ +} + QString RouterController::route() const { return m_route; diff --git a/src/app/RouterController.h b/src/app/RouterController.h index c5ac9b19..a0fd405c 100644 --- a/src/app/RouterController.h +++ b/src/app/RouterController.h @@ -18,6 +18,9 @@ class RouterController final : public QObject { public: explicit RouterController(QObject *parent = nullptr); + // `startRoute` is what the shell shows before the app has initialised: + // the provider's login page when it has one, otherwise home. + explicit RouterController(const QString& startRoute, QObject *parent = nullptr); QString route() const; QString previousRoute() const; diff --git a/src/main.cpp b/src/main.cpp index 35a0fb4f..37d98b49 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,7 @@ #include "player/PlayerController.h" #include "provider/Provider.h" #include "provider/ProviderRegistry.h" +#include "providers/local/LocalProvider.h" #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) #include "platform/UpdateController.h" #endif @@ -91,6 +92,23 @@ namespace { +// A start-up option: `--name value` or `--name=value` on the command line, +// else the environment variable, else the fallback. +QString optionValue( + const QStringList& arguments, const QString& option, const char *environmentVariable, const QString& fallback = {}) +{ + for (qsizetype index = 1; index < arguments.size(); ++index) { + const QString& argument = arguments.at(index); + if (argument == option && index + 1 < arguments.size()) + return arguments.at(index + 1); + if (argument.startsWith(option + QLatin1Char('='))) + return argument.mid(option.size() + 1); + } + if (qEnvironmentVariableIsSet(environmentVariable)) + return QString::fromLocal8Bit(qgetenv(environmentVariable)); + return fallback; +} + // The window's stock incubation controller advances async QML construction // ~5 ms per frame, so a page whose creation costs ~200 ms of CPU takes // 500-700 ms of wall time to instantiate. A timer-driven controller with a @@ -600,16 +618,27 @@ int main(int argc, char **argv) } JellyfinNative::TlsTrustController tlsTrust; - // One provider, chosen here until the first-run picker exists. It - // outlives the player and the app controller, which hold its parts by - // pointer, so it is declared before them. The app only ever sees it as - // a Provider. + // Every provider the build knows about is registered; the one the app + // runs on is chosen from the command line or the environment until the + // first-run picker exists. Providers outlive the player and the app + // controller, which hold their parts by pointer, so they are declared + // before them. JellyfinNative::ProviderRegistry providers; auto jellyfin = std::make_unique(JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, capabilities.deviceName, QString::fromLatin1(kAppVersion) }); providers.add(jellyfin.get()); - providers.setActive(jellyfin.get()); + auto local + = std::make_unique(optionValue(arguments, QStringLiteral("--library-root"), + "SPOOL_LOCAL_LIBRARY", QStandardPaths::writableLocation(QStandardPaths::MoviesLocation))); + providers.add(local.get()); + const QString requestedProvider + = optionValue(arguments, QStringLiteral("--provider"), "SPOOL_PROVIDER", jellyfin->id()); + if (!providers.setActive(requestedProvider)) { + logLine("provider: unknown provider %s; using %s", qPrintable(requestedProvider), qPrintable(jellyfin->id())); + providers.setActive(jellyfin.get()); + } JellyfinNative::Provider *provider = providers.active(); + logLine("provider: %s", qPrintable(provider->id())); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, @@ -727,7 +756,11 @@ int main(int argc, char **argv) provider->setLocale(localization->bcp47Locale()); QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, provider, [provider, loc = localization.get()]() { provider->setLocale(loc->bcp47Locale()); }); - auto router = std::make_unique(); + // A source with its own sign-in opens on it; one without goes straight + // to home. + auto router = std::make_unique( + provider->capabilities().testFlag(JellyfinNative::Provider::Auth) ? QStringLiteral("login") + : QStringLiteral("home")); JellyfinNative::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); applicationHooks.settings = controller->settings(); diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp new file mode 100644 index 00000000..32ae34e1 --- /dev/null +++ b/src/providers/local/LocalProvider.cpp @@ -0,0 +1,369 @@ +#include "LocalProvider.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace JellyfinNative { + +namespace { + + constexpr auto kLibraryId = "local"; + + // What mpv opens without help. Playlists and subtitle sidecars are left + // out: they are not items, and mpv finds the sidecars itself. + const QSet& videoSuffixes() + { + static const QSet suffixes { QStringLiteral("mkv"), QStringLiteral("mp4"), QStringLiteral("webm"), + QStringLiteral("mov"), QStringLiteral("avi"), QStringLiteral("m4v"), QStringLiteral("ts"), + QStringLiteral("mpg"), QStringLiteral("mpeg"), QStringLiteral("wmv") }; + return suffixes; + } + + const QSet& audioSuffixes() + { + static const QSet suffixes { QStringLiteral("mp3"), QStringLiteral("flac"), QStringLiteral("ogg"), + QStringLiteral("opus"), QStringLiteral("m4a"), QStringLiteral("wav"), QStringLiteral("aac") }; + return suffixes; + } + + bool titleLess(const MovieItem& left, const MovieItem& right) + { + return left.title.localeAwareCompare(right.title) < 0; + } + +} + +// The PlaybackSource half. Kept as a separate QObject because a provider is +// already one and moc allows a class one QObject base. +class LocalProvider::Playback final : public PlaybackSource { +public: + explicit Playback(LocalProvider *provider) + : PlaybackSource(provider) + , m_provider(provider) + { + } + + QByteArray mediaRequestHeaders() const override + { + return {}; + } + QUrl mediaOrigin() const override + { + return {}; + } + int playbackParallelRequests() const override + { + return 1; + } + bool signedIn() const override + { + return true; + } + QString trickplayTileUrl(const QString&, int, int) const override + { + return {}; + } + QCoro::Task resolvePlayback(MovieItem item, bool) override + { + co_return m_provider->playbackSession(item.id); + } + QCoro::Task> fetchMediaSegments(QString) override + { + co_return {}; + } + QCoro::Task> fetchSeriesEpisodes(QString) override + { + co_return {}; + } + QCoro::Task reportPlaybackStart(PlaybackSession, double, int, bool) override + { + co_return; + } + QCoro::Task reportPlaybackProgress(PlaybackSession, qint64, bool, double, int, bool) override + { + co_return; + } + QCoro::Task reportPlaybackStopped(PlaybackSession, qint64, bool, double) override + { + co_return; + } + +private: + LocalProvider *m_provider; +}; + +LocalProvider::LocalProvider(QString libraryRoot, QObject *parent) + : Provider(parent) + , m_root(QDir(std::move(libraryRoot)).absolutePath()) + , m_playback(new Playback(this)) +{ + m_libraryName = QDir(m_root).dirName(); + if (m_libraryName.isEmpty()) + m_libraryName = QStringLiteral("Local files"); +} + +LocalProvider::~LocalProvider() = default; + +QString LocalProvider::id() const +{ + return QStringLiteral("local"); +} + +QString LocalProvider::displayName() const +{ + return QStringLiteral("Local files"); +} + +Provider::Capabilities LocalProvider::capabilities() const +{ + return Search | UserItemState; +} + +PlaybackSource *LocalProvider::playback() +{ + return m_playback; +} + +bool LocalProvider::restoreFromStorage(QVariantMap, std::vector) +{ + if (!m_scanned) + scan(); + emit sessionStarted(); + return true; +} + +void LocalProvider::scan() +{ + m_scanned = true; + m_records.clear(); + m_index.clear(); + const QDir root(m_root); + QDirIterator files(m_root, QDir::Files | QDir::Readable, QDirIterator::Subdirectories); + while (files.hasNext()) { + const QFileInfo info(files.next()); + const QString suffix = info.suffix().toLower(); + const bool video = videoSuffixes().contains(suffix); + if (!video && !audioSuffixes().contains(suffix)) + continue; + Record record; + record.path = info.absoluteFilePath(); + record.modified = info.lastModified(); + MovieItem& item = record.item; + item.id = root.relativeFilePath(record.path); + item.title = info.completeBaseName(); + item.itemType = video ? QStringLiteral("Movie") : QStringLiteral("Audio"); + item.path = record.path; + item.dateCreated = record.modified.toString(Qt::ISODate); + MediaSourceInfo source; + source.id = item.id; + source.name = info.fileName(); + source.path = record.path; + source.container = suffix; + source.protocol = QStringLiteral("File"); + source.size = info.size(); + item.mediaSources.push_back(source); + m_records.push_back(std::move(record)); + } + std::sort(m_records.begin(), m_records.end(), + [](const Record& left, const Record& right) { return titleLess(left.item, right.item); }); + for (size_t index = 0; index < m_records.size(); ++index) + m_index.insert(m_records[index].item.id, index); +} + +QString LocalProvider::libraryScopeKey() const +{ + return QStringLiteral("local:") + m_root; +} + +const LocalProvider::Record *LocalProvider::record(const QString& itemId) const +{ + const auto found = m_index.constFind(itemId); + return found == m_index.cend() ? nullptr : &m_records[*found]; +} + +LocalProvider::Record *LocalProvider::record(const QString& itemId) +{ + const auto found = m_index.constFind(itemId); + return found == m_index.cend() ? nullptr : &m_records[*found]; +} + +std::vector LocalProvider::items(int startIndex, int limit) const +{ + std::vector page; + const size_t begin = static_cast(std::max(0, startIndex)); + const size_t end = limit > 0 ? std::min(m_records.size(), begin + static_cast(limit)) : m_records.size(); + for (size_t index = begin; index < end; ++index) + page.push_back(m_records[index].item); + return page; +} + +QCoro::Task LocalProvider::fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap) +{ + PagedMovieItems page; + page.startIndex = startIndex; + page.limit = limit; + // One flat library: the folder's own listing and anything that asks for + // its children. Every other browse shape (people, seasons, genres) has + // nothing behind it here. + const bool wholeLibrary = descriptor.kind == BrowseKind::Library && descriptor.id == QLatin1String(kLibraryId); + if (wholeLibrary || descriptor.kind == BrowseKind::FolderChildren) { + page.items = items(startIndex, limit); + page.totalRecordCount = static_cast(m_records.size()); + } + co_return page; +} + +QCoro::Task LocalProvider::fetchItemDetails(QString itemId) +{ + if (const Record *found = record(itemId)) + co_return found->item; + throw std::runtime_error("No such file in the library."); +} + +QCoro::Task> LocalProvider::fetchSeasons(QString) +{ + co_return {}; +} + +QCoro::Task> LocalProvider::fetchEpisodes(QString, QString) +{ + co_return {}; +} + +QCoro::Task> LocalProvider::fetchResumeItems(int limit) +{ + std::vector resumable; + for (const Record& entry : m_records) { + if (entry.item.resumeTicks > 0 && !entry.item.played) + resumable.push_back(entry.item); + if (limit > 0 && static_cast(resumable.size()) >= limit) + break; + } + co_return resumable; +} + +QCoro::Task> LocalProvider::fetchNextUpEpisodes(int) +{ + co_return {}; +} + +QCoro::Task> LocalProvider::fetchLatestItems(QString, int limit) +{ + std::vector byDate; + byDate.reserve(m_records.size()); + for (const Record& entry : m_records) + byDate.push_back(&entry); + std::sort(byDate.begin(), byDate.end(), + [](const Record *left, const Record *right) { return left->modified > right->modified; }); + std::vector latest; + for (const Record *entry : byDate) { + if (limit > 0 && static_cast(latest.size()) >= limit) + break; + latest.push_back(entry->item); + } + co_return latest; +} + +QCoro::Task> LocalProvider::fetchSimilarItems(QString, int) +{ + co_return {}; +} + +QCoro::Task LocalProvider::fetchItemsByPerson(QString, int) +{ + co_return {}; +} + +QCoro::Task> LocalProvider::fetchLibraries() +{ + LibraryItem library; + library.id = QLatin1String(kLibraryId); + library.name = m_libraryName; + library.collectionType = QStringLiteral("movies"); + co_return std::vector { library }; +} + +QCoro::Task LocalProvider::fetchLibraryFilterOptions(QString, QString) +{ + co_return {}; +} + +QCoro::Task> LocalProvider::fetchItemsByIds(QStringList itemIds) +{ + std::vector found; + for (const QString& itemId : itemIds) { + if (const Record *entry = record(itemId)) + found.push_back(entry->item); + } + co_return found; +} + +QCoro::Task> LocalProvider::searchItems(QString searchTerm, int limit) +{ + const QString term = searchTerm.trimmed(); + std::vector matches; + for (const Record& entry : m_records) { + if (limit > 0 && static_cast(matches.size()) >= limit) + break; + if (term.isEmpty() || entry.item.title.contains(term, Qt::CaseInsensitive)) + matches.push_back(entry.item); + } + co_return matches; +} + +QCoro::Task> LocalProvider::fetchSearchSuggestions(int limit) +{ + co_return items(0, limit); +} + +QCoro::Task LocalProvider::setItemFavorite(QString itemId, bool favorite) +{ + if (Record *entry = record(itemId)) + entry->item.favorite = favorite; + co_return; +} + +QCoro::Task LocalProvider::setItemPlayed(QString itemId, bool played) +{ + if (Record *entry = record(itemId)) { + entry->item.played = played; + if (played) { + entry->item.resumeTicks = 0; + ++entry->item.playCount; + } + } + co_return; +} + +QCoro::Task LocalProvider::setItemPlaybackPosition(QString itemId, qint64 positionTicks) +{ + if (Record *entry = record(itemId)) + entry->item.resumeTicks = std::max(0, positionTicks); + co_return; +} + +PlaybackSession LocalProvider::playbackSession(const QString& itemId) const +{ + const Record *entry = record(itemId); + if (!entry) + throw std::runtime_error("No such file in the library."); + PlaybackSession session; + session.itemId = entry->item.id; + session.title = entry->item.title; + session.itemType = entry->item.itemType; + session.url = QUrl::fromLocalFile(entry->path).toString(); + session.mediaSourceId = entry->item.id; + session.container = entry->item.mediaSources.front().container; + session.startTimeTicks = entry->item.resumeTicks; + session.runtimeTicks = entry->item.runtimeTicks; + return session; +} + +} // namespace JellyfinNative diff --git a/src/providers/local/LocalProvider.h b/src/providers/local/LocalProvider.h new file mode 100644 index 00000000..63b42a76 --- /dev/null +++ b/src/providers/local/LocalProvider.h @@ -0,0 +1,134 @@ +#pragma once + +#include "../../provider/ArtworkSource.h" +#include "../../provider/Catalog.h" +#include "../../provider/PlaybackSource.h" +#include "../../provider/Provider.h" +#include "../../provider/SearchSource.h" +#include "../../provider/UserItemStateSink.h" + +#include + +#include +#include +#include + +#include + +// The providers a build ships live here, one directory each. The Jellyfin +// provider still sits in src/api and src/app; it moves to +// src/providers/jellyfin/ in Phase 5, once the core/ and providers/ layout +// lands. + +namespace JellyfinNative { + +// A deliberately simple media source: every media file under one folder, +// presented as a single library with no server behind it. It exists to +// prove that the shell and the pages need nothing a folder cannot give +// them, and it stays as the contract's permanent test fixture. Favourite, +// played and resume position are kept in memory for the process; nothing is +// reported anywhere. +class LocalProvider final : public Provider, + public Catalog, + public SearchSource, + public UserItemStateSink, + public ArtworkSource { + Q_OBJECT + +public: + explicit LocalProvider(QString libraryRoot, QObject *parent = nullptr); + ~LocalProvider() override; + + QString id() const override; + QString displayName() const override; + Capabilities capabilities() const override; + PlaybackSource *playback() override; + Catalog *catalog() override + { + return this; + } + ArtworkSource *artwork() override + { + return this; + } + SearchSource *search() override + { + return this; + } + UserItemStateSink *itemState() override + { + return this; + } + void registerQmlSingletons() override { } + + bool ready() const override + { + return true; + } + // Scans the folder and announces the session, since there is no sign-in + // to wait for. + bool restoreFromStorage(QVariantMap values, std::vector profiles) override; + + QString libraryRoot() const + { + return m_root; + } + // Reads the folder again. Called once from restoreFromStorage(); a test + // calls it directly. + void scan(); + + // Catalog, SearchSource, UserItemStateSink + bool signedIn() const override + { + return true; + } + QString libraryScopeKey() const override; + QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; + QCoro::Task fetchItemDetails(QString itemId) override; + QCoro::Task> fetchSeasons(QString seriesId) override; + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; + QCoro::Task> fetchResumeItems(int limit = 24) override; + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; + QCoro::Task> fetchLibraries() override; + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchItemsByIds(QStringList itemIds) override; + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + QCoro::Task setItemFavorite(QString itemId, bool favorite) override; + QCoro::Task setItemPlayed(QString itemId, bool played) override; + QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; + // ArtworkSource: a folder has no artwork. + QString imageUrl(const ImageRequest&) const override + { + return {}; + } + + // What the player asks for. Everything is answered from the scan; the + // report calls complete without doing anything. + PlaybackSession playbackSession(const QString& itemId) const; + +private: + struct Record { + MovieItem item; + QString path; + QDateTime modified; + }; + class Playback; + + const Record *record(const QString& itemId) const; + Record *record(const QString& itemId); + std::vector items(int startIndex, int limit) const; + + QString m_root; + QString m_libraryName; + std::vector m_records; + QHash m_index; + Playback *m_playback = nullptr; + bool m_scanned = false; +}; + +} // namespace JellyfinNative diff --git a/tests/providers/LocalProviderTest.cpp b/tests/providers/LocalProviderTest.cpp new file mode 100644 index 00000000..0fdb7359 --- /dev/null +++ b/tests/providers/LocalProviderTest.cpp @@ -0,0 +1,146 @@ +#include "providers/local/LocalProvider.h" +#include "provider/Catalog.h" +#include "provider/PlaybackSource.h" +#include "provider/Provider.h" +#include "provider/SearchSource.h" +#include "provider/UserItemStateSink.h" + +#include "TestMain.h" + +#include + +#include +#include +#include + +#include +#include +#include + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +bool hasTitle(const std::vector& items, const QString& title) +{ + for (const auto& item : items) { + if (item.title == title) + return true; + } + return false; +} + +} // namespace + +// The folder-of-files provider against the media fixtures: the one library, +// its page, details, search, item state and a playable session, all from +// nothing but a directory listing. +JELLYFIN_TEST_MAIN("local-provider") +{ + QCoreApplication app(argc, argv); + using namespace JellyfinNative; + + const QString fixtures = QDir(QStringLiteral(TEST_SOURCE_DIR)).filePath(QStringLiteral("tests/media/fixtures")); + LocalProvider provider(fixtures); + require(provider.id() == QStringLiteral("local"), "the provider is named local"); + require(provider.ready(), "a folder needs no sign-in"); + require(provider.capabilities() == (Provider::Search | Provider::UserItemState), + "a folder searches and remembers item state, nothing more"); + require( + provider.catalog() && provider.playback() && provider.artwork() && provider.search() && provider.itemState(), + "the required and the claimed optional interfaces are served"); + require(!provider.groupPlayback() && !provider.remotePlayback() && !provider.streamQuality(), + "unclaimed capabilities have no object"); + + int sessions = 0; + QObject::connect(&provider, &Provider::sessionStarted, &app, [&sessions] { ++sessions; }); + require(provider.restoreFromStorage({}, {}), "restoring announces a default profile"); + require(sessions == 1, "a source without sign-in announces its session when restored"); + + Catalog *catalog = provider.catalog(); + const auto libraries = QCoro::waitFor(catalog->fetchLibraries()); + require(libraries.size() == 1, "one library per folder"); + require(libraries.front().id == QStringLiteral("local"), "the library id is fixed"); + require(libraries.front().name == QStringLiteral("fixtures"), "the library is named after the folder"); + + // Media files only: the playlist and the subtitle sidecar are not items. + const auto page = QCoro::waitFor( + catalog->fetchBrowsePage(BrowseDescriptor::library(libraries.front().id, libraries.front().collectionType))); + require(page.totalRecordCount == 4, "four media files in the fixtures"); + require(page.items.size() == 4, "the first page holds them all"); + require(hasTitle(page.items, QStringLiteral("audio")), "the flac is listed"); + require(hasTitle(page.items, QStringLiteral("direct-mpeg2")), "the mkv is listed"); + require(hasTitle(page.items, QStringLiteral("remux-h264")), "the mp4 is listed"); + require(hasTitle(page.items, QStringLiteral("transcode-0")), "the transport stream is listed"); + require(!hasTitle(page.items, QStringLiteral("subtitle")) && !hasTitle(page.items, QStringLiteral("transcode")), + "sidecars and playlists are not items"); + require(page.items.front().title == QStringLiteral("audio"), "items are sorted by title"); + require(page.items.front().itemType == QStringLiteral("Audio"), "audio files are audio items"); + require(page.items[1].itemType == QStringLiteral("Movie"), "video files are movies"); + require(page.items[1].isPlayable(), "video items are playable"); + + const auto paged = QCoro::waitFor( + catalog->fetchBrowsePage(BrowseDescriptor::library(QStringLiteral("local"), QStringLiteral("movies")), 1, 2)); + require(paged.items.size() == 2 && paged.startIndex == 1 && paged.totalRecordCount == 4, "paging is honoured"); + require(paged.items.front().title == QStringLiteral("direct-mpeg2"), "paging starts where asked"); + + const auto other = QCoro::waitFor(catalog->fetchBrowsePage(BrowseDescriptor::person(QStringLiteral("nobody")))); + require(other.items.empty() && other.totalRecordCount == 0, "browse shapes a folder cannot serve are empty"); + + const MovieItem mkv = page.items[1]; + const MovieItem details = QCoro::waitFor(catalog->fetchItemDetails(mkv.id)); + require(details.id == mkv.id && details.title == mkv.title, "details return the listed item"); + require(details.mediaSources.size() == 1 && details.mediaSources.front().container == QStringLiteral("mkv"), + "the file is its own media source"); + bool missingThrows = false; + try { + QCoro::waitFor(catalog->fetchItemDetails(QStringLiteral("nope.mkv"))); + } catch (const std::runtime_error&) { + missingThrows = true; + } + require(missingThrows, "an unknown id is an error, not an empty item"); + + const auto byIds = QCoro::waitFor(catalog->fetchItemsByIds({ mkv.id, QStringLiteral("nope.mkv") })); + require(byIds.size() == 1 && byIds.front().id == mkv.id, "lookup by ids skips unknown ones"); + require(QCoro::waitFor(catalog->fetchLatestItems({}, 2)).size() == 2, "latest items honour the limit"); + require(QCoro::waitFor(catalog->fetchResumeItems()).empty(), "nothing is resumable before anything played"); + + SearchSource *search = provider.search(); + const auto hits = QCoro::waitFor(search->searchItems(QStringLiteral("H264"))); + require(hits.size() == 1 && hits.front().title == QStringLiteral("remux-h264"), "search matches titles, any case"); + require(QCoro::waitFor(search->searchItems(QStringLiteral("zzz"))).empty(), "search misses cleanly"); + require(QCoro::waitFor(search->fetchSearchSuggestions(3)).size() == 3, "suggestions honour the limit"); + + UserItemStateSink *state = provider.itemState(); + QCoro::waitFor(state->setItemPlaybackPosition(mkv.id, 5'000'000)); + require(QCoro::waitFor(catalog->fetchItemDetails(mkv.id)).resumeTicks == 5'000'000, "position is remembered"); + require(QCoro::waitFor(catalog->fetchResumeItems()).size() == 1, "a started item is resumable"); + QCoro::waitFor(state->setItemFavorite(mkv.id, true)); + require(QCoro::waitFor(catalog->fetchItemDetails(mkv.id)).favorite, "favourite is remembered"); + QCoro::waitFor(state->setItemPlayed(mkv.id, true)); + const MovieItem played = QCoro::waitFor(catalog->fetchItemDetails(mkv.id)); + require(played.played && played.resumeTicks == 0 && played.playCount == 1, "played clears the resume point"); + require(QCoro::waitFor(catalog->fetchResumeItems()).empty(), "a finished item is no longer resumable"); + + PlaybackSource *playback = provider.playback(); + require(playback->signedIn() && playback->mediaRequestHeaders().isEmpty() && playback->mediaOrigin().isEmpty(), + "local playback needs no headers and no trusted origin"); + const PlaybackSession session = QCoro::waitFor(playback->resolvePlayback(mkv, false)); + require(session.itemId == mkv.id && session.title == mkv.title, "the session names the item"); + const QUrl url(session.url); + require(url.isLocalFile() && url.toLocalFile() == QDir(fixtures).filePath(QStringLiteral("direct-mpeg2.mkv")), + "the session URL is the file itself"); + require(session.container == QStringLiteral("mkv"), "the container is the file's suffix"); + require(QCoro::waitFor(playback->fetchMediaSegments(mkv.id)).empty(), "a folder has no segments"); + require(provider.artwork()->imageUrl({ mkv.id, {}, QStringLiteral("Primary"), 300 }).isEmpty(), + "a folder has no artwork"); + + std::cout << "local provider ok\n"; + return 0; +} diff --git a/tools/check-module-seam.sh b/tools/check-module-seam.sh index 623213eb..6a54a999 100755 --- a/tools/check-module-seam.sh +++ b/tools/check-module-seam.sh @@ -26,7 +26,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" core_dirs=(src/platform src/player src/media src/provider src/common src/cache src/diagnostics) -provider_dirs=(src/api src/discovery) +provider_dirs=(src/api src/discovery src/providers) group_files() { sed -n "/^set($1/,/^)/p" CMakeLists.txt | grep -E '^\s+src/' | sed -E 's/^\s+//' From 6f09d9dda9f043326bf823f8ee6c488a93766bbe Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Sep 2026 19:40:58 +1000 Subject: [PATCH 017/146] docs: describe the finished provider surface and the two providers --- AGENTS.md | 73 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2dea1d4a..195e8407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,43 +31,62 @@ The goal is one core that several backends can sit on (spool-jellyfin, spool-plex, spool-stremio). `CMakeLists.txt` groups sources into -`SPOOL_PLATFORM_SOURCES`, `SPOOL_PLAYER_SOURCES`, `SPOOL_SHELL_SOURCES` and -`SPOOL_JELLYFIN_SOURCES` to mark where that cut goes. They still build as one -library; the grouping exists so the split stays mechanical. +`SPOOL_PLATFORM_SOURCES`, `SPOOL_PLAYER_SOURCES`, `SPOOL_SHELL_SOURCES`, +`SPOOL_JELLYFIN_SOURCES` and `SPOOL_LOCAL_SOURCES` to mark where that cut +goes. They still build as one library; the grouping exists so the split stays +mechanical. - Core (the platform, player and shell groups plus everything under `src/platform`, `src/player`, `src/media`, `src/provider`, `src/common`, - `src/cache` and `src/diagnostics`) never includes `src/api/` or - `src/discovery/`. `tools/check-module-seam.sh` enforces that and runs as - the `module-seam` ctest; `--strict` also treats every file still listed in - `SPOOL_JELLYFIN_SOURCES` as provider and currently reports one leftover - (`DatabaseManager` reaches `AccountProfile`). + `src/cache` and `src/diagnostics`) never includes `src/api/`, + `src/discovery/` or `src/providers/`. `tools/check-module-seam.sh` enforces + that and runs as the `module-seam` ctest; `--strict` also treats every file + still listed in `SPOOL_JELLYFIN_SOURCES` as provider and passes too. - A media source is a `Provider` (`src/provider/Provider.h`): an id, a - display name, a set of capability flags and a `PlaybackSource`. - `ProviderRegistry` holds the active one and publishes its flags as the - `ProviderCapabilities` QML singleton (eleven booleans: auth, discovery, - search, userItemState, playbackReporting, segments, libraryManagement, - syncPlay, remoteControl, quickConnect, peerRelay). Shared QML gates every - provider-specific control on one of those names; the provider registers - the singletons only its own QML reaches (`Session`, `SyncPlay`, + display name, capability flags, and the interfaces it serves. `playback()` + (`PlaybackSource`), `catalog()` (`Catalog`) and `artwork()` + (`ArtworkSource`) are required; `search()`, `itemState()`, + `streamQuality()`, `groupPlayback()` and `remotePlayback()` are null + without the matching capability. Session-shaped hooks (`ready()`, + `restoreFromStorage()`, `setDeviceId()`, `handleUnauthorized()`) and + signals (`sessionStarted`, `sessionEnded`, `busyChanged`, `errorOccurred`, + `toastRequested`, `contentChanged`) are how the app hears about the source + without knowing which one it is. `AppController` is the composition root, + is core, and talks to the source through nothing else. +- `ProviderRegistry` holds every provider the build knows and the active one, + and publishes the active flags as the `ProviderCapabilities` QML singleton + (twelve booleans: auth, discovery, search, userItemState, + playbackReporting, segments, libraryManagement, syncPlay, remoteControl, + quickConnect, peerRelay, streamQuality). Shared QML gates every + provider-specific control on one of those names; a provider registers the + singletons only its own QML reaches (`Session`, `SyncPlay`, `RemoteControl`, `Management`, `QuickConnect`, `Discovery`, - `DiscoveredServers`) from `registerQmlSingletons()`. -- `JellyfinProvider` (`src/api`) composes everything Jellyfin-specific: the - facade, discovery and its cached server list, the session, QuickConnect, - remote control, and after `attach()` SyncPlay, library management and the - settings bridge. It owns the session-driven lifecycle of those parts; - `AppController` is the composition root above it and constructs none of - them. Nothing under `src/platform` or `src/diagnostics` includes - `AppController`: the platform layer takes `ApplicationHooks` and the render - benchmark `RenderBenchmarkHooks`, both filled in by `main.cpp`. -- The player talks to its media source only through - `src/provider/PlaybackSource.h`; `JellyfinApiFacade` implements it. + `DiscoveredServers`) from `registerQmlSingletons()`, and shared QML never + names them without first checking the capability. +- Two providers exist. `JellyfinProvider` (`src/api`) composes everything + Jellyfin-specific: the facade, discovery and its cached server list, the + session, QuickConnect, remote control, and after `attach()` SyncPlay, + library management and the settings bridge; it owns their session-driven + lifecycle. `LocalProvider` (`src/providers/local`) serves a folder of media + files with no server: it is deliberately simple, proves the shell needs + nothing a folder cannot give it, and stays as the contract's permanent + fixture (`local-provider` ctest). `main.cpp` registers both and activates + one: `--provider ` or `SPOOL_PROVIDER` (default `jellyfin`), and for + the local one `--library-root ` or `SPOOL_LOCAL_LIBRARY` (default the + Videos directory). With no `auth` capability the shell opens on home. +- The player talks to its media source only through `PlaybackSource`, the + queue and pages through `Catalog`, `SearchSource` and `UserItemStateSink`, + the quality picker through `StreamQualityControl`, and SyncPlay and remote + control through `GroupPlayback` and `RemotePlayback`. `JellyfinApiFacade`, + `SyncPlayController` and `RemoteControlController` implement those. `SettingsController` emits the preferences a source needs and takes the account's remote settings back through `applyRemote*()`; `JellyfinSettingsBridge` in `src/api` is the Jellyfin side of that. Its schema drops the account rows when the active provider has no `auth`. `configurePlatformPlaybackCapabilities()` takes an applier callback, not - the facade. + the facade. Nothing under `src/platform` or `src/diagnostics` includes + `AppController`: the platform layer takes `ApplicationHooks` and the render + benchmark `RenderBenchmarkHooks`, both filled in by `main.cpp`. - `qml/primitives` and `qml/theme` reach exactly two singletons, `Art.url` and `Settings.uiScalePercent`. Keep it that way; page- and shell-level QML is where backend-shaped data belongs. From e1fa10a9335bfafee4a9643ea805c9e81dace0c5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Sun, 20 Sep 2026 17:39:05 +1000 Subject: [PATCH 018/146] docs: add the provider split plan The plan the provider seam work follows, through the phases that have landed and the ones that have not: the two-repo shape, the capability contract, the picker and runtime download path, and the directory move and extraction still to come. --- docs/provider-split-plan.md | 366 ++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 docs/provider-split-plan.md diff --git a/docs/provider-split-plan.md b/docs/provider-split-plan.md new file mode 100644 index 00000000..ede1370a --- /dev/null +++ b/docs/provider-split-plan.md @@ -0,0 +1,366 @@ +# Spool provider split plan + +Last updated 2026-09-20. Living document: the plan doc is at +https://claude.ai/code/artifact/dfa7b95e-4398-4f8c-b373-77fd06bb6a67 and this +file is the copy that travels with the code. + +## Decision + +One Spool app, many providers, two repos. This repo stays `spool` and keeps core +and app together, the way Kodi's main repo holds the player, the skin engine and +the app. Only providers get their own repos: `spool-jellyfin` first, and a debrid +or Stremio-style provider later against the same contract. Users pick a provider +on first run and the app fetches it at runtime once each platform is proven, with +providers built in until then. + +The boundary gets drawn in place first and proven with a throwaway second +provider. Nothing moves between repos until a fake provider runs with zero +changes to the UI layer. Core and app stay here as separate directories with a +public header boundary between them, so a separate `spool-core` can be extracted +by a directory move if a second app ever needs it. No repo rename. + +What this rules out: two independent apps that each build on core. Browse, +details, search, player overlay, queue and settings are the same screens +regardless of where the bytes come from. Forking them recreates the +Jellyfin-client situation where every UI fix lands twice. + +Also ruled out for now: a separate `spool-core` repo. With one app it would hold +about 3k lines of glue and branding, and every user-facing release would need a +core tag plus a pin bump in the app. Stremio splits core from shell because it +has four shells. Kodi has one app and does not split. Extract core the day a +second app or a community frontend actually exists. + +## Status + +Phases 1 to 4 are done on `refactor/provider-seam`. The tree builds, all 77 +tests pass, and `tools/check-module-seam.sh --strict` reports no core file +including provider code. A folder-of-files provider runs every core screen with +no Jellyfin singletons registered. Phase 5 is next. + +| Landed | What it is | +| --- | --- | +| Media model | `src/media/MediaTypes` replaces JellyfinTypes; AuthSession and DiscoveredServer moved provider-side | +| Player seam | `src/provider/PlaybackSource` is all the player sees; the X-Emby-Token header lives in the facade | +| Settings inversion | SettingsController emits, `JellyfinSettingsBridge` listens | +| Catalog contract | `Catalog`, `SearchSource`, `UserItemStateSink`, `ArtworkSource` interfaces; seven controllers and artwork moved to core | +| Registry | `Provider`, `ProviderRegistry`, `ProviderCapabilities` singleton driven from C++; `JellyfinProvider` composes everything Jellyfin-specific | +| QML gating | Every provider-specific control gated on `ProviderCapabilities`; Jellyfin-only QML under `qml/providers/jellyfin` | +| Seam check | `module-seam` ctest fails the build if core includes `src/api`, `src/discovery` or `src/providers` | +| Local provider | `src/providers/local` serves a folder of files; `--provider local --library-root DIR` selects it | + +## Target layout + +Two repos. This one keeps about 86% of the ~95k lines measured on `master` at +a69c8da. + +| Repo | Holds | Lines | +| --- | --- | --- | +| `spool` (this repo) | Core: player engine, 10-foot UI kit, all screens, platform layer for 6 targets, settings, diagnostics, cache, artwork pipeline, provider contract, build and packaging for every target, the mpv submodule. App: composition root, branding, store metadata, icons, i18n, VERSION. Contract spec under `docs/contract/` | ~82k | +| `spool-jellyfin` | Jellyfin auth and login screens, server discovery, item queries, playback negotiation, session reporting, SyncPlay, remote control, playlist and collection management | ~13k | + +**Why core and app stay together.** The app layer is about 3k lines: +AppController, main.cpp and the files a store or package manager cares about. +Almost everything that feels like "the app" is reusable: settings, router, +artwork loading, the content and browse models, the deep-link codec, the +Spool-to-Spool remote protocol. Those are core so the next provider gets them for +free, and what is left is too thin to be worth a release step of its own. It +lives in `apps/spool/` here, built only against core's public headers. If that +directory starts growing, core has leaked. + +## The provider contract + +A provider is a set of capabilities it opts into, not one fat backend interface. +A debrid provider implements the first three and nothing else. Jellyfin +implements all of them. + +| Capability | What it covers | Required | +| --- | --- | --- | +| Catalog | Libraries, browse pages, filter options, details, seasons and episodes, resume, next up, latest, similar, by person | Yes | +| Stream | Resolve an item to something mpv can open, including media sources, tracks and bandwidth choice | Yes | +| Artwork | Image URLs for an item, trickplay tiles | Yes | +| Auth | Login flow and its screens, session persistence, credential store use, expiry | No | +| Discovery | Find servers on the LAN | No | +| Search | Text search and suggestions | No | +| User item state | Favourite, played, playback position | No | +| Playback reporting | Start, progress, stop, capabilities | No | +| Segments | Intro and credit markers for skip cards | No | +| Stream quality | Bitrate and height overrides, the quality ladder | No | +| Library management | Playlists, collections, rename, delete | No | +| SyncPlay | Group watch with a shared clock | No | +| Remote control | Cast to and control other sessions of the same backend | No | +| Peer relay | A datagram path between two Spool instances via the backend | No | + +The player only needs Stream, Playback reporting and Segments. That is the whole +player-side seam and it is small. + +**Shape of the code.** Core defines the capability interfaces and a registry. A +provider registers itself, declares which capabilities it serves, and supplies +its own QML for the screens only it needs (login, account linking, remote +targets). The same provider builds as a static library for bundling and as a +shared library for runtime download. Core screens ask the registry whether the +active provider has a capability and show or hide controls accordingly. + +**The media model is core.** The structs in `src/media/MediaTypes.h` +(LibraryItem, MovieItem, MediaStreamInfo, MediaSourceInfo, PlaybackSession, +SubtitlePreferences, MediaSegment, TrickplayInfo and the rest) are what every +screen and the player consume. Fields that only Jellyfin has, like server and +session ids, become opaque provider handles. Library state that no server keeps, +which is the debrid case, lands in the existing cache tables, which are already +generic. + +Fields still carrying Jellyfin's shape, left for a human decision: the +`mediaSourceId`, `playSessionId` and `playMethod` on PlaybackSession; the image +`*Tag` fields on MovieItem, LibraryItem and PersonItem; `playlistItemId`, +`locationType`, `isVirtualItem`, `recursiveItemCount`; every `*Ticks` field, +which is Jellyfin's 100 ns unit; the string enums on MediaStreamInfo and +MediaSegment; and MetaJson's PascalCase policy with its `RuntimeTicks` and +`ImageTag` key aliases. + +## Provider picker and runtime download + +Users pick a provider on first run and the app fetches it. Providers ship built +into the binary until each platform's download path is proven, so the picker +looks and behaves the same on day one and the download is a detail users never +notice. The mechanism is the same one Kodi uses for add-ons; the difference is a +curated list of first-party providers and no add-on browser. + +**The picker.** A first-run screen listing first-party providers: Jellyfin now, +Stremio-style and debrid as they exist. Picking one runs that provider's login or +account-linking flow. Settings gets a "Change source" entry that returns to the +picker. One provider is active at a time. Aggregating several sources into one +home page is a real design problem and is out of scope. + +**Sequencing.** + +1. Picker with providers built in, static link. +2. Download path on Linux and Windows, where loading a fetched shared library + just works. +3. macOS once downloaded plugins are signed with the team ID so library + validation passes. +4. Android and webOS after the spike below. + +| Platform | Runtime load | Blocker | +| --- | --- | --- | +| Linux | Works | None | +| Windows | Works | None | +| macOS | Works with signing | Hardened runtime library validation: plugins signed with the same team ID, or drop the entitlement | +| Android | Works technically | Google Play forbids downloading native code; fine while updates stay outside Play | +| webOS | Expected to work | Likely fine without root: Kodi loads add-on binaries on webOS and Spool already self-updates there. Confirm with a half-day spike | + +**ABI-safe boundary.** A native plugin must match the app's Qt build, compiler +and struct layouts, so a stable logical API is not enough. The plugin interface +is pure virtual classes carrying an interface version integer, and item data +crosses it as QVariantMap or JSON, never as C++ structs. The app refuses a plugin +whose interface version differs from its own. Slightly slower than passing +structs, immune to layout changes. + +**Lockstep release train.** This repo's release CI builds every first-party +provider for all 6 targets from the pinned submodules, with this repo's own +toolchain, and publishes the plugin binaries plus a provider index JSON with each +release. The app downloads from the index for its own version and platform. +Providers cannot drift from the app because the same job built both. A +provider-only workflow rebuilds providers against the current `spool` tag and +updates the index, so a Jellyfin fix ships without an app release. Provider repos +never run their own multi-platform builds. + +**Fallback if webOS blocks native loading.** Providers as QML and JS bundles: no +ABI, no signing, no Play policy problem. The Jellyfin facade is mostly HTTP, JSON +and URL building, so it is about 3k lines of JS to write, with heavier pieces +like bandwidth policy and the SyncPlay clock kept in core as generic services. +Decide after the spike, not before. + +## Phases 1 to 4: draw the boundary in place (done) + +All of this happened in this repo with the app shipping normally throughout. + +```mermaid +flowchart LR + P1["1 Media model
JellyfinTypes becomes core types"] + P1 --> P2["2 Player seam
narrow interface"] + P2 --> P3["3 Registry
capabilities, provider QML"] + P3 --> P4["4 Fake provider
proves the boundary"] +``` + +**Phase 0: rename, deferred.** The `JellyfinNative` namespace, the +`JellyfinWebOS` QML module, the `jellyfin-core` and `jellyfin-native` CMake +targets and the `qtfin` i18n prefix all name provider-agnostic code after +Jellyfin. The rename is cosmetic and touches every file, so it is folded into the +Phase 5 directory move, where every file moves anyway. Nothing depends on it. + +**Phase 1: media model.** JellyfinTypes moved to `src/media/MediaTypes`, with +AuthSession and DiscoveredServer pushed provider-side. Fields unchanged; the +audit list is above. + +**Phase 2: player seam.** PlayerController, PlayQueueController and +PlaybackReporter take a `PlaybackSource *`. The facade implements it. Nothing +under `src/player` includes anything from `src/api`. SettingsController lost the +facade and talks through `JellyfinSettingsBridge`. The platform layer reports +codec capabilities through a callback instead of taking the facade. + +**Phase 3: provider registry.** `Provider` with capability flags, a +`ProviderRegistry`, and a `ProviderCapabilities` QML singleton driven from C++. +`JellyfinProvider` composes the facade, discovery, session, QuickConnect, +SyncPlay, remote control, library management and the settings bridge, and +registers its own QML singletons. Every provider-specific control in shared QML +is gated on a capability flag; Jellyfin-only QML lives under +`qml/providers/jellyfin`. + +**Phase 4: fake provider.** `src/providers/local` serves a folder of media files +as one library. It forced six real core changes: the start route is chosen from +the Auth capability rather than always being login; AppController null-guards +SyncPlay and remote control; a provider with no sign-in emits `sessionStarted` +from storage restore; the quality picker became a capability; the series +audio-track cache key stopped assuming a server and user id; and Catalog and +PlaybackSource grew the libraries, filter options, items-by-ids, segments and +stream resolution that only AppController had been getting from the facade. + +Done means: two providers build, the Jellyfin app is unchanged for users, and +core includes nothing from `src/api`, `src/discovery` or `src/providers`. The +`module-seam` ctest enforces it. + +## Phases 5 to 7: split out the provider + +One in-tree reshuffle, then one extraction, then a release. The app keeps +building at every step. + +**Phase 5: in-tree directories that mirror the future repos.** Move code so this +repo's layout already looks like three repos in one, and fold the deferred rename +into the same pass. + +| Directory | Becomes | Builds as | +| --- | --- | --- | +| `core/` (src, qml, tests, tools, cmake, packaging, mpv) | stays here | `spool-core` static library plus a `spool_add_app()` CMake function | +| `providers/jellyfin/` | `spool-jellyfin` | `spool-provider-jellyfin`, static for bundling and shared for download, only public core headers | +| `providers/local/` | stays here as the fixture | `spool-provider-local` | +| `apps/spool/` | stays here | The executable, via `spool_add_app()` | + +The rule that makes the split safe: `providers/*` and `apps/*` may include only +headers core exports on purpose, from a `spool/` include prefix. If the Jellyfin +provider builds against core's public headers alone while sitting in the same +tree, extracting it is a directory move. + +**Phase 6: extract the provider.** Create `spool-jellyfin` from +`providers/jellyfin/` with `git subtree split` so file history survives. It +consumes this repo as a git submodule pinned to a tag, the same way this repo +already consumes the mpv fork. Its CI builds the provider against that pin and +runs `tests/api` and `tests/discovery`. + +This repo then consumes `spool-jellyfin` back as a submodule under `providers/`, +pinned to a tag. Release CI builds it for all 6 targets alongside the app, +bundles it into the binary, and publishes the plugin binaries and provider index +for the download path. + +CI here keeps two app builds: the real `spool` with the Jellyfin pin, and the +local-fixture app that needs no external repo at all. + +**Phase 7: first release.** Tag `spool` at the current user-facing version and +`spool-jellyfin` v1.0.0, with the pins matching. Users see no change. + +After this the debrid provider starts as a third repo against a contract that +already has two real implementations and one fixture. + +## How core is consumed + +Pinned, not tracked at head. For a two-person team a core change needing a tag +before a provider can use it is a small tax, and it is far cheaper than a core +push breaking the app on a Tuesday. + +| Repo | Depends on | How | +| --- | --- | --- | +| `spool` | mpv fork, `spool-jellyfin` | git submodules, pinned to tags, as today for mpv | +| `spool-jellyfin` | `spool` (core headers only) | git submodule, pinned to a tag | + +Core uses semver on `spool`'s tags. A provider states the major it targets. The +provider's pin only serves its standalone CI: when built inside `spool` it +compiles against the checked-out core, and the semver major is the compatibility +promise. The existing submodule checks in `tools/` (flake sync, remote refs) +already cover this pattern for mpv and extend to the new pin with no new +machinery. + +**Packaging lives in `core/` and nowhere else.** This is the biggest hidden cost +of a bad split, so it is a hard rule. Core exports one CMake entry point, +`spool_add_app(NAME ID ICONS PROVIDERS ...)`, that produces every target's +output: webOS ipk, Android apk, macOS dmg with signing and notarisation, Windows +installer, AppImage, Arch package. `apps/spool/` supplies `appinfo.json.in`, the +desktop and metainfo files, icons, VERSION and the open source notices, and calls +that function. The flake, `build-ipk.sh`, the scripts under `tools/`, and the +GitHub workflows all move under `core/` and stay callable from any app directory. +Renovate stays in each repo for its own pins. + +Release flow for a normal user-facing change is unchanged from today: fix, tag, +release. For a Jellyfin-only fix: fix in `spool-jellyfin`, tag, bump the pin here, +and run the provider-only workflow to publish new plugin binaries against the +current `spool` tag. Once the download path is live on a platform, that needs no +app release. The version users see is this repo's VERSION file. + +## Where the tests go + +About 10.7k of the 12.5k test lines follow core. Only `tests/app` splits down the +middle. + +| Suite | Goes to | Notes | +| --- | --- | --- | +| tests/qml | core | Primitives, shell, navigation, provider gating | +| tests/player | core | Uses the PlaybackSource interface with a stub | +| tests/app (core half) | core | ContentModel, SettingsSchema, SettingsController, Router, UpdateManifest, SpoolLink, SpoolRemoteProtocol, ArtworkUrl, ProviderRegistry | +| tests/common, cache, platform, diagnostics, tools | core | MetaJsonTest's Jellyfin parsing cases move with the parser | +| tests/providers | core | The local fixture; the permanent contract test | +| tests/api, tests/discovery | jellyfin | Untouched | +| tests/app (provider half) | jellyfin | SyncPlay drift, clock, queue handoff, Session, RemoteControl, BrowseSession, LibraryQuery | +| tests/media (ffmpeg compliance) | core | Belongs with the player | + +Core gets contract tests that run against every registered provider, starting +with the local fixture, so a provider that claims a capability has to pass them. +The Jellyfin provider runs those same contract tests against a recorded server in +its own CI. The test runner and TestMain stay in core and get exported alongside +`spool_add_app()`, so providers do not each grow their own harness. + +## What will bite + +- **The rename touches every file.** ~300 files name the namespace, the QML + module or the CMake targets. Land it in a quiet window with no large branches + open, and rebase anything outstanding the same day. +- **SyncPlay and remote control reach deep into the player.** The player exposes + sync playback speed and the queue outline for SyncPlay's benefit. Those stay as + generic player hooks; only the controller that drives them is provider-side. +- **SpoolLink relays through the Jellyfin server.** The peer link is a core + feature but its only transport today is provider-owned. Design it so a provider + without a relay reports the capability as absent. +- **Runtime plugins are an ABI contract, not an API one.** A Qt patch bump or a + new struct field breaks every downloaded provider unless data crosses the + boundary as QVariant or JSON and the interface is versioned. Build the boundary + that way before any plugin is ever downloaded, and never let a provider repo + build its own binaries. +- **Do not extract a `spool-common` repo.** Shared-utility repos become dumping + grounds and a coordination tax on every change. Each layer keeps its own + helpers and tolerates a little duplication. +- **Do not start the debrid provider early.** It is the reward for finishing, not + a way to test the contract. The local fixture is the test. + +Rules while this is in flight: + +1. `master` builds and ships at every commit. No long-lived split branch. +2. New code lands on the correct side of the line. Anything Jellyfin-specific + goes under `src/api` or a provider directory, never into player, shell or + primitives. +3. Provider-specific controls in core QML are gated on a capability flag. +4. The fake provider is not deleted. It is the contract's regression test + forever. + +Known loose ends from Phase 4: + +- `SearchController` and `UserItemStateController` take their interfaces + unguarded, so a provider lacking those capabilities would crash rather than + degrade. +- Remote command payloads crossing `RemotePlayback` still carry Jellyfin's JSON + envelope. +- `settingsSchema` is a CONSTANT QML property, so switching provider at runtime + would not refresh the account rows. Fine while the provider is fixed at + startup. + +Open questions: + +- [ ] webOS spike: can the jailed app load a downloaded shared library? +- [ ] macOS: sign downloaded plugins with the team ID, or drop library + validation? +- [ ] Who owns the media model field audit? From 83964905d846bf7b11aa98b0d64038985eed0c82 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 05:30:39 +1000 Subject: [PATCH 019/146] feat(provider): add bounded script runtime and delayed QML warming --- CMakeLists.txt | 10 + docs/portable-provider-decisions.md | 39 ++ sdk/CMakeLists.txt | 9 + sdk/README.md | 36 ++ sdk/provider-contract-runner.cpp | 57 +++ sdk/provider.d.ts | 11 + src/main.cpp | 14 + src/provider/ProviderQmlCache.cpp | 103 +++++ src/provider/ProviderQmlCache.h | 46 ++ src/provider/ScriptRuntime.cpp | 536 +++++++++++++++++++++++ src/provider/ScriptRuntime.h | 28 ++ tests/providers/ProviderQmlCacheTest.cpp | 77 ++++ tests/providers/ScriptRuntimeTest.cpp | 150 +++++++ tests/providers/fixtures/provider.mjs | 34 ++ tests/tools/provider_package_test.py | 104 +++++ tools/provider-package.py | 218 +++++++++ 16 files changed, 1472 insertions(+) create mode 100644 docs/portable-provider-decisions.md create mode 100644 sdk/CMakeLists.txt create mode 100644 sdk/README.md create mode 100644 sdk/provider-contract-runner.cpp create mode 100644 sdk/provider.d.ts create mode 100644 src/provider/ProviderQmlCache.cpp create mode 100644 src/provider/ProviderQmlCache.h create mode 100644 src/provider/ScriptRuntime.cpp create mode 100644 src/provider/ScriptRuntime.h create mode 100644 tests/providers/ProviderQmlCacheTest.cpp create mode 100644 tests/providers/ScriptRuntimeTest.cpp create mode 100644 tests/providers/fixtures/provider.mjs create mode 100755 tests/tools/provider_package_test.py create mode 100755 tools/provider-package.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9bbb7148..80758d63 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -260,6 +260,10 @@ set(SPOOL_SHELL_SOURCES src/provider/GroupPlayback.h src/provider/PlaybackSource.h src/provider/Provider.h + src/provider/ProviderQmlCache.cpp + src/provider/ProviderQmlCache.h + src/provider/ScriptRuntime.cpp + src/provider/ScriptRuntime.h src/provider/ProviderRegistry.cpp src/provider/ProviderRegistry.h src/provider/RemotePlayback.h @@ -814,6 +818,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endif() set(JELLYFIN_TESTS_providers local-provider tests/providers/LocalProviderTest.cpp + script-runtime tests/providers/ScriptRuntimeTest.cpp + provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp ) set(JELLYFIN_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp @@ -899,6 +905,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() find_package(Python3 REQUIRED COMPONENTS Interpreter) + add_test(NAME provider-package + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") + add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) + target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) if(JELLYFIN_NATIVE_WEBOS) set(JELLYFIN_FFMPEG_AUDIT_PLATFORM webos) elseif(APPLE) diff --git a/docs/portable-provider-decisions.md b/docs/portable-provider-decisions.md new file mode 100644 index 00000000..7c944f75 --- /dev/null +++ b/docs/portable-provider-decisions.md @@ -0,0 +1,39 @@ +# Portable provider implementation decisions + +## Baseline + +Inspected `refactor/provider-seam` at `e0de68c18c3740bc22421b99848fa9f7b2f0e788`. +`nix run .#tests` built the native release app and passed 77/77 tests before the implementation changes. This is desktop correctness evidence, not TV performance evidence. + +The current application has a single active `Provider`, source-global artwork and playback credentials, native Jellyfin authentication/API logic, and hardcoded Jellyfin route components. These remain migration work until explicitly marked completed below; the existence of a script runtime does not complete their replacement. + +## Jellyfin parity inventory + +The inspected `JellyfinApiFacade` surface requires all of the following before removing the native client: + +- Authentication by name and QuickConnect; current account identity, configuration and policy; cultures; discovery and manual server entry; stored profiles and credentials. +- Libraries, query options and filters, bounded browse pages, item details, bulk ID lookups, seasons, episodes, resume, next-up, latest, search, suggestions, similar titles, and person credits. +- Playlists: create, append/insert, remove, move and rename. Collections: create, append and remove. Item rename and deletion. +- Favourite, played and playback-position state. +- Media-source selection, subtitle and audio selection, direct/remux/transcoded negotiation, device profiles, quality limits, bandwidth policy, segments and trickplay. +- Start/progress/stop reports, session capabilities, remote-session discovery and commands. +- SyncPlay groups, server clock and ping, buffering, scheduled controls, queue replacement/editing and WebSocket events. +- Provider login/profile UI, management, remote-control and SyncPlay UI, plus account settings integration. + +Native transport, playback, rendering, bandwidth measurement and generic timing policies should remain native. Endpoint construction, backend parsing and backend session protocol belong in the independently packaged client. + +## Decisions + +### QML warming + +Retain `QQmlComponent` objects rather than instantiate every provider screen. Compilation uses `QQmlComponent::Asynchronous`, begins after the first frame plus a delay, and processes one component at a time. This avoids running hidden authentication flows, completion handlers or timers. Existing memory-budgeted route-instance caching serves a separate purpose and remains in place. Memory pressure abandons warming for the process lifetime and releases retained components. Retention is not a guarantee that every frame or object creation will be below 100 ms. + +### Experimental worker profile + +The draft worker profile uses module exports, explicit source factories and Promise-based operations. It does not assume Node/browser globals or async-function syntax. Host-authorised HTTP origins and persistent source IDs are supplied by native callers. Backend JS and its native transport live on one worker thread per runtime; native owned results cross the thread boundary. An interrupted module must be discarded, not silently resumed. + +This in-process runtime is for trusted/reviewed code. A QJSEngine is not a sandbox, and a host facade alone cannot enforce downloaded-QML security. Independent downloaded-package activation must not be enabled until package verification and channel policy are implemented. + +## Unexercised external gates + +No webOS device was built, deployed or tested. Minimum supported device/firmware performance, interpreter-only on-device timings, Android/TV and Apple/Google store approvals remain unverified. Desktop test timings must not be represented as those results. diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt new file mode 100644 index 00000000..42eb2371 --- /dev/null +++ b/sdk/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.22) +project(SpoolProviderContract LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 REQUIRED COMPONENTS Core Qml) +add_executable(provider-contract-runner provider-contract-runner.cpp) +target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) +install(TARGETS provider-contract-runner RUNTIME DESTINATION bin) diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 00000000..9096e8d9 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,36 @@ +# Spool provider SDK: experimental API 0.1 + +The compatibility revision is exactly `0.1`. Additive fixes retain that revision; a breaking change requires `0.2`. Spool and provider release versions are independent. No historical-version adapter is implemented. + +## Worker profile + +An ES module exports `createSource(configuration)`, returning an object whose own methods implement operations. Each operation receives `(arguments, host)` and returns a plain object or a Promise for one. Create per-source state in the factory closure, not in module globals. Use Promise syntax; Node, browser globals and async-function syntax are not part of this baseline. + +`host.http(url, {method, headers, body})` returns a Promise for `{status, body}`. The source's authorised HTTP(S) origins are selected by native code. Redirects are returned to the provider, not automatically followed. Cookies are neither loaded nor saved implicitly. HTTP error statuses remain inspectable; transport failure rejects. Parse and normalise response text in the worker. Never return backend response payloads indiscriminately. + +Native limits: 8 MiB decoded HTTP responses; 1 MiB request bodies; four concurrent HTTP requests per operation; eight operations per source; 32 active operations and 64 queued submissions per runtime; 16 source objects per runtime. Operation deadline is 15 seconds, transport inactivity deadline 10 seconds, and uninterrupted JS execution budget 500 ms. The watchdog covers Promise continuations as well as direct calls. Exceeding execution budget disables the module; create a fresh runtime for explicit recovery. + +Results are plain owned native values, bounded to 50,000 values, nesting depth 20, arrays of 10,000 elements and 4 MiB of string data. Non-finite and unsafe numeric values are rejected. Represent large counters and exact timestamps as decimal strings. IDs are opaque strings. Source IDs are host authority, not provider-controlled credential selectors. + +Removing a source cancels its outstanding native requests and completes pending operations with an error. Other sources remain available. Shutdown also completes outstanding operations. Native callers receive `QCoro::Task` on their calling thread. There are no cross-thread `QJSValue` objects. + +This is a trusted/reviewed in-process execution profile, **not a sandbox**. Timers, durable secret/storage services, module manager and UI-to-worker RPC are not yet public services. Do not declare that the full portable-provider plan is implemented by this runtime alone. + +## Contract runner + +Build this directory with CMake and the host Qt development environment: + +``` +cmake -S sdk -B build/provider-sdk +cmake --build build/provider-sdk +build/provider-sdk/provider-contract-runner /path/to/provider/tests/contract.mjs +QV4_FORCE_INTERPRETER=1 build/provider-sdk/provider-contract-runner /path/to/provider/tests/contract.mjs +``` + +The test module exports `run()`, returning a Promise or throwing on failure. It runs in a real QJSEngine, not Node. The host's runtime tests additionally exercise actual asynchronous HTTP, thread ownership, source isolation, cancellation and runaway JS. Put a process timeout around external contract tests: this small test runner is not the production worker watchdog. + +## Source packages + +`tools/provider-package.py build PATH --output provider.zip` creates a deterministic source ZIP. `validate provider.zip` checks its manifest, required host features, known permissions, declared UI components/imports, paths, file types and size limits. Unknown offered and optional extensions are allowed; missing required extensions reject the package. Every QML component must be listed so it can be validated and warmed. + +Validation does not authenticate publisher identity. A release digest or provenance attestation is not a substitute for an authenticated catalogue with freshness, rotation and revocation. The application does not yet install or activate these ZIPs. Do not enable downloaded code in store builds on the strength of this tool. diff --git a/sdk/provider-contract-runner.cpp b/sdk/provider-contract-runner.cpp new file mode 100644 index 00000000..a8fdd99f --- /dev/null +++ b/sdk/provider-contract-runner.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include +#include + +class ContractResult final : public QObject { + Q_OBJECT +public: + using QObject::QObject; + Q_INVOKABLE void complete(bool success) + { + if (settled) + return; + settled = true; + std::fprintf( + success ? stdout : stderr, "Qt %s provider contract %s\n", qVersion(), success ? "passed" : "failed"); + QCoreApplication::exit(success ? 0 : 1); + } + +private: + bool settled = false; +}; + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + if (app.arguments().size() != 2) { + std::fprintf(stderr, "usage: provider-contract-runner tests.mjs\n"); + return 2; + } + QJSEngine engine; + ContractResult result; + QJSEngine::setObjectOwnership(&result, QJSEngine::CppOwnership); + QTimer::singleShot(0, &app, [&] { + const QJSValue module = engine.importModule(QFileInfo(app.arguments()[1]).absoluteFilePath()); + if (module.isError() || !module.property(QStringLiteral("run")).isCallable()) { + std::fprintf(stderr, "Contract module did not load or export run()\n"); + result.complete(false); + return; + } + QJSValue invoke = engine.evaluate(QStringLiteral(R"JS( + (function(module, result) { + try { + Promise.resolve(module.run()).then(function() { result.complete(true); }, + function() { result.complete(false); }); + } catch (error) { result.complete(false); } + }) + )JS")); + invoke.call({ module, engine.newQObject(&result) }); + }); + QTimer::singleShot(10000, &app, [&] { result.complete(false); }); + return app.exec(); +} + +#include "provider-contract-runner.moc" diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts new file mode 100644 index 00000000..d1d6a544 --- /dev/null +++ b/sdk/provider.d.ts @@ -0,0 +1,11 @@ +/** Experimental compatibility revision 0.1; additive fixes keep this revision. */ +export interface HttpResponse { status: number; body: string } +export interface HttpOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; body?: string } +export interface Host { http(url: string, options?: HttpOptions): Promise } +export type Value = null | boolean | string | number | Value[] | { [key: string]: Value }; +export type Operation = (arguments_: Record, host: Host) => Record | Promise>; +export type Source = Record; +export type SourceFactory = (configuration: Record) => Source; +export interface ItemRef { sourceId: string; itemId: string } +export interface VariantRef extends ItemRef { variantId: string } +export interface Page { items: T[]; cursor: string | null; total: number | null; exhausted: boolean } diff --git a/src/main.cpp b/src/main.cpp index 37d98b49..5d803ba1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ #include "player/MpvVideoItem.h" #include "player/PlayerController.h" #include "provider/Provider.h" +#include "provider/ProviderQmlCache.h" #include "provider/ProviderRegistry.h" #include "providers/local/LocalProvider.h" #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) @@ -39,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -742,6 +744,18 @@ int main(int argc, char **argv) // Parented to the engine so it outlives every incubator and dies with it. window.engine()->setIncubationController(new BoostedIncubationController(window.engine())); + auto *providerQmlCache = new JellyfinNative::ProviderQmlCache(window.engine()); + QList providerQmlSources; + QDirIterator providerQmlFiles(QStringLiteral(":/qt/qml/JellyfinWebOS/qml/providers"), { QStringLiteral("*.qml") }, + QDir::Files, QDirIterator::Subdirectories); + while (providerQmlFiles.hasNext()) + providerQmlSources.append(QUrl(QStringLiteral("qrc") + providerQmlFiles.next())); + providerQmlCache->addSources(providerQmlSources); + QObject::connect( + &window, &QQuickWindow::frameSwapped, providerQmlCache, [providerQmlCache] { providerQmlCache->start(); }, + Qt::SingleShotConnection); + QObject::connect(controller.get(), &JellyfinNative::AppController::aggressiveMemoryPressure, providerQmlCache, + &JellyfinNative::ProviderQmlCache::clear); window.engine()->addImageProvider( QStringLiteral("artwork"), new JellyfinNative::ArtworkImageProvider(artworkService.get())); window.engine()->addImageProvider(QStringLiteral("mpv-overlay"), window.createOverlayImageProvider()); diff --git a/src/provider/ProviderQmlCache.cpp b/src/provider/ProviderQmlCache.cpp new file mode 100644 index 00000000..bdb3f796 --- /dev/null +++ b/src/provider/ProviderQmlCache.cpp @@ -0,0 +1,103 @@ +#include "ProviderQmlCache.h" + +#include +#include +#include + +namespace JellyfinNative { + +ProviderQmlCache::ProviderQmlCache(QQmlEngine *engine) + : QObject(engine) + , m_engine(engine) +{ + m_timer.setSingleShot(true); + connect(&m_timer, &QTimer::timeout, this, &ProviderQmlCache::advance); +} + +void ProviderQmlCache::addSources(const QList& sources) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (m_stopped) + return; + for (const QUrl& source : sources) { + // Callers supply only sources from selected, trusted packages. Neither + // remote URLs nor arbitrary recursive import discovery belong here. + if ((source.scheme() != QStringLiteral("qrc") && !source.isLocalFile()) || m_known.contains(source)) + continue; + m_known.insert(source); + m_pending.enqueue(source); + } + if (m_started && !m_timer.isActive() && !m_loading && !m_pending.isEmpty()) + m_timer.start(100); +} + +void ProviderQmlCache::start(int delayMs) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (m_started || m_stopped) + return; + m_started = true; + m_timer.start(qMax(0, delayMs)); +} + +void ProviderQmlCache::advance() +{ + if (m_stopped || m_loading) + return; + if (m_pending.isEmpty()) { + emit finished(); + return; + } + m_loading = new QQmlComponent(m_engine, this); + connect(m_loading, &QQmlComponent::statusChanged, this, &ProviderQmlCache::settle); + m_loading->loadUrl(m_pending.dequeue(), QQmlComponent::Asynchronous); + // Cached components can complete without an asynchronous status change. + settle(); +} + +void ProviderQmlCache::settle() +{ + if (!m_loading || m_loading->isLoading() || m_loading->isNull()) + return; + QQmlComponent *component = m_loading; + m_loading = nullptr; + disconnect(component, nullptr, this, nullptr); + const QUrl url = component->url(); + if (component->isReady()) { + m_retained.insert(url, component); + emit componentReady(url); + } else { + // Do not log provider error strings: they can contain private paths + // and credential-bearing URLs. Normal activation reports the error. + component->deleteLater(); + emit componentFailed(url); + } + // One compilation at a time, with an event-loop gap between components. + // No timer remains running after the finite launch queue is exhausted. + if (!m_stopped) + m_timer.start(100); +} + +void ProviderQmlCache::clear() +{ + Q_ASSERT(thread() == QThread::currentThread()); + m_stopped = true; + m_timer.stop(); + m_pending.clear(); + m_known.clear(); + if (m_loading) { + disconnect(m_loading, nullptr, this, nullptr); + m_loading->deleteLater(); + m_loading = nullptr; + } + qDeleteAll(m_retained); + m_retained.clear(); + m_engine->trimComponentCache(); +} + +int ProviderQmlCache::retainedCount() const +{ + return m_retained.size(); +} + +} // namespace JellyfinNative diff --git a/src/provider/ProviderQmlCache.h b/src/provider/ProviderQmlCache.h new file mode 100644 index 00000000..a45bc815 --- /dev/null +++ b/src/provider/ProviderQmlCache.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class QQmlComponent; +class QQmlEngine; + +namespace JellyfinNative { + +// Retains compiled components, not instances: warming must not run provider +// bindings, Component.onCompleted handlers, timers or authentication flows. +class ProviderQmlCache final : public QObject { + Q_OBJECT + +public: + explicit ProviderQmlCache(QQmlEngine *engine); + void addSources(const QList& sources); + void start(int delayMs = 2500); + void clear(); + int retainedCount() const; + +signals: + void finished(); + void componentReady(const QUrl& url); + void componentFailed(const QUrl& url); + +private: + void advance(); + void settle(); + + QQmlEngine *m_engine; + QTimer m_timer; + QQueue m_pending; + QSet m_known; + QHash m_retained; + QQmlComponent *m_loading = nullptr; + bool m_started = false; + bool m_stopped = false; +}; + +} // namespace JellyfinNative diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp new file mode 100644 index 00000000..020ddb24 --- /dev/null +++ b/src/provider/ScriptRuntime.cpp @@ -0,0 +1,536 @@ +#include "ScriptRuntime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace JellyfinNative { +namespace { + using Completion = std::shared_ptr>; + + void fail(const Completion& completion, const char *code) + { + completion->setException(std::make_exception_ptr(std::runtime_error(code))); + completion->finish(); + } + + QCoro::Task awaitResult(QFuture future) + { + auto result = qCoro(future); + co_return co_await result.takeResult(); + } + + // Sleeps while JS is idle. Qt explicitly permits setInterrupted() from another + // thread. The mutex protects engine lifetime, not JS heap access. + class ScriptWatchdog { + public: + explicit ScriptWatchdog(QJSEngine *engine) + : m_engine(engine) + , m_thread([this] { run(); }) + { + } + ~ScriptWatchdog() + { + { + std::lock_guard lock(m_mutex); + m_stopping = true; + m_engine->setInterrupted(true); + } + m_changed.notify_one(); + m_thread.join(); + } + void arm() + { + std::lock_guard lock(m_mutex); + m_armed = true; + m_deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + m_changed.notify_one(); + } + void disarm() + { + std::lock_guard lock(m_mutex); + m_armed = false; + m_changed.notify_one(); + } + void interrupt() + { + std::lock_guard lock(m_mutex); + m_engine->setInterrupted(true); + } + + private: + void run() + { + std::unique_lock lock(m_mutex); + while (!m_stopping) { + m_changed.wait(lock, [this] { return m_stopping || m_armed; }); + if (m_stopping) + break; + const auto deadline = m_deadline; + if (!m_changed.wait_until(lock, deadline, + [this, deadline] { return m_stopping || !m_armed || m_deadline != deadline; })) { + m_engine->setInterrupted(true); + m_armed = false; + } + } + } + QJSEngine *m_engine; + std::mutex m_mutex; + std::condition_variable m_changed; + bool m_stopping = false; + bool m_armed = false; + std::chrono::steady_clock::time_point m_deadline; + std::thread m_thread; + }; + + // Validation and ownership conversion in one walk; no JSON stringify/parse + // round-trip, no JS object retained by native models. Limits also reject cycles. + QVariant ownValue(const QJSValue& value, int& nodes, qsizetype& bytes, int depth = 0) + { + if (++nodes > 50000 || depth > 20 || bytes > 4 * 1024 * 1024) + throw std::runtime_error("result_limit"); + if (value.isNull() || value.isUndefined()) + return {}; + if (value.isBool()) + return value.toBool(); + if (value.isNumber()) { + const double number = value.toNumber(); + if (!std::isfinite(number) || std::abs(number) > 9007199254740991.0) + throw std::runtime_error("unsafe_number"); + return number; + } + if (value.isString()) { + const QString text = value.toString(); + bytes += text.size() * sizeof(QChar); + if (bytes > 4 * 1024 * 1024) + throw std::runtime_error("result_limit"); + return text; + } + if (value.isCallable() || value.isQObject() || !value.isObject()) + throw std::runtime_error("invalid_result"); + if (value.isArray()) { + const quint32 length = value.property(QStringLiteral("length")).toUInt(); + if (length > 10000) + throw std::runtime_error("result_limit"); + QVariantList list; + list.reserve(length); + for (quint32 i = 0; i < length; ++i) + list.append(ownValue(value.property(i), nodes, bytes, depth + 1)); + return list; + } + QVariantMap map; + QJSValueIterator iterator(value); + while (iterator.hasNext()) { + iterator.next(); + bytes += iterator.name().size() * sizeof(QChar); + map.insert(iterator.name(), ownValue(iterator.value(), nodes, bytes, depth + 1)); + } + return map; + } + + bool sameOrigin(const QUrl& left, const QUrl& right) + { + return left.scheme() == right.scheme() && left.host() == right.host() + && left.port(left.scheme() == QStringLiteral("https") ? 443 : 80) + == right.port(right.scheme() == QStringLiteral("https") ? 443 : 80); + } +} // namespace + +class ScriptOperation final : public QObject { + Q_OBJECT +public: + ScriptOperation(QJSEngine *engine, QNetworkAccessManager *network, ScriptWatchdog *watchdog, Completion completion, + QList origins, QObject *parent) + : QObject(parent) + , engine(engine) + , network(network) + , watchdog(watchdog) + , completion(std::move(completion)) + , origins(std::move(origins)) + { + deadline.setSingleShot(true); + deadline.setInterval(15000); + connect(&deadline, &QTimer::timeout, this, [this] { cancel("operation_timeout"); }); + deadline.start(); + } + ~ScriptOperation() override + { + if (!settled) + fail(completion, "runtime_shutdown"); + } + void cancel(const char *code) + { + if (settled) + return; + settled = true; + fail(completion, code); + release(); + } + Q_INVOKABLE void resolve(const QJSValue& result) + { + if (settled) + return; + try { + if (!result.isObject() || result.isArray() || result.isError()) + throw std::runtime_error("invalid_result"); + int nodes = 0; + qsizetype bytes = 0; + QVariantMap owned = ownValue(result, nodes, bytes).toMap(); + if (engine->isInterrupted()) + throw std::runtime_error("script_interrupted"); + settled = true; + completion->addResult(std::move(owned)); + completion->finish(); + release(); + } catch (const std::exception&) { + cancel("invalid_result"); + } + } + Q_INVOKABLE void reject(const QJSValue&) + { + // Provider exceptions can carry tokens and response bodies. The public + // error is a stable code, never the untrusted exception's string value. + cancel("provider_error"); + } + Q_INVOKABLE void request( + const QString& address, const QVariantMap& options, QJSValue resolveCallback, QJSValue rejectCallback) + { + if (settled) + return; + const QUrl url(address); + const bool allowed = url.isValid() && url.userInfo().isEmpty() + && (url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("http")) + && std::any_of( + origins.begin(), origins.end(), [&url](const QUrl& origin) { return sameOrigin(url, origin); }); + if (!allowed || replies.size() >= 4) { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("request_denied")) }); + return; + } + const QByteArray method = options.value(QStringLiteral("method"), QStringLiteral("GET")).toString().toLatin1(); + if (method != "GET" && method != "POST" && method != "DELETE" && method != "PUT" && method != "PATCH") { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("method_denied")) }); + return; + } + const QByteArray body = options.value(QStringLiteral("body")).toString().toUtf8(); + if (body.size() > 1024 * 1024) { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("request_limit")) }); + return; + } + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + request.setTransferTimeout(10000); + const QVariantMap headers = options.value(QStringLiteral("headers")).toMap(); + for (auto it = headers.cbegin(); it != headers.cend(); ++it) { + const QByteArray name = it.key().toLatin1(); + const QByteArray value = it.value().toString().toUtf8(); + if (name.contains('\r') || name.contains('\n') || value.contains('\r') || value.contains('\n') + || name.compare("host", Qt::CaseInsensitive) == 0 + || name.compare("content-length", Qt::CaseInsensitive) == 0) { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("header_denied")) }); + return; + } + request.setRawHeader(name, value); + } + QNetworkReply *reply = network->sendCustomRequest(request, method, body); + reply->setReadBufferSize(256 * 1024); + replies.insert(reply); + auto buffer = std::make_shared(); + connect(reply, &QIODevice::readyRead, this, [this, reply, buffer] { + const QByteArray chunk = reply->readAll(); + if (buffer->size() + chunk.size() > 8 * 1024 * 1024) { + cancel("response_limit"); + return; + } + buffer->append(chunk); + }); + connect( + reply, &QNetworkReply::finished, this, [this, reply, buffer, resolveCallback, rejectCallback]() mutable { + replies.remove(reply); + reply->deleteLater(); + if (settled) + return; + const QByteArray finalBytes = reply->readAll(); + if (buffer->size() + finalBytes.size() > 8 * 1024 * 1024) { + cancel("response_limit"); + return; + } + buffer->append(finalBytes); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + watchdog->arm(); + if (status == 0 || (status < 400 && reply->error() != QNetworkReply::NoError)) { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("network_error")) }); + } else { + QJSValue response = engine->newObject(); + response.setProperty(QStringLiteral("status"), status); + response.setProperty(QStringLiteral("body"), QString::fromUtf8(*buffer)); + resolveCallback.call({ response }); + } + if (engine->isInterrupted()) + cancel("script_interrupted"); + }); + } + +private: + void release() + { + deadline.stop(); + const auto pending = std::exchange(replies, {}); + for (QNetworkReply *reply : pending) { + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + deleteLater(); + } + QJSEngine *engine; + QNetworkAccessManager *network; + ScriptWatchdog *watchdog; + Completion completion; + QList origins; + QTimer deadline; + QSet replies; + bool settled = false; +}; + +class ScriptWorker final : public QObject { +public: + explicit ScriptWorker(QString entryPoint) + : entryPoint(std::move(entryPoint)) + { + } + void initialize() + { + engine = std::make_unique(); + watchdog = std::make_unique(engine.get()); + network = std::make_unique(); + auto *dispatcher = QAbstractEventDispatcher::instance(); + connect(dispatcher, &QAbstractEventDispatcher::awake, this, [this] { + if (watchdog) + watchdog->arm(); + }); + connect(dispatcher, &QAbstractEventDispatcher::aboutToBlock, this, [this] { + if (!watchdog) + return; + watchdog->disarm(); + if (engine->isInterrupted()) { + for (auto& source : sources) + for (ScriptOperation *operation : source.operations) + operation->cancel("script_interrupted"); + } + }); + watchdog->arm(); + module = engine->importModule(entryPoint); + // Promise syntax is the portable baseline; no Node/browser globals or + // async-function syntax is required from the bundled Qt JS engine. + invoke = engine->evaluate(QStringLiteral(R"JS( + (function(source, method, args, bridge) { + const host = Object.freeze({ + http: function(url, options) { + return new Promise(function(resolve, reject) { + bridge.request(url, options || {}, resolve, reject); + }); + } + }); + try { + Promise.resolve(source[method](args, host)).then( + function(value) { bridge.resolve(value); }, + function(error) { bridge.reject(error); }); + } catch (error) { bridge.reject(error); } + }) + )JS")); + } + void add(QString id, QVariantMap config, QList origins, const Completion& completion) + { + if (!engine) + initialize(); + if (engine->isInterrupted() || module.isError()) { + fail(completion, "module_unavailable"); + return; + } + if (id.isEmpty() || sources.contains(id) || sources.size() >= 16) { + fail(completion, "source_conflict_or_limit"); + return; + } + watchdog->arm(); + QJSValue source = module.property(QStringLiteral("createSource")).call({ engine->toScriptValue(config) }); + if (source.isError() || !source.isObject() || engine->isInterrupted()) { + fail(completion, "source_initialization_failed"); + return; + } + sources.insert(id, { source, std::move(origins), {} }); + completion->addResult(QVariantMap { { QStringLiteral("sourceId"), id } }); + completion->finish(); + } + void call(const QString& id, const QString& method, const QVariantMap& args, const Completion& completion) + { + auto source = sources.find(id); + if (!engine || engine->isInterrupted() || source == sources.end()) { + fail(completion, "source_unavailable"); + return; + } + if (source->operations.size() >= 8 || activeOperations >= 32) { + fail(completion, "operation_limit"); + return; + } + if (method.startsWith(QLatin1Char('_')) || !source->object.hasOwnProperty(method) + || !source->object.property(method).isCallable()) { + fail(completion, "unsupported_operation"); + return; + } + auto *operation + = new ScriptOperation(engine.get(), network.get(), watchdog.get(), completion, source->origins, this); + source->operations.insert(operation); + ++activeOperations; + connect(operation, &QObject::destroyed, this, [this, id, operation] { + --activeOperations; + auto source = sources.find(id); + if (source != sources.end()) + source->operations.remove(operation); + }); + QJSEngine::setObjectOwnership(operation, QJSEngine::CppOwnership); + watchdog->arm(); + invoke.call({ source->object, QJSValue(method), engine->toScriptValue(args), engine->newQObject(operation) }); + if (engine->isInterrupted()) { + for (auto& source : sources) { + for (ScriptOperation *pending : source.operations) + pending->cancel("script_interrupted"); + } + } + } + void remove(const QString& id) + { + auto source = sources.find(id); + if (source == sources.end()) + return; + for (ScriptOperation *operation : source->operations) + operation->cancel("source_removed"); + sources.erase(source); + } + void shutdown() + { + const auto ids = sources.keys(); + for (const auto& id : ids) + remove(id); + // Destroy bridges before the engine and its JS callbacks. + const auto operations = findChildren(QString(), Qt::FindDirectChildrenOnly); + qDeleteAll(operations); + invoke = QJSValue(); + module = QJSValue(); + watchdog.reset(); + network.reset(); + engine.reset(); + } + +private: + struct Source { + QJSValue object; + QList origins; + QSet operations; + }; + QString entryPoint; + std::unique_ptr engine; + std::unique_ptr watchdog; + std::unique_ptr network; + QJSValue module; + QJSValue invoke; + QHash sources; + int activeOperations = 0; +}; + +struct ScriptRuntime::Private { + QThread thread; + ScriptWorker *worker; + std::atomic queued = 0; +}; + +ScriptRuntime::ScriptRuntime(QString entryPoint, QObject *parent) + : QObject(parent) + , d(std::make_unique()) +{ + d->worker = new ScriptWorker(std::move(entryPoint)); + d->worker->moveToThread(&d->thread); + connect(&d->thread, &QThread::finished, d->worker, &QObject::deleteLater); + d->thread.setObjectName(QStringLiteral("provider-js")); + d->thread.start(); +} + +ScriptRuntime::~ScriptRuntime() +{ + QMetaObject::invokeMethod(d->worker, [worker = d->worker] { worker->shutdown(); }, Qt::BlockingQueuedConnection); + d->thread.quit(); + d->thread.wait(); +} + +QCoro::Task ScriptRuntime::addSource(QString sourceId, QVariantMap configuration, QList origins) +{ + auto completion = std::make_shared>(); + completion->start(); + auto future = completion->future(); + if (d->queued.fetch_add(1) >= 64) { + --d->queued; + fail(completion, "queue_limit"); + return awaitResult(std::move(future)); + } + QMetaObject::invokeMethod( + d->worker, + [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), configuration = std::move(configuration), + origins = std::move(origins), completion]() mutable { + --state->queued; + worker->add(std::move(sourceId), std::move(configuration), std::move(origins), completion); + }, + Qt::QueuedConnection); + return awaitResult(std::move(future)); +} + +QCoro::Task ScriptRuntime::call(QString sourceId, QString method, QVariantMap arguments) +{ + auto completion = std::make_shared>(); + completion->start(); + auto future = completion->future(); + if (d->queued.fetch_add(1) >= 64) { + --d->queued; + fail(completion, "queue_limit"); + return awaitResult(std::move(future)); + } + QMetaObject::invokeMethod( + d->worker, + [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), + arguments = std::move(arguments), completion] { + --state->queued; + worker->call(sourceId, method, arguments, completion); + }, + Qt::QueuedConnection); + return awaitResult(std::move(future)); +} + +void ScriptRuntime::removeSource(const QString& sourceId) +{ + QMetaObject::invokeMethod( + d->worker, [worker = d->worker, sourceId] { worker->remove(sourceId); }, Qt::QueuedConnection); +} + +} // namespace JellyfinNative + +#include "ScriptRuntime.moc" diff --git a/src/provider/ScriptRuntime.h b/src/provider/ScriptRuntime.h new file mode 100644 index 00000000..8bb9d41f --- /dev/null +++ b/src/provider/ScriptRuntime.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace JellyfinNative { + +// Experimental provider API 0.1. JS values never leave the worker. The host +// chooses persistent source IDs and authorised origins, not provider code. +class ScriptRuntime final : public QObject { + Q_OBJECT +public: + explicit ScriptRuntime(QString entryPoint, QObject *parent = nullptr); + ~ScriptRuntime() override; + + QCoro::Task addSource(QString sourceId, QVariantMap configuration, QList origins); + QCoro::Task call(QString sourceId, QString method, QVariantMap arguments = {}); + void removeSource(const QString& sourceId); + +private: + struct Private; + std::unique_ptr d; +}; + +} // namespace JellyfinNative diff --git a/tests/providers/ProviderQmlCacheTest.cpp b/tests/providers/ProviderQmlCacheTest.cpp new file mode 100644 index 00000000..cfb73a76 --- /dev/null +++ b/tests/providers/ProviderQmlCacheTest.cpp @@ -0,0 +1,77 @@ +#include "provider/ProviderQmlCache.h" +#include "TestMain.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +void waitUntil(const std::function& condition) +{ + QElapsedTimer deadline; + deadline.start(); + while (!condition() && deadline.elapsed() < 5000) { + QCoreApplication::processEvents(); + QThread::msleep(1); + } + require(condition(), "asynchronous QML compilation must finish"); +} +} + +JELLYFIN_TEST_MAIN("provider-qml-cache") +{ + QGuiApplication app(argc, argv); + QTemporaryDir directory; + require(directory.isValid(), "fixture directory created"); + const auto source = [&](const QString& name, const QByteArray& bytes) { + QFile file(directory.filePath(name)); + require(file.open(QIODevice::WriteOnly), "fixture writable"); + require(file.write(bytes) == bytes.size(), "fixture written"); + return QUrl::fromLocalFile(file.fileName()); + }; + const QUrl a = source("A.qml", "import QtQml\nQtObject { Component.onCompleted: probe.objectName = 'created' }"); + const QUrl b = source("B.qml", "import QtQml\nQtObject { required property string label }"); + const QUrl broken = source("Broken.qml", "this is not QML"); + QQmlEngine engine; + QObject probe; + engine.rootContext()->setContextProperty("probe", &probe); + JellyfinNative::ProviderQmlCache cache(&engine); + int failures = 0; + bool finished = false; + QObject::connect( + &cache, &JellyfinNative::ProviderQmlCache::componentFailed, &app, [&](const QUrl&) { ++failures; }); + QObject::connect(&cache, &JellyfinNative::ProviderQmlCache::finished, &app, [&] { finished = true; }); + cache.addSources({ a, b, broken, a }); + cache.start(30); + require(cache.retainedCount() == 0, "warming waits for the launch delay"); + waitUntil([&] { return finished; }); + require(cache.retainedCount() == 2 && failures == 1, "unique valid provider components retained across failures"); + require(probe.objectName().isEmpty(), "warming does not instantiate provider code"); + QQmlComponent activated(&engine, a, QQmlComponent::PreferSynchronous); + std::unique_ptr object(activated.create()); + require(object && probe.objectName() == "created", "retained component activates normally on demand"); + cache.clear(); + require(cache.retainedCount() == 0, "memory pressure releases retained components"); + cache.addSources({ a }); + cache.start(0); + QCoreApplication::processEvents(); + require(cache.retainedCount() == 0, "memory pressure does not restart idle warming"); + return 0; +} diff --git a/tests/providers/ScriptRuntimeTest.cpp b/tests/providers/ScriptRuntimeTest.cpp new file mode 100644 index 00000000..1e0747c4 --- /dev/null +++ b/tests/providers/ScriptRuntimeTest.cpp @@ -0,0 +1,150 @@ +#include "provider/ScriptRuntime.h" +#include "TestMain.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +void rejects(QCoro::Task task, const char *message) +{ + bool failed = false; + try { + QCoro::waitFor(std::move(task)); + } catch (const std::exception& error) { + failed = true; + require(!QByteArray(error.what()).contains("private-token"), "errors must not disclose provider secrets"); + } + require(failed, message); +} +} + +JELLYFIN_TEST_MAIN("script-runtime") +{ + QCoreApplication app(argc, argv); + using JellyfinNative::ScriptRuntime; + QTcpServer server; + require(server.listen(QHostAddress::LocalHost), "fixture server listens"); + const QString origin = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort()); + QList received; + std::function cancelSlow; + QObject::connect(&server, &QTcpServer::newConnection, &app, [&] { + while (QTcpSocket *socket = server.nextPendingConnection()) { + QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket, &received, &cancelSlow] { + QByteArray request = socket->property("request").toByteArray() + socket->readAll(); + socket->setProperty("request", request); + if (!request.contains("\r\n\r\n") || socket->property("answered").toBool()) + return; + socket->setProperty("answered", true); + received.append(request); + if (request.startsWith("GET /slow ")) { + cancelSlow(); + return; + } + if (request.startsWith("GET /error ")) { + socket->write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); + socket->disconnectFromHost(); + return; + } + if (request.startsWith("GET /truncated ")) { + socket->write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{}"); + socket->disconnectFromHost(); + return; + } + if (request.startsWith("GET /redirect ")) { + socket->write("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: " + "0\r\nConnection: close\r\n\r\n"); + socket->disconnectFromHost(); + return; + } + if (request.startsWith("GET /oversized ")) { + const QByteArray large(9 * 1024 * 1024, 'x'); + socket->write("HTTP/1.1 200 OK\r\nContent-Length: " + QByteArray::number(large.size()) + + "\r\nConnection: close\r\n\r\n"); + socket->write(large); + socket->disconnectFromHost(); + return; + } + const QByteArray body = R"({"items":[{"id":"same-id","name":"Example"}],"cursor":"next"})"; + socket->write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nSet-Cookie: " + "session=private\r\nConnection: close\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body); + socket->disconnectFromHost(); + }); + } + }); + const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); + auto runtime = std::make_unique(entry); + const auto add = [&](const QString& id) { + return runtime->addSource( + id, { { "origin", origin }, { "label", id }, { "token", id + "-token" } }, { QUrl(origin) }); + }; + QCoro::waitFor(add("a")); + QCoro::waitFor(add("b")); + auto a = runtime->call("a", "page"); + auto b = runtime->call("b", "page"); + const auto pageA = QCoro::waitFor(std::move(a)); + const auto pageB = QCoro::waitFor(std::move(b)); + require(pageA.value("items").toList().front().toMap().value("title") == "a: Example", "source A owns its mapping"); + require(pageB.value("items").toList().front().toMap().value("title") == "b: Example", "source B owns its mapping"); + require(pageA.value("cursor") == "next", "opaque continuation survives conversion"); + require(pageA.value("calls").toInt() == 1 && pageB.value("calls").toInt() == 1, + "source factories have independent state"); + require(received.size() == 2, "both concurrent sources request independently"); + require(received[0].contains("a-token") != received[1].contains("a-token"), "tokens do not cross source requests"); + QCoro::waitFor(runtime->call("b", "page")); + require(!received.back().contains("Cookie:"), "shared transport does not inherit another source's cookies"); + require(QCoro::waitFor(runtime->call("a", "raw", { { "path", "error" } })).value("status").toInt() == 401, + "HTTP errors remain provider-interpretable responses"); + require(QCoro::waitFor(runtime->call("a", "raw", { { "path", "redirect" } })).value("status").toInt() == 302, + "redirects are not followed with source credentials"); + rejects(runtime->call("a", "raw", { { "path", "oversized" } }), "decompressed responses are bounded"); + rejects(runtime->call("a", "raw", { { "path", "truncated" } }), "truncated successful responses reject"); + rejects(runtime->call("a", "denied", { { "url", "http://127.0.0.1:1/private" } }), "unauthorised origin rejected"); + rejects(runtime->call("a", "throws"), "synchronous exceptions settle operations"); + rejects(runtime->call("a", "cycle"), "cyclic results fail bounded conversion"); + rejects(runtime->call("a", "largeInteger"), "unsafe integer results rejected"); + rejects(runtime->call("a", "missing"), "missing feature reports unsupported operation"); + cancelSlow = [&] { runtime->removeSource("a"); }; + auto pending = runtime->call("a", "raw", { { "path", "slow" } }); + rejects(std::move(pending), "source removal cancels pending operations"); + require(QCoro::waitFor(runtime->call("b", "state")).value("calls").toInt() == 2, + "removal leaves other source operational"); + QCoro::waitFor(add("a")); + require(QCoro::waitFor(runtime->call("a", "state")).value("calls").toInt() == 0, + "replacement gets a fresh source generation"); + auto shutdownPending = runtime->call("b", "never"); + runtime.reset(); + rejects(std::move(shutdownPending), "shutdown settles retained operations"); + + for (const QString method : { QStringLiteral("spin"), QStringLiteral("asyncSpin") }) { + ScriptRuntime runaway(entry); + QCoro::waitFor(runaway.addSource("a", {}, {})); + QElapsedTimer elapsed; + elapsed.start(); + int guiTurns = 0; + QTimer responsiveness; + QObject::connect(&responsiveness, &QTimer::timeout, &app, [&] { ++guiTurns; }); + responsiveness.start(10); + rejects(runaway.call("a", method), "runaway JS is interrupted including Promise continuations"); + require(elapsed.elapsed() < 3000 && guiTurns >= 5, "watchdog bounds runaway work without blocking GUI events"); + rejects(runaway.call("a", "state"), "interrupted module is disabled rather than silently reused"); + } + return 0; +} diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs new file mode 100644 index 00000000..e40e7164 --- /dev/null +++ b/tests/providers/fixtures/provider.mjs @@ -0,0 +1,34 @@ +export function createSource(config) { + let calls = 0; + return { + raw: function(args, host) { + return host.http(config.origin + '/' + args.path); + }, + page: function(args, host) { + return host.http(config.origin + '/items', { + headers: {Authorization: config.token} + }).then(function(response) { + if (response.status !== 200) + throw new Error('HTTP failure'); + const body = JSON.parse(response.body); + return { + items: body.items.map(function(item) { + return {id: item.id, title: config.label + ': ' + item.name}; + }), + cursor: body.cursor, + calls: ++calls + }; + }); + }, + denied: function(args, host) { + return host.http(args.url).then(function() { return {}; }); + }, + throws: function() { throw new Error('private-token-never-log'); }, + cycle: function() { const value = {}; value.self = value; return value; }, + largeInteger: function() { return {size: 9007199254740992}; }, + never: function() { return new Promise(function() {}); }, + spin: function() { while (true) {} }, + asyncSpin: function() { return Promise.resolve().then(function() { while (true) {} }); }, + state: function() { return {calls: calls}; } + }; +} diff --git a/tests/tools/provider_package_test.py b/tests/tools/provider_package_test.py new file mode 100755 index 00000000..eb1b2a63 --- /dev/null +++ b/tests/tools/provider_package_test.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import pathlib +import stat +import tempfile +import unittest +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("provider_package", ROOT / "tools/provider-package.py") +package = importlib.util.module_from_spec(spec) +spec.loader.exec_module(package) + + +def fixture(): + manifest = {"format": 1, "id": "test.provider", "version": "0.1.0", "api": "0.1", + "entry": "logic/provider.mjs", "publisher": "test", "license": "MPL-2.0", + "offers": {"future-feature": "0.1"}, "requires": {"http": "0.1"}, + "optional": {"unknown-optional": "0.1"}, "permissions": ["network:configured-origins"], + "channels": ["desktop"], "stateSchema": 1, "ui": {"modules": [], "components": []}} + return {"manifest.json": json.dumps(manifest).encode(), "logic/provider.mjs": b"export function createSource() {return {};}", + "LICENSE": b"fixture license", "NOTICE": b"synthetic test data"} + + +class PackageTest(unittest.TestCase): + def archive(self, files, metadata=None): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + path = pathlib.Path(directory.name) / "provider.zip" + with zipfile.ZipFile(path, "w") as archive: + for name, data in files.items(): + entry = zipfile.ZipInfo(name) + entry.external_attr = ((metadata or {}).get(name, stat.S_IFREG | 0o644)) << 16 + archive.writestr(entry, data) + return path + + def test_optional_and_offered_extensions_do_not_reject(self): + manifest, files = package.read_package(self.archive(fixture())) + self.assertEqual(manifest["id"], "test.provider") + self.assertIn("logic/provider.mjs", files) + + def test_required_extensions_permissions_and_api_fail_closed(self): + for key, value in (("requires", {"unknown": "0.1"}), ("permissions", ["shell"]), ("api", "0.2"), ("format", 2)): + with self.subTest(key=key): + files = fixture() + manifest = json.loads(files["manifest.json"]) + manifest[key] = value + files["manifest.json"] = json.dumps(manifest).encode() + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + + def test_unsafe_and_case_colliding_paths(self): + for name in ("../escape.qml", "/escape.qml", "logic/../escape.qml", "logic\\escape.qml", "C:/escape.qml", "logic/CON.qml", "logic/provider.mjs.", "LOGIC/PROVIDER.MJS"): + with self.subTest(name=name): + files = fixture() + files[name] = b"bad" + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + + def test_symlink_and_executable_modes(self): + for mode in (stat.S_IFLNK | 0o777, stat.S_IFREG | 0o755, stat.S_IFIFO | 0o644): + with self.subTest(mode=mode), self.assertRaises(ValueError): + package.read_package(self.archive(fixture(), {"logic/provider.mjs": mode})) + + def test_native_payload_disguised_as_source(self): + files = fixture() + files["logic/provider.mjs"] = b"\x7fELFfake" + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + + def test_import_escape_and_undeclared_qml(self): + files = fixture() + files["logic/provider.mjs"] = b'import "../../private.mjs";' + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + files = fixture() + files["ui/Hidden.qml"] = b"import QtQuick\nItem {}" + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + + def test_expanded_size_limit_before_read(self): + files = fixture() + files["resources/bomb.txt"] = b"0" * (package.MAX_FILE + 1) + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + + def test_build_is_reproducible_and_round_trips(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + source = root / "source" + for name, data in fixture().items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + a, b = root / "a.zip", root / "b.zip" + package.build(source, a) + package.build(source, b) + self.assertEqual(a.read_bytes(), b.read_bytes()) + self.assertEqual(package.read_package(a)[1], fixture()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/provider-package.py b/tools/provider-package.py new file mode 100755 index 00000000..2eca3b01 --- /dev/null +++ b/tools/provider-package.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Build and validate reviewed provider source ZIPs; never execute their code. + +ZIP/hash validation does not authenticate a publisher. Installation and catalogue +trust belong to the host; this tool deliberately cannot activate a package. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import re +import stat +import sys +import zipfile + +MAX_ARCHIVE = 16 * 1024 * 1024 +MAX_EXPANDED = 32 * 1024 * 1024 +MAX_FILE = 8 * 1024 * 1024 +MAX_FILES = 512 +EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".map"} +TEXT = {".mjs", ".js", ".qml", ".json", ".svg", ".txt", ".map"} +MODULES = {"QtQuick", "QtQuick.Controls", "QtQuick.Layouts", "QtQml", "Spool.Ui"} +PERMISSIONS = {"network:configured-origins", "storage:source", "credentials:source"} +CHANNELS = {"desktop", "webos", "android-direct", "google-play", "apple-bundled"} +NATIVE_MAGIC = (b"\x7fELF", b"MZ", b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\0asm") + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def path_name(name: str) -> pathlib.PurePosixPath: + if not name or "\\" in name or ":" in name or any(ord(c) < 32 for c in name): + raise ValueError("invalid package path") + path = pathlib.PurePosixPath(name) + if path.is_absolute() or any(part in ("", ".", "..") for part in name.split("/")): + raise ValueError("non-canonical package path") + if any(part.rstrip(" .") != part for part in path.parts): + raise ValueError("ambiguous package path") + if any(part.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(10)], *[f"LPT{i}" for i in range(10)]} for part in path.parts): + raise ValueError("reserved package path") + return path + + +def validate_manifest(manifest: dict, paths: set[str]) -> None: + required = {"format", "id", "version", "api", "entry", "publisher", "license", "offers", "requires", "optional", "permissions", "channels", "stateSchema", "ui"} + if not isinstance(manifest, dict) or set(manifest) != required: + raise ValueError("unknown or missing manifest fields") + if manifest["format"] != 1 or manifest["api"] != "0.1": + raise ValueError("unsupported manifest format or API revision") + if not isinstance(manifest["id"], str) or not re.fullmatch(r"[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+", manifest["id"]): + raise ValueError("invalid module identity") + if not isinstance(manifest["version"], str) or not re.fullmatch(r"0\.\d+\.\d+(?:-[a-z0-9.-]+)?", manifest["version"]): + raise ValueError("invalid experimental release version") + if not isinstance(manifest["publisher"], str) or not manifest["publisher"] or not isinstance(manifest["license"], str) or not manifest["license"]: + raise ValueError("publisher and license required") + if type(manifest["stateSchema"]) is not int or manifest["stateSchema"] < 1: + raise ValueError("invalid state schema") + for key, known in (("permissions", PERMISSIONS), ("channels", CHANNELS)): + value = manifest[key] + if not isinstance(value, list) or not value or any(not isinstance(v, str) for v in value) or len(set(value)) != len(value) or not set(value) <= known: + raise ValueError(f"unknown or duplicate {key}") + for key in ("offers", "requires", "optional"): + if not isinstance(manifest[key], dict) or any(not isinstance(name, str) or revision != "0.1" for name, revision in manifest[key].items()): + raise ValueError(f"invalid extension declarations: {key}") + # Offered unknown extensions are harmless. Required unknown host services + # are not: reject rather than evaluating a module that cannot work. + supported = {"http", "source-context", "native-list", "ui-host"} + if not set(manifest["requires"]) <= supported: + raise ValueError("unsupported required host extension") + if not isinstance(manifest["entry"], str) or manifest["entry"] not in paths or not manifest["entry"].endswith(".mjs"): + raise ValueError("missing JS entry point") + ui = manifest["ui"] + if not isinstance(ui, dict) or set(ui) != {"modules", "components"}: + raise ValueError("invalid UI declaration") + if not isinstance(ui["modules"], list) or any(not isinstance(v, str) for v in ui["modules"]) or not set(ui["modules"]) <= MODULES: + raise ValueError("unsupported QML import") + if not isinstance(ui["components"], list) or any(not isinstance(v, str) or v not in paths or not v.endswith(".qml") for v in ui["components"]): + raise ValueError("missing UI component") + if set(ui["components"]) != {p for p in paths if p.endswith(".qml")}: + raise ValueError("every QML component must be declared for validation and warming") + if "LICENSE" not in paths or "NOTICE" not in paths: + raise ValueError("license and attribution notices required") + + +def validate_files(files: dict[str, bytes]) -> dict: + if not files or len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: + raise ValueError("expanded package limit") + folded = set() + for name, data in files.items(): + path = path_name(name) + if name.casefold() in folded: + raise ValueError("case-colliding paths") + folded.add(name.casefold()) + if path.name not in {"LICENSE", "NOTICE"} and path.suffix.lower() not in EXTENSIONS: + raise ValueError("unsupported payload type") + if len(data) > MAX_FILE or data.startswith(NATIVE_MAGIC): + raise ValueError("oversized or executable payload") + if path.suffix.lower() in TEXT or path.name in {"LICENSE", "NOTICE"}: + text = data.decode("utf-8") + if "\x00" in text: + raise ValueError("binary data in source") + if "manifest.json" not in files: + raise ValueError("missing manifest") + manifest = json.loads(files["manifest.json"], object_pairs_hook=unique_object) + validate_manifest(manifest, set(files)) + # Static checks are review aids, NOT a QML sandbox. Dynamic import/resource + # loading still requires trusted code and host channel policy. + for name, data in files.items(): + suffix = pathlib.PurePosixPath(name).suffix + if suffix not in {".qml", ".js", ".mjs"}: + continue + text = data.decode("utf-8") + for target in re.findall(r"(?:\bfrom\s*|\bimport\s*\(?\s*)['\"]([^'\"]+)['\"]", text): + if not target.startswith("./") or ".." in target.split("/") or "\\" in target or ":" in target: + raise ValueError("script imports must stay within the package directory") + resolved = str(pathlib.PurePosixPath(name).parent / target[2:]) + if resolved not in files and not any(p.startswith(resolved + "/") for p in files): + raise ValueError("missing local import") + if suffix == ".qml": + imports = re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", text, re.MULTILINE) + if not set(imports) <= set(manifest["ui"]["modules"]): + raise ValueError("undeclared QML import") + return manifest + + +def read_package(path: pathlib.Path) -> tuple[dict, dict[str, bytes]]: + if path.stat().st_size > MAX_ARCHIVE: + raise ValueError("archive size limit") + files = {} + total = 0 + with zipfile.ZipFile(path) as archive: + entries = archive.infolist() + if len(entries) > MAX_FILES: + raise ValueError("archive entry limit") + for entry in entries: + path_name(entry.filename) + mode = entry.external_attr >> 16 + if entry.is_dir() or stat.S_ISLNK(mode) or (stat.S_IFMT(mode) not in (0, stat.S_IFREG)) or mode & 0o111: + raise ValueError("only non-executable regular files are allowed") + if entry.flag_bits & 1 or entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise ValueError("unsupported ZIP encoding") + total += entry.file_size + if entry.file_size > MAX_FILE or total > MAX_EXPANDED: + raise ValueError("expanded archive size limit") + if entry.filename in files: + raise ValueError("duplicate archive path") + with archive.open(entry) as member: + data = member.read(MAX_FILE + 1) + if len(data) != entry.file_size or len(data) > MAX_FILE: + raise ValueError("invalid archive member size") + files[entry.filename] = data + return validate_files(files), files + + +def build(source: pathlib.Path, destination: pathlib.Path) -> dict: + files = {} + for root in ("manifest.json", "logic", "ui", "resources", "LICENSE", "NOTICE"): + path = source / root + if not path.exists(): + continue + if path.is_symlink(): + raise ValueError("symlink in package source") + candidates = [path] if path.is_file() else path.rglob("*") + for entry in candidates: + if entry.is_symlink(): + raise ValueError("symlink in package source") + if entry.is_file(): + if entry.stat().st_size > MAX_FILE: + raise ValueError("source file size limit") + files[entry.relative_to(source).as_posix()] = entry.read_bytes() + if len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: + raise ValueError("source package size limit") + manifest = validate_files(files) + destination.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, data in sorted(files.items()): + info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + read_package(destination) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("build", "validate")) + parser.add_argument("path", type=pathlib.Path) + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + if args.command == "build": + if not args.output: + parser.error("build requires --output") + manifest = build(args.path, args.output) + archive = args.output + else: + manifest, _ = read_package(args.path) + archive = args.path + print(json.dumps({"id": manifest["id"], "version": manifest["version"], "size": archive.stat().st_size, + "sha256": hashlib.file_digest(archive.open("rb"), "sha256").hexdigest()}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ValueError, OSError, zipfile.BadZipFile) as error: + print(f"provider package rejected: {error}", file=sys.stderr) + raise SystemExit(1) From 0dd98c700f72fdef19518157f938d4101352892a Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 05:35:22 +1000 Subject: [PATCH 020/146] docs(provider): record channel gates and expose warming completion --- docs/provider-channel-policy.md | 48 +++++++++++++++++++++++++++++++++ src/main.cpp | 5 ++++ 2 files changed, 53 insertions(+) create mode 100644 docs/provider-channel-policy.md diff --git a/docs/provider-channel-policy.md b/docs/provider-channel-policy.md new file mode 100644 index 00000000..4d80005b --- /dev/null +++ b/docs/provider-channel-policy.md @@ -0,0 +1,48 @@ +# Portable provider channel policy and review notes + +Status: implementation/review preparation, not a store approval or a claim that the package manager exists. Checked primary policy text on 21 September 2026. + +## Current implementation + +The app still uses its bundled native Jellyfin client. The experimental worker and source-package tooling do not expose an application installer or remote activation path. No remote switch can enable downloaded provider code. The standalone source ZIP validator does not authenticate its publisher, and the runtime is not a hostile-code sandbox. + +## Required delivery policy + +| Channel | Bundled source packages | Downloaded source activation | +| --- | --- | --- | +| Windows/Linux/direct macOS | Same pinned provider sources | Only after authenticated catalogue, verified archive installation and restart recovery are implemented | +| webOS Developer Mode | Same pinned provider sources | Same trust gate, plus actual unrooted app-data loading/import/interpreter validation | +| Android outside Play | Same pinned provider sources | Same trust gate and app-private storage; no native provider payloads | +| Android/TV Google Play | Bundle official packages regardless | Seek review of the interpreted JS/QML host surface; never an APK self-updater or native library installer | +| iOS/tvOS/Mac App Store | Multiple bundled providers from the same source implementations | Keep disabled pending review/permission for the actual hosted software and native API exposure | + +Package `channels` declarations express eligibility, not authority. Only the host's compiled distribution policy and verified publisher/catalogue metadata may permit activation. A provider must not grant itself new access, enable an installer, or change the channel. + +## Google Play reviewer explanation + +Proposed feature: Spool is a native media client for user-authorised media servers. Portable providers implement endpoint/authentication protocols and optional Qt Quick screens. They are not remote media data interpreted as program code. The native application continues to own transport/TLS, media models, playback/decoding/rendering and platform integration. + +The experimental implementation uses a separate worker QJSEngine for protocol logic. Provider operations receive an origin-scoped HTTP facade, not an Android Context, general shell, arbitrary filesystem or unrestricted player commands. Redirects and implicit cookie propagation are disabled. Source removal and watchdog interruption settle pending operations. GUI-side QML is a separate execution surface and must be reviewed independently; worker origin checks alone do not constrain every QML network/resource loader. + +The intended delivery is reviewed source-only ZIPs with manifests and explicit access requirements. Archive tooling rejects native executable payloads, plugin declarations by unsupported file type, unsafe paths, symlinks and size-limit violations. These static checks do not replace content review or a secure catalogue. The final implementation must authenticate artifacts and metadata, enforce freshness/revocation, preserve accounts on incompatible updates, and provide disable/recovery controls before downloaded activation is submitted. + +There is no intended APK, DEX, JAR or native `.so` download path in the Play channel. Host updates must use Google Play. The app must not use provider delivery to bypass permission, payment, intellectual-property, privacy, network-abuse or content policies. + +### Evidence required before submitting the feature + +- An actual Android/TV build containing the declared Qt/QML import set and showing both bundled and verified source-loaded operation. +- Reviewer-accessible demo server and synthetic/public-domain media, not personal credentials. +- Recording of install/account setup, access consent, version inspection, update/restart, incompatible-package rejection and disable/recovery. +- Exact host-facade and native-type exposure inventory, including QML imports, dynamic loading, external navigation and network-capable controls. +- Signed metadata/rotation/revocation design and tests; package validation alone is insufficient. +- Privacy/Data Safety declarations that match credentials, configured server URLs, diagnostics and remote-provider access. +- Content-access policy and moderation/reporting mechanisms appropriate to the actual offered services. +- Confirmation that downloaded packages cannot enable a host updater, invoke arbitrary processes, or load native plugins. + +These materials are an engineering/review checklist, not completed submissions. The current implementation is not ready for the downloaded-code submission. + +## Policy basis + +Google's [Device and Network Abuse full policy](https://support.google.com/googleplay/android-developer/answer/16559646?hl=en) prohibits self-updates outside Play and downloading executable code such as DEX/JAR/native libraries, with an exception for code running in a VM/interpreter providing indirect Android API access. It separately requires runtime-loaded interpreted code not to allow potential Play-policy violations. The exception does not automatically approve Qt JS/QML provider delivery. + +Apple's [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) section 4.7 permits certain non-embedded software but requires prior permission to expose native platform APIs/technologies (4.7.2), explicit consent for data/permission sharing (4.7.3), a software index with universal links (4.7.4), and age/content safeguards. Sections 2.5.2 and the Mac App Store-specific 2.4.5 rules also require review of the actual distribution design. Until that review is resolved, use bundled-provider updates through app releases; do not promise approval or remotely unlock downloading. diff --git a/src/main.cpp b/src/main.cpp index 5d803ba1..7196f1f3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -751,6 +751,11 @@ int main(int argc, char **argv) while (providerQmlFiles.hasNext()) providerQmlSources.append(QUrl(QStringLiteral("qrc") + providerQmlFiles.next())); providerQmlCache->addSources(providerQmlSources); + QObject::connect(providerQmlCache, &JellyfinNative::ProviderQmlCache::finished, &app, + [providerQmlCache, expected = providerQmlSources.size()] { + logLine("provider QML: warmed %d/%lld components", providerQmlCache->retainedCount(), + static_cast(expected)); + }); QObject::connect( &window, &QQuickWindow::frameSwapped, providerQmlCache, [providerQmlCache] { providerQmlCache->start(); }, Qt::SingleShotConnection); From 2b8f6c1861e5430081acbdcb62fabb89b690cf10 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 05:41:35 +1000 Subject: [PATCH 021/146] feat(provider): add cancellable worker timers and deadline coverage --- sdk/README.md | 4 +++- sdk/provider.d.ts | 5 +++- src/provider/ScriptRuntime.cpp | 34 +++++++++++++++++++++++++++ tests/providers/ScriptRuntimeTest.cpp | 11 ++++++++- tests/providers/fixtures/provider.mjs | 6 +++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/sdk/README.md b/sdk/README.md index 9096e8d9..6ab41781 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -8,13 +8,15 @@ An ES module exports `createSource(configuration)`, returning an object whose ow `host.http(url, {method, headers, body})` returns a Promise for `{status, body}`. The source's authorised HTTP(S) origins are selected by native code. Redirects are returned to the provider, not automatically followed. Cookies are neither loaded nor saved implicitly. HTTP error statuses remain inspectable; transport failure rejects. Parse and normalise response text in the worker. Never return backend response payloads indiscriminately. +`host.delay(milliseconds)` supplies a worker-owned one-shot Promise timer for protocol polling/backoff. Delays are limited to 0–10,000 ms and 16 outstanding timers per operation. Timers are cancelled with their owning operation/source; they never become process-global recurring polling. + Native limits: 8 MiB decoded HTTP responses; 1 MiB request bodies; four concurrent HTTP requests per operation; eight operations per source; 32 active operations and 64 queued submissions per runtime; 16 source objects per runtime. Operation deadline is 15 seconds, transport inactivity deadline 10 seconds, and uninterrupted JS execution budget 500 ms. The watchdog covers Promise continuations as well as direct calls. Exceeding execution budget disables the module; create a fresh runtime for explicit recovery. Results are plain owned native values, bounded to 50,000 values, nesting depth 20, arrays of 10,000 elements and 4 MiB of string data. Non-finite and unsafe numeric values are rejected. Represent large counters and exact timestamps as decimal strings. IDs are opaque strings. Source IDs are host authority, not provider-controlled credential selectors. Removing a source cancels its outstanding native requests and completes pending operations with an error. Other sources remain available. Shutdown also completes outstanding operations. Native callers receive `QCoro::Task` on their calling thread. There are no cross-thread `QJSValue` objects. -This is a trusted/reviewed in-process execution profile, **not a sandbox**. Timers, durable secret/storage services, module manager and UI-to-worker RPC are not yet public services. Do not declare that the full portable-provider plan is implemented by this runtime alone. +This is a trusted/reviewed in-process execution profile, **not a sandbox**. Durable secret/storage services, module manager and UI-to-worker RPC are not yet public services. Do not declare that the full portable-provider plan is implemented by this runtime alone. ## Contract runner diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index d1d6a544..e43d4e21 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -1,7 +1,10 @@ /** Experimental compatibility revision 0.1; additive fixes keep this revision. */ export interface HttpResponse { status: number; body: string } export interface HttpOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; body?: string } -export interface Host { http(url: string, options?: HttpOptions): Promise } +export interface Host { + http(url: string, options?: HttpOptions): Promise; + delay(milliseconds: number): Promise; +} export type Value = null | boolean | string | number | Value[] | { [key: string]: Value }; export type Operation = (arguments_: Record, host: Host) => Record | Promise>; export type Source = Record; diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index 020ddb24..ce46cfa6 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -212,6 +212,29 @@ class ScriptOperation final : public QObject { // error is a stable code, never the untrusted exception's string value. cancel("provider_error"); } + Q_INVOKABLE void delay(int milliseconds, QJSValue resolveCallback, QJSValue rejectCallback) + { + if (settled) + return; + if (milliseconds < 0 || milliseconds > 10000 || timers.size() >= 16) { + rejectCallback.call({ engine->toScriptValue(QStringLiteral("timer_limit")) }); + return; + } + auto *timer = new QTimer(this); + timer->setSingleShot(true); + timers.insert(timer); + connect(timer, &QTimer::timeout, this, [this, timer, resolveCallback]() mutable { + timers.remove(timer); + timer->deleteLater(); + if (settled) + return; + watchdog->arm(); + resolveCallback.call(); + if (engine->isInterrupted()) + cancel("script_interrupted"); + }); + timer->start(milliseconds); + } Q_INVOKABLE void request( const QString& address, const QVariantMap& options, QJSValue resolveCallback, QJSValue rejectCallback) { @@ -296,6 +319,11 @@ class ScriptOperation final : public QObject { void release() { deadline.stop(); + const auto pendingTimers = std::exchange(timers, {}); + for (QTimer *timer : pendingTimers) { + timer->stop(); + timer->deleteLater(); + } const auto pending = std::exchange(replies, {}); for (QNetworkReply *reply : pending) { disconnect(reply, nullptr, this, nullptr); @@ -311,6 +339,7 @@ class ScriptOperation final : public QObject { QList origins; QTimer deadline; QSet replies; + QSet timers; bool settled = false; }; @@ -351,6 +380,11 @@ class ScriptWorker final : public QObject { return new Promise(function(resolve, reject) { bridge.request(url, options || {}, resolve, reject); }); + }, + delay: function(milliseconds) { + return new Promise(function(resolve, reject) { + bridge.delay(milliseconds, resolve, reject); + }); } }); try { diff --git a/tests/providers/ScriptRuntimeTest.cpp b/tests/providers/ScriptRuntimeTest.cpp index 1e0747c4..99f8ab79 100644 --- a/tests/providers/ScriptRuntimeTest.cpp +++ b/tests/providers/ScriptRuntimeTest.cpp @@ -116,24 +116,33 @@ JELLYFIN_TEST_MAIN("script-runtime") "redirects are not followed with source credentials"); rejects(runtime->call("a", "raw", { { "path", "oversized" } }), "decompressed responses are bounded"); rejects(runtime->call("a", "raw", { { "path", "truncated" } }), "truncated successful responses reject"); + require(QCoro::waitFor(runtime->call("a", "delay", { { "milliseconds", 10 } })).value("completed").toBool(), + "native worker timers resume source Promise continuations"); + rejects(runtime->call("a", "delay", { { "milliseconds", -1 } }), "negative delay rejected"); + rejects(runtime->call("a", "delay", { { "milliseconds", 10001 } }), "timer duration is bounded"); rejects(runtime->call("a", "denied", { { "url", "http://127.0.0.1:1/private" } }), "unauthorised origin rejected"); rejects(runtime->call("a", "throws"), "synchronous exceptions settle operations"); rejects(runtime->call("a", "cycle"), "cyclic results fail bounded conversion"); rejects(runtime->call("a", "largeInteger"), "unsafe integer results rejected"); rejects(runtime->call("a", "missing"), "missing feature reports unsupported operation"); + auto pendingTimer = runtime->call("a", "delay", { { "milliseconds", 10000 } }); cancelSlow = [&] { runtime->removeSource("a"); }; auto pending = runtime->call("a", "raw", { { "path", "slow" } }); rejects(std::move(pending), "source removal cancels pending operations"); + rejects(std::move(pendingTimer), "source removal also cancels timer continuations"); require(QCoro::waitFor(runtime->call("b", "state")).value("calls").toInt() == 2, "removal leaves other source operational"); QCoro::waitFor(add("a")); require(QCoro::waitFor(runtime->call("a", "state")).value("calls").toInt() == 0, "replacement gets a fresh source generation"); + rejects(runtime->call("b", "never"), "operation deadline settles a Promise that never resolves"); + require(QCoro::waitFor(runtime->call("b", "state")).value("calls").toInt() == 2, + "timing out one operation does not disable the source"); auto shutdownPending = runtime->call("b", "never"); runtime.reset(); rejects(std::move(shutdownPending), "shutdown settles retained operations"); - for (const QString method : { QStringLiteral("spin"), QStringLiteral("asyncSpin") }) { + for (const QString method : { QStringLiteral("spin"), QStringLiteral("asyncSpin"), QStringLiteral("timerSpin") }) { ScriptRuntime runaway(entry); QCoro::waitFor(runaway.addSource("a", {}, {})); QElapsedTimer elapsed; diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index e40e7164..5124642e 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -4,6 +4,12 @@ export function createSource(config) { raw: function(args, host) { return host.http(config.origin + '/' + args.path); }, + delay: function(args, host) { + return host.delay(args.milliseconds).then(function() { return {completed: true}; }); + }, + timerSpin: function(args, host) { + return host.delay(10).then(function() { while (true) {} }); + }, page: function(args, host) { return host.http(config.origin + '/items', { headers: {Authorization: config.token} From 70250405a366c563ddf153f0c6ace4a0ae6159df Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 05:48:03 +1000 Subject: [PATCH 022/146] build(provider): bundle pinned Jellyfin sources and verify embedded contracts --- CMakeLists.txt | 43 +++++++++- .../bundled/spool-jellyfin-0.1.0-alpha.1.zip | Bin 0 -> 11291 bytes providers/lock.json | 14 ++++ sdk/README.md | 8 ++ src/main.cpp | 10 ++- src/provider/ScriptRuntime.cpp | 2 + tests/providers/ScriptRuntimeTest.cpp | 3 + tests/providers/fixtures/bundled-jellyfin.mjs | 32 ++++++++ tests/tools/provider_package_test.py | 28 +++++++ tools/bundle-providers.py | 76 ++++++++++++++++++ 10 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip create mode 100644 providers/lock.json create mode 100644 tests/providers/fixtures/bundled-jellyfin.mjs create mode 100755 tools/bundle-providers.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 80758d63..f85f033e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -341,6 +341,42 @@ qt_add_executable(jellyfin-native src/app/GraphicsStartup.h ) +# Bundle immutable, verified source bytes. This resource path also exercises +# source loading with no developer-generated QML/JS cache in provider tests. +find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +set(SPOOL_JELLYFIN_SOURCE_DIR "" CACHE PATH "Explicit development override for the portable Jellyfin source") +set(SPOOL_PROVIDER_RESOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/portable-providers") +set(spool_provider_bundle_args + "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json" "${SPOOL_PROVIDER_RESOURCE_DIR}") +if(SPOOL_JELLYFIN_SOURCE_DIR) + list(APPEND spool_provider_bundle_args --jellyfin-source "${SPOOL_JELLYFIN_SOURCE_DIR}") + file(GLOB_RECURSE spool_provider_override_files CONFIGURE_DEPENDS + "${SPOOL_JELLYFIN_SOURCE_DIR}/logic/*" + "${SPOOL_JELLYFIN_SOURCE_DIR}/ui/*" + "${SPOOL_JELLYFIN_SOURCE_DIR}/resources/*") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${spool_provider_override_files} + "${SPOOL_JELLYFIN_SOURCE_DIR}/manifest.json" + "${SPOOL_JELLYFIN_SOURCE_DIR}/LICENSE" + "${SPOOL_JELLYFIN_SOURCE_DIR}/NOTICE") +endif() +file(GLOB spool_provider_archives CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/providers/bundled/*.zip") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/bundle-providers.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/provider-package.py" + ${spool_provider_archives}) +execute_process( + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/bundle-providers.py" + ${spool_provider_bundle_args} + RESULT_VARIABLE spool_provider_bundle_result + ERROR_VARIABLE spool_provider_bundle_error + OUTPUT_QUIET) +if(NOT spool_provider_bundle_result EQUAL 0) + message(FATAL_ERROR "Bundling provider sources failed: ${spool_provider_bundle_error}") +endif() +target_sources(jellyfin-native PRIVATE "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") + set(JELLYFIN_NATIVE_TARGETS jellyfin-core jellyfin-native) jellyfin_configure_platform_targets(jellyfin-native jellyfin-core) foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) @@ -904,11 +940,16 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() endforeach() - find_package(Python3 REQUIRED COMPONENTS Interpreter) add_test(NAME provider-package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) + target_sources(provider-contract-runner PRIVATE "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") + add_test(NAME bundled-jellyfin + COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") + add_test(NAME bundled-jellyfin-interpreter + COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") + set_tests_properties(bundled-jellyfin-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1") if(JELLYFIN_NATIVE_WEBOS) set(JELLYFIN_FFMPEG_AUDIT_PLATFORM webos) elseif(APPLE) diff --git a/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip b/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip new file mode 100644 index 0000000000000000000000000000000000000000..35a53de0e2fe3a560b018fb721e493a8ce924d0e GIT binary patch literal 11291 zcmZ{qb8s&(*XMtAdv9&qwr$(CZQFKl?e^BTZQHi(cJI6I&g{I;>?U(2lgxbc$2rL) z`Q*q;fq)_d0078;MgR~fDmd@{*Hsk&03iO2NJ|Kb$SR3cY1_nMu_b;_>&?;Ik4_4v z;>cxPnmXWhMkKN5L-cXB?eZ#aB@aqhl-an(Ren9G$)Svqc98U7o5B+_DK?CH`jx9^ zyIu1&+qgVkv|8BcbTu`db(VdPJZ`sFd)E0@e}5$LN&0kE`!Z{7tOclgJT<=dtvvQ7 z;c2ryx-!95`xQ{WbD(d%jh}kztyR~-HDkDFwRKxg#riUA>4!P7Tt3M!b9Nx+&CDv=mZ3EAspdt2A#<$b-8Wq6?rubOXwz(MN}S>83tb0*69?D;T5aw zBH(epH@+;zY^k<$?Wh*A!6dC^QRy6Io>n99#Xa9WHE&f zoKiKWud+#3Y9}Lk-wbzyhsBinI(`KRYh^#DB7+QT)D*>M6?Q$R;DXQV>|(&V#=`oa zRIK1VyBdX^FGcpafAuXMTP*Hwj zM;y+ZN7tOC&l3>_jmI%-QCfl)T~0c~^Yk@H|5B=*=SiLL^_cn*zh*5SXi<2r!h8dk=b}$IN-)L`kA=|(C7>ip4;G1oX%67+Q^0^rBk zcpZdimssh&VroZ!t=*_im4Dl~b6`E`uhvFS{sFORccE<{tz0WodvGmf8SG^Ab_J-}r0ax{_(1vprHafVnBOIl|O zXXxv1Ad{7-JHn!R%T1^z6TR_mH`4s%Kh-aaf&;?bf*rRypKqt`;}f>GBI;HRIT==s+8vSy!+B}wKrjwj4h@=^oi2jGFbc@DFWfoQ#?wq{nsT8xhKef9ti(0h7KS`~3fbKik- zm`~~rX!h`|hs5pTx^QLZ+ju

~RD6NDmW;4RD0K3(M$Oo=*$kf<>fcNzr@R<+V&^Lt6@HRPjF;4~BM_By!`Xiy0m z_?f-t>FW}95l)a;{i%aF4^umD@nM9fGZi5O{sl=ZaZFx7sK-8UwkwX`+gP(MZ%Ke|ig-mcaHWlUxt;yW zGUtI&<*`9aM$64Zl&dq-KYv#BMG6D@6vkSf$0`3VM0 z)Ct?sEVm67>DRRr)t|SL?#p*5GQq{)Rho5PGDG5OndT*3G?63Wf9g}-qG%?N@P*nzg#EsmDM%~CM}REI&b%Sn<+kc+uc6<%i4I?U zTDdQ^&+VG9i$2Z?|=*2!SqK!YIaXn;4ngi*vHIrHCIjnl$M9~H3{A1ddn z2{>yo@wrRre+Bcz+xc0*q#*u^x{*nLMdgEvJTy2% zV5%(`aQ5YDT@IBc`&mW9x~Ta3{}O%Y$4C(#JNdB9E~GJ=o z$Z+xGE$H_UR50-^in-1+_A6p>HPQk6@F+Ph1yVeDCAZ+M}DIZ4Ejb@c|6WQF`K|aDdX$K{wvZ# zL9G5oVBO_5cyK_i!93H=PAK;pHr%p$N`p$ij17B=5#LK8_o0NrW-WvpCw|r%Mk>cE z6^qF(GW9uuE%R$JpXT4eOt>0%%6wQQg>xNOabWp?6-ny#m<+)$DJiZS*^Em{u2@k+_;%40r?h2G;8r*V_1t(b?||!_o9sljU--C z!I2=o*OZX}6>dbP;IKZcTd|nFH8F(7?3AcBxi2=|O33(JkzPa1PB4y-EBeVg5llNe zX0qM}m-Ett-7z~M*2<1nJGZhw1#N$mu~NV-*9oeD(jl@JS|U+M*RN5kA=|EkcD2fV z&s)v5#gh%}(pc#Br41U6eCkL*vgevyChuyMsks|^ezBl^3^wIGHeGcs`@W(4tkSw@ zCl4Ql{3(e;a8$8^xMepR09oc(E+gDOx7E(SXDXSAbPB0gZ77rSO^Vvq6rCwgC~gge zm<48k;&PVyy%-HP-BRZv`zz2#$7u}z#-t1B*3%LBmI_-}AJQDdxc^Av7H?S3zVKLk z%k6p*!9TGC&O=Biu8Y+ERHGeKT@MOAO84Fp_@YO^K?8o3E9^KpsYLV^1Wl-^x;eDV znMER!+3^q@BA>immy?-B!(Bi#nOCzTs&)t^M~%2J#HXsluN|q+c%3H14PxV%GFN@? z6=a*uv}U6!3pI3r2HM*>Uwd31maW=fpKZIz_(-_PHU>5g9H3*}BBfd}cz%vq1Bpy6 z6w4;LPjJU;ay0wC(6L=R2r_lm20UXDfkx@YawV+F`z+|aR-Cb@xx=zjHtPf&4P!(0bh4_ovxM2^^bHg6zr?)d!bU<}nomRRV&<(Iy- zD*5(QhI%Qo`3=4WbPBn}(H3h=5Vo6TR&~jGOX#oDxWMgchTXVcaoTDSn~WB#x|EAt zHydB#0t206U~QaGXT-R1(?-Epb51AtE~nkg=qu=^BLa;vUMS%~05_b9NFX>RBk%}5 zdTNYQ3d4)lrsHDmLZhFok9UPmN4Vynfxn=kaA+2&dHkmn;EuCjN{^O)jsr&mng-uj zE*ML7m;9C=&{FD&xwb0rY&Q^-py~l0;!ED2==JW^2niv20eu}xZ`~WiQ%}oi_NxoB z7ATJzJW98~{nLhWB`VgWO#E65HUQN|+}0PrfMqtQUNK%CYDxZ33<3AZM7Tn-|c_)2+{ONRodVv*t-V* z8h%&aVT3K2Ig<0E&LJA^O?OXP6#2rnh1Z!=g&h;l<<7x^^%`?Uwq@ZtNyhEL60RK) zA9rX^MuLre;DtrdWd-8gf~~;flP?g1>~Fv)HEtV#8;;UxCnP$&@Yo-a07O$>`b^LR zHmZTlSwnfxS+akk#k&tm8yibhi~QUl=?Fk%PSTzONf)HLh!`~0n@M4Iosvc)J;e{dMv2hYOb z1;Z$aQ-yt=6iGY8ZSHV`$ti(1-?!Yef{lGj=LWS?mjx^2RO`5 z;vo$95ad_zUr0gm48tL{E4P%w4uuGlDm+GrCVM3!joYR%G$+g3l2c!gp)4#hAC==e zF-=i!V==}f2?mOj3mV64Ee$#Rt%WdjDc|So!yE=_)iDK3bBmUHmalQzaw!PdnJi17 zF^-ek4oE}FMz!Ju{`w+%1UjRSEyM8kY@vUOvjwwDQ^Xxeh zsIn%u(FDCp@5TS5k`4shq#-dRbRPI>s=w_J(Z+)-Ii(w~)$XK8l44Vp#$U`VYz`c; zS_x}Ig#>eA3b`Sk_)8J5K)T0eS6{{t={k4EY}A>py(wjCP`t@Ah^3!HL zlHU~X!<5Z9_O9S_RR_Frpx`;ley_uu1GwsxDZWfYXxEMQCCDFnH4yA+h;arLx6zA( zI4c*S#_dkzU~xo`)pAge%CZ=RG{I?^zSQg(sCvDkgmIQx@nD=I@jx`?f#90Y2%NUp*ky!w^iSWV#~&ut?_ecIo| z%-uc*(ZzITi#x?=m|AL*t>`ij_zdpO1?P)?kpt&2IqFjXy_t4D@mIn zpKd*Hng`eNci~83l?Zg6|1S+T%>~9CIQCo|x%FE&u`g5vi>MK9(ahpaTZcsl-^e#M zrvZ0Kre!RBL869r&a&Zb>aw6~+}63$IdgDA$=RwE<5*DHI38(oOB=m9-nN|iQAX7- zCLf0aarIZDf-4AiE1Pb@($wUE5wZ}iFOa&Z)0?i=-d%$B0rJASTNl+$A6cY zmldVA6)fgZdABykm{5)knHy1UJo^ES_0PY@?*x-chn6DF&`Rb23Z0;gC;p@2=(Pdu zzn1t2r*3ZmrnWy9x0#hXpajjvI`D6Ezid=r znY`t!dB@`IkQNp{LJ*625((CLp|weQK|94TH8YV7=1su98#lq2o5X_j1DGN=wMu6O zanjhIZ?65=otrn4^!ulvg0<+qYIZNw5kWUp421`DER8*JWBus6I$LgQ>3F@2Ij}QD ze6&zh-g%F7mp7SBnG{bbIk`f9heYV4x;h!RJ6%N`jLeJawBg&p$C9%gd=HeA(aoKz z0clnqHU`-oj~vsqc}TJuQB)?OB*G$_P!%#e}!7{NK;oxwcv}6F?BknwrH> z_iM&`$asf;kR_k`ke5leNH%4uCp5N_Ds+#yUO=(;aNCU_ZZ;BV@@GpsXg1DGa0z2_}CemU4&mmvE*!Dtzu)BYaE&fot=!}wOqn%q zG`P6J{CYnlOeSb*@uB|nw|aE_+_51|M|Sjk0msBuhg)x{oj$~V;CcFG(QfDS_C4*T zM#WnXfo-MA!#_<*Mzra~hF^^n%S?jqsp)w;xW2c|D@D*h=Nf1}S!Cg`aGa5XLAn8z z_{qYYxC<>geV%VLnfC_f@+Zu|Cm`Kt8-pJ2FQrGH)vv`H+HXCiM1=Am5c&UsI8|lH zQe=SufRBF${tt1=$|?VYoHhy*u^aTzogY;0)&YxS?E(jvE^J{wjpVyuK$0aytdZvO z^}k;(CeWk08qoxbLI=_n*dm49OVHB-kfIB~bcoSaOs^#>ATZF5oXBUZU>Cq; zN%RB7p3|sRx06%&@m){-zMM^a7kGjdhrN5Ya@vB&mUOE({7BSlE-6fc{L=WaiTY>8 zM4oTA!%DXMN~J+-(o@L7W zRySwDw?h-R9eCY4?T{@kJv+V*cL#BFJ$@W0e6oJ6 zt2wq~c)H!#+`m=Io#0I62(P;CKO@HPUknL?3aQd{hVD7pr?1`@9i1l&zRFU`BQJbVh(D1w#YoEPIx8{N>k2Nq&<`5GLxkTfeC0` z#6F3HoZ;Udm|D~F-j|np@-j%vW`N;hugG4dq$a)IPtlYI>QZ% z6Rs=J)mdYm?U}QO4X6{5_~WMoeVWvfEwXB?xuPD}4?}y5+!F_wM-w-|b`v}g-)?UE z;5t7gXO>u=>0M@^p>1S-i4DMwfoKWtQy%fM@$8j8TVNQDK+e)FGurMEU$m?hBghwvNb`{_49t@wGh z8~}#?I;{JroS*ckuh~t&L>DF$hv-JW=Ycxpj3N*Scm>>i;G3ZEn|sHX)3gR0{B*Z--P$eOh@= zSp}$s*8|I98yScda}u;#c^M@2{Y9l=Pp${TZ2MiiHzGM=octE!7+Kb;sIL;BUaJ*> zOwq%-Y&XT98&eXE2|u!Urmsl};|0OU$yo5)3j%I!-0`j-n*>y_!W@Sd^8L)|XJPrI zeA)H-5o&7C&@n&Ft6kr*1)aIL{|(@0$2u)=9m zz_b2fi$hz!32742qiRMGXBmzS+$PK2W)1kDMc9WlF|KS*;!e>Nzusqry%lwU0U3I< znUDmBo~IhP_Bt~_@yh^TNrJ_`Akh(@RZ9~V)1X#s%6>S0?<966za!%fbb(f6%D>qL zmom0cNMJyoppXbd^SQr}gaP5K5@6|mI@_peT2r5EXBbm%AIY<u&HK5EGf@;-(B_M2?ASxe={ zhIS5fOL8D0^S$>m$XHqhwVl)-Xj_pEI!FRZB#~`o$KGvaS~r3kb(eLsqX}n3gOJvE zo_Bh8bP`N`Y0`YS+lGnX55R?X!oznv*UCLpxfbMs%7}*0ite7=pJ@SE z^PaBVpH4^AV`P__2Of3@EFWQAa#1Z6__dq7JKD>PS-K9lVPB$hphKJH(gK+mcgR$p z+li)fV0JUKtQtmzTDogKG{+&p!p*>BG!+~k9e1&A8IN1QK;6sD5@(v1&mnH+BF1;S zCEI;O-E`O(-&h0i+IEJ?&CL-gY{aL>;Lq|Nr(#k zy8NK`cH3gK`E>3v?~8YW^$B$6JM%Mq!?s$;pF6H!O2u01`D;8aTt2YG*T>a0HM=-B ztN|bf_-Zh_2Ul63LI^`HlT!v7Zibkrk1-PwMR3(&>7(i>XziEQ0Kxe)M+#;p6t9@G zfwJw9L-%wshY^j1ar|9!Qq(GqZ1A;DLoytRQ7VpQse< zb%3qb$y&Y~jlK8chM5ht6eCXn`i4{X9CW`bU(fZP1FTl#o3Q41?m3W$Le zd#L5RnhN*(F9mHLC8^Rgb~Y8@b_g+6N7e)82cU+=esL3;uYH_d==M~WAV&1Q!71*u z8MH$xb_0>MA6{C4Tes+t0r93?k}?;x3Q?JsDl>w&v>pq@Itkow(9zW-axHDNd!n}O zmQ6-?-N$j?b{E=UGTofQ-`Kj+iMVP*_$PGRUT12BpK|-2YNT-tpiw44tt70OnAtOlKDF#CXj;D&6 z(N9{C(vIUG?mo?#pj$VARwqAmT5abRH4>FQ|7nN!Uek!&pUb>3ppu}N+>V7*JO}H&gA7y!# zu@9*S6Iuwk(yxZQ%Hp5bn;pQzvMFlK(XOG&LblyRx{hGW2 zV`Uz}EaB>$1r?M0=#^OkW4{K+f|S8~NOVpOrK)0e5=mj45o@9M*f`RZ*R5?8J6>_{ zF&l=BkY=?Dr3;f)+r@lSETi!!*q8=#?%$XNCfS+Kl`gIG4P`SM|DrR1JxfgVUH^uG zCdVTc@y`Dy8~W!@Y^-)vM+9}7A%+n`aj=7%v$CyooD9izx=|pP>yGA;j+wb#$RTjC z@sm3P@+fi%hWp4$i4_$1Jtp!stk3@W;HMl=9}{*j#i~4O%pB=HXH^#8XK9dyx%``q zLDJ@pen*O{>H~i%tA;bngfU2XZK%iRWxF+{Ton%a}591*wlQjW8;?UHO zndMsJ_F9jQuV>_KljPb!nB-1`l(8JzRcY?3!@0KPk^ggVL|xaa=ZF4a(V)ZY%BVJ> z7cBEIGCT9PQp|j;<#B(aaA-5|#KA`s@9(psOAM>7@x>amTZ!JAbJvL{O6(jx`P)bQ z(2agq!RcrT>a`N3%klxPZ#Z-srKAYSjlQYgAR#Q+&ew6wjLGIR^_)oIpec+}14UuP z7T|P{4k3~wxh$hHhHIx?gEDJ76R=oJ?2~lFj2V0}jekF2h>`1{trbG)qUv>*+Ueqm zE;8Q(WIr?wKuw7kn6Jt;r1EIC7`{eBF}SU%46&^OPt>$uMwy6xjf&PWPc;aBem`*E z-`6m{Ka`I}t@sEFB|aGu>;aIudD{*)f3WZr5rW`CVGe=X3NV+Jp0W4W*wM;%Zl5;? z6V@KT^~>u)5T-NBvQlLgVB^+U@)&#Pk_G(k+Y8bNmxdA>a!$C_9>hoFNHns{EZZAH zC8r?*UJjc%m>$mzGF7hTPB@=-5p4|~U&ums1LE!yxF$SdoS|co4qk=o8GXgF7Rov? zi^#L=>epVR0iqZTS>x6Q%>IO58JznOYsH@49s}BBP_V6un96FzlO~Yu7JJ(x_B9sF z*M`i~LE^{w!&Y)ZVL530RWdDRYVBk<7QjTX0HGDH_*XI;3U#`IJhhPl93>APdnAlRz)#IrF`*( zW3;C-pL&qpwD-Z_PT4``qfx*%`xP|}&hGfJ-Z0xX@$ko(xQhgH zs}}Y^r`f>8tB40K?9#6HDp((FmHWPXe-`M5*uONxlKK>kgP{`&Q~ks`0Q^z58g8XNfeFc1jygm@YxT)`%_S#D zFT-U}%NYsMeZ^_I|2zBWSeE#rcGrykahse@1zzxkBRCGf zcgo=7Z(}R!(fU#{P$m%={J#yKPjwH?RQ%L7zORv$(?IS#<|)+MPI~P-M(@XCr%5O^ zG(Vk^!Upo~!}_`0g)y7q+~ECIOj@1xCrlkuxP6+xHDAF)+mYy2(n>|C?8x+qg30*E zQFPkrqp}xHX9LfprS}qHgSxcxv*rXMDr(889PX7gA8s2kF*s^HmF-0_3E#P=yE=eP zbcg)8Q>sYkah*t4OhWIY!QVb!v z3?ux<$O!KMh>zJ7-=U7gy8Tapul>o?BduF=%KjgkL!SJZQ1_Upv0e3v5|?hRGCCBL zD`{UYnt>J?yac$kV>OfH?UKaH@OpaV9Of%#GQA2MYdE;LDST82r>4u`^xj@Y(J5o* zHf0hN?8I>@^}7?VUnQNBB`Mu%p`4}kh>X{aXIKS=p;m+QhtEKxqybt;d@)aTfCM*N zflPN?E`joqcb$ZmP_$KuLOc~kecaDqHFQi%ivz@kgjvc!8W@pxe|ZANmnykJze{!} zHqg$5$0ryg=3R#KSYG>!%6I7H*z0d}T|W%DR0d3C62@9u(WtD9gY$MonmPK~hWB@G z2#}lttXu8uny4Fq0;vw1JaPL^F zq(5QS65KeRcZ~AvPQC|=*bU=E4!q!eHlrr(NSwbJ>n6S$?c{%?;fcntHS~K*prZDf zl8LI+l4)|{N;iQ); z>KNb;yF^*m&{TXibyk-1>*4=})xde)j^zoNQfUdB{hWV}n^D z#YgRlDt_s}!`W-TLu1}*FCd32eUGQTB6=5m^|7wzzCu|jo0L8fjx-;V=eQi;`>io6 zrG6lWP>jYD@q;b8;R+QzJf_LTHLCf@s`UHBFB$(CQ-5U0qa&7)Pv_xmc{8ehj_dAv z6Tn9q?#5co+)(^mRa9N!&5m{%G~OfJWQy$kn2zzszC8}|^SCNh!&A8x7Lu@`rR9z+ z_z1?vT79dlfe)+frvaue`cCcNVg5fE+9U>gfzCf-SquyS!2DxqHU_p9rY26#w3beG zwkC=Kwg>;<Z(yZ2+?^WxT zta{J)b^7RwsO z_&#A3#gvp#oPI;1pbQU@jeq>Lwd>U2fpeFgp*+EzH0A;Wj>H^d$$g~bZ>8kFb<+^M zr-;>8&W@A&2g)fpqWZRLO>*k(2B89G$1`5Gw|uqgbyrm zdS1@|y3|bjF*DWPy~{Nzm!h+7sDNP4k)+oJ_OeFf%el_{IOda=0s=+``TwWL{tL$c zE=2+V)%>4?+5Z&%Cv^UA2>?(OaPV*D|3=XNsrgTA`hPVlsQ)jB`k(s$Tr&SxpHA@~ Y3rJoH?7vVr0Q6ry{MQmt{a5dQ0Uf}QcK`qY literal 0 HcmV?d00001 diff --git a/providers/lock.json b/providers/lock.json new file mode 100644 index 00000000..9c61667b --- /dev/null +++ b/providers/lock.json @@ -0,0 +1,14 @@ +{ + "format": 1, + "providers": [ + { + "id": "spool.jellyfin", + "version": "0.1.0-alpha.1", + "repository": "https://github.com/spool-player/spool-jellyfin", + "revision": "673647c83ff64714ee745aac09829fb63db6ee42", + "archive": "bundled/spool-jellyfin-0.1.0-alpha.1.zip", + "size": 11291, + "sha256": "125e332388f92f983012efaa1c3b90162fe6d1af505bf0d4a7ec056eaf3b1228" + } + ] +} diff --git a/sdk/README.md b/sdk/README.md index 6ab41781..19c5bd6c 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -36,3 +36,11 @@ The test module exports `run()`, returning a Promise or throwing on failure. It `tools/provider-package.py build PATH --output provider.zip` creates a deterministic source ZIP. `validate provider.zip` checks its manifest, required host features, known permissions, declared UI components/imports, paths, file types and size limits. Unknown offered and optional extensions are allowed; missing required extensions reject the package. Every QML component must be listed so it can be validated and warmed. Validation does not authenticate publisher identity. A release digest or provenance attestation is not a substitute for an authenticated catalogue with freshness, rotation and revocation. The application does not yet install or activate these ZIPs. Do not enable downloaded code in store builds on the strength of this tool. + +## Bundled sources and development overrides + +`providers/lock.json` pins the provider repository revision, source ZIP size and SHA-256. Normal CMake configuration validates the checked-in ZIP and embeds the exact source files under `qrc:/providers//`. It never downloads a mutable latest release. The bundled Jellyfin source contract is executed in both normal and interpreter-only Qt modes by ctest. + +For unreleased provider development, configure `-DSPOOL_JELLYFIN_SOURCE_DIR=/absolute/path/to/spool-jellyfin`. This explicit local override uses the same source validation and resource construction path, without changing the committed pin or fetching/tagging a release. CMake watches provider logic, UI, resources and manifest changes. Clear the cache option to return to pinned bytes. + +Bundling this alpha source does not switch normal app operations away from the existing native Jellyfin implementation; application-level migration remains unfinished. Runtime ZIP installation still needs authenticated metadata and transactional activation/recovery. diff --git a/src/main.cpp b/src/main.cpp index 7196f1f3..f490745c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -746,10 +746,12 @@ int main(int argc, char **argv) window.engine()->setIncubationController(new BoostedIncubationController(window.engine())); auto *providerQmlCache = new JellyfinNative::ProviderQmlCache(window.engine()); QList providerQmlSources; - QDirIterator providerQmlFiles(QStringLiteral(":/qt/qml/JellyfinWebOS/qml/providers"), { QStringLiteral("*.qml") }, - QDir::Files, QDirIterator::Subdirectories); - while (providerQmlFiles.hasNext()) - providerQmlSources.append(QUrl(QStringLiteral("qrc") + providerQmlFiles.next())); + for (const QString& root : + { QStringLiteral(":/qt/qml/JellyfinWebOS/qml/providers"), QStringLiteral(":/providers") }) { + QDirIterator providerQmlFiles(root, { QStringLiteral("*.qml") }, QDir::Files, QDirIterator::Subdirectories); + while (providerQmlFiles.hasNext()) + providerQmlSources.append(QUrl(QStringLiteral("qrc") + providerQmlFiles.next())); + } providerQmlCache->addSources(providerQmlSources); QObject::connect(providerQmlCache, &JellyfinNative::ProviderQmlCache::finished, &app, [providerQmlCache, expected = providerQmlSources.size()] { diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index ce46cfa6..38910df0 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -382,6 +382,8 @@ class ScriptWorker final : public QObject { }); }, delay: function(milliseconds) { + if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > 10000) + return Promise.reject("timer_limit"); return new Promise(function(resolve, reject) { bridge.delay(milliseconds, resolve, reject); }); diff --git a/tests/providers/ScriptRuntimeTest.cpp b/tests/providers/ScriptRuntimeTest.cpp index 99f8ab79..bf17bd27 100644 --- a/tests/providers/ScriptRuntimeTest.cpp +++ b/tests/providers/ScriptRuntimeTest.cpp @@ -120,6 +120,9 @@ JELLYFIN_TEST_MAIN("script-runtime") "native worker timers resume source Promise continuations"); rejects(runtime->call("a", "delay", { { "milliseconds", -1 } }), "negative delay rejected"); rejects(runtime->call("a", "delay", { { "milliseconds", 10001 } }), "timer duration is bounded"); + rejects(runtime->call("a", "delay", { { "milliseconds", 1.5 } }), "fractional timer values are not truncated"); + rejects( + runtime->call("a", "delay", { { "milliseconds", 4294967296.0 } }), "timer values cannot wrap native integers"); rejects(runtime->call("a", "denied", { { "url", "http://127.0.0.1:1/private" } }), "unauthorised origin rejected"); rejects(runtime->call("a", "throws"), "synchronous exceptions settle operations"); rejects(runtime->call("a", "cycle"), "cyclic results fail bounded conversion"); diff --git a/tests/providers/fixtures/bundled-jellyfin.mjs b/tests/providers/fixtures/bundled-jellyfin.mjs new file mode 100644 index 00000000..cb264af6 --- /dev/null +++ b/tests/providers/fixtures/bundled-jellyfin.mjs @@ -0,0 +1,32 @@ +import {createSource} from 'qrc:/providers/spool.jellyfin/logic/provider.mjs'; + +function require(value) { + if (!value) throw new Error('bundled provider contract failed'); +} + +export function run() { + const source = createSource({server: 'https://fixture.invalid', userId: 'user', token: 'fixture-token'}); + const host = {http: function(url, options) { + require(options.headers.Authorization.indexOf('fixture-token') >= 0); + if (url.indexOf('/PlaybackInfo') >= 0) { + require(JSON.parse(options.body).MediaSourceId === 'chosen'); + return Promise.resolve({status: 200, body: JSON.stringify({PlaySessionId: 'session', MediaSources: [ + {Id: 'other', SupportsDirectPlay: true}, + {Id: 'chosen', SupportsDirectPlay: true} + ]})}); + } + require(url.indexOf('StartIndex=1') >= 0 && url.indexOf('Limit=1') >= 0); + return Promise.resolve({status: 200, body: JSON.stringify({TotalRecordCount: 2, Items: [ + {Id: 'film', Name: 'Example', Type: 'Movie', ProductionYear: 2020, ProviderIds: {Imdb: 'tt1'}} + ]})}); + }}; + return source.search({query: 'Example', cursor: '1', limit: 1}, host).then(function(page) { + require(page.exhausted && page.cursor === null); + require(page.items[0].id === 'film' && page.items[0].externalIds.Imdb === 'tt1'); + return source.resolve({itemId: 'film', variantId: 'chosen'}, host); + }).then(function(playback) { + require(playback.variantId === 'chosen'); + require(playback.video.url.indexOf('MediaSourceId=chosen') >= 0); + require(playback.video.headers['X-Emby-Token'] === 'fixture-token'); + }); +} diff --git a/tests/tools/provider_package_test.py b/tests/tools/provider_package_test.py index eb1b2a63..33451a06 100755 --- a/tests/tools/provider_package_test.py +++ b/tests/tools/provider_package_test.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import importlib.util +import hashlib import json import pathlib import stat @@ -11,6 +12,9 @@ spec = importlib.util.spec_from_file_location("provider_package", ROOT / "tools/provider-package.py") package = importlib.util.module_from_spec(spec) spec.loader.exec_module(package) +bundle_spec = importlib.util.spec_from_file_location("bundle_providers", ROOT / "tools/bundle-providers.py") +bundle = importlib.util.module_from_spec(bundle_spec) +bundle_spec.loader.exec_module(bundle) def fixture(): @@ -99,6 +103,30 @@ def test_build_is_reproducible_and_round_trips(self): self.assertEqual(a.read_bytes(), b.read_bytes()) self.assertEqual(package.read_package(a)[1], fixture()) + def test_build_rejects_changed_pin_and_mismatched_identity(self): + archive = self.archive(fixture()) + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + target = root / "provider.zip" + target.write_bytes(archive.read_bytes()) + valid = {"id": "test.provider", "version": "0.1.0", "repository": "https://example.invalid/provider", + "revision": "a" * 40, "archive": "provider.zip", "size": target.stat().st_size, + "sha256": hashlib.sha256(target.read_bytes()).hexdigest()} + lock = root / "lock.json" + for key, value in (("sha256", "0" * 64), ("size", valid["size"] + 1), + ("id", "different.provider"), ("version", "0.1.1")): + with self.subTest(key=key): + pin = dict(valid) + pin[key] = value + lock.write_text(json.dumps({"format": 1, "providers": [pin]})) + with self.assertRaises(ValueError): + bundle.materialize(lock, root / "output") + self.assertFalse((root / "output/providers.qrc").exists()) + lock.write_text(json.dumps({"format": 1, "providers": [valid]})) + bundle.materialize(lock, root / "output") + self.assertEqual((root / "output/test.provider/logic/provider.mjs").read_bytes(), + fixture()["logic/provider.mjs"]) + if __name__ == "__main__": unittest.main() diff --git a/tools/bundle-providers.py b/tools/bundle-providers.py new file mode 100755 index 00000000..abdf6bbb --- /dev/null +++ b/tools/bundle-providers.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Verify repository-pinned source packages and materialise Qt build resources. + +This is build tooling, not an application installer. Trusted repository pins +supply the expected bytes; no mutable latest URL or package code is executed. +""" +import argparse +import hashlib +import importlib.util +import json +import pathlib +import re +import xml.etree.ElementTree as ET + +spec = importlib.util.spec_from_file_location("provider_package", pathlib.Path(__file__).with_name("provider-package.py")) +package = importlib.util.module_from_spec(spec) +spec.loader.exec_module(package) + + +def materialize(lock_path, output, override=None): + lock = json.loads(lock_path.read_text(), object_pairs_hook=package.unique_object) + if set(lock) != {"format", "providers"} or lock["format"] != 1 or not isinstance(lock["providers"], list): + raise ValueError("unsupported provider lock") + root = ET.Element("RCC") + seen = set() + for pin in lock["providers"]: + if set(pin) != {"id", "version", "repository", "revision", "archive", "size", "sha256"}: + raise ValueError("invalid provider pin") + if not re.fullmatch(r"[0-9a-f]{40}", pin["revision"]) or not re.fullmatch(r"[0-9a-f]{64}", pin["sha256"]): + raise ValueError("invalid immutable source identity") + archive = lock_path.parent / str(package.path_name(pin["archive"])) + using_override = pin["id"] == "spool.jellyfin" and override is not None + if using_override: + archive = output / "override.zip" + package.build(override, archive) + else: + if archive.stat().st_size != pin["size"]: + raise ValueError("pinned package size mismatch") + with archive.open("rb") as source: + digest = hashlib.file_digest(source, "sha256").hexdigest() + if digest != pin["sha256"]: + raise ValueError("pinned package digest mismatch") + manifest, files = package.read_package(archive) + if manifest["id"] != pin["id"] or (not using_override and manifest["version"] != pin["version"]): + raise ValueError("package identity does not match pin") + if manifest["id"] in seen: + raise ValueError("duplicate module in bundle") + seen.add(manifest["id"]) + resource = ET.SubElement(root, "qresource", prefix="/providers/" + manifest["id"]) + directory = output / manifest["id"] + for name, data in sorted(files.items()): + target = directory / name + target.parent.mkdir(parents=True, exist_ok=True) + # Preserve timestamps for identical contents in incremental builds. + if not target.exists() or target.read_bytes() != data: + target.write_bytes(data) + ET.SubElement(resource, "file", alias=name).text = str(target.resolve()) + output.mkdir(parents=True, exist_ok=True) + qrc = output / "providers.qrc" + data = ET.tostring(root, encoding="utf-8", xml_declaration=True) + if not qrc.exists() or qrc.read_bytes() != data: + qrc.write_bytes(data) + return qrc + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lock", type=pathlib.Path) + parser.add_argument("output", type=pathlib.Path) + parser.add_argument("--jellyfin-source", type=pathlib.Path) + args = parser.parse_args() + print(materialize(args.lock, args.output, args.jellyfin_source)) + + +if __name__ == "__main__": + main() From a38bf577dee684261c773aad8ddccb923c4cb808 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 06:13:02 +1000 Subject: [PATCH 023/146] feat(provider): persist source identity and isolate stale operations --- CMakeLists.txt | 3 + sdk/README.md | 8 + src/app/SearchController.cpp | 4 + src/main.cpp | 17 ++ src/provider/ProviderRegistry.cpp | 5 - src/provider/ProviderRegistry.h | 25 ++ src/provider/ProviderRegistrySources.cpp | 363 +++++++++++++++++++++++ tests/app/SearchControllerTest.cpp | 118 ++++++++ tests/providers/SourceRegistryTest.cpp | 131 ++++++++ tests/providers/fixtures/provider.mjs | 1 + 10 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 src/provider/ProviderRegistrySources.cpp create mode 100644 tests/app/SearchControllerTest.cpp create mode 100644 tests/providers/SourceRegistryTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f85f033e..7254d933 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -265,6 +265,7 @@ set(SPOOL_SHELL_SOURCES src/provider/ScriptRuntime.cpp src/provider/ScriptRuntime.h src/provider/ProviderRegistry.cpp + src/provider/ProviderRegistrySources.cpp src/provider/ProviderRegistry.h src/provider/RemotePlayback.h src/provider/SearchSource.h @@ -815,6 +816,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) router-controller tests/app/RouterControllerTest.cpp library-query tests/app/LibraryQueryTest.cpp content-model-controller tests/app/ContentModelControllerTest.cpp + search-controller tests/app/SearchControllerTest.cpp remote-control-controller tests/app/RemoteControlControllerTest.cpp spool-remote-protocol tests/app/SpoolRemoteProtocolTest.cpp spool-link-codec tests/app/SpoolLinkCodecTest.cpp @@ -855,6 +857,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) set(JELLYFIN_TESTS_providers local-provider tests/providers/LocalProviderTest.cpp script-runtime tests/providers/ScriptRuntimeTest.cpp + source-registry tests/providers/SourceRegistryTest.cpp provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp ) set(JELLYFIN_TESTS_player diff --git a/sdk/README.md b/sdk/README.md index 19c5bd6c..c3c7608f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -44,3 +44,11 @@ Validation does not authenticate publisher identity. A release digest or provena For unreleased provider development, configure `-DSPOOL_JELLYFIN_SOURCE_DIR=/absolute/path/to/spool-jellyfin`. This explicit local override uses the same source validation and resource construction path, without changing the committed pin or fetching/tagging a release. CMake watches provider logic, UI, resources and manifest changes. Clear the cache option to return to pinned bytes. Bundling this alpha source does not switch normal app operations away from the existing native Jellyfin implementation; application-level migration remains unfinished. Runtime ZIP installation still needs authenticated metadata and transactional activation/recovery. + +## Native source identity + +`ProviderRegistry` owns one runtime per registered portable module and maintains persistent source UUIDs in the existing durable database, separate from disposable caches. A host-created `(module ID, account ID, source key)` identifies the configured source across launches; changing a token, authorised origin or display label does not change that UUID. Account/source keys must be opaque identifiers, not credential-bearing URLs. + +`restoreSources()` restores identity and enable/disable metadata only. `configureSource()` supplies current configuration/credentials and native-authorised origins explicitly; these are not copied into the public source index or its QML snapshot. `callSource()` retains the exact source generation across the coroutine boundary. Disabling/removing a source cancels its work without changing other accounts, and removing a source does not remove the account or its credentials. Interrupted modules become unavailable without resetting other modules. + +The application registers its bundled portable module and restores this index after the first frame. Automatic migration of legacy account configuration and normal application browsing/playback to these contexts is not complete. The `Sources` QML singleton currently exposes only the cached public metadata snapshot; it does not expose arbitrary cross-source worker calls to provider UI. diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index 8ba046bc..2e002b22 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -27,6 +27,9 @@ void SearchController::setQuery(const QString& query) { const QString trimmed = query.trimmed(); if (m_query != trimmed) { + // A response arriving during debounce belongs to the old text, even + // though the replacement request has not been submitted yet. + m_searchGeneration.invalidate(); m_query = trimmed; emit queryChanged(); } @@ -76,6 +79,7 @@ void SearchController::search(const QString& query) { const QString trimmed = query.trimmed(); if (m_query != trimmed) { + m_searchGeneration.invalidate(); m_query = trimmed; emit queryChanged(); } diff --git a/src/main.cpp b/src/main.cpp index f490745c..3a195f06 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include "app/RouterController.h" #include "app/UserItemStateController.h" #include "cache/DatabaseManager.h" +#include "common/AsyncTask.h" #include "common/LogRotation.h" #include "common/TlsTrust.h" #include "diagnostics/Diagnostics.h" @@ -626,6 +627,21 @@ int main(int argc, char **argv) // controller, which hold their parts by pointer, so they are declared // before them. JellyfinNative::ProviderRegistry providers; + providers.registerModule( + QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); + QObject::connect( + &window, &QQuickWindow::frameSwapped, &providers, + [&providers, &database] { + QTimer::singleShot(3000, &providers, [&providers, &database] { + JellyfinNative::Async::runScoped( + &providers, providers.restoreSources(&database), [] {}, + [](const std::exception_ptr&) { + qWarning("Portable source metadata could not be restored; existing accounts were retained."); + }, + "restore portable sources"); + }); + }, + Qt::SingleShotConnection); auto jellyfin = std::make_unique(JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, capabilities.deviceName, QString::fromLatin1(kAppVersion) }); providers.add(jellyfin.get()); @@ -825,6 +841,7 @@ int main(int argc, char **argv) platformInfo->insert(QStringLiteral("splashImageUrl"), splashImageUrl(appRootPath)); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "App", controller.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ProviderCapabilities", providers.capabilities()); + qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Sources", &providers); provider->registerQmlSingletons(); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Art", artworkService.get()); qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Browse", controller->browse()); diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index e38a4bb7..ea83e67d 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -17,11 +17,6 @@ void ProviderCapabilities::setFlags(Provider::Capabilities flags) emit changed(); } -ProviderRegistry::ProviderRegistry(QObject *parent) - : QObject(parent) -{ -} - void ProviderRegistry::add(Provider *provider) { if (!provider || std::find(m_providers.begin(), m_providers.end(), provider) != m_providers.end()) diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 477373df..969bd7af 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -1,6 +1,10 @@ #pragma once #include "Provider.h" +#include +#include +#include +#include #include #include @@ -9,6 +13,8 @@ namespace JellyfinNative { +class DatabaseManager; + // The active provider's capability flags as twelve booleans, registered in // QML as the ProviderCapabilities singleton. The property names are the // contract with shared QML: a gated control binds to one of them by name. @@ -96,9 +102,22 @@ class ProviderCapabilities final : public QObject { // Providers are added by whoever constructs them and are not owned here. class ProviderRegistry final : public QObject { Q_OBJECT + Q_PROPERTY(QVariantList configuredSources READ configuredSources NOTIFY configuredSourcesChanged) public: explicit ProviderRegistry(QObject *parent = nullptr); + ~ProviderRegistry() override; + + // Portable modules are process-wide; configured source factories are + // independent accounts/servers. Browsing selection never owns their life. + void registerModule(const QString& moduleId, const QString& entryPoint); + QCoro::Task restoreSources(DatabaseManager *database); + QCoro::Task configureSource(QString moduleId, QString accountId, QString sourceKey, QString label, + QVariantMap configuration, QList authorisedOrigins); + QCoro::Task callSource(QString sourceId, QString operation, QVariantMap arguments = {}); + QCoro::Task setSourceEnabled(QString sourceId, bool enabled); + QCoro::Task removeSource(QString sourceId); + QVariantList configuredSources() const; void add(Provider *provider); Provider *provider(const QString& id) const; @@ -122,9 +141,15 @@ class ProviderRegistry final : public QObject { signals: void activeChanged(); + void configuredSourcesChanged(); + void sourceRemoved(const QString& sourceId); private: void refreshCapabilities(); + void refreshSourceSnapshot(); + QCoro::Task persistSources(); + struct PortableState; + std::unique_ptr m_portable; std::vector m_providers; Provider *m_active = nullptr; diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp new file mode 100644 index 00000000..25a53edb --- /dev/null +++ b/src/provider/ProviderRegistrySources.cpp @@ -0,0 +1,363 @@ +#include "ProviderRegistry.h" + +#include "ScriptRuntime.h" +#include "cache/DatabaseManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace JellyfinNative { +namespace { + const QString stateKey = QStringLiteral("portable/sources/1"); + + bool validModule(const QString& id) + { + static const QRegularExpression pattern(QStringLiteral("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$")); + return id.size() <= 128 && pattern.match(id).hasMatch(); + } + + bool boundedIdentity(const QString& value) + { + return !value.isEmpty() && value.size() <= 256; + } +} // namespace + +struct ProviderRegistry::PortableState { + struct Source { + QString id; + QString module; + QString account; + QString key; + QString label; + bool enabled = true; + bool configured = false; + bool active = false; + quint64 generation = 0; + QVariantMap configuration; + QList origins; + }; + + Source *find(const QString& id) + { + const auto found + = std::find_if(sources.begin(), sources.end(), [&id](const Source& source) { return source.id == id; }); + return found == sources.end() ? nullptr : &*found; + } + + void requireReady() const + { + if (!restored || !database) + throw std::runtime_error("source_registry_not_ready"); + if (mutating) + throw std::runtime_error("source_registry_busy"); + } + + QPointer database; + QHash modules; + QList sources; + QVariantList snapshot; + bool restored = false; + bool mutating = false; +}; + +ProviderRegistry::ProviderRegistry(QObject *parent) + : QObject(parent) + , m_portable(std::make_unique()) +{ +} + +ProviderRegistry::~ProviderRegistry() = default; + +void ProviderRegistry::registerModule(const QString& moduleId, const QString& entryPoint) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (!validModule(moduleId) || entryPoint.isEmpty() || m_portable->modules.contains(moduleId) + || m_portable->modules.size() >= 4) + throw std::runtime_error("invalid_or_duplicate_module"); + m_portable->modules.insert(moduleId, new ScriptRuntime(entryPoint, this)); +} + +QCoro::Task ProviderRegistry::restoreSources(DatabaseManager *database) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (!database || m_portable->restored || m_portable->mutating) + throw std::runtime_error("source_registry_already_initialized"); + QPointer guard(this); + m_portable->mutating = true; + const auto unlock = qScopeGuard([guard] { + if (guard) + guard->m_portable->mutating = false; + }); + const QString stored = co_await database->loadSettingAsync(stateKey); + if (!guard) + throw std::runtime_error("source_registry_removed"); + if (stored.size() > 128 * 1024) + throw std::runtime_error("source_state_limit"); + QList restored; + if (!stored.isEmpty()) { + const QJsonDocument document = QJsonDocument::fromJson(stored.toUtf8()); + const QJsonObject root = document.object(); + if (!document.isObject() || root.value(QStringLiteral("format")).toInt() != 1 + || !root.value(QStringLiteral("sources")).isArray()) + throw std::runtime_error("incompatible_source_state"); + const QJsonArray records = root.value(QStringLiteral("sources")).toArray(); + if (records.size() > 64) + throw std::runtime_error("source_state_limit"); + for (const QJsonValue& value : records) { + const QJsonObject row = value.toObject(); + PortableState::Source source; + source.id = row.value(QStringLiteral("id")).toString(); + source.module = row.value(QStringLiteral("module")).toString(); + source.account = row.value(QStringLiteral("account")).toString(); + source.key = row.value(QStringLiteral("key")).toString(); + source.label = row.value(QStringLiteral("label")).toString(); + source.enabled = row.value(QStringLiteral("enabled")).toBool(); + if (QUuid(source.id).isNull() || !validModule(source.module) || !boundedIdentity(source.account) + || !boundedIdentity(source.key) || source.label.size() > 256 + || !row.value(QStringLiteral("enabled")).isBool()) + throw std::runtime_error("invalid_source_state"); + if (std::any_of(restored.begin(), restored.end(), [&source](const auto& existing) { + return existing.id == source.id + || (existing.module == source.module && existing.account == source.account + && existing.key == source.key); + })) + throw std::runtime_error("duplicate_source_state"); + restored.append(std::move(source)); + } + } + m_portable->database = database; + m_portable->sources = std::move(restored); + m_portable->restored = true; + refreshSourceSnapshot(); +} + +QCoro::Task ProviderRegistry::persistSources() +{ + QJsonArray records; + for (const auto& source : m_portable->sources) { + records.append(QJsonObject { { QStringLiteral("id"), source.id }, { QStringLiteral("module"), source.module }, + { QStringLiteral("account"), source.account }, { QStringLiteral("key"), source.key }, + { QStringLiteral("label"), source.label }, { QStringLiteral("enabled"), source.enabled } }); + } + const QString value = QString::fromUtf8( + QJsonDocument(QJsonObject { { QStringLiteral("format"), 1 }, { QStringLiteral("sources"), records } }) + .toJson(QJsonDocument::Compact)); + if (!m_portable->database) + throw std::runtime_error("source_store_unavailable"); + QPointer guard(this); + m_portable->database->saveSetting(stateKey, value); + const QString stored = co_await m_portable->database->loadSettingAsync(stateKey); + if (!guard) + throw std::runtime_error("source_registry_removed"); + if (stored != value) + throw std::runtime_error("source_state_write_failed"); +} + +QCoro::Task ProviderRegistry::configureSource(QString moduleId, QString accountId, QString sourceKey, + QString label, QVariantMap configuration, QList authorisedOrigins) +{ + Q_ASSERT(thread() == QThread::currentThread()); + m_portable->requireReady(); + if (!m_portable->modules.contains(moduleId) || !boundedIdentity(accountId) || !boundedIdentity(sourceKey) + || label.size() > 256) + throw std::runtime_error("invalid_source_configuration"); + QPointer guard(this); + m_portable->mutating = true; + const auto unlock = qScopeGuard([guard] { + if (guard) + guard->m_portable->mutating = false; + }); + auto found = std::find_if(m_portable->sources.begin(), m_portable->sources.end(), [&](const auto& source) { + return source.module == moduleId && source.account == accountId && source.key == sourceKey; + }); + const bool created = found == m_portable->sources.end(); + if (created) { + if (m_portable->sources.size() >= 64) + throw std::runtime_error("source_limit"); + PortableState::Source source; + source.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + source.module = moduleId; + source.account = accountId; + source.key = sourceKey; + m_portable->sources.append(std::move(source)); + found = std::prev(m_portable->sources.end()); + } + const QString id = found->id; + const QString oldLabel = found->label; + found->label = std::move(label); + try { + co_await persistSources(); + } catch (...) { + if (guard) { + if (created) + m_portable->sources.removeLast(); + else + m_portable->find(id)->label = oldLabel; + } + throw; + } + if (!guard) + throw std::runtime_error("source_registry_removed"); + auto *source = m_portable->find(id); + ScriptRuntime *runtime = m_portable->modules.value(moduleId); + const bool changed + = !source->configured || source->configuration != configuration || source->origins != authorisedOrigins; + if (changed) { + if (source->active) + runtime->removeSource(id); + source->active = false; + ++source->generation; + source->configuration = std::move(configuration); + source->origins = std::move(authorisedOrigins); + source->configured = true; + } + if (source->enabled && !source->active) { + const auto activate = runtime->addSource(id, source->configuration, source->origins); + try { + co_await activate; + } catch (...) { + if (guard) + refreshSourceSnapshot(); + throw; + } + if (!guard) + throw std::runtime_error("source_registry_removed"); + m_portable->find(id)->active = true; + } + refreshSourceSnapshot(); + co_return id; +} + +QCoro::Task ProviderRegistry::callSource(QString sourceId, QString operation, QVariantMap arguments) +{ + Q_ASSERT(thread() == QThread::currentThread()); + auto *source = m_portable->find(sourceId); + if (!source || !source->enabled || !source->active) + throw std::runtime_error("source_unavailable"); + QPointer guard(this); + const quint64 generation = source->generation; + const QString module = source->module; + QVariantMap result; + try { + result = co_await m_portable->modules.value(module)->call(sourceId, std::move(operation), std::move(arguments)); + } catch (const std::exception& error) { + const QByteArray code(error.what()); + if (guard && (code == "script_interrupted" || code == "source_unavailable")) { + for (auto& candidate : m_portable->sources) { + if (candidate.module == module) { + candidate.active = false; + ++candidate.generation; + } + } + refreshSourceSnapshot(); + } + throw; + } + if (!guard) + throw std::runtime_error("source_registry_removed"); + source = m_portable->find(sourceId); + if (!source || !source->enabled || !source->active || source->generation != generation) + throw std::runtime_error("source_generation_changed"); + co_return result; +} + +QCoro::Task ProviderRegistry::setSourceEnabled(QString sourceId, bool enabled) +{ + Q_ASSERT(thread() == QThread::currentThread()); + m_portable->requireReady(); + auto *source = m_portable->find(sourceId); + if (!source) + throw std::runtime_error("source_unavailable"); + if (source->enabled == enabled) + co_return; + QPointer guard(this); + m_portable->mutating = true; + const auto unlock = qScopeGuard([guard] { + if (guard) + guard->m_portable->mutating = false; + }); + ScriptRuntime *runtime = m_portable->modules.value(source->module); + source->enabled = enabled; + ++source->generation; + if (source->active && runtime) + runtime->removeSource(sourceId); + source->active = false; + refreshSourceSnapshot(); + co_await persistSources(); + if (!guard) + throw std::runtime_error("source_registry_removed"); + source = m_portable->find(sourceId); + if (enabled && source->configured && runtime) { + co_await runtime->addSource(sourceId, source->configuration, source->origins); + if (!guard) + throw std::runtime_error("source_registry_removed"); + m_portable->find(sourceId)->active = true; + refreshSourceSnapshot(); + } +} + +QCoro::Task ProviderRegistry::removeSource(QString sourceId) +{ + Q_ASSERT(thread() == QThread::currentThread()); + m_portable->requireReady(); + auto *source = m_portable->find(sourceId); + if (!source) + co_return; + QPointer guard(this); + m_portable->mutating = true; + const auto unlock = qScopeGuard([guard] { + if (guard) + guard->m_portable->mutating = false; + }); + if (auto *runtime = m_portable->modules.value(source->module)) + runtime->removeSource(sourceId); + source->active = false; + ++source->generation; + const auto previous = m_portable->sources; + m_portable->sources.removeIf([&sourceId](const auto& candidate) { return candidate.id == sourceId; }); + refreshSourceSnapshot(); + try { + co_await persistSources(); + } catch (...) { + if (guard) { + m_portable->sources = previous; + refreshSourceSnapshot(); + } + throw; + } + if (!guard) + throw std::runtime_error("source_registry_removed"); + emit sourceRemoved(sourceId); +} + +QVariantList ProviderRegistry::configuredSources() const +{ + return m_portable->snapshot; +} + +void ProviderRegistry::refreshSourceSnapshot() +{ + QVariantList snapshot; + snapshot.reserve(m_portable->sources.size()); + for (const auto& source : m_portable->sources) { + snapshot.append(QVariantMap { { QStringLiteral("sourceId"), source.id }, + { QStringLiteral("moduleId"), source.module }, { QStringLiteral("label"), source.label }, + { QStringLiteral("enabled"), source.enabled }, { QStringLiteral("available"), source.active } }); + } + if (snapshot != m_portable->snapshot) { + m_portable->snapshot = std::move(snapshot); + emit configuredSourcesChanged(); + } +} + +} // namespace JellyfinNative diff --git a/tests/app/SearchControllerTest.cpp b/tests/app/SearchControllerTest.cpp new file mode 100644 index 00000000..7483b54d --- /dev/null +++ b/tests/app/SearchControllerTest.cpp @@ -0,0 +1,118 @@ +#include "app/SearchController.h" +#include "TestMain.h" +#include "provider/SearchSource.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace JellyfinNative; + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +class PendingSearch final : public SearchSource { +public: + bool signedIn() const override + { + return true; + } + QCoro::Task> searchItems(QString query, int) override + { + auto pending = std::make_shared>>(); + pending->start(); + requests[query] = pending; + auto future = pending->future(); + auto awaitable = qCoro(future); + try { + auto result = co_await awaitable.takeResult(); + ++completions; + co_return result; + } catch (...) { + ++completions; + throw; + } + } + QCoro::Task> fetchSearchSuggestions(int) override + { + co_return std::vector {}; + } + void complete(const QString& query, bool error = false) + { + auto pending = requests.at(query); + if (error) { + pending->setException(std::make_exception_ptr(std::runtime_error("fixture search failure"))); + } else { + MovieItem item; + item.id = query; + item.title = query; + item.itemType = QStringLiteral("Movie"); + pending->addResult(std::vector { item }); + } + pending->finish(); + } + void waitForCompletion(int count) + { + QElapsedTimer elapsed; + elapsed.start(); + while (completions < count && elapsed.elapsed() < 2000) { + QCoreApplication::processEvents(); + QThread::msleep(1); + } + require(completions >= count, "controlled search response must settle"); + } + int completions = 0; + std::map>>> requests; +}; +} + +JELLYFIN_TEST_MAIN("search-controller") +{ + QCoreApplication app(argc, argv); + PendingSearch source; + SearchController controller(&source, nullptr); + int errors = 0; + QObject::connect(&controller, &SearchController::errorOccurred, &app, [&](const QString&) { ++errors; }); + + controller.search("alpha"); + controller.setQuery("beta"); + source.complete("alpha"); + source.waitForCompletion(1); + require(controller.query() == "beta" && controller.resultCount() == 0, + "a response for the previous query must not publish during the new query's debounce interval"); + + controller.submit(); + source.complete("beta"); + source.waitForCompletion(2); + require(controller.resultCount() == 1 && controller.movieResults()->get(0).title == "beta", + "the current query publishes its results"); + + controller.search("gamma"); + controller.setQuery("delta"); + source.complete("gamma", true); + source.waitForCompletion(3); + require(errors == 0 && controller.movieResults()->get(0).title == "beta", + "a stale failure must neither surface an error nor erase retained results"); + + controller.submit(); + controller.clear(); + source.complete("delta"); + source.waitForCompletion(4); + require(controller.resultCount() == 0 && controller.query().isEmpty() && !controller.busy(), + "clearing search suppresses late completion"); + return 0; +} diff --git a/tests/providers/SourceRegistryTest.cpp b/tests/providers/SourceRegistryTest.cpp new file mode 100644 index 00000000..aeb2af03 --- /dev/null +++ b/tests/providers/SourceRegistryTest.cpp @@ -0,0 +1,131 @@ +#include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" + +#include +#include +#include + +#include +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +template void rejects(QCoro::Task task, const char *message) +{ + bool failed = false; + try { + QCoro::waitFor(std::move(task)); + } catch (const std::exception&) { + failed = true; + } + require(failed, message); +} +} + +JELLYFIN_TEST_MAIN("source-registry") +{ + QCoreApplication app(argc, argv); + using namespace JellyfinNative; + QTemporaryDir directory; + require(directory.isValid(), "isolated persistent state directory created"); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); + DatabaseManager database; + require(database.initialize(directory.filePath("cache.sqlite")), "source metadata uses existing durable state"); + const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); + const QString secret = QStringLiteral("private-source-token-not-in-metadata"); + AccountProfile account; + account.profileId = QStringLiteral("account-a"); + account.serverId = QStringLiteral("server"); + account.serverUrl = QStringLiteral("https://fixture.invalid"); + account.userId = QStringLiteral("user-a"); + account.accessToken = secret; + database.upsertAccountProfile(account); + const auto configure + = [&](ProviderRegistry& registry, const QString& module, const QString& account, const QString& label) { + return registry.configureSource(module, account, "server-key", label, + { { "label", label }, { "token", secret } }, { QUrl("https://fixture.invalid") }); + }; + QString a, b, c; + { + ProviderRegistry registry; + registry.registerModule("fixture.one", entry); + registry.registerModule("fixture.two", entry); + QCoro::waitFor(registry.restoreSources(&database)); + a = QCoro::waitFor(configure(registry, "fixture.one", "account-a", "A")); + b = QCoro::waitFor(configure(registry, "fixture.one", "account-b", "B")); + c = QCoro::waitFor(configure(registry, "fixture.two", "account-a", "C")); + require(a != b && a != c && b != c, "module and account scopes have distinct persistent source IDs"); + auto first = registry.callSource(a, "bump"); + auto second = registry.callSource(b, "bump"); + auto third = registry.callSource(c, "bump"); + require(QCoro::waitFor(std::move(first)).value("label") == "A", "first source has its own factory context"); + require( + QCoro::waitFor(std::move(second)).value("label") == "B", "second account is not an active-provider switch"); + require(QCoro::waitFor(std::move(third)).value("label") == "C", "another module runs independently"); + auto pending = registry.callSource(a, "delay", { { "milliseconds", 10000 } }); + QCoro::waitFor(registry.setSourceEnabled(a, false)); + rejects(std::move(pending), "disabling a source cancels its pending operation"); + rejects(registry.callSource(a, "bump"), "disabled source cannot be used"); + require(QCoro::waitFor(registry.callSource(b, "bump")).value("calls").toInt() == 2, + "disabling A leaves B's context and state unchanged"); + const QString renamed = QCoro::waitFor(configure(registry, "fixture.one", "account-b", "Renamed B")); + require(renamed == b, "configuration or display-name changes do not reidentify a source"); + const auto publicData = QJsonDocument::fromVariant(registry.configuredSources()).toJson(); + const auto persisted = QCoro::waitFor(database.loadSettingAsync("portable/sources/1")); + require(!publicData.contains(secret.toUtf8()) && !persisted.contains(secret), + "neither UI metadata nor durable source index contains credentials"); + require(!persisted.contains("fixture.invalid"), "authorised resource addresses are not source identity"); + rejects(registry.callSource(b, "spin"), "a failing module is interrupted"); + rejects(registry.callSource(b, "bump"), "interrupted module contexts are unavailable"); + require(QCoro::waitFor(registry.callSource(c, "bump")).value("calls").toInt() == 2, + "module failure is contained and does not reset another module"); + } + { + ProviderRegistry restored; + restored.registerModule("fixture.one", entry); + restored.registerModule("fixture.two", entry); + QCoro::waitFor(restored.restoreSources(&database)); + require(restored.configuredSources().size() == 3, + "all configured identities survive process-level registry recreation"); + rejects(restored.callSource(b, "bump"), + "restoring public metadata does not invent credentials or activate a source"); + require(QCoro::waitFor(configure(restored, "fixture.one", "account-a", "A")) == a, + "disabled source preserves its identity across restarts"); + rejects(restored.callSource(a, "bump"), "disable preference survives credential restoration"); + QCoro::waitFor(restored.setSourceEnabled(a, true)); + require(QCoro::waitFor(restored.callSource(a, "bump")).value("calls").toInt() == 1, + "reenabling starts a clean execution context under the same persistent ID"); + require(QCoro::waitFor(configure(restored, "fixture.one", "account-b", "B")) == b, + "second account restores the same persistent ID"); + require(QCoro::waitFor(configure(restored, "fixture.two", "account-a", "C")) == c, + "other module preserves its separate account namespace"); + QCoro::waitFor(restored.removeSource(a)); + rejects(restored.callSource(a, "bump"), "removed source handles cannot be reused"); + require(QCoro::waitFor(restored.callSource(c, "bump")).value("label") == "C", + "removal does not affect another module"); + } + { + ProviderRegistry restored; + QCoro::waitFor(restored.restoreSources(&database)); + require(restored.configuredSources().size() == 2, + "source deletion is durable independently of module availability"); + } + const auto accounts = QCoro::waitFor(database.loadAccountProfilesAsync()); + require(accounts.size() == 1 && accounts.front().accessToken == secret, + "removing a source does not remove its account or credentials"); + const QString incompatible = QStringLiteral("{\"format\":2,\"sources\":[]}"); + database.saveSetting("portable/sources/1", incompatible); + ProviderRegistry incompatibleRegistry; + rejects(incompatibleRegistry.restoreSources(&database), "future authoritative source state fails closed"); + require(QCoro::waitFor(database.loadSettingAsync("portable/sources/1")) == incompatible, + "incompatible authoritative state is retained rather than wiped as a cache"); + return 0; +} diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index 5124642e..727c4059 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -1,6 +1,7 @@ export function createSource(config) { let calls = 0; return { + bump: function() { return {calls: ++calls, label: config.label}; }, raw: function(args, host) { return host.http(config.origin + '/' + args.path); }, From d04bb7f2f4c2649c2e22be294c87d184b4f8a32d Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 06:21:15 +1000 Subject: [PATCH 024/146] fix(provider): reject unavailable host extensions and UI imports --- tests/tools/provider_package_test.py | 12 +++++++++++- tools/provider-package.py | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/tools/provider_package_test.py b/tests/tools/provider_package_test.py index 33451a06..0d8d56c2 100755 --- a/tests/tools/provider_package_test.py +++ b/tests/tools/provider_package_test.py @@ -45,7 +45,8 @@ def test_optional_and_offered_extensions_do_not_reject(self): self.assertIn("logic/provider.mjs", files) def test_required_extensions_permissions_and_api_fail_closed(self): - for key, value in (("requires", {"unknown": "0.1"}), ("permissions", ["shell"]), ("api", "0.2"), ("format", 2)): + for key, value in (("requires", {"unknown": "0.1"}), ("requires", {"ui-host": "0.1"}), + ("requires", {"native-list": "0.1"}), ("permissions", ["shell"]), ("api", "0.2"), ("format", 2)): with self.subTest(key=key): files = fixture() manifest = json.loads(files["manifest.json"]) @@ -83,6 +84,15 @@ def test_import_escape_and_undeclared_qml(self): with self.assertRaises(ValueError): package.read_package(self.archive(files)) + def test_unavailable_public_ui_module_rejected(self): + files = fixture() + manifest = json.loads(files["manifest.json"]) + manifest["ui"] = {"modules": ["Spool.Ui"], "components": ["ui/Page.qml"]} + files["manifest.json"] = json.dumps(manifest).encode() + files["ui/Page.qml"] = b"import Spool.Ui\nPage {}" + with self.assertRaises(ValueError): + package.read_package(self.archive(files)) + def test_expanded_size_limit_before_read(self): files = fixture() files["resources/bomb.txt"] = b"0" * (package.MAX_FILE + 1) diff --git a/tools/provider-package.py b/tools/provider-package.py index 2eca3b01..db589def 100755 --- a/tools/provider-package.py +++ b/tools/provider-package.py @@ -21,7 +21,7 @@ MAX_FILES = 512 EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".map"} TEXT = {".mjs", ".js", ".qml", ".json", ".svg", ".txt", ".map"} -MODULES = {"QtQuick", "QtQuick.Controls", "QtQuick.Layouts", "QtQml", "Spool.Ui"} +MODULES = {"QtQuick", "QtQuick.Controls", "QtQuick.Layouts", "QtQml"} PERMISSIONS = {"network:configured-origins", "storage:source", "credentials:source"} CHANNELS = {"desktop", "webos", "android-direct", "google-play", "apple-bundled"} NATIVE_MAGIC = (b"\x7fELF", b"MZ", b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\0asm") @@ -72,7 +72,7 @@ def validate_manifest(manifest: dict, paths: set[str]) -> None: raise ValueError(f"invalid extension declarations: {key}") # Offered unknown extensions are harmless. Required unknown host services # are not: reject rather than evaluating a module that cannot work. - supported = {"http", "source-context", "native-list", "ui-host"} + supported = {"http", "source-context", "timers"} if not set(manifest["requires"]) <= supported: raise ValueError("unsupported required host extension") if not isinstance(manifest["entry"], str) or manifest["entry"] not in paths or not manifest["entry"].endswith(".mjs"): From cb00dc88e2cd280ed7b07a36f5eaa621773d92da Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 10:03:56 +1000 Subject: [PATCH 025/146] fix(provider): load bundled modules through the worker registry --- CMakeLists.txt | 2 ++ src/provider/ScriptRuntime.cpp | 8 +++++++- tests/providers/SourceRegistryTest.cpp | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7254d933..78324e35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -943,6 +943,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() endforeach() + qt_add_resources(provider_test_bundle_sources "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") + target_sources(providers-tests PRIVATE ${provider_test_bundle_sources}) add_test(NAME provider-package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index 38910df0..f6d4a5e7 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -370,7 +370,13 @@ class ScriptWorker final : public QObject { } }); watchdog->arm(); - module = engine->importModule(entryPoint); + const QUrl entryUrl(entryPoint); + // importModule's entry argument is a file name, unlike an ES import's + // URL specifier. Native Qt resource files use :/ rather than qrc:/. + const QString moduleFile = entryUrl.scheme() == QStringLiteral("qrc") ? QLatin1Char(':') + entryUrl.path() + : entryUrl.isLocalFile() ? entryUrl.toLocalFile() + : entryPoint; + module = engine->importModule(moduleFile); // Promise syntax is the portable baseline; no Node/browser globals or // async-function syntax is required from the bundled Qt JS engine. invoke = engine->evaluate(QStringLiteral(R"JS( diff --git a/tests/providers/SourceRegistryTest.cpp b/tests/providers/SourceRegistryTest.cpp index aeb2af03..3bee35fc 100644 --- a/tests/providers/SourceRegistryTest.cpp +++ b/tests/providers/SourceRegistryTest.cpp @@ -48,6 +48,21 @@ JELLYFIN_TEST_MAIN("source-registry") account.userId = QStringLiteral("user-a"); account.accessToken = secret; database.upsertAccountProfile(account); + { + ProviderRegistry bundled; + bundled.registerModule("spool.jellyfin", "qrc:/providers/spool.jellyfin/logic/provider.mjs"); + QCoro::waitFor(bundled.restoreSources(&database)); + const QString source = QCoro::waitFor(bundled.configureSource("spool.jellyfin", "jellyfin-account", "library", + "Bundled source", { { "server", "https://fixture.invalid" }, { "userId", "user" }, { "token", secret } }, + { QUrl("https://fixture.invalid") })); + const auto artwork = QCoro::waitFor(bundled.callSource(source, "artwork", { { "itemId", "film" } })); + const QUrl url(artwork.value("url").toString()); + require(url.host() == "fixture.invalid" && url.path() == "/Items/film/Images/Primary", + "the actual bundled source executes through the worker registry"); + require(artwork.value("headers").toMap().value("X-Emby-Token") == secret && !url.toString().contains(secret), + "bundled artwork returns resource-specific credentials outside the URL"); + QCoro::waitFor(bundled.removeSource(source)); + } const auto configure = [&](ProviderRegistry& registry, const QString& module, const QString& account, const QString& label) { return registry.configureSource(module, account, "server-key", label, From 68097fb8e7e15d69ae3b2f56b6e0e555953b27d5 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 10:05:55 +1000 Subject: [PATCH 026/146] fix(build): share generated provider resources across consumers --- CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 78324e35..d31e38a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -376,7 +376,8 @@ execute_process( if(NOT spool_provider_bundle_result EQUAL 0) message(FATAL_ERROR "Bundling provider sources failed: ${spool_provider_bundle_error}") endif() -target_sources(jellyfin-native PRIVATE "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") +qt_add_resources(spool_provider_bundle_sources "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") +target_sources(jellyfin-native PRIVATE ${spool_provider_bundle_sources}) set(JELLYFIN_NATIVE_TARGETS jellyfin-core jellyfin-native) jellyfin_configure_platform_targets(jellyfin-native jellyfin-core) @@ -943,13 +944,12 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() endforeach() - qt_add_resources(provider_test_bundle_sources "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") - target_sources(providers-tests PRIVATE ${provider_test_bundle_sources}) + target_sources(providers-tests PRIVATE ${spool_provider_bundle_sources}) add_test(NAME provider-package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) - target_sources(provider-contract-runner PRIVATE "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") + target_sources(provider-contract-runner PRIVATE ${spool_provider_bundle_sources}) add_test(NAME bundled-jellyfin COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") add_test(NAME bundled-jellyfin-interpreter From 20991bbe283a8b82ed3d0797fa97fa128f58aeef Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Mon, 21 Sep 2026 15:09:42 +1000 Subject: [PATCH 027/146] feat(provider): host scoped QML actions with bounded native list models --- CMakeLists.txt | 3 + sdk/README.md | 10 + src/provider/ProviderRegistry.h | 4 +- src/provider/ProviderRegistrySources.cpp | 15 +- src/provider/ProviderUiContext.cpp | 266 ++++++++++++++++++++++ src/provider/ProviderUiContext.h | 92 ++++++++ src/provider/ScriptRuntime.cpp | 28 ++- src/provider/ScriptRuntime.h | 3 +- tests/providers/ProviderUiContextTest.cpp | 151 ++++++++++++ tests/providers/fixtures/Selection.qml | 40 ++++ tests/providers/fixtures/provider.mjs | 6 + 11 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/provider/ProviderUiContext.cpp create mode 100644 src/provider/ProviderUiContext.h create mode 100644 tests/providers/ProviderUiContextTest.cpp create mode 100644 tests/providers/fixtures/Selection.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index d31e38a4..61a35030 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,6 +262,8 @@ set(SPOOL_SHELL_SOURCES src/provider/Provider.h src/provider/ProviderQmlCache.cpp src/provider/ProviderQmlCache.h + src/provider/ProviderUiContext.cpp + src/provider/ProviderUiContext.h src/provider/ScriptRuntime.cpp src/provider/ScriptRuntime.h src/provider/ProviderRegistry.cpp @@ -859,6 +861,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) local-provider tests/providers/LocalProviderTest.cpp script-runtime tests/providers/ScriptRuntimeTest.cpp source-registry tests/providers/SourceRegistryTest.cpp + provider-ui-context tests/providers/ProviderUiContextTest.cpp provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp ) set(JELLYFIN_TESTS_player diff --git a/sdk/README.md b/sdk/README.md index c3c7608f..8b0bd72e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -52,3 +52,13 @@ Bundling this alpha source does not switch normal app operations away from the e `restoreSources()` restores identity and enable/disable metadata only. `configureSource()` supplies current configuration/credentials and native-authorised origins explicitly; these are not copied into the public source index or its QML snapshot. `callSource()` retains the exact source generation across the coroutine boundary. Disabling/removing a source cancels its work without changing other accounts, and removing a source does not remove the account or its credentials. Interrupted modules become unavailable without resetting other modules. The application registers its bundled portable module and restores this index after the first frame. Automatic migration of legacy account configuration and normal application browsing/playback to these contexts is not complete. The `Sources` QML singleton currently exposes only the cached public metadata snapshot; it does not expose arbitrary cross-source worker calls to provider UI. + +## Source-bound UI action bridge + +`ProviderUiContext` is a native-owned action object constructed with one source and the GUI QML engine. Pass it as an initial property when mounting a trusted provider component; do not hand the component the global registry. `request(operation, arguments)` returns a GUI-engine Promise whose worker result is bounded to 512 values/64 KiB before JS conversion. + +Use `requestList(operation, arguments, append)` for bulk rows. The operation returns `{items: [...], ...smallPageMetadata}`; `items` moves into the context's native `rows` model, while the Promise resolves with just page metadata after insertion completes. The model exposes `record` and `title` roles, commits at most 32 rows per event-loop turn and retains at most 10,000 rows. Role reads do not invoke backend JS. Providers should use virtualised ListView/GridView delegates and plain text for untrusted labels. + +`complete(result)` returns an action result with native `sourceId` authority; `close()` returns cancellation. Both settle once. Dismissing a component must call `close()`, and destroying the native context cancels outstanding work. Each context owns a separate native operation scope: closing a picker does not remove its source or cancel another picker on that source. Source removal/disable closes its open contexts. + +The `tests/providers/fixtures/Selection.qml` action exercises a 2,000-row virtualised list, async completion and dismissal. This bridge is not yet the full negotiated UI-host extension: shared routing, focus restoration, localisation/notification services and real Jellyfin component migration remain incomplete. Packages requiring `ui-host`, `native-list` or `Spool.Ui` continue to fail compatibility validation until that full public surface is provided. diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 969bd7af..b1436c14 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -114,7 +114,9 @@ class ProviderRegistry final : public QObject { QCoro::Task restoreSources(DatabaseManager *database); QCoro::Task configureSource(QString moduleId, QString accountId, QString sourceKey, QString label, QVariantMap configuration, QList authorisedOrigins); - QCoro::Task callSource(QString sourceId, QString operation, QVariantMap arguments = {}); + QCoro::Task callSource( + QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}); + void cancelSourceScope(const QString& sourceId, const QString& scope); QCoro::Task setSourceEnabled(QString sourceId, bool enabled); QCoro::Task removeSource(QString sourceId); QVariantList configuredSources() const; diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp index 25a53edb..eecbab8e 100644 --- a/src/provider/ProviderRegistrySources.cpp +++ b/src/provider/ProviderRegistrySources.cpp @@ -238,7 +238,8 @@ QCoro::Task ProviderRegistry::configureSource(QString moduleId, QString co_return id; } -QCoro::Task ProviderRegistry::callSource(QString sourceId, QString operation, QVariantMap arguments) +QCoro::Task ProviderRegistry::callSource( + QString sourceId, QString operation, QVariantMap arguments, QString scope) { Q_ASSERT(thread() == QThread::currentThread()); auto *source = m_portable->find(sourceId); @@ -249,7 +250,8 @@ QCoro::Task ProviderRegistry::callSource(QString sourceId, QString const QString module = source->module; QVariantMap result; try { - result = co_await m_portable->modules.value(module)->call(sourceId, std::move(operation), std::move(arguments)); + result = co_await m_portable->modules.value(module)->call( + sourceId, std::move(operation), std::move(arguments), std::move(scope)); } catch (const std::exception& error) { const QByteArray code(error.what()); if (guard && (code == "script_interrupted" || code == "source_unavailable")) { @@ -271,6 +273,15 @@ QCoro::Task ProviderRegistry::callSource(QString sourceId, QString co_return result; } +void ProviderRegistry::cancelSourceScope(const QString& sourceId, const QString& scope) +{ + const auto *source = m_portable->find(sourceId); + if (source && source->active) { + if (auto *runtime = m_portable->modules.value(source->module)) + runtime->cancelScope(sourceId, scope); + } +} + QCoro::Task ProviderRegistry::setSourceEnabled(QString sourceId, bool enabled) { Q_ASSERT(thread() == QThread::currentThread()); diff --git a/src/provider/ProviderUiContext.cpp b/src/provider/ProviderUiContext.cpp new file mode 100644 index 00000000..35613ec9 --- /dev/null +++ b/src/provider/ProviderUiContext.cpp @@ -0,0 +1,266 @@ +#include "ProviderUiContext.h" + +#include "ProviderRegistry.h" + +#include +#include +#include + +#include +#include + +namespace JellyfinNative { +namespace { + bool smallResult(const QVariant& value, int& values, qsizetype& bytes) + { + if (++values > 512) + return false; + if (value.metaType().id() == QMetaType::QVariantMap) { + const auto map = value.toMap(); + for (auto it = map.cbegin(); it != map.cend(); ++it) { + bytes += it.key().size() * sizeof(QChar); + if (!smallResult(it.value(), values, bytes)) + return false; + } + } else if (value.metaType().id() == QMetaType::QVariantList) { + const auto list = value.toList(); + for (const auto& item : list) { + if (!smallResult(item, values, bytes)) + return false; + } + } else if (value.metaType().id() == QMetaType::QString) { + bytes += value.toString().size() * sizeof(QChar); + } + return bytes <= 64 * 1024; + } +} +ProviderListModel::ProviderListModel(QObject *parent) + : QAbstractListModel(parent) +{ + m_commitTimer.setSingleShot(true); + connect(&m_commitTimer, &QTimer::timeout, this, &ProviderListModel::commitBatch); +} + +int ProviderListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_rows.size(); +} + +QVariant ProviderListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) + return {}; + if (role == Qt::UserRole + 1) + return m_rows.at(index.row()); + if (role == Qt::DisplayRole) + return m_rows.at(index.row()).toMap().value(QStringLiteral("title")); + return {}; +} + +QHash ProviderListModel::roleNames() const +{ + return { { Qt::UserRole + 1, QByteArrayLiteral("record") }, { Qt::DisplayRole, QByteArrayLiteral("title") } }; +} + +bool ProviderListModel::enqueue(QVariantList rows, bool append) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (!m_pending.isEmpty() || rows.size() + (append ? m_rows.size() : 0) > 10000 + || std::any_of(rows.begin(), rows.end(), + [](const QVariant& row) { return row.metaType().id() != QMetaType::QVariantMap; })) + return false; + if (!append) { + beginResetModel(); + m_rows.clear(); + endResetModel(); + } + m_pending = std::move(rows); + m_position = 0; + m_commitTimer.start(0); + return true; +} + +void ProviderListModel::commitBatch() +{ + Q_ASSERT(thread() == QThread::currentThread()); + const qsizetype count = std::min(32, m_pending.size() - m_position); + if (count > 0) { + const int first = m_rows.size(); + beginInsertRows({}, first, first + count - 1); + for (qsizetype i = 0; i < count; ++i) + m_rows.append(std::move(m_pending[m_position++])); + endInsertRows(); + } + if (m_position < m_pending.size()) { + m_commitTimer.start(0); + } else { + m_pending.clear(); + m_position = 0; + emit committed(); + } +} + +void ProviderListModel::cancelPending() +{ + m_commitTimer.stop(); + m_pending.clear(); + m_position = 0; +} + +ProviderUiContext::ProviderUiContext(ProviderRegistry *registry, QString sourceId, QQmlEngine *engine) + : QObject(engine) + , m_registry(registry) + , m_engine(engine) + , m_sourceId(std::move(sourceId)) + , m_scope(QUuid::createUuid().toString(QUuid::WithoutBraces)) + , m_rows(this) +{ + QQmlEngine::setObjectOwnership(this, QQmlEngine::CppOwnership); + connect(&m_rows, &ProviderListModel::committed, this, [this] { + const quint64 request = std::exchange(m_listRequest, 0); + auto result = std::exchange(m_listResult, {}); + settle(request, result, true); + }); + connect(registry, &ProviderRegistry::sourceRemoved, this, [this](const QString& id) { + if (id == m_sourceId) + close(); + }); + connect(registry, &QObject::destroyed, this, &ProviderUiContext::close); + connect(registry, &ProviderRegistry::configuredSourcesChanged, this, [this] { + if (!m_registry) + return; + const auto sources = m_registry->configuredSources(); + const bool available = std::any_of(sources.begin(), sources.end(), [this](const QVariant& value) { + const auto source = value.toMap(); + return source.value(QStringLiteral("sourceId")) == m_sourceId + && source.value(QStringLiteral("available")).toBool(); + }); + if (!available) + close(); + }); +} + +ProviderUiContext::~ProviderUiContext() +{ + close(); +} + +QJSValue ProviderUiContext::request(const QString& operation, const QVariantMap& arguments) +{ + return begin(operation, arguments, false, false); +} + +QJSValue ProviderUiContext::requestList(const QString& operation, const QVariantMap& arguments, bool append) +{ + return begin(operation, arguments, true, append); +} + +QJSValue ProviderUiContext::begin(const QString& operation, const QVariantMap& arguments, bool list, bool append) +{ + Q_ASSERT(thread() == QThread::currentThread()); + if (!m_engine) + return {}; + QJSValue bundle = m_engine->evaluate(QStringLiteral(R"JS( + (function() { + let resolve, reject; + const promise = new Promise(function(ok, fail) { resolve = ok; reject = fail; }); + return {promise: promise, resolve: resolve, reject: reject}; + })() + )JS")); + QJSValue promise = bundle.property(QStringLiteral("promise")); + if (m_closed || !m_registry || m_pending.size() >= 8 || (list && m_listRequest)) { + bundle.property(QStringLiteral("reject")).call({ QJSValue(QStringLiteral("action_unavailable")) }); + return promise; + } + const quint64 id = ++m_next; + m_pending.insert(id, { bundle.property(QStringLiteral("resolve")), bundle.property(QStringLiteral("reject")) }); + if (list) + m_listRequest = id; + QPointer guard(this); + m_registry->callSource(m_sourceId, operation, arguments, m_scope) + .then( + [guard, id, list, append](QVariantMap result) { + if (guard) + guard->resolved(id, std::move(result), list, append); + }, + [guard, id](const std::exception&) { + if (guard) + guard->rejected(id); + }); + return promise; +} + +void ProviderUiContext::resolved(quint64 id, QVariantMap value, bool list, bool append) +{ + if (m_closed || !m_pending.contains(id)) + return; + if (list) { + const QVariant items = value.take(QStringLiteral("items")); + if (items.metaType().id() != QMetaType::QVariantList || !m_rows.enqueue(items.toList(), append)) { + rejected(id); + return; + } + m_listResult = std::move(value); + } else { + settle(id, value, true); + } +} + +void ProviderUiContext::rejected(quint64 id) +{ + if (m_listRequest == id) { + m_listRequest = 0; + m_listResult.clear(); + m_rows.cancelPending(); + } + settle(id, {}, false); +} + +void ProviderUiContext::settle(quint64 id, const QVariantMap& value, bool success) +{ + if (!m_engine || !m_pending.contains(id)) + return; + const Pending pending = m_pending.take(id); + int values = 0; + qsizetype bytes = 0; + success = success && smallResult(value, values, bytes); + if (success) + pending.resolve.call({ m_engine->toScriptValue(value) }); + else + pending.reject.call({ QJSValue(QStringLiteral("action_cancelled_or_failed")) }); +} + +void ProviderUiContext::cancelRequests() +{ + if (m_registry) + m_registry->cancelSourceScope(m_sourceId, m_scope); + m_rows.cancelPending(); + m_listRequest = 0; + m_listResult.clear(); + const auto ids = m_pending.keys(); + for (quint64 id : ids) + settle(id, {}, false); +} + +void ProviderUiContext::close() +{ + if (m_closed) + return; + m_closed = true; + cancelRequests(); + emit closedChanged(); + emit finished({}, true); +} + +void ProviderUiContext::complete(const QVariantMap& result) +{ + if (m_closed) + return; + m_closed = true; + cancelRequests(); + emit closedChanged(); + QVariantMap scopedResult = result; + scopedResult.insert(QStringLiteral("sourceId"), m_sourceId); + emit finished(scopedResult, false); +} +} // namespace JellyfinNative diff --git a/src/provider/ProviderUiContext.h b/src/provider/ProviderUiContext.h new file mode 100644 index 00000000..fd0f1963 --- /dev/null +++ b/src/provider/ProviderUiContext.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QQmlEngine; + +namespace JellyfinNative { +class ProviderRegistry; + +// Values are already native-owned when they arrive here. Role reads never +// enter a provider engine; insertion is bounded per GUI event-loop turn. +class ProviderListModel final : public QAbstractListModel { + Q_OBJECT +public: + explicit ProviderListModel(QObject *parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role) const override; + QHash roleNames() const override; + bool enqueue(QVariantList rows, bool append); + void cancelPending(); + +signals: + void committed(); + +private: + void commitBatch(); + QVariantList m_rows; + QVariantList m_pending; + qsizetype m_position = 0; + QTimer m_commitTimer; +}; + +// A single mounted action, bound by native code to one persistent source. +// QML sees this facade, never a worker QObject or the global registry. +class ProviderUiContext final : public QObject { + Q_OBJECT + Q_PROPERTY(QString sourceId READ sourceId CONSTANT) + Q_PROPERTY(bool closed READ closed NOTIFY closedChanged) + Q_PROPERTY(JellyfinNative::ProviderListModel *rows READ rows CONSTANT) +public: + ProviderUiContext(ProviderRegistry *registry, QString sourceId, QQmlEngine *engine); + ~ProviderUiContext() override; + QString sourceId() const + { + return m_sourceId; + } + bool closed() const + { + return m_closed; + } + ProviderListModel *rows() + { + return &m_rows; + } + Q_INVOKABLE QJSValue request(const QString& operation, const QVariantMap& arguments = {}); + Q_INVOKABLE QJSValue requestList(const QString& operation, const QVariantMap& arguments = {}, bool append = false); + Q_INVOKABLE void complete(const QVariantMap& result); + Q_INVOKABLE void close(); + +signals: + void closedChanged(); + void finished(const QVariantMap& result, bool cancelled); + +private: + QJSValue begin(const QString& operation, const QVariantMap& arguments, bool list, bool append); + void resolved(quint64 id, QVariantMap value, bool list, bool append); + void rejected(quint64 id); + void settle(quint64 id, const QVariantMap& value, bool success); + void cancelRequests(); + + struct Pending { + QJSValue resolve; + QJSValue reject; + }; + QPointer m_registry; + QPointer m_engine; + QString m_sourceId; + QString m_scope; + bool m_closed = false; + quint64 m_next = 0; + QHash m_pending; + ProviderListModel m_rows; + quint64 m_listRequest = 0; + QVariantMap m_listResult; +}; +} // namespace JellyfinNative diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index f6d4a5e7..c8bbbf50 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -159,6 +159,7 @@ namespace { class ScriptOperation final : public QObject { Q_OBJECT public: + QString scope; ScriptOperation(QJSEngine *engine, QNetworkAccessManager *network, ScriptWatchdog *watchdog, Completion completion, QList origins, QObject *parent) : QObject(parent) @@ -425,7 +426,8 @@ class ScriptWorker final : public QObject { completion->addResult(QVariantMap { { QStringLiteral("sourceId"), id } }); completion->finish(); } - void call(const QString& id, const QString& method, const QVariantMap& args, const Completion& completion) + void call(const QString& id, const QString& method, const QVariantMap& args, const QString& scope, + const Completion& completion) { auto source = sources.find(id); if (!engine || engine->isInterrupted() || source == sources.end()) { @@ -443,6 +445,7 @@ class ScriptWorker final : public QObject { } auto *operation = new ScriptOperation(engine.get(), network.get(), watchdog.get(), completion, source->origins, this); + operation->scope = scope; source->operations.insert(operation); ++activeOperations; connect(operation, &QObject::destroyed, this, [this, id, operation] { @@ -461,6 +464,16 @@ class ScriptWorker final : public QObject { } } } + void cancelScope(const QString& id, const QString& scope) + { + const auto source = sources.constFind(id); + if (scope.isEmpty() || source == sources.cend()) + return; + for (ScriptOperation *operation : source->operations) { + if (operation->scope == scope) + operation->cancel("action_cancelled"); + } + } void remove(const QString& id) { auto source = sources.find(id); @@ -546,7 +559,7 @@ QCoro::Task ScriptRuntime::addSource(QString sourceId, QVariantMap return awaitResult(std::move(future)); } -QCoro::Task ScriptRuntime::call(QString sourceId, QString method, QVariantMap arguments) +QCoro::Task ScriptRuntime::call(QString sourceId, QString method, QVariantMap arguments, QString scope) { auto completion = std::make_shared>(); completion->start(); @@ -559,14 +572,21 @@ QCoro::Task ScriptRuntime::call(QString sourceId, QString method, Q QMetaObject::invokeMethod( d->worker, [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), - arguments = std::move(arguments), completion] { + arguments = std::move(arguments), scope = std::move(scope), completion] { --state->queued; - worker->call(sourceId, method, arguments, completion); + worker->call(sourceId, method, arguments, scope, completion); }, Qt::QueuedConnection); return awaitResult(std::move(future)); } +void ScriptRuntime::cancelScope(const QString& sourceId, const QString& scope) +{ + QMetaObject::invokeMethod( + d->worker, [worker = d->worker, sourceId, scope] { worker->cancelScope(sourceId, scope); }, + Qt::QueuedConnection); +} + void ScriptRuntime::removeSource(const QString& sourceId) { QMetaObject::invokeMethod( diff --git a/src/provider/ScriptRuntime.h b/src/provider/ScriptRuntime.h index 8bb9d41f..c69906b9 100644 --- a/src/provider/ScriptRuntime.h +++ b/src/provider/ScriptRuntime.h @@ -17,7 +17,8 @@ class ScriptRuntime final : public QObject { ~ScriptRuntime() override; QCoro::Task addSource(QString sourceId, QVariantMap configuration, QList origins); - QCoro::Task call(QString sourceId, QString method, QVariantMap arguments = {}); + QCoro::Task call(QString sourceId, QString method, QVariantMap arguments = {}, QString scope = {}); + void cancelScope(const QString& sourceId, const QString& scope); void removeSource(const QString& sourceId); private: diff --git a/tests/providers/ProviderUiContextTest.cpp b/tests/providers/ProviderUiContextTest.cpp new file mode 100644 index 00000000..f2a6694b --- /dev/null +++ b/tests/providers/ProviderUiContextTest.cpp @@ -0,0 +1,151 @@ +#include "provider/ProviderUiContext.h" +#include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +void waitFor(const std::function& condition) +{ + QElapsedTimer timeout; + timeout.start(); + while (!condition() && timeout.elapsed() < 5000) { + QCoreApplication::processEvents(); + QThread::msleep(1); + } + require(condition(), "provider UI action completes asynchronously"); +} +} + +JELLYFIN_TEST_MAIN("provider-ui-context") +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QGuiApplication app(argc, argv); + using namespace JellyfinNative; + QTemporaryDir directory; + require(directory.isValid(), "isolated UI source database created"); + DatabaseManager database; + require(database.initialize(directory.filePath("cache.sqlite")), "durable source index opens"); + ProviderRegistry registry; + registry.registerModule("fixture.ui", QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs")); + QCoro::waitFor(registry.restoreSources(&database)); + const QString source = QCoro::waitFor(registry.configureSource( + "fixture.ui", "account", "server", "Fixture", { { "label", "Untrusted title" } }, {})); + QQmlEngine engine; + ProviderUiContext first(®istry, source, &engine); + ProviderUiContext second(®istry, source, &engine); + engine.globalObject().setProperty("first", engine.newQObject(&first)); + engine.globalObject().setProperty("second", engine.newQObject(&second)); + engine.evaluate(QStringLiteral(R"JS( + var firstCancelled = false, secondCompleted = false; + first.request('delay', {milliseconds: 10000}).then(function() {}, function() { firstCancelled = true; }); + second.request('delay', {milliseconds: 10}).then(function() { secondCompleted = true; }); + )JS")); + first.close(); + waitFor([&] { + return engine.globalObject().property("firstCancelled").toBool() + && engine.globalObject().property("secondCompleted").toBool(); + }); + require(!second.closed(), "dismissing one action does not close another action on the same source"); + require(QCoro::waitFor(registry.callSource(source, "bump")).value("calls").toInt() == 1, + "action cancellation leaves the source context running"); + engine.evaluate(QStringLiteral(R"JS( + var bulkRejected = false; + second.request('candidates', {count: 2000}).then(function() {}, function() { bulkRejected = true; }); + )JS")); + waitFor([&] { return engine.globalObject().property("bulkRejected").toBool(); }); + + int batches = 0; + bool modelThreadCorrect = true; + QObject::connect( + second.rows(), &QAbstractItemModel::rowsInserted, &app, [&](const QModelIndex&, int begin, int end) { + ++batches; + modelThreadCorrect = modelThreadCorrect && QThread::currentThread() == app.thread(); + require(end - begin + 1 <= 32, "large provider lists commit bounded native batches"); + }); + QQmlComponent component( + &engine, QUrl::fromLocalFile(QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/Selection.qml"))); + if (!component.isReady()) + std::cerr << component.errorString().toStdString(); + require(component.isReady(), "provider-owned selection component loads"); + std::unique_ptr page( + component.createWithInitialProperties({ { "action", QVariant::fromValue(&second) } })); + require(bool(page), "provider component receives a source-bound action context"); + waitFor([&] { return page->property("loaded").toBool() || page->property("failed").toBool(); }); + require(!page->property("failed").toBool() && second.rows()->rowCount() == 2000, + "provider-owned virtualized UI receives every candidate through its native model"); + require(modelThreadCorrect && batches > 1, "all incremental model mutations run on the GUI thread"); + const auto record = second.rows()->data(second.rows()->index(1999), Qt::UserRole + 1).toMap(); + require(record.value("variantId") == "file-1999", "last candidate keeps its exact version identity"); + for (int i = 0; i < 100; ++i) + second.rows()->data(second.rows()->index(i), Qt::UserRole + 1); + require(QCoro::waitFor(registry.callSource(source, "state")).value("calls").toInt() == 1, + "native role reads never call provider backend operations"); + const QPersistentModelIndex selectedIndex(second.rows()->index(1999)); + engine.evaluate(QStringLiteral(R"JS( + var appended = false; + second.requestList('candidates', {count: 2}, true).then(function() { appended = true; }); + )JS")); + waitFor([&] { return engine.globalObject().property("appended").toBool(); }); + require(second.rows()->rowCount() == 2002 && selectedIndex.isValid() + && selectedIndex.data(Qt::UserRole + 1).toMap().value("variantId") == "file-1999", + "pagination preserves native selection identity instead of resetting the model"); + int finished = 0; + QVariantMap selected; + bool cancelled = true; + QObject::connect(&second, &ProviderUiContext::finished, &app, [&](const QVariantMap& value, bool isCancelled) { + ++finished; + selected = value; + cancelled = isCancelled; + }); + require(QMetaObject::invokeMethod(page.get(), "choose", Q_ARG(QVariant, QVariant("file-1999"))), + "provider QML can return its selected variant"); + require(!cancelled && selected.value("variantId") == "file-1999" && selected.value("sourceId") == source, + "native host retains source authority over provider action results"); + page.reset(); + second.close(); + require(finished == 1, "completion and destruction settle the action exactly once"); + + ProviderUiContext dismissed(®istry, source, &engine); + QQmlComponent dismissComponent(&engine); + dismissComponent.setData( + "import QtQml\nQtObject { required property var action; Component.onDestruction: action.close() }", QUrl()); + std::unique_ptr dismissedPage( + dismissComponent.createWithInitialProperties({ { "action", QVariant::fromValue(&dismissed) } })); + require(bool(dismissedPage), "dismissal fixture creates"); + bool dismissedCancelled = false; + QObject::connect(&dismissed, &ProviderUiContext::finished, &app, + [&](const QVariantMap&, bool value) { dismissedCancelled = value; }); + dismissedPage.reset(); + require(dismissedCancelled && dismissed.closed(), "dismissing mounted provider UI returns cancellation"); + + auto destroyed = std::make_unique(®istry, source, &engine); + engine.globalObject().setProperty("destroyedAction", engine.newQObject(destroyed.get())); + engine.evaluate(QStringLiteral(R"JS( + var destroyedCancelled = false; + destroyedAction.request('delay', {milliseconds: 10000}).then(function() {}, + function() { destroyedCancelled = true; }); + )JS")); + destroyed.reset(); + waitFor([&] { return engine.globalObject().property("destroyedCancelled").toBool(); }); + return 0; +} diff --git a/tests/providers/fixtures/Selection.qml b/tests/providers/fixtures/Selection.qml new file mode 100644 index 00000000..18021a3c --- /dev/null +++ b/tests/providers/fixtures/Selection.qml @@ -0,0 +1,40 @@ +import QtQuick + +FocusScope { + id: root + required property var action + property bool loaded: false + property bool failed: false + width: 640 + height: 480 + focus: true + + Component.onCompleted: action.requestList("candidates", { + count: 2000 + }).then(function () { + root.loaded = true + }, function () { + root.failed = true + }) + Component.onDestruction: action.close() + Keys.onEscapePressed: action.close() + + ListView { + anchors.fill: parent + model: root.action.rows + delegate: Text { + required property var record + width: ListView.view.width + height: 40 + text: record.title + textFormat: Text.PlainText + } + } + + function choose(variantId) { + action.complete({ + variantId: variantId, + sourceId: "forged-source" + }) + } +} diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index 727c4059..435d003d 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -2,6 +2,12 @@ export function createSource(config) { let calls = 0; return { bump: function() { return {calls: ++calls, label: config.label}; }, + candidates: function(args) { + const rows = []; + for (let i = 0; i < args.count; ++i) + rows.push({id: String(i), title: config.label + ' ' + i, variantId: 'file-' + i}); + return {items: rows, exhausted: true}; + }, raw: function(args, host) { return host.http(config.origin + '/' + args.path); }, From 34bd3a201dd6e3319e8ac8a1062fbfb5631e2d37 Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 21 Sep 2026 16:52:38 +0000 Subject: [PATCH 028/146] fix(bench): include page construction and record measurement context Classify routes before synchronous Loader work and guard reentrant promotion. Replace stale per-step singleShots with an owned deadline, record failures and runtime context, and reject incomparable reports. Timing schema 2 requires new baselines. Validated Python report regressions and five actual route-function scenarios with Node; the original route code fails the cold-classification regression. Qt test registered but not run: Qt 6.11 SDK is unavailable in this environment. --- CMakeLists.txt | 1 + qml/shell/RouteStack.qml | 28 +++--- src/diagnostics/RenderBenchmark.cpp | 92 +++++++++++++++---- src/diagnostics/RenderBenchmark.h | 15 ++- src/main.cpp | 2 + .../diagnostics/RouteTransitionTimingTest.cpp | 41 +++++++++ .../fixtures/route-transition-timing.js | 76 +++++++++++++++ tests/tools/RenderBenchmarkReportTest.py | 15 +++ tools/compare-render-benchmark.py | 26 ++++-- 9 files changed, 252 insertions(+), 44 deletions(-) create mode 100644 tests/diagnostics/RouteTransitionTimingTest.cpp create mode 100644 tests/diagnostics/fixtures/route-transition-timing.js diff --git a/CMakeLists.txt b/CMakeLists.txt index 61a35030..b490e292 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -842,6 +842,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) ) set(JELLYFIN_TESTS_diagnostics input-latency-monitor tests/diagnostics/InputLatencyMonitorTest.cpp + route-transition-timing tests/diagnostics/RouteTransitionTimingTest.cpp system-performance-monitor tests/diagnostics/SystemPerformanceMonitorTest.cpp ) set(JELLYFIN_TESTS_discovery diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index e479e519..388c7dfd 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -107,24 +107,24 @@ FocusScope { function showRoute() { const key = pageKey(route) const existing = pages[key] - const promoted = existing && existing.status === Loader.Loading - // Somebody is looking at the screen waiting for this page. The - // incubator's whole purpose is to protect frames it does not know are - // being wasted: it spreads a cold build over eighteen of them and the - // gui thread sits idle for half that window. Build it in one go. - const loader = loaderFor(key, true) - pendingLoader = loader - // Finish in-flight incubation synchronously when someone is actively - // waiting on this exact page: a promoted prewarm the user beat to the - // punch, or the startup route while nothing else is on screen yet. - if (promoted || activeRoute === "") - loader.asynchronous = false - const warm = loader.status === Loader.Ready && Boolean(loader.item) + // Classify the cache BEFORE loaderFor() can synchronously create a page. + // Start timing there too: otherwise cold construction disappears from + // the sample and the newly constructed page is misreported as a hit. + const promoted = Boolean(existing && existing.status === Loader.Loading) + const warm = Boolean(existing && existing.status === Loader.Ready && existing.item) const cacheHit = warm ? "hit" : promoted ? "promoted" : "miss" + pendingLoader = null uiTransitionToken = InputLatency.beginUiTransition("route:" + route + (warm ? ":warm" : ":cold"), activeRoute, route, cacheHit) settleWatchdog.restart() - if (warm) { + + const loader = loaderFor(key, true) + pendingLoader = loader + // Finishing a promoted loader can emit onLoaded synchronously and + // activate it here. Do not activate the same page a second time below. + if (promoted || activeRoute === "") + loader.asynchronous = false + if (pendingLoader === loader && loader.status === Loader.Ready && Boolean(loader.item)) { InputLatency.mark(uiTransitionToken, "instance") activatePending() } diff --git a/src/diagnostics/RenderBenchmark.cpp b/src/diagnostics/RenderBenchmark.cpp index 7d47c070..b61237a0 100644 --- a/src/diagnostics/RenderBenchmark.cpp +++ b/src/diagnostics/RenderBenchmark.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -92,12 +95,30 @@ RenderBenchmark::RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *r }); m_pump = new QTimer(this); - m_pump->setInterval(8); + m_pumpIntervalMs = std::clamp(envInt("SPOOL_BENCH_PUMP_MS", 8), 0, 1000); + m_pump->setInterval(std::max(1, m_pumpIntervalMs)); connect(m_pump, &QTimer::timeout, this, [this] { if (m_window) m_window->requestUpdate(); }); + // One owned timer, stopped on success. Previously each step left an + // uncancelled singleShot behind, which could time out a later transition. + m_stepDeadline = new QTimer(this); + m_stepDeadline->setSingleShot(true); + connect(m_stepDeadline, &QTimer::timeout, this, [this] { + if (!m_awaitingSample) + return; + const QString route = m_script.value(m_position); + qWarning() << "render benchmark: route" << route << "never reported a frame"; + m_failures.append(QVariantMap { { QStringLiteral("route"), route }, + { QStringLiteral("pass"), m_pass }, { QStringLiteral("step"), m_position }, + { QStringLiteral("reason"), QStringLiteral("route_timeout") } }); + m_awaitingSample = false; + m_pump->stop(); + QTimer::singleShot(0, this, [this] { step(); }); + }); + m_scrollGapTimer = new QChronoTimer(this); connect(m_scrollGapTimer, &QChronoTimer::timeout, this, [this] { const qint64 nowNs = m_scrollClock.nsecsElapsed(); @@ -190,7 +211,8 @@ void RenderBenchmark::beginScrollWalk() qInfo() << "render benchmark: walking" << m_libraryName << (m_listMode ? "as a list" : "as a grid"); applyViewMode(); m_scrollPosition = 0; - m_pump->start(); + if (m_pumpIntervalMs > 0) + m_pump->start(); // Times how long the library has had to arrive, which is what the first // step's patience is measured against. m_scrollClock.start(); @@ -345,29 +367,29 @@ void RenderBenchmark::step() m_awaitingSample = true; m_stepTimer.start(); - m_pump->start(); + if (m_pumpIntervalMs > 0) + m_pump->start(); + m_stepDeadline->start(m_stepTimeoutMs); m_router->replace(route); if (m_window) m_window->requestUpdate(); - // A route that never reports is a failure worth seeing rather than a - // hang: give up on it and carry on with the rest of the walk. - QTimer::singleShot(m_stepTimeoutMs, this, [this, route] { - if (!m_awaitingSample) - return; - qWarning() << "render benchmark: route" << route << "never reported a frame"; - m_awaitingSample = false; - m_pump->stop(); - QTimer::singleShot(0, this, [this] { step(); }); - }); + } void RenderBenchmark::recordSample() { + QVariantMap metrics = m_latency->lastRouteMetrics(); + // A timed-out transition may publish late while a different route is now + // awaited. It must not stand in for the current step's result. + if (metrics.value(QStringLiteral("routeTo")).toString() != m_script.value(m_position)) + return; m_awaitingSample = false; + m_stepDeadline->stop(); m_pump->stop(); - QVariantMap metrics = m_latency->lastRouteMetrics(); if (!metrics.isEmpty() && m_pass >= 0) { + metrics.insert(QStringLiteral("requestToSampleMs"), + static_cast(m_stepTimer.nsecsElapsed()) / 1000000.0); metrics.insert(QStringLiteral("pass"), m_pass); metrics.insert(QStringLiteral("step"), m_position); m_samples.append(metrics); @@ -390,15 +412,43 @@ void RenderBenchmark::finish() samples.append(QJsonObject::fromVariantMap(sample.toMap())); QJsonObject report; + // Schema 2 includes synchronous route construction. Do not compare its + // route totals to schema-1 reports that started timing after construction. + report.insert(QStringLiteral("schemaVersion"), 2); + report.insert(QStringLiteral("measurementScope"), QStringLiteral("route-transition")); + report.insert(QStringLiteral("timingOrigin"), QStringLiteral("before-page-construction")); + report.insert(QStringLiteral("presentationObservable"), QStringLiteral("qt-frameSwapped")); + report.insert(QStringLiteral("providerPipelineMeasured"), false); + report.insert(QStringLiteral("providerId"), m_hooks.providerId); + report.insert(QStringLiteral("providerRuntime"), m_hooks.providerRuntime); + report.insert(QStringLiteral("qtVersion"), QString::fromLatin1(qVersion())); + report.insert(QStringLiteral("qpaPlatform"), QGuiApplication::platformName()); + report.insert(QStringLiteral("cpuArchitecture"), QSysInfo::currentCpuArchitecture()); + report.insert(QStringLiteral("buildAbi"), QSysInfo::buildAbi()); + report.insert(QStringLiteral("deviceLabel"), qEnvironmentVariable("SPOOL_BENCH_DEVICE_LABEL")); + report.insert(QStringLiteral("framePumpMs"), m_pumpIntervalMs); + report.insert(QStringLiteral("warmupPasses"), m_warmup); + report.insert(QStringLiteral("settleMs"), m_settleMs); + report.insert(QStringLiteral("stepTimeoutMs"), m_stepTimeoutMs); + report.insert(QStringLiteral("cacheMode"), m_forceCold ? QStringLiteral("memory-pressure-eviction") + : QStringLiteral("resident-after-warmup")); + report.insert(QStringLiteral("interpreterRequested"), qEnvironmentVariable("QV4_FORCE_INTERPRETER")); + report.insert(QStringLiteral("complete"), m_failures.isEmpty()); + report.insert(QStringLiteral("failures"), QJsonArray::fromVariantList(m_failures)); + if (m_window) { + report.insert(QStringLiteral("windowWidth"), m_window->width()); + report.insert(QStringLiteral("windowHeight"), m_window->height()); + report.insert(QStringLiteral("devicePixelRatio"), m_window->devicePixelRatio()); + report.insert(QStringLiteral("graphicsApi"), static_cast(m_window->rendererInterface()->graphicsApi())); + } report.insert(QStringLiteral("recordedAt"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); report.insert(QStringLiteral("script"), QJsonArray::fromStringList(m_script)); report.insert(QStringLiteral("iterations"), m_iterations); report.insert(QStringLiteral("cold"), m_forceCold); report.insert(QStringLiteral("frameBudgetMs"), m_latency ? m_latency->frameBudgetMs() : 16.67); - // Which paint path produced these numbers. It decides whether the frame - // gaps mean anything: the software backend rasterises on the CPU, so on a - // small runner a gap is mostly llvmpipe's throughput rather than anything - // this application did. + // Preserve the requested backend alongside the actual renderer API. + // Qt Quick's software adaptation is not the Mesa llvmpipe OpenGL driver; + // neither offscreen swap callbacks nor timer lateness prove display pacing. report.insert(QStringLiteral("quickBackend"), qEnvironmentVariable("QT_QUICK_BACKEND")); // What a frame-budget timer drifted by while the app was doing nothing. // A transition gap only means something when it is worse than this. @@ -415,19 +465,21 @@ void RenderBenchmark::finish() report.insert(QStringLiteral("listMode"), m_listMode); const QByteArray json = QJsonDocument(report).toJson(QJsonDocument::Indented); + bool written = true; if (m_outputPath.isEmpty()) { qInfo().noquote() << json; } else { QDir().mkpath(QFileInfo(m_outputPath).absolutePath()); QFile file(m_outputPath); if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - file.write(json); + written = file.write(json) == json.size() && file.flush(); qInfo() << "render benchmark: wrote" << m_samples.size() << "samples to" << m_outputPath; } else { + written = false; qWarning() << "render benchmark: could not write" << m_outputPath; } } - QCoreApplication::exit(m_samples.isEmpty() && m_scrollSamples.isEmpty() ? 1 : 0); + QCoreApplication::exit(!written || !m_failures.isEmpty() || (m_samples.isEmpty() && m_scrollSamples.isEmpty()) ? 1 : 0); } } // namespace JellyfinNative diff --git a/src/diagnostics/RenderBenchmark.h b/src/diagnostics/RenderBenchmark.h index 424d1fa7..151e8682 100644 --- a/src/diagnostics/RenderBenchmark.h +++ b/src/diagnostics/RenderBenchmark.h @@ -28,7 +28,12 @@ struct RenderBenchmarkHooks { // decodeMsTotal, decodedPixelsTotal and decodedImagesTotal, as the // artwork service counts them. std::function artworkDecodeTotals; - // Sheds every cache the app can, so a walk measures cold pages. + // Identifies the implementation actually serving the UI, not merely a + // registered module. Update this when the JS application path is wired. + QString providerId; + QString providerRuntime; + // Requests application memory-pressure eviction. This does not flush Qt's + // disk/AOT caches, the OS page cache, or necessarily every resident page. std::function forceColdCaches; }; @@ -89,6 +94,9 @@ class RenderBenchmark final : public QObject { // been swapped, so the harness has to keep asking for one. QQuickWindow *m_window = nullptr; QTimer *m_pump = nullptr; + QTimer *m_stepDeadline = nullptr; + int m_pumpIntervalMs = 8; + QVariantList m_failures; // The idle probe is the same kind of timer on the same interval as the // transition gap timer, so a pause that would show up as a dropped frame @@ -122,9 +130,8 @@ class RenderBenchmark final : public QObject { // How long to sit still between steps. Doubles as the idle-probe window, // so the noise floor is sampled over the same span a transition occupies. int m_settleMs = 120; - // Drop every cached page before each step, so the walk measures what a - // route costs to build rather than what it costs to reveal. This is the - // case a television lives in, where memory keeps nothing resident. + // Request memory-pressure eviction before each step. Record actual + // cache-hit classification separately; this is not a fully cold process. bool m_forceCold = false; int m_position = -1; diff --git a/src/main.cpp b/src/main.cpp index 3a195f06..4585c80f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -924,6 +924,8 @@ int main(int argc, char **argv) // with what each one cost written out, so page-switch cost is a number in // CI rather than an impression. JellyfinNative::RenderBenchmarkHooks benchmarkHooks; + benchmarkHooks.providerId = provider ? provider->id() : QString(); + benchmarkHooks.providerRuntime = QStringLiteral("native"); benchmarkHooks.libraries = controller->libraries(); benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; benchmarkHooks.outstandingArtworkRequests diff --git a/tests/diagnostics/RouteTransitionTimingTest.cpp b/tests/diagnostics/RouteTransitionTimingTest.cpp new file mode 100644 index 00000000..2d082b11 --- /dev/null +++ b/tests/diagnostics/RouteTransitionTimingTest.cpp @@ -0,0 +1,41 @@ +#include "TestMain.h" + +#include +#include +#include + +#include +#include + +namespace { +QString read(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + std::cerr << "cannot read route timing fixture\n"; + std::exit(1); + } + return QString::fromUtf8(file.readAll()); +} +} + +JELLYFIN_TEST_MAIN("route-transition-timing") +{ + QCoreApplication app(argc, argv); + const QString route = read(QStringLiteral(TEST_SOURCE_DIR "/qml/shell/RouteStack.qml")); + const qsizetype begin = route.indexOf(QStringLiteral(" function showRoute() {")); + const qsizetype end = route.indexOf(QStringLiteral(" function activatePending() {"), begin); + if (begin < 0 || end <= begin) { + std::cerr << "route timing test cannot locate the actual route functions\n"; + return 1; + } + QJSEngine engine; + const QString fixture = read(QStringLiteral(TEST_SOURCE_DIR "/tests/diagnostics/fixtures/route-transition-timing.js")); + const QJSValue result = engine.evaluate(fixture + '\n' + route.mid(begin, end - begin) + + QStringLiteral("\nrunRouteTransitionTests();")); + if (result.isError() || result.toInt() != 5) { + std::cerr << result.toString().toStdString() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/diagnostics/fixtures/route-transition-timing.js b/tests/diagnostics/fixtures/route-transition-timing.js new file mode 100644 index 00000000..832d0b8d --- /dev/null +++ b/tests/diagnostics/fixtures/route-transition-timing.js @@ -0,0 +1,76 @@ +// Runs the actual RouteStack.showRoute/handleLoaded JS with deterministic loader +// and clock doubles. This tests ordering/reentrancy, not Qt rendering timings. +var Loader = { Ready: 1, Loading: 2, Error: 3 }; +var pages, route, activeRoute, pendingLoader, uiTransitionToken; +var elapsed, startedAt, sampleName, sampleCacheHit, instanceMs, activations, forcedLoads; +var loadMode; +var console = { info: function() {} }; +var settleWatchdog = { restart: function() {} }; +var InputLatency = { + beginUiTransition: function(name, from, to, hit) { + startedAt = elapsed; + sampleName = name; + sampleCacheHit = hit; + return 1; + }, + mark: function(token, stage) { + if (stage === 'instance') instanceMs = elapsed - startedAt; + } +}; +function pageKey(value) { return value; } +function activatePending() { ++activations; pendingLoader = null; } +function require(condition, message) { if (!condition) throw new Error(message); } +function loaderFor(key, synchronous) { + if (pages[key]) return pages[key]; + require(synchronous, 'foreground construction remains synchronous'); + elapsed += 17; + var loader = { status: loadMode === 'ready' ? Loader.Ready : Loader.Loading, + item: loadMode === 'ready' ? {children: []} : null, pageCacheKey: key }; + pages[key] = loader; + // Actual Loader.onLoaded may run inside setSource before loaderFor returns. + if (loader.status === Loader.Ready) handleLoaded(loader); + return loader; +} +function reset() { + pages = {}; route = 'search'; activeRoute = 'home'; pendingLoader = null; + elapsed = 0; startedAt = -1; sampleName = ''; sampleCacheHit = ''; + instanceMs = -1; activations = 0; forcedLoads = 0; loadMode = 'ready'; +} +function runRouteTransitionTests() { + reset(); + showRoute(); + require(sampleCacheHit === 'miss' && sampleName === 'route:search:cold', 'new page must be a cold miss'); + require(instanceMs === 17, 'synchronous construction must be inside timing'); + require(activations === 1, 'synchronous construction activates once'); + + reset(); + pages.search = {status: Loader.Ready, item: {children: []}}; + showRoute(); + require(sampleCacheHit === 'hit' && instanceMs === 0 && activations === 1, 'resident hit measured separately'); + + reset(); + var promoted = {status: Loader.Loading, item: null}; + Object.defineProperty(promoted, 'asynchronous', {set: function(value) { + if (value === false) { + ++forcedLoads; elapsed += 13; + this.item = {children: []}; this.status = Loader.Ready; + handleLoaded(this); + } + }}); + pages.search = promoted; + showRoute(); + require(sampleCacheHit === 'promoted' && instanceMs === 13, 'promotion includes synchronous finish'); + require(activations === 1 && forcedLoads === 1, 'onLoaded promotion must not activate twice'); + + reset(); loadMode = 'loading'; + showRoute(); + require(activations === 0 && pendingLoader === pages.search, 'unready page must wait'); + elapsed += 20; pages.search.item = {children: []}; pages.search.status = Loader.Ready; + handleLoaded(pages.search); + require(instanceMs === 37 && activations === 1, 'asynchronous completion keeps original timing'); + + reset(); activeRoute = ''; + showRoute(); + require(sampleCacheHit === 'miss' && instanceMs === 17 && activations === 1, 'startup includes construction'); + return 5; +} diff --git a/tests/tools/RenderBenchmarkReportTest.py b/tests/tools/RenderBenchmarkReportTest.py index 5103a6c2..798310f8 100755 --- a/tests/tools/RenderBenchmarkReportTest.py +++ b/tests/tools/RenderBenchmarkReportTest.py @@ -79,6 +79,21 @@ def main() -> int: run(script, report({"home": [200.0] * 4, "search": [200.0] * 4}, cold=True), baseline=baseline) run(script, report({"home": [200.0] * 4, "search": [200.0] * 4}, backend="rhi"), baseline=baseline) + # New timing includes construction: old and new origins are not an A/B pair. + for key, value in (("schemaVersion", 2), ("qpaPlatform", "wayland"), + ("framePumpMs", 0), ("windowWidth", 3840), ("library", "Movies")): + changed = report({"home": [200.0] * 4, "search": [200.0] * 4}) + changed[key] = value + output = run(script, changed, baseline=baseline) + assert "no comparison made" in output, output + assert "+900%" not in output, "incompatible reports must not print per-route deltas" + + incomplete = report({"home": [1.0] * 4}) + incomplete["complete"] = False + incomplete["failures"] = [{"reason": "route_timeout"}] + run(script, incomplete, warn_only=True, expected=1) + run(script, baseline, baseline=incomplete, warn_only=True, expected=1) + # Warning-only must not hide a broken measurement with no samples. run(script, report({}), warn_only=True, expected=1) return 0 diff --git a/tools/compare-render-benchmark.py b/tools/compare-render-benchmark.py index d74c2a5c..68034030 100755 --- a/tools/compare-render-benchmark.py +++ b/tools/compare-render-benchmark.py @@ -20,10 +20,10 @@ from pathlib import Path METRICS = [ - ("maxGapMs", "worst frame gap"), + ("maxGapMs", "timer lateness"), ("wallMs", "wall"), ("guiCpuMs", "gui cpu"), - ("instanceMs", "construct"), + ("instanceMs", "instance ready"), ("actualSwaps", "swaps"), ] @@ -68,11 +68,17 @@ def main() -> int: report = load(args.current) samples = report.get("samples", []) + if report.get("complete") is False or report.get("failures"): + print("render benchmark: incomplete run; inspect failures before comparing", file=sys.stderr) + return 1 if not samples: print("render benchmark: no samples recorded", file=sys.stderr) return 1 current = summarise(report) baseline_report = load(args.baseline) if args.baseline and args.baseline.exists() else None + if baseline_report and (baseline_report.get("complete") is False or baseline_report.get("failures")): + print("render benchmark: incomplete baseline; refusing comparison", file=sys.stderr) + return 1 baseline = summarise(baseline_report) if baseline_report else None budget = float(samples[0].get("frameBudgetMs", 16.7)) cold = report.get("cold", False) @@ -80,6 +86,11 @@ def main() -> int: current.keys() == baseline.keys() and cold == baseline_report.get("cold", False) and report.get("quickBackend", "") == baseline_report.get("quickBackend", "") + and all(report.get(key) == baseline_report.get(key) for key in ( + "schemaVersion", "timingOrigin", "script", "qpaPlatform", "graphicsApi", + "framePumpMs", "windowWidth", "windowHeight", "devicePixelRatio", + "library", "listMode", "frameBudgetMs", "measurementScope", + )) ) overall = sum(entry["wallMs"] for entry in current.values()) was_overall = sum(entry["wallMs"] for entry in baseline.values()) if comparable else None @@ -93,20 +104,23 @@ def main() -> int: f"Overall transition time: **{overall:.1f} ms**{format_delta(overall, was_overall)} " "(sum of per-route medians; idle settle delays excluded).", "", - "Route timings, CPU shares, construction costs and frame gaps are diagnostic only.", + "Route timings and CPU shares are diagnostic. Timer lateness is not a dropped-frame count.", + "Swap callbacks are not measured panel presentation; provider response processing is not isolated here.", ] + if report.get("schemaVersion", 1) < 2: + lines += ["", "Legacy timing: synchronous page construction may be excluded and cold loads labelled warm."] if args.warn_only: lines += ["", "Warning-only on shared CI runners. Run on dedicated hardware before enabling performance gates."] if baseline and not comparable: - lines += ["", "Baseline route set, warm/cold mode or rendering backend differs; no overall comparison made."] - lines += ["", "| route | " + " | ".join(label for _, label in METRICS) + " | worst gap |", + lines += ["", "Baseline workload, timing origin or rendering environment differs; no comparison made."] + lines += ["", "| route | " + " | ".join(label for _, label in METRICS) + " | max lateness |", "|" + "---|" * (len(METRICS) + 2)] for route in sorted(current): entry = current[route] cells = [route] for key, _ in METRICS: value = entry[key] - was = baseline.get(route, {}).get(key) if baseline else None + was = baseline.get(route, {}).get(key) if comparable else None unit = "" if key == "actualSwaps" else " ms" precision = 0 if key == "actualSwaps" else 1 cells.append(f"{value:.{precision}f}{unit}{format_delta(value, was)}") From f5c18af10b5abc32a2f160bd4374da8e953e0c1d Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 21 Sep 2026 17:00:01 +0000 Subject: [PATCH 029/146] feat(provider): decode listing pages into source-scoped native data on worker Add a typed coroutine result path that shares runtime cancellation and watchdog handling with small RPCs. Preserve opaque cursors, optional totals, external IDs and exact ticks; apply trusted source identity and explicit output bounds. Normal application composition remains native pending catalog/account/resource integration. Add decoder, typed-runtime, registry-isolation and interpreter-mode tests for the next Qt run. These C++ changes were source-reviewed but not compiled here: Qt 6.11 is unavailable. Package/report Python tests, JS syntax and strict module seam checks pass. --- CMakeLists.txt | 5 + sdk/README.md | 28 +++ src/provider/ProviderMediaPage.cpp | 235 ++++++++++++++++++++++ src/provider/ProviderMediaPage.h | 39 ++++ src/provider/ProviderRegistry.h | 3 + src/provider/ProviderRegistrySources.cpp | 35 ++++ src/provider/ScriptRuntime.cpp | 113 +++++++++-- src/provider/ScriptRuntime.h | 6 + tests/providers/ProviderMediaPageTest.cpp | 122 +++++++++++ tests/providers/SourceRegistryTest.cpp | 9 + tests/providers/fixtures/provider.mjs | 11 + 11 files changed, 589 insertions(+), 17 deletions(-) create mode 100644 src/provider/ProviderMediaPage.cpp create mode 100644 src/provider/ProviderMediaPage.h create mode 100644 tests/providers/ProviderMediaPageTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b490e292..2ed56c1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -264,6 +264,8 @@ set(SPOOL_SHELL_SOURCES src/provider/ProviderQmlCache.h src/provider/ProviderUiContext.cpp src/provider/ProviderUiContext.h + src/provider/ProviderMediaPage.cpp + src/provider/ProviderMediaPage.h src/provider/ScriptRuntime.cpp src/provider/ScriptRuntime.h src/provider/ProviderRegistry.cpp @@ -861,6 +863,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) set(JELLYFIN_TESTS_providers local-provider tests/providers/LocalProviderTest.cpp script-runtime tests/providers/ScriptRuntimeTest.cpp + provider-media-page tests/providers/ProviderMediaPageTest.cpp source-registry tests/providers/SourceRegistryTest.cpp provider-ui-context tests/providers/ProviderUiContextTest.cpp provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp @@ -948,6 +951,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() endforeach() + add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) + set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${JELLYFIN_TEST_ENV}") target_sources(providers-tests PRIVATE ${spool_provider_bundle_sources}) add_test(NAME provider-package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") diff --git a/sdk/README.md b/sdk/README.md index 8b0bd72e..5034f71a 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -62,3 +62,31 @@ Use `requestList(operation, arguments, append)` for bulk rows. The operation ret `complete(result)` returns an action result with native `sourceId` authority; `close()` returns cancellation. Both settle once. Dismissing a component must call `close()`, and destroying the native context cancels outstanding work. Each context owns a separate native operation scope: closing a picker does not remove its source or cancel another picker on that source. Source removal/disable closes its open contexts. The `tests/providers/fixtures/Selection.qml` action exercises a 2,000-row virtualised list, async completion and dismissal. This bridge is not yet the full negotiated UI-host extension: shared routing, focus restoration, localisation/notification services and real Jellyfin component migration remain incomplete. Packages requiring `ui-host`, `native-list` or `Spool.Ui` continue to fail compatibility validation until that full public surface is provided. + +## Native typed listing path (integration in progress) + +`ScriptRuntime::callMediaPage()` and the generation-guarded +`ProviderRegistry::callSourceMediaPage()` return `QCoro::Task`. +The operation still returns the existing normalized JS page shape +`{items, cursor, total, exhausted}`. The worker validates/converts those fields +**directly** into native-owned media items; it does not first create a generic +`QVariantMap` tree and then convert all rows on the GUI thread. Small RPCs and +custom UI actions retain `callSource()` and their existing bounded native maps. + +Each returned item carries the host-created source ID alongside `MovieItem` +and its complete normalized external-ID map. Keep that envelope through +routing/caching; do not discard it simply to fit an old single-source model. +Ticks accept safe nonnegative JS integers or exact decimal strings within +signed 64-bit range. Unknown totals stay optional and cursors stay opaque. +The caller selects a row bound (100 by default, hard ceiling 1,000); this is an +allocation/safety limit, not evidence that a 1,000-row page meets any latency +budget. Source removal, operation-scope cancellation and watchdog behaviour +are shared with ordinary calls. + +This decoder covers the current provider's **listing** schema. It is not a +completed catalog adapter, details/variant/playback decoder, artwork credential +bridge, account bootstrap, or native/JS application switch. Add those pieces +and test against real Jellyfin before describing the portable client as wired. +The existing native Jellyfin implementation remains the normal application +path. These host-side types are experimental internals, not a new public SDK +version or a reason to rebuild the portable provider ZIP. diff --git a/src/provider/ProviderMediaPage.cpp b/src/provider/ProviderMediaPage.cpp new file mode 100644 index 00000000..b07d59dd --- /dev/null +++ b/src/provider/ProviderMediaPage.cpp @@ -0,0 +1,235 @@ +#include "ProviderMediaPage.h" + +#include +#include + +#include +#include +#include +#include + +namespace JellyfinNative::Detail { +namespace { + constexpr qsizetype maximumTextBytes = 4 * 1024 * 1024; + constexpr double maximumSafeInteger = 9007199254740991.0; + + [[noreturn]] void invalid() + { + // Never put provider strings or response contents into public errors. + throw std::runtime_error("invalid_media_page"); + } + + bool absent(const QJSValue& value) + { + return value.isNull() || value.isUndefined(); + } + + void object(const QJSValue& value) + { + if (!value.isObject() || value.isArray() || value.isCallable() || value.isQObject() || value.isError()) + invalid(); + } + + qint64 integer(const QJSValue& value, qint64 fallback = 0) + { + if (absent(value)) + return fallback; + if (value.isNumber()) { + const double number = value.toNumber(); + if (!std::isfinite(number) || number < 0 || number > maximumSafeInteger || std::trunc(number) != number) + invalid(); + return static_cast(number); + } + // Exact ticks/byte counts can exceed JS's safe integer range. Providers + // must transmit those as decimal strings, never as rounded doubles. + if (!value.isString()) + invalid(); + const QString decimal = value.toString(); + if (decimal.isEmpty() || decimal.size() > 19) + invalid(); + for (const QChar digit : decimal) { + if (digit < QLatin1Char('0') || digit > QLatin1Char('9')) + invalid(); + } + bool ok = false; + const qint64 result = decimal.toLongLong(&ok); + if (!ok) + invalid(); + return result; + } + + int smallInteger(const QJSValue& value) + { + const qint64 result = integer(value); + if (result > std::numeric_limits::max()) + invalid(); + return static_cast(result); + } + + bool boolean(const QJSValue& value) + { + if (absent(value)) + return false; + if (!value.isBool()) + invalid(); + return value.toBool(); + } + + double number(const QJSValue& value) + { + if (absent(value)) + return 0; + if (!value.isNumber() || !std::isfinite(value.toNumber())) + invalid(); + return value.toNumber(); + } + + struct Reader { + qsizetype textBytes = 0; + + QString text(const QJSValue& value, qsizetype maximumLength = 64 * 1024) + { + if (absent(value)) + return {}; + if (!value.isString()) + invalid(); + QString result = value.toString(); + if (result.size() > maximumLength) + invalid(); + textBytes += result.size() * sizeof(QChar); + if (textBytes > maximumTextBytes) + invalid(); + return result; + } + + QStringList strings(const QJSValue& value) + { + if (absent(value)) + return {}; + if (!value.isArray()) + invalid(); + const quint32 length = value.property(QStringLiteral("length")).toUInt(); + if (length > 256) + invalid(); + QStringList result; + result.reserve(length); + for (quint32 i = 0; i < length; ++i) + result.append(text(value.property(i), 4096)); + return result; + } + + ProviderMediaItem item(const QJSValue& row, const QString& sourceId) + { + object(row); + ProviderMediaItem result; + result.sourceId = sourceId; // Ignore any source identity in provider data. + MovieItem& media = result.media; + media.id = text(row.property(QStringLiteral("id")), 1024); + if (media.id.isEmpty()) + invalid(); + media.title = text(row.property(QStringLiteral("title"))); + media.sortName = text(row.property(QStringLiteral("sortName"))); + if (media.sortName.isEmpty()) + media.sortName = media.title; + media.itemType = text(row.property(QStringLiteral("type")), 128); + media.overview = text(row.property(QStringLiteral("overview"))); + media.posterTag = text(row.property(QStringLiteral("posterTag")), 4096); + media.backdropTag = text(row.property(QStringLiteral("backdropTag")), 4096); + media.logoTag = text(row.property(QStringLiteral("logoTag")), 4096); + media.seriesId = text(row.property(QStringLiteral("seriesId")), 1024); + media.seasonId = text(row.property(QStringLiteral("seasonId")), 1024); + media.seriesName = text(row.property(QStringLiteral("seriesName"))); + media.year = smallInteger(row.property(QStringLiteral("year"))); + media.seasonNumber = smallInteger(row.property(QStringLiteral("season"))); + media.episodeNumber = smallInteger(row.property(QStringLiteral("episode"))); + media.runtimeTicks = integer(row.property(QStringLiteral("runtimeTicks"))); + media.resumeTicks = integer(row.property(QStringLiteral("resumeTicks"))); + media.favorite = boolean(row.property(QStringLiteral("favorite"))); + media.played = boolean(row.property(QStringLiteral("played"))); + media.genres = strings(row.property(QStringLiteral("genres"))); + media.tags = strings(row.property(QStringLiteral("tags"))); + media.studios = strings(row.property(QStringLiteral("studios"))); + media.officialRating = text(row.property(QStringLiteral("officialRating")), 1024); + media.communityRating = number(row.property(QStringLiteral("communityRating"))); + + const QJSValue ids = row.property(QStringLiteral("externalIds")); + if (!absent(ids)) { + object(ids); + QJSValueIterator iterator(ids); + int count = 0; + while (iterator.hasNext()) { + iterator.next(); + if (++count > 64 || iterator.name().isEmpty() || iterator.name().size() > 128) + invalid(); + const QString key = iterator.name().trimmed().toLower(); + const QString id = text(iterator.value(), 1024); + if (key.isEmpty()) + invalid(); + if (id.isEmpty()) + continue; + if (result.externalIds.contains(key) && result.externalIds.value(key) != id) + invalid(); + result.externalIds.insert(key, id); + } + } + media.imdbId = result.externalIds.value(QStringLiteral("imdb")); + media.tmdbId = result.externalIds.value(QStringLiteral("tmdb")); + + const QJSValue people = row.property(QStringLiteral("people")); + if (!absent(people)) { + if (!people.isArray()) + invalid(); + const quint32 length = people.property(QStringLiteral("length")).toUInt(); + if (length > 512) + invalid(); + media.people.reserve(length); + for (quint32 i = 0; i < length; ++i) { + const QJSValue person = people.property(i); + object(person); + PersonItem entry; + entry.id = text(person.property(QStringLiteral("id")), 1024); + entry.name = text(person.property(QStringLiteral("name"))); + entry.type = text(person.property(QStringLiteral("type")), 128); + entry.role = text(person.property(QStringLiteral("role"))); + entry.imageTag = text(person.property(QStringLiteral("imageTag")), 4096); + media.people.append(std::move(entry)); + } + } + return result; + } + }; +} // namespace + +ProviderMediaPage readProviderMediaPage(const QJSValue& value, const QString& sourceId, int maximumItems) +{ + if (sourceId.isEmpty() || sourceId.size() > 256 || maximumItems < 1 || maximumItems > 1000) + invalid(); + object(value); + const QJSValue rows = value.property(QStringLiteral("items")); + const QJSValue exhausted = value.property(QStringLiteral("exhausted")); + if (!rows.isArray() || !exhausted.isBool()) + invalid(); + const quint32 length = rows.property(QStringLiteral("length")).toUInt(); + if (length > static_cast(maximumItems)) + invalid(); + Reader reader; + ProviderMediaPage result; + result.sourceId = sourceId; + result.exhausted = exhausted.toBool(); + const QJSValue cursor = value.property(QStringLiteral("cursor")); + if (!absent(cursor)) { + result.cursor = reader.text(cursor, 4096); + if (result.cursor->isEmpty()) + invalid(); + } + if (!result.exhausted && !result.cursor) + invalid(); + const QJSValue total = value.property(QStringLiteral("total")); + if (!absent(total)) + result.total = integer(total); + result.items.reserve(length); + for (quint32 i = 0; i < length; ++i) + result.items.push_back(reader.item(rows.property(i), sourceId)); + return result; +} +} // namespace JellyfinNative::Detail diff --git a/src/provider/ProviderMediaPage.h b/src/provider/ProviderMediaPage.h new file mode 100644 index 00000000..16213da4 --- /dev/null +++ b/src/provider/ProviderMediaPage.h @@ -0,0 +1,39 @@ +#pragma once + +#include "../media/MediaTypes.h" + +#include +#include + +#include +#include + +class QJSValue; + +namespace JellyfinNative { + +// Experimental native-owned listing data. Keep the source with each item when +// aggregating pages; MovieItem::id alone is only meaningful within that source. +// This is not a stable SDK or a complete details/playback descriptor. +struct ProviderMediaItem { + QString sourceId; + MovieItem media; + QMap externalIds; +}; + +struct ProviderMediaPage { + QString sourceId; + std::vector items; + std::optional cursor; + std::optional total; + bool exhausted = false; +}; + +namespace Detail { +// Invoke only on the owning script engine's thread. Reads the normalized +// provider schema directly, not Jellyfin JSON, and never retains JS values. +// The caller supplies trusted source identity and an explicit page-size bound. +ProviderMediaPage readProviderMediaPage(const QJSValue& value, const QString& sourceId, int maximumItems = 100); +} + +} // namespace JellyfinNative diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index b1436c14..d62dce17 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -1,6 +1,7 @@ #pragma once #include "Provider.h" +#include "ProviderMediaPage.h" #include #include #include @@ -116,6 +117,8 @@ class ProviderRegistry final : public QObject { QVariantMap configuration, QList authorisedOrigins); QCoro::Task callSource( QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}); + QCoro::Task callSourceMediaPage(QString sourceId, QString operation, + QVariantMap arguments = {}, QString scope = {}, int maximumItems = 100); void cancelSourceScope(const QString& sourceId, const QString& scope); QCoro::Task setSourceEnabled(QString sourceId, bool enabled); QCoro::Task removeSource(QString sourceId); diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp index eecbab8e..3b8a96b6 100644 --- a/src/provider/ProviderRegistrySources.cpp +++ b/src/provider/ProviderRegistrySources.cpp @@ -273,6 +273,41 @@ QCoro::Task ProviderRegistry::callSource( co_return result; } +QCoro::Task ProviderRegistry::callSourceMediaPage( + QString sourceId, QString operation, QVariantMap arguments, QString scope, int maximumItems) +{ + Q_ASSERT(thread() == QThread::currentThread()); + auto *source = m_portable->find(sourceId); + if (!source || !source->enabled || !source->active) + throw std::runtime_error("source_unavailable"); + QPointer guard(this); + const quint64 generation = source->generation; + const QString module = source->module; + ProviderMediaPage result; + try { + result = co_await m_portable->modules.value(module)->callMediaPage( + sourceId, std::move(operation), std::move(arguments), std::move(scope), maximumItems); + } catch (const std::exception& error) { + const QByteArray code(error.what()); + if (guard && (code == "script_interrupted" || code == "source_unavailable")) { + for (auto& candidate : m_portable->sources) { + if (candidate.module == module) { + candidate.active = false; + ++candidate.generation; + } + } + refreshSourceSnapshot(); + } + throw; + } + if (!guard) + throw std::runtime_error("source_registry_removed"); + source = m_portable->find(sourceId); + if (!source || !source->enabled || !source->active || source->generation != generation) + throw std::runtime_error("source_generation_changed"); + co_return result; +} + void ProviderRegistry::cancelSourceScope(const QString& sourceId, const QString& scope) { const auto *source = m_portable->find(sourceId); diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index c8bbbf50..a6af3393 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -28,18 +29,65 @@ namespace JellyfinNative { namespace { using Completion = std::shared_ptr>; - void fail(const Completion& completion, const char *code) + template + void fail(const std::shared_ptr>& completion, const char *code) { completion->setException(std::make_exception_ptr(std::runtime_error(code))); completion->finish(); } - QCoro::Task awaitResult(QFuture future) + template + QCoro::Task awaitResult(QFuture future) { auto result = qCoro(future); co_return co_await result.takeResult(); } + // Type erasure is per operation, not per item/property. Both small RPCs + // and bulk native pages use the same cancellation/HTTP/watchdog lifecycle. + struct ResultSink { + virtual ~ResultSink() = default; + virtual void prepare(const QJSValue& value) = 0; + virtual void complete() = 0; + virtual void reject(const char *code) = 0; + }; + using OperationCompletion = std::shared_ptr; + + template + class TypedResultSink final : public ResultSink { + public: + TypedResultSink(std::shared_ptr> promise, Decoder decoder) + : promise(std::move(promise)) + , decoder(std::move(decoder)) + { + } + void prepare(const QJSValue& value) override + { + result.emplace(decoder(value)); + } + void complete() override + { + promise->addResult(std::move(result.value())); + promise->finish(); + result.reset(); + } + void reject(const char *code) override + { + fail(promise, code); + } + + private: + std::shared_ptr> promise; + Decoder decoder; + std::optional result; + }; + + template + OperationCompletion resultSink(std::shared_ptr> promise, Decoder decoder) + { + return std::make_shared>(std::move(promise), std::move(decoder)); + } + // Sleeps while JS is idle. Qt explicitly permits setInterrupted() from another // thread. The mutex protects engine lifetime, not JS heap access. class ScriptWatchdog { @@ -160,7 +208,7 @@ class ScriptOperation final : public QObject { Q_OBJECT public: QString scope; - ScriptOperation(QJSEngine *engine, QNetworkAccessManager *network, ScriptWatchdog *watchdog, Completion completion, + ScriptOperation(QJSEngine *engine, QNetworkAccessManager *network, ScriptWatchdog *watchdog, OperationCompletion completion, QList origins, QObject *parent) : QObject(parent) , engine(engine) @@ -177,14 +225,14 @@ class ScriptOperation final : public QObject { ~ScriptOperation() override { if (!settled) - fail(completion, "runtime_shutdown"); + completion->reject("runtime_shutdown"); } void cancel(const char *code) { if (settled) return; settled = true; - fail(completion, code); + completion->reject(code); release(); } Q_INVOKABLE void resolve(const QJSValue& result) @@ -194,14 +242,11 @@ class ScriptOperation final : public QObject { try { if (!result.isObject() || result.isArray() || result.isError()) throw std::runtime_error("invalid_result"); - int nodes = 0; - qsizetype bytes = 0; - QVariantMap owned = ownValue(result, nodes, bytes).toMap(); + completion->prepare(result); if (engine->isInterrupted()) throw std::runtime_error("script_interrupted"); settled = true; - completion->addResult(std::move(owned)); - completion->finish(); + completion->complete(); release(); } catch (const std::exception&) { cancel("invalid_result"); @@ -336,7 +381,7 @@ class ScriptOperation final : public QObject { QJSEngine *engine; QNetworkAccessManager *network; ScriptWatchdog *watchdog; - Completion completion; + OperationCompletion completion; QList origins; QTimer deadline; QSet replies; @@ -427,20 +472,20 @@ class ScriptWorker final : public QObject { completion->finish(); } void call(const QString& id, const QString& method, const QVariantMap& args, const QString& scope, - const Completion& completion) + const OperationCompletion& completion) { auto source = sources.find(id); if (!engine || engine->isInterrupted() || source == sources.end()) { - fail(completion, "source_unavailable"); + completion->reject("source_unavailable"); return; } if (source->operations.size() >= 8 || activeOperations >= 32) { - fail(completion, "operation_limit"); + completion->reject("operation_limit"); return; } if (method.startsWith(QLatin1Char('_')) || !source->object.hasOwnProperty(method) || !source->object.property(method).isCallable()) { - fail(completion, "unsupported_operation"); + completion->reject("unsupported_operation"); return; } auto *operation @@ -569,12 +614,46 @@ QCoro::Task ScriptRuntime::call(QString sourceId, QString method, Q fail(completion, "queue_limit"); return awaitResult(std::move(future)); } + const auto sink = resultSink(completion, [](const QJSValue& value) { + int nodes = 0; + qsizetype bytes = 0; + return ownValue(value, nodes, bytes).toMap(); + }); + QMetaObject::invokeMethod( + d->worker, + [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), + arguments = std::move(arguments), scope = std::move(scope), sink] { + --state->queued; + worker->call(sourceId, method, arguments, scope, sink); + }, + Qt::QueuedConnection); + return awaitResult(std::move(future)); +} + +QCoro::Task ScriptRuntime::callMediaPage( + QString sourceId, QString method, QVariantMap arguments, QString scope, int maximumItems) +{ + auto completion = std::make_shared>(); + completion->start(); + auto future = completion->future(); + if (maximumItems < 1 || maximumItems > 1000) { + fail(completion, "invalid_page_limit"); + return awaitResult(std::move(future)); + } + if (d->queued.fetch_add(1) >= 64) { + --d->queued; + fail(completion, "queue_limit"); + return awaitResult(std::move(future)); + } + const auto sink = resultSink(completion, [sourceId, maximumItems](const QJSValue& value) { + return Detail::readProviderMediaPage(value, sourceId, maximumItems); + }); QMetaObject::invokeMethod( d->worker, [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), - arguments = std::move(arguments), scope = std::move(scope), completion] { + arguments = std::move(arguments), scope = std::move(scope), sink] { --state->queued; - worker->call(sourceId, method, arguments, scope, completion); + worker->call(sourceId, method, arguments, scope, sink); }, Qt::QueuedConnection); return awaitResult(std::move(future)); diff --git a/src/provider/ScriptRuntime.h b/src/provider/ScriptRuntime.h index c69906b9..3e0c9ffe 100644 --- a/src/provider/ScriptRuntime.h +++ b/src/provider/ScriptRuntime.h @@ -1,5 +1,7 @@ #pragma once +#include "ProviderMediaPage.h" + #include #include #include @@ -18,6 +20,10 @@ class ScriptRuntime final : public QObject { QCoro::Task addSource(QString sourceId, QVariantMap configuration, QList origins); QCoro::Task call(QString sourceId, QString method, QVariantMap arguments = {}, QString scope = {}); + // Bulk listing path: decode directly on the worker into typed native + // items. No QVariant tree or row conversion is handed to the GUI thread. + QCoro::Task callMediaPage(QString sourceId, QString method, QVariantMap arguments = {}, + QString scope = {}, int maximumItems = 100); void cancelScope(const QString& sourceId, const QString& scope); void removeSource(const QString& sourceId); diff --git a/tests/providers/ProviderMediaPageTest.cpp b/tests/providers/ProviderMediaPageTest.cpp new file mode 100644 index 00000000..d6304ad3 --- /dev/null +++ b/tests/providers/ProviderMediaPageTest.cpp @@ -0,0 +1,122 @@ +#include "TestMain.h" +#include "provider/ProviderMediaPage.h" +#include "provider/ScriptRuntime.h" + +#include +#include + +#include +#include +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +template +void rejects(QCoro::Task task, const char *message) +{ + bool rejected = false; + try { + QCoro::waitFor(std::move(task)); + } catch (const std::exception&) { + rejected = true; + } + require(rejected, message); +} +} + +JELLYFIN_TEST_MAIN("provider-media-page") +{ + QCoreApplication app(argc, argv); + using namespace JellyfinNative; + QJSEngine engine; + const auto read = [&](const QString& expression, int limit = 100) { + const QJSValue value = engine.evaluate(expression); + require(!value.isError(), "fixture is valid JavaScript"); + return Detail::readProviderMediaPage(value, QStringLiteral("trusted-source"), limit); + }; + const auto invalid = [&](const QString& expression, int limit = 100) { + bool rejected = false; + try { + read(expression, limit); + } catch (const std::exception& error) { + require(QByteArray(error.what()) == "invalid_media_page", "errors contain no provider data"); + rejected = true; + } + require(rejected, "invalid typed page must reject"); + }; + const auto page = read(QStringLiteral(R"JS(({ + sourceId: 'spoofed', total: 2, exhausted: false, cursor: 'opaque:next', items: [{ + id: 'film', sourceId: 'another-spoof', title: 'Example', type: 'Movie', year: 2020, + externalIds: {IMDb: 'tt123', Tmdb: '456', tvdb: '789'}, + runtimeTicks: '9007199254740993', resumeTicks: '123456789', + favorite: true, played: false, genres: ['Drama'], people: [{id: 'p', name: 'Person'}] + }] + }))JS")); + require(page.sourceId == "trusted-source" && page.items.front().sourceId == page.sourceId, + "host source identity overrides attempted spoofing at page and item level"); + const auto& movie = page.items.front().media; + require(movie.id == "film" && movie.title == "Example" && movie.sortName == movie.title, + "native listing fields and fallback sort title are populated"); + require(movie.runtimeTicks == 9007199254740993LL && movie.resumeTicks == 123456789, + "exact decimal ticks never round through a JS double"); + require(movie.imdbId == "tt123" && movie.tmdbId == "456" + && page.items.front().externalIds.value("tvdb") == "789", "all external identifiers survive conversion"); + require(page.cursor == QStringLiteral("opaque:next") && page.total == 2 && !page.exhausted, + "pagination retains opaque cursor, known total and exhaustion separately"); + require(movie.genres.size() == 1 && movie.people.size() == 1 && movie.favorite, + "bounded metadata lists and native flags are copied"); + const auto empty = read(QStringLiteral("({items: [], total: null, cursor: null, exhausted: true})")); + require(empty.items.empty() && !empty.total && !empty.cursor && empty.exhausted, + "unknown total is not a fabricated zero"); + const auto sparse = read(QStringLiteral("({items: [{id:'x', year:null, season:0, episode:0}], exhausted:true})")); + require(sparse.items.front().media.year == 0 && sparse.items.front().media.seasonNumber == 0, + "nullable metadata and season zero are valid"); + read(QStringLiteral("({items: [], cursor:'next', exhausted:false})")); // sparse filtered page may advance + invalid(QStringLiteral("null")); + invalid(QStringLiteral("({items: {}, exhausted: true})")); + invalid(QStringLiteral("({items: [], exhausted: 'true'})")); + invalid(QStringLiteral("({items: [], exhausted: false, cursor: null})")); + invalid(QStringLiteral("({items: [], exhausted: false, cursor: 7})")); + invalid(QStringLiteral("({items: [], exhausted: true, total: -1})")); + invalid(QStringLiteral("({items: [{title:'no id'}], exhausted: true})")); + invalid(QStringLiteral("({items: [{id:'x', runtimeTicks:9007199254740992}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', runtimeTicks:'9223372036854775808'}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', runtimeTicks:'1e3'}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', year:2020.5}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', title:42}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', favorite:'false'}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x', externalIds:{TMDB:'1', tmdb:'2'}}], exhausted:true})")); + invalid(QStringLiteral("({items: [{id:'x'},{id:'y'}], exhausted:true})"), 1); + invalid(QStringLiteral("({items: [], exhausted:true})"), 0); + invalid(QStringLiteral("({items: [{id:'x', title:'x'.repeat(65537)}], exhausted:true})")); + + const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); + ScriptRuntime runtime(entry); + QCoro::waitFor(runtime.addSource("a", {{"label", "A"}}, {})); + QCoro::waitFor(runtime.addSource("b", {{"label", "B"}}, {})); + auto a = runtime.callMediaPage("a", "mediaPage", {{"count", 72}}); + auto b = runtime.callMediaPage("b", "mediaPage", {{"count", 72}}); + const auto resultA = QCoro::waitFor(std::move(a)); + const auto resultB = QCoro::waitFor(std::move(b)); + require(resultA.items.size() == 72 && resultB.items.size() == 72, "worker delivers complete typed pages"); + require(resultA.items.front().sourceId == "a" && resultB.items.front().sourceId == "b" + && resultA.items.front().media.id == resultB.items.front().media.id, + "overlapping backend IDs retain their originating source"); + rejects(runtime.callMediaPage("a", "mediaPage", {{"count", 4}}, {}, 3), "requested output bound enforced"); + rejects(runtime.callMediaPage("a", "mediaPage", {{"count", 1}}, {}, 0), "invalid caller bound rejected"); + auto cancelled = runtime.callMediaPage("a", "delayedMediaPage", {}, "closed-action"); + runtime.cancelScope("a", "closed-action"); + rejects(std::move(cancelled), "typed page operations share scope cancellation"); + require(QCoro::waitFor(runtime.call("b", "state")).contains("calls"), + "small RPC path still works after typed-page validation failure/cancellation"); + require(QCoro::waitFor(runtime.callMediaPage("a", "mediaPage", {{"count", 1}})).items.size() == 1, + "invalid result does not kill the source"); + return 0; +} diff --git a/tests/providers/SourceRegistryTest.cpp b/tests/providers/SourceRegistryTest.cpp index 3bee35fc..832d0acc 100644 --- a/tests/providers/SourceRegistryTest.cpp +++ b/tests/providers/SourceRegistryTest.cpp @@ -85,9 +85,18 @@ JELLYFIN_TEST_MAIN("source-registry") require( QCoro::waitFor(std::move(second)).value("label") == "B", "second account is not an active-provider switch"); require(QCoro::waitFor(std::move(third)).value("label") == "C", "another module runs independently"); + const auto typedA = QCoro::waitFor(registry.callSourceMediaPage(a, "mediaPage", {{"count", 2}})); + const auto typedB = QCoro::waitFor(registry.callSourceMediaPage(b, "mediaPage", {{"count", 2}})); + require(typedA.sourceId == a && typedB.sourceId == b + && typedA.items.front().sourceId == a && typedB.items.front().sourceId == b, + "typed pages preserve registry-created source identities"); + auto typedPending = registry.callSourceMediaPage(a, "delayedMediaPage"); auto pending = registry.callSource(a, "delay", { { "milliseconds", 10000 } }); QCoro::waitFor(registry.setSourceEnabled(a, false)); rejects(std::move(pending), "disabling a source cancels its pending operation"); + rejects(std::move(typedPending), "disabling a source also cancels its typed page"); + rejects(registry.callSourceMediaPage(a, "mediaPage", {{"count", 1}}), + "disabled source cannot supply a typed page"); rejects(registry.callSource(a, "bump"), "disabled source cannot be used"); require(QCoro::waitFor(registry.callSource(b, "bump")).value("calls").toInt() == 2, "disabling A leaves B's context and state unchanged"); diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index 435d003d..adc79cd0 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -8,6 +8,17 @@ export function createSource(config) { rows.push({id: String(i), title: config.label + ' ' + i, variantId: 'file-' + i}); return {items: rows, exhausted: true}; }, + mediaPage: function(args) { + const rows = []; + for (let i = 0; i < args.count; ++i) + rows.push({id: String(i), sourceId: 'spoofed', title: config.label + ' ' + i, + type: 'Movie', year: 2020, externalIds: {Tmdb: String(i + 1)}, + runtimeTicks: '12345678900'}); + return {items: rows, cursor: null, total: args.count, exhausted: true}; + }, + delayedMediaPage: function(args, host) { + return host.delay(10000).then(function() { return {items: [], exhausted: true}; }); + }, raw: function(args, host) { return host.http(config.origin + '/' + args.path); }, From 0f654705b7e6b1d0a5099666742fdf216b764a59 Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 21 Sep 2026 17:06:46 +0000 Subject: [PATCH 030/146] docs(provider): prioritize real Jellyfin A/B integration and handoff --- docs/provider-next-agent-plan.md | 307 +++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 docs/provider-next-agent-plan.md diff --git a/docs/provider-next-agent-plan.md b/docs/provider-next-agent-plan.md new file mode 100644 index 00000000..8d10bd38 --- /dev/null +++ b/docs/provider-next-agent-plan.md @@ -0,0 +1,307 @@ +# Spool portable providers: remaining implementation and A/B handoff + +**22 September 2026 · implementation brief, not a completion claim** + +## 1. Objective and authority + +The next useful milestone is a build in which the user can select **native Jellyfin or portable JavaScript Jellyfin**, browse the same real library, search, open details and play a selected version, then compare responsiveness. Do not make that milestone wait for the whole provider ecosystem, updater or Stremio implementation. + +The final architecture remains one ruthlessly fast native Qt/libmpv application with multiple first- and third-party JS/QML providers and multiple accounts/servers usable concurrently. Provider authors publish **one source ZIP per release**, not a native platform/CPU build matrix. Official Jellyfin is bundled; independently installed compatible packages can override bundled packages on supported channels. Provider implementation, account, source, item and version identities are different things. + +The implementer may change interfaces, directory layout and sequencing when code or measurements justify it. Prefer a simple correct implementation over speculative frameworks. Rewrites are cheap at this stage: reuse working behaviour, tests, the native player/rendering/model infrastructure and useful dependency boundaries, not an unsuitable implementation merely because it exists. Preserve user credentials and authoritative state; disposable cache schemas may be reset deliberately. + +### Non-negotiable performance boundary + +``` +native controller / QCoro call + -> provider operation on its worker-owned QJSEngine + -> native asynchronous HTTP + -> worker-side JSON parsing and provider normalization + -> bounded conversion into native-owned page data + -> source/generation validation + -> bounded GUI-thread model updates + -> existing Qt Quick delegates and native scene graph +``` + +No backend-provider invocation from model-role reads, layout, scrolling or per-frame rendering. No worker QJSValue or QObject crosses into the UI engine. Small QML handlers may run on the GUI thread; backend calls, substantial transforms and broad result processing must not. Do not insert stringify/parse round trips between JS normalization and native data. Repeatedly visiting a cached page should bypass the provider when its freshness policy permits. + +Target the first useful page frame in **under 100 ms when the necessary response/cached data is available** on the minimum supported webOS device. Measure input-to-useful-frame separately, including network time. Preserve smooth animation while work is pending. A 100-ms transition allowance is not permission to block the GUI for 100 ms. Start with a provisional approximately 1–2 ms budget for an individual GUI-side model commit, then validate it on hardware; a fixed number of rows is not a time guarantee. + +No minimum webOS model/firmware is actually established in the supplied materials. Use available hardware for labelled provisional results and settle the supported minimum explicitly before claiming acceptance. Do not silently replace “minimum webOS” with a developer desktop or an assumed C2. + +## 2. Baseline and this handoff + +The uploaded Git repository is on `refactor/provider-seam` at: + +``` +c1cb0652a9d72ae954602482511f3d147e4f37c9 +``` + +The accompanying session report is `PORTABLE-PROVIDER-SESSION-20260921.md`. It reports 85/85 tests passing **before this handoff**. It explicitly says normal application browsing/playback still uses native Jellyfin. Stremio, the application cutover, independent package installation and device validation are not done. + +Two local implementation commits have been added on `review/provider-ab-handoff`: + +| Commit | Change | Important limit | +| --- | --- | --- | +| `215f67b` | Start route timing before synchronous construction; correctly classify cache hits; avoid double activation on synchronous promotion; use a cancellable benchmark deadline; record runtime/environment/failure metadata; reject unlike/incomplete report comparisons. | Timing schema 2 needs fresh baselines. Actual Qt rendering and full app compilation were not run here. | +| `c21f977` | Add worker-side, directly decoded native media pages with source identity, external IDs, opaque cursor, optional total and exact ticks. Expose `callMediaPage` and generation-guarded `callSourceMediaPage`; share cancellation/watchdog handling with small RPCs; add tests. | Listing foundation only. Normal app wiring remains native; these new C++ changes require compilation and Qt tests. | + +The documentation commit containing this plan follows those commits. No remote was pushed, no provider repository was modified, and no machine/TV belonging to the user was contacted. The original untracked `spool-portable-provider-implementation-plan.md` was left unchanged and uncommitted. + +### Verification in this environment + +Passed: + +- `tests/tools/RenderBenchmarkReportTest.py` against the updated comparison tool, including incomplete-run rejection and mismatched timing/environment cases. +- Ten `tests/tools/provider_package_test.py` tests. +- `tools/check-module-seam.sh --strict`. +- Five deterministic scenarios executing the actual `RouteStack.showRoute`/`handleLoaded` functions with JS loader/clock doubles under Node. The original function fails the cold-miss regression; the new function passes. This is a function-ordering test, not a QML rendering benchmark. +- Syntax check of the changed JS fixture; the existing bundled Jellyfin protocol smoke fixture under Node with only its import URL redirected to the bundled source. This is not verification in Qt's engine. +- `git diff --check`. + +Not run: the new C++ tests, the full Qt suite, actual application A/B tests, renderer timing, physical TV tests, store tests. CMake stops at Qt discovery because this environment has no Qt 6.11 development installation or Nix. Do not lower the project's Qt requirement just to accommodate this environment. The supplied handoff archive includes the configure failure log and patch application instructions. + +## 3. What the uploaded benchmark actually measures + +Source reports: `provider-baseline-warm.json`, `provider-baseline-cold.json`, `provider-baseline-summary.json` and the session report. The raw files are preserved separately in the handoff archive. + +- Linux desktop, offscreen Qt Quick **software adaptation**, native local-provider route workload. +- Five measured iterations, 30 transitions each. The route script is `home -> search -> settings -> home -> libraryGrid -> openSourceNotices -> home`; already-current routes are skipped. +- No named library (`library` is empty), no scroll samples, no submitted search query in this script. Opening a search page is not executing a backend search. +- All warm samples report zero instrumented delegate creations. Every cold sample reports `cacheHit: hit` and a `:warm` name. Neither observation establishes a populated media-view workload. +- The harness requests updates every **8 ms**, irrespective of the nominal 16.667-ms frame budget. That is not a physical 60-Hz display measurement. +- The “cold” option calls application critical-memory-pressure eviction before transitions. It is not a fresh process or a flush of every OS, Qt disk or AOT cache. + +Nearest-rank results, recomputed from the supplied raw samples: + +| Route | Warm wall median / p95 (ms) | Eviction wall median / p95 (ms) | Eviction GUI CPU median (ms) | +| --- | ---: | ---: | ---: | +| Home | 6.754 / 7.266 | 20.515 / 28.374 | 4.971 | +| Library grid | 6.115 / 6.432 | 4.116 / 4.743 | 2.544 | +| Open-source notices | 6.409 / 6.695 | 5.270 / 5.683 | 3.716 | +| Search page | 6.717 / 6.789 | 3.106 / 3.633 | 2.102 | +| Settings | 6.729 / 7.149 | 28.919 / 34.278 | 9.044 | + +Home has ten samples; each other route has five. The pooled p95 values (7.149 and 29.205 ms) are not per-route guarantees or robust tail estimates from a large experiment. + +### Measurement defects found in the uploaded source + +`qml/shell/RouteStack.qml::showRoute()` called synchronous `loaderFor()` and potentially completed a promoted loader **before** starting `InputLatency.beginUiTransition()`. It then classified the newly created ready loader as a cache hit. Construction could therefore be excluded from both the wall and GUI CPU sample. This is reproduced by the function-level regression test, not merely guessed from the labels. + +The first handoff commit moves classification and timing before construction. It preserves the foreground loading policy rather than mixing measurement repair with a speculative UI performance rewrite. A promoted loader can emit `onLoaded` synchronously, so the new pending-loader guard also prevents double activation. + +The benchmark also left a separate uncancelled timeout for each step. A successful earlier step's timeout could interrupt a later awaiting step. It now uses one owned deadline timer, records failures and fails incomplete runs. New metadata records the timing origin, QPA platform, Qt/ABI, renderer API, dimensions, requested interpreter state, frame-pump interval and actual native provider identity. A new `requestToSampleMs` stopwatch offers a cross-check around the route request. + +### Interpretation + +These numbers do **not** measure an added JS-provider cost. They provide no basis for claiming an extra two frames caused by JS. The approximately 8–10 ms of measured GUI CPU in the settings eviction samples is worth investigating on weaker hardware, especially since some construction was omitted. That is a pre-existing/cross-cutting UI concern, not a reason to revert the provider language decision. + +`presentMs` includes time after content readiness until Qt's observed frame callback; it is not panel latency. `actualSwaps` counts Qt callbacks. `budgetIntervals` rounds total time into nominal intervals; it is not dropped frames. `maxGapMs` is periodic timer lateness, not continuous GUI occupancy. Zero lateness in a short sample does not prove there was no blocking. GUI CPU is cumulative thread CPU across the measured interval, not necessarily one uninterrupted function. + +Do not scale desktop wall time by an invented TV CPU multiplier. Do not treat the software adaptation as Mesa llvmpipe or as the TV's GPU path. Qt documents the separate software adaptation and the semantics of frame callbacks [R1–R3]. + +## 4. Immediate work: obtain a trustworthy test build + +### Step A — validate the local commits and keep a native control + +1. Apply the handoff commits on the existing branch without rewriting its history. Preserve unrelated dirty/untracked work. There is no need to recreate the main repo or the Jellyfin repo. +2. Build with the project's pinned toolchain. The supported full entry point is `nix run .#tests`; targeted runs can use the generated build directory from that workflow. Run at least `route-transition-timing`, `provider-media-page`, `provider-media-page-interpreter`, `script-runtime`, `source-registry`, `provider-ui-context`, the bundled Jellyfin contracts and report/package tests, then the regular suite. +3. Inspect and fix any compilation/behaviour defects in the new C++ code. It was deliberately delivered with tests and an explicit uncompiled status, not presented as a green release. +4. Check the shared sink refactor with both ordinary `QVariantMap` calls and typed page calls. Validate cancellation, shutdown, invalid results and watchdog failure, not only the happy path. +5. Run the normal native application with no new selection flag and confirm unchanged user behaviour. Do not call it the JS build just because a script module was registered or warmed. +6. Re-record native route baselines using timing schema 2. Keep renderer, scale, window size, viewport and cache mode consistent. Do not compare old post-construction totals with corrected totals as a regression. + +Avoid spending this phase on renaming every namespace, extracting another shared repo, changing archive compression or implementing backwards compatibility. The API remains experimental 0.x; extensions are separately versioned. + +### Step B — wire the first real JS catalog/search path + +Use the existing provider interfaces and native models where appropriate. Add a portable/source-bound adapter that invokes actual bundled Jellyfin operations through the registry and returns typed values to the controller. `callSourceMediaPage()` now supplies the expensive worker-side part; it is not yet a `Catalog` or `SearchSource` implementation. + +The new `ProviderMediaPage` is intentionally a listing envelope. Each item retains `sourceId`, its current `MovieItem` fields and normalized external IDs. Keep that source identity through navigation, caches and subsequent operations; do not discard it to fit an old global-source assumption. Extend/refactor native models if necessary. Do not quietly route details/artwork through whichever account is selected later. + +Map real operations: libraries, bounded filtered/sorted browse, paged search, resume, next-up, latest, seasons/episodes, similar and details. First make a narrow real vertical slice work, then add the rest. New typed decoders are appropriate for large details/variant payloads; small authentication/settings operations may keep the bounded generic RPC path. Do not relocate bulk result conversion to GUI coroutine continuations. + +Resolve pagination deliberately. The old `Catalog` API takes numeric offsets; portable pages expose opaque cursors and possibly unknown totals. A temporary Jellyfin-only adapter may translate its known offset protocol, but core must not assume every provider cursor is an integer. Retain per-query cursor/exhaustion state; reject non-advancing repeated cursors or bound recovery. Do not treat an empty filtered page as final when it advertises a valid advancing cursor. Preserve cancellation and request-generation checks before committing rows. + +**Prevent false performance wins:** validate row counts, ordering, metadata used by delegates, artwork and page bounds against the native path. Returning fewer fields/items or an empty result is not an optimization unless the same product behaviour is preserved and the workload difference is explicitly measured. + +### Step C — bootstrap a real account, then bind resources correctly + +For the first A/B build, reuse the user's existing native saved-account/login UI as a temporary bootstrap into the portable source registry. Do not block that test on completing all provider-owned login screens. Clearly label this temporary mixed bootstrap; it must not proxy catalog/search/playback requests to the native facade. + +Resolve the saved profile to stable host-created account/source identity. Supply the current server/user/token/device/locale configuration and explicit allowed origins to the JS source factory. Reconfiguration must not generate new source IDs or overwrite the credentials of another account. Never put tokens in CLI arguments, benchmark outputs, public metadata or source IDs. + +Adopt source/resource-specific artwork and playback descriptors. Existing artwork handling still has global-auth assumptions. The UI must not show a real library by silently using a native global credential singleton. Image, subtitle and video headers/trust context can differ and must follow their resource. Keep native downloads, decoding, caching and GPU upload. + +Integrate the existing TLS trust policy with worker networking; self-signed/local-server handling must not become blanket `ignoreSslErrors()`. Unauthorized responses must produce a useful source-specific state rather than wiping unrelated results. Do not invent missing storage or credential-service support because the manifest names a permission. + +### Step D — add a genuine backend selector and enough playback + +Introduce a temporary explicit native/portable-JS Jellyfin selector (CLI/environment or developer setting; choose the smallest suitable surface). No flag currently exists for this. Make diagnostics report the **effective** backend, not just the requested value. Reject an unavailable JS selection rather than falling back silently. + +Select before composition and keep the same core UI, fonts, model policy and playback implementation. Ensure only the chosen path submits catalog requests and playback reports. Reusing a native session bootstrap must not instantiate duplicate progress reporting or protocol timers. Delayed QML warming, disk caches and diagnostics settings must be matched between comparison runs. + +Wire basic details, variant discovery, exact variant selection, direct playback and start/progress/stop reporting. Check seek/resume, audio/subtitles, error handling and the currently selected version. Follow-up work extends transcoding/device profiles and the remaining parity matrix, but a variant must never silently become a different edition/file. + +### First user-testable milestone: acceptance + +- User can explicitly choose native or JS Jellyfin in the same build. +- Actual source provenance shows JS calls for the operations under test. +- A real account browses and paginates a populated library, searches, opens details and plays the chosen version. +- Counts, visible metadata, artwork, identity and basic state/reporting are correct. +- Cancellation/back-navigation cannot publish stale results or corrupt selection. +- Scrolling never calls the backend script for existing row data. +- Repeated/cold page transitions can be measured on identical build/runtime conditions. +- Known unsupported features are visible in the handoff; there is no silent native fallback. + +Do not claim this milestone merely because all unit tests pass or local-fixture navigation is fast. Keep the native implementation until the comparison and parity work have actually justified removing it. + +## 5. Performance measurement that answers the real question + +Use three complementary comparisons: + +1. **Matched native versus JS in the same current build** to attribute provider overhead without confusing it with unrelated UI changes. +2. **Current app versus the actual pre-split executable** for total user-visible regression. Apply equivalent instrumentation to a throwaway baseline build or use a common external observation method; do not compare different timer origins. +3. **Controlled response replay/loopback fixture** to isolate processing from server/network variation, followed by a real server on LAN and target-device tests. + +Record build/provider revision and package hash, effective backend, Qt version, interpreter request, actual renderer/QPA, viewport/scale, device identifier chosen for diagnostics, source count, page limit, payload bytes, result count, cache state, warmup and samples. Do not log response contents, tokens or credential-bearing URLs. If a metadata field is merely a user label rather than measured fact, say so. + +Add optional request tracing with a host-generated operation ID. Useful boundaries: controller dispatch; worker dequeue; HTTP start/response complete; JS normalization complete; native conversion complete; future received; first GUI batch committed; first useful viewport/frame callback. HTTP waiting, queueing, conversion and rendering must not be collapsed into “JS time.” Measure bytes/rows and worker/GUI CPU when available. Keep hot-path tracing disabled or bounded in normal builds; do not add an unbounded event log. + +The worker sink creates a natural place to time native conversion. A small measured hook around provider normalization can separate it from transport. Choose practical instrumentation rather than a large tracing framework. Measure memory and GC/tail behaviour across sustained navigation, not only the first tiny page. + +Workloads must include: + +- Populated movie/TV pages at ordinary sizes (e.g. the existing 72-row default), later pages and filters/sorts; already-normalized cached revisits. +- A search query actually issued to one source and then several sources; responses finishing together and out of order. +- Detail/episode/variant lists, long titles/Unicode, missing metadata, unknown totals and a representative large but bounded response. +- Custom provider selection lists with tens/hundreds of rows and the existing 2,000-candidate fixture; verify virtualization and retained focus. +- Cold process, cold source/engine, evicted page instances, cold artwork and warmed resident pages as **separate labelled cases**. +- High-bitrate playback while browsing/searching, low-memory pressure, a slow/offline source, cancellation and prolonged repeated queries. +- Interpreter-only JS as well as the platform default. JIT must not be necessary to meet the supported profile. + +For display pacing, disable the artificial pump (`SPOOL_BENCH_PUMP_MS=0`) where the normal on-screen render loop suffices; record the setting. Offscreen reports remain useful regression diagnostics but never become panel measurements. Report per-route/per-stage distributions as well as pooled summaries. Use enough repeats for meaningful tails and preserve failures rather than dropping slow samples. + +If a trace regresses, optimize the measured cause: eliminate unused payloads, avoid repeated normalization, cache native pages, reduce bridge churn, bound concurrency, improve model commits, or fix costly QML construction/bindings. Do not jump to Wasm/native providers before identifying an actual provider execution bottleneck. + +**TV safety:** current repository instructions prohibit installing/launching without explicit user permission and prohibit mutating the installed app tree. `tools/webos/bench-device.sh` currently rewrites the installed executable into a wrapper and changes ownership; do not run it as-is. Provide a supported packaged diagnostic/launch path instead, using the platform's actual supported mechanism. Do not assume root is available. No TV action was performed in this session. + +## 6. Complete the source-aware application after the A/B slice + +Registry-level source identity already exists; application ownership still needs conversion. A selected browsing source only chooses the displayed library. The player, queue, outstanding requests, artwork and state writes retain their own source context independently. + +Make source-scoped item/version references first-class in routes, queue entries, cache keys and actions. Source/account removal or disable must cancel the appropriate work and invalidate stale handles, not redirect them. Resolve player features against the playing source and UI actions against their bound source, not a global capability singleton. Run two accounts in one module and another module simultaneously with deliberately colliding backend IDs. + +Add persistent account/source management and migrations from existing profiles. Preserve tokens, settings and authoritative watch state. Differentiate disconnect/disable/remove code/remove source/delete data. Background network failure must not erase a valid configured identity or another source's result cache. + +Review source/module/operation limits against realistic concurrent use. The current bounded runtime is a useful starting point, not a promise that hardcoding four modules is the final ecosystem policy. Avoid one network burst or provider CPU burst starving native rendering. Keep runtime lifetime management off the interactive hot path; the current runtime destructor waits for its worker and should not be triggered by a routine page transition. + +## 7. Complete Jellyfin behaviour and provider-owned UI + +The published Jellyfin repo is reported at `spool-player/spool-jellyfin`; the application pins payload revision `673647c83ff64714ee745aac09829fb63db6ee42` and a deterministic 11,291-byte ZIP. A later reported repo commit updates the SDK snapshot without changing the payload. Inspect the actual current repo and local override before altering pins; do not treat every repository commit as a changed provider release. + +Use `gh repo view`/`gh repo clone` or an existing sibling checkout when available. `SPOOL_JELLYFIN_SOURCE_DIR` already supports unreleased local integration. Do not force a release/tag/pin bump for every joint edit. Do not edit the vendored ZIP as the authoritative source or silently bypass its digest checks. No remote pushes unless explicitly authorized. + +Audit the native parity inventory in `docs/portable-provider-decisions.md` and preserve relevant protocol fixtures. The JS client has endpoint coverage, not proven complete parity. Specific inspection leads from the current payload: + +- `item()` normalizes a subset of requested metadata. Album fields, some inherited artwork, dates/status, external URLs and detailed media-source information used by existing screens need auditing; details requests ask for information the mapper may not return. +- `raw.ParentIndexNumber || null` conflates season zero with missing metadata. Preserve zero where meaningful. +- `String(raw.Id)` can turn a missing ID into the literal `undefined`; validate required IDs rather than manufacturing them. +- Full source negotiation, native capability/device-profile construction, stream-quality policy, audio/subtitle selection, segments/trickplay, transcoding cleanup and event-driven updates need real-server coverage. +- SyncPlay/remote functionality needs backend events/WebSockets as well as REST endpoints. Do not describe REST stubs as feature parity. + +Port protocol operations and provider-specific controllers to JS/QML while leaving generic native timing, playback and transport algorithms native. Avoid a permanent JS facade that still calls Jellyfin's old C++ endpoints; that defeats independently maintained portable providers. + +Move Jellyfin login, linking/profile, management, remote and SyncPlay UI to the provider repository once the public UI-host facilities exist. Provider-specific UI must not require editing shared Spool routes. Preserve navigation tests and behaviour, not fixed globals such as `Session`, `SyncPlay` and `Management` in shared QML. + +The existing `ProviderUiContext`/native list bridge is useful. Finish provider route/action descriptors, creation with source context, Back/cancel, focus restoration, page lifetimes, progress/errors, localization and constrained host operations. A provider should own a complete custom file/torrent/source-selection workflow and return an explicit variant or cancellation. Core need not understand that workflow's domain. + +Keep core content models native and efficient. The generic custom-list `record` map is a separate convenience surface; profile repeated map conversion for large custom delegates and expose appropriate native roles if necessary. Do not replace all core media models with generic JS arrays because the bridge exists. + +## 8. Federated search and exact-version selection + +Build a core coordinator over all enabled/searchable source instances. Query concurrently within a fair budget; display each completed page independently, retain per-source cursor/loading/error state and discard stale generations immediately when the query changes. One unavailable server cannot block or clear successful results. + +Group **logical titles**, retaining all source-specific items/versions. Prefer compatible external IDs, scoped by entity type. Otherwise provisionally group conservative normalized title + exact year + media type. Known conflicting IDs override heuristics; an ID-less result must not bridge two contradictory identified groups. Keep missing/uncertain matches separate unless additional evidence resolves them. + +Do not use grouping as permission to overwrite watched state or resume across copies. Editions can share an identity but differ in timeline. For TV, series identity and episode ordering are separate problems; episode matching needs compatible episode IDs or validated numbering. Keep stable result-group identity, remote focus and menu selection during incremental merges or later splits. + +The chooser shows source/server and provider, quality/provenance/edition and optional **basename**, disabled by default. Unknown metadata stays unknown; do not probe every stream to fill a search screen. One source may expose many variants. Propagate the selected `VariantRef` into resolution and fail rather than substitute an unrelated edition. + +On selecting a logical title, query applicable sources for availability even if they did not return the original text-search hit, where their protocol/index supports this. Catalog matches and confirmed stream versions are different states. Cross-provider scores are not automatically comparable; begin with a deterministic understandable ranking and avoid disruptive reranking. + +## 9. Stremio and future provider validation + +Stremio is still missing. It should exercise the generic contract early after the Jellyfin A/B slice, before stabilizing APIs. A minimal real catalog -> details -> stream-list -> chosen stream -> playback flow is more valuable than completing every generic manager first. + +Inspect with `gh repo view spool-player/spool-stremio`. If absent, create that independently maintained repo with the intended ownership, matching visibility and appropriate licence using `gh repo create` once remote creation is authorized. Do not recreate `spool-jellyfin`. No nested/mutually recursive application submodules; consume the documented small SDK/test tools and export/pin them reproducibly. + +A Spool Stremio provider is not the same thing as each remote Stremio add-on. Implement manifest/resource handling, configurable endpoints, catalog/search pagination, metadata identity, stream discovery, request headers, subtitles and source preparation as supported. Stream-only add-ons can contribute playable versions for an identity found elsewhere; they need not supply a catalog hit. + +Provider QML owns service-specific release/file selection and preparation screens. Distinguish an existing stream, a resource awaiting preparation and an unavailable resource. Actual peer-to-peer torrent transport, if later required, is separate native/remote service functionality; do not accidentally add an entire torrent engine to every script client. + +Plex/Emby remain future implementations unless the user expands scope. Keep the interface neutral enough for their account/server enumeration, media versions, artwork and reporting. Their future presence should not require a new single-active-backend rewrite. + +## 10. Finish the experimental SDK and extension model + +Keep base API revisions at 0.x; bump for an actual breaking base-contract change, not each app build. Version feature/UI extensions independently. No historical-API adapters or universal ABI framework now. The new typed native page envelope is host implementation work, not a native provider ABI or a reason for CPU-specific ZIPs. + +Separate offered provider capabilities from required/optional host services. Unknown offered extensions can be ignored. Missing optional host extensions require real fallback paths, including loading optional QML only when its imports/types exist. Missing required extensions reject activation. Test absent extensions by using the remaining UI, not only importing a module. + +Document the actual supported JS syntax/modules and QML import baseline. Provider factories, per-source state, asynchronous lifetime, cancellation, errors, storage, credentials, resource ownership and UI mounting are the important contracts. Do not expose all concrete core-controller pointers or every private UI component as public API. + +The current validator intentionally rejects unavailable public UI modules/extensions. Keep that fail-closed behaviour until those facilities really exist; update the runtime, validator, manifest capabilities and tests together. Prefer a small real interface to a schema that advertises future services. + +Retain contract tests in the actual shipped QJSEngine, including interpreter mode. Node can supplement syntax/protocol tests but is not proof of Qt compatibility. Produce one platform-independent source ZIP; platform runtime testing is not a native compilation matrix. Host-generated QML/JS caches are disposable runtime optimizations, not required architecture-specific provider payloads. + +## 11. Provider installation, updates and distribution + +ZIP packaging/structural validation already exists. Publisher authentication and activation do not. Keep those distinct. + +Implement a small catalogue/resolver/downloader/activation path with authenticated metadata, exact hashes/lengths, trusted publishers, key rotation, freshness and revocation. Use a maintained design/cryptographic implementation; do not invent a signing protocol. Existing artifact provenance alone does not establish who the user trusts or prevent stale catalogue replay. + +Validate/extract before execution, preserving path/type/size/symlink/case-collision and executable-payload protections. Store immutable versioned packages in user/app-private data, not inside the AppImage, APK or signed application bundle. Keep credentials and authoritative provider state elsewhere. Do not run provider installer scripts. + +Select one compatible version per provider per process. Bundle official Jellyfin; let a verified compatible installed release override it on restart. Updates affect all that module's source instances, preserving identity. Keep compatible known-good recovery, but respect state schema changes and revoked releases. An offline server is not evidence that the new module is broken. + +The resolver uses base/extension/JS/QML/channel/state requirements, not exact Spool version by default. Publish artifacts first and authenticated catalogue metadata last. Stage compatible provider replacements before app-managed incompatible transitions; external/store updates must still start with incompatible modules disabled and accounts retained. One abandoned third-party provider must not block the entire app ecosystem or essential security fixes. + +Expose module installation/enabling/removal separately from account/source configuration and remote add-on setup. Provide useful failure/recovery explanations without exposing credentials. Start with trusted/reviewed packages. Qt JS/QML in-process execution is **not a security sandbox**; static import checks and a narrow facade do not make arbitrary hostile QML safe. Explicitly resolve third-party publisher trust before enabling unrestricted repositories. + +### Channel requirements + +- Desktop direct distributions: bundled packages plus verified source downloads/overrides; retain native host signing/packaging. +- NixOS: AppImage through `appimage-run` is sufficient initially. No Nix-specific provider binaries, package manager or runtime-linking project. +- webOS: test ordinary JS/QML/resource loading from application data on an unrooted target. Native-library execution is no longer required for providers. Use replacement IPK composition only if a real source/resource deployment limitation remains. Never implement it speculatively or compile C++ on the TV. +- Android/Android TV: Google Play distribution is required. Build the reviewed interpreted-source path with documented host access, signed packages and controlled imports; bundle official providers regardless. No downloaded native payloads or native self-updater in that channel. Recheck the actual current Play policy and submission requirements before release. +- Apple: same source packages/runtime, initially bundled. Obtain review/permission for the actual downloaded code and native UI/host interface before enabling independent downloads. Do not represent JS as automatic approval or enable an unapproved path later through a remote flag. Multiple bundled providers and configurable remote services remain available. + +No platform should require provider authors to maintain separate JS implementations simply because its delivery policy differs. Platform-specific optional host capabilities must be declared and handled, not hidden in native binary variants. + +## 12. Final parity, acceptance and cleanup + +Before dropping native Jellyfin, verify the full inventory: authentication/QuickConnect, saved profiles and policy, discovery/manual server entry, all browse/filter/detail/person/list operations, playlists/collections/management, user state, playback and exact version negotiation, tracks/subtitles, transcode/device profiles/cleanup, reports, segments/trickplay, remote sessions and SyncPlay events/clock/queue/UI. + +Provider-agnostic Spool watch-together is subsequent core work, not a blocker for the first JS comparison. Reuse generic native clock/drift/playback hooks; leave Jellyfin-native group interoperability optional. The proposed initial room relay is Worker/Durable Object + WebSockets, not STUN alone. Resolve each participant's own authorized stream and handle edition/timeline differences explicitly; never relay other participants' credential-bearing stream URLs as generic room state. + +Run the multi-source matrix: two accounts in one provider plus another provider; overlapping IDs; source disable/removal during search/playback/UI actions; stale replies; failed/expired authentication; foreground browsing while another source plays; large simultaneous results; downloaded override/restart/recovery; missing optional/required extensions; low-memory pressure. Validate files/resources and source configurations across actual distribution packages. + +Remove obsolete C++ protocol code, hardcoded provider globals, dead compatibility scaffolding and stale documents only once the replacement is genuinely used and tested. Keep useful fixtures, native core optimizations and deterministic contract tests. Do not keep both protocol implementations permanently merely to avoid a rewrite; the temporary native selector is a measurement/parity tool. + +## 13. Working and reporting rules + +Make small conventional commits. Do not push, publish tags, mutate remote repositories, install/launch on a TV or drive the user's desktop without the appropriate explicit authorization. Do not modify mpv for this provider work unless a demonstrated independent issue requires it; its submodule history is separate. + +End the next session with an accurate handoff: commits, commands actually run, build/test results, effective backend now reachable, unsupported features, any data/schema changes, device/channel tests not run, benchmark provenance and the exact next implementation step. Report the first useful A/B milestone when it is reached; do not disappear into the full updater or a new framework first. + +**Priority order:** validate these commits -> real JS catalog/account/artwork integration -> explicit native/JS comparison build with basic playback -> source-aware application and full Jellyfin/UI parity -> federated search/version selection and real Stremio -> verified independent delivery/channel testing -> cleanup/stabilization. + +## References and evidence boundary + +Repository paths and commit IDs above refer to the uploaded snapshot plus the local handoff commits. Original benchmark statistics come from the attached JSON files. Session-report statements are attributed reports, not tests rerun here. Estimates, budgets and sequencing are engineering decisions to verify. + +- [R1] Qt, QQuickWindow: https://doc.qt.io/qt-6/qquickwindow.html — frame callbacks and rendering lifecycle. +- [R2] Qt Quick performance considerations: https://doc.qt.io/qt-6/qtquick-performance.html — GUI-thread budgets, batching and model/worker considerations. +- [R3] Qt Quick software adaptation: https://doc.qt.io/qt-6/qtquick-visualcanvas-adaptations-software.html — distinct non-accelerated rendering path. + +The existing channel-policy document links official Google/Apple rules. Revalidate them during channel implementation/submission; this plan is not a store approval. From 72bad8fd510bf00fdae27f3a96b5dc533785877c Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 03:55:12 +1000 Subject: [PATCH 031/146] feat(api): wire portable JavaScript Jellyfin adapter and backend selector --- CMakeLists.txt | 4 + src/api/JellyfinJsAdapter.cpp | 670 ++++++++++++++++++++++++++++ src/api/JellyfinJsAdapter.h | 113 +++++ src/api/JellyfinProvider.cpp | 70 ++- src/api/JellyfinProvider.h | 24 + src/main.cpp | 48 +- tests/api/JellyfinJsAdapterTest.cpp | 95 ++++ 7 files changed, 998 insertions(+), 26 deletions(-) create mode 100644 src/api/JellyfinJsAdapter.cpp create mode 100644 src/api/JellyfinJsAdapter.h create mode 100644 tests/api/JellyfinJsAdapterTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ed56c1d..a4a740db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,6 +293,8 @@ set(SPOOL_JELLYFIN_SOURCES src/api/ItemsQuery.h src/api/JellyfinApiFacade.cpp src/api/JellyfinApiFacade.h + src/api/JellyfinJsAdapter.cpp + src/api/JellyfinJsAdapter.h src/api/JellyfinSession.h src/api/JellyfinSettingsBridge.cpp src/api/JellyfinProvider.cpp @@ -812,6 +814,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) items-query tests/api/ItemsQueryTest.cpp playback-negotiation tests/api/PlaybackNegotiationTest.cpp jellyfin-api-facade-url tests/api/JellyfinApiFacadeUrlTest.cpp + jellyfin-js-adapter tests/api/JellyfinJsAdapterTest.cpp ) set(JELLYFIN_TESTS_app browse-session-controller tests/app/BrowseSessionControllerTest.cpp @@ -954,6 +957,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${JELLYFIN_TEST_ENV}") target_sources(providers-tests PRIVATE ${spool_provider_bundle_sources}) + target_sources(api-tests PRIVATE ${spool_provider_bundle_sources}) add_test(NAME provider-package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) diff --git a/src/api/JellyfinJsAdapter.cpp b/src/api/JellyfinJsAdapter.cpp new file mode 100644 index 00000000..6d06f2eb --- /dev/null +++ b/src/api/JellyfinJsAdapter.cpp @@ -0,0 +1,670 @@ +#include "JellyfinJsAdapter.h" + +#include "../common/MetaJson.h" +#include "../provider/ProviderMediaPage.h" +#include "../provider/ProviderRegistry.h" + +#include +#include +#include +#include +#include + +#include + +namespace JellyfinNative { + +class JellyfinJsAdapter::Playback final : public PlaybackSource { + Q_OBJECT + +public: + explicit Playback(JellyfinJsAdapter *adapter, QObject *parent = nullptr) + : PlaybackSource(parent) + , m_adapter(adapter) + { + } + + QByteArray mediaRequestHeaders() const override + { + return m_adapter->mediaRequestHeaders(); + } + + QUrl mediaOrigin() const override + { + return m_adapter->mediaOrigin(); + } + + int playbackParallelRequests() const override + { + return 2; + } + + bool signedIn() const override + { + return m_adapter->signedIn(); + } + + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override + { + return m_adapter->trickplayTileUrl(itemId, width, tileIndex); + } + + QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override + { + return m_adapter->resolvePlayback(std::move(item), forceTranscode); + } + + QCoro::Task> fetchMediaSegments(QString itemId) override + { + return m_adapter->fetchMediaSegments(std::move(itemId)); + } + + QCoro::Task> fetchSeriesEpisodes(QString seriesId) override + { + return m_adapter->fetchEpisodes(std::move(seriesId)); + } + + QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted) override + { + return m_adapter->reportPlaybackStart(std::move(session), playbackRate, volume, muted); + } + + QCoro::Task reportPlaybackProgress(PlaybackSession session, qint64 positionTicks, bool paused, + double playbackRate, int volume, bool muted) override + { + return m_adapter->reportPlaybackProgress( + std::move(session), positionTicks, paused, playbackRate, volume, muted); + } + + QCoro::Task reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) override + { + return m_adapter->reportPlaybackStopped(std::move(session), positionTicks, failed, playbackRate); + } + + void emitCredentialsChanged() + { + emit credentialsChanged(); + } + +private: + JellyfinJsAdapter *m_adapter; +}; + +JellyfinJsAdapter::JellyfinJsAdapter(ProviderRegistry *registry, QObject *parent) + : QObject(parent) + , m_registry(registry) + , m_playback(new Playback(this, this)) +{ +} + +JellyfinJsAdapter::~JellyfinJsAdapter() = default; + +void JellyfinJsAdapter::configure(const QString& sourceId, const QString& serverUrl, const QString& sessionToken) +{ + const bool changed = m_sourceId != sourceId || m_sessionToken != sessionToken; + m_sourceId = sourceId; + m_serverUrl = serverUrl; + m_sessionToken = sessionToken; + if (changed && m_playback) + m_playback->emitCredentialsChanged(); +} + +void JellyfinJsAdapter::clear() +{ + m_sourceId.clear(); + m_serverUrl.clear(); + m_sessionToken.clear(); + if (m_playback) + m_playback->emitCredentialsChanged(); +} + +void JellyfinJsAdapter::setDeviceId(const QString& deviceId) +{ + m_deviceId = deviceId; +} + +PlaybackSource *JellyfinJsAdapter::playback() +{ + return m_playback; +} + +bool JellyfinJsAdapter::signedIn() const +{ + return !m_sourceId.isEmpty(); +} + +QString JellyfinJsAdapter::libraryScopeKey() const +{ + return m_sourceId; +} + +QCoro::Task JellyfinJsAdapter::fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) +{ + if (!descriptor.isValid() || m_sourceId.isEmpty()) + co_return PagedMovieItems { {}, 0, std::max(0, startIndex), std::clamp(limit, 1, 200) }; + + const int effectiveStart = std::max(0, startIndex); + const int effectiveLimit = std::clamp(limit, 1, 100); + + QString operation = QStringLiteral("browse"); + QVariantMap args; + args.insert(QStringLiteral("cursor"), QString::number(effectiveStart)); + args.insert(QStringLiteral("limit"), effectiveLimit); + + switch (descriptor.kind) { + case BrowseKind::Library: + if (!descriptor.id.isEmpty()) + args.insert(QStringLiteral("parentId"), descriptor.id); + if (!descriptor.collectionType.isEmpty()) + args.insert(QStringLiteral("collectionType"), descriptor.collectionType); + break; + case BrowseKind::FolderChildren: + args.insert(QStringLiteral("parentId"), descriptor.id); + args.insert(QStringLiteral("recursive"), false); + break; + case BrowseKind::BoxSet: + args.insert(QStringLiteral("parentId"), descriptor.id); + args.insert(QStringLiteral("recursive"), false); + break; + case BrowseKind::Genre: + if (!descriptor.id.isEmpty()) + args.insert(QStringLiteral("parentId"), descriptor.id); + if (!descriptor.collectionType.isEmpty()) + args.insert(QStringLiteral("collectionType"), descriptor.collectionType); + args.insert( + QStringLiteral("filters"), QVariantMap { { QStringLiteral("Genres"), QVariantList { descriptor.name } } }); + break; + case BrowseKind::Studio: + if (!descriptor.id.isEmpty()) + args.insert(QStringLiteral("parentId"), descriptor.id); + if (!descriptor.collectionType.isEmpty()) + args.insert(QStringLiteral("collectionType"), descriptor.collectionType); + args.insert( + QStringLiteral("filters"), QVariantMap { { QStringLiteral("StudioIds"), QVariantList { descriptor.id } } }); + break; + case BrowseKind::SeriesSeasons: + operation = QStringLiteral("seasons"); + args.insert(QStringLiteral("seriesId"), descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId); + break; + case BrowseKind::SeasonEpisodes: + operation = QStringLiteral("episodes"); + args.insert(QStringLiteral("seriesId"), descriptor.seriesId); + if (!descriptor.seasonId.isEmpty()) + args.insert(QStringLiteral("seasonId"), descriptor.seasonId); + break; + case BrowseKind::Person: + operation = QStringLiteral("personItems"); + args.insert(QStringLiteral("personId"), descriptor.id); + break; + case BrowseKind::Playlist: + case BrowseKind::ArtistAlbums: + case BrowseKind::None: + break; + } + + if (descriptor.kind == BrowseKind::Library) { + if (queryOptions.contains(QStringLiteral("sortBy"))) + args.insert(QStringLiteral("sortBy"), queryOptions.value(QStringLiteral("sortBy"))); + if (queryOptions.contains(QStringLiteral("sortOrder"))) + args.insert(QStringLiteral("sortOrder"), queryOptions.value(QStringLiteral("sortOrder"))); + if (queryOptions.contains(QStringLiteral("filters"))) { + const QVariantMap rawFilters = queryOptions.value(QStringLiteral("filters")).toMap(); + QVariantMap filtered; + static const QStringList allowed = { QStringLiteral("Filters"), QStringLiteral("Genres"), + QStringLiteral("OfficialRatings"), QStringLiteral("Tags"), QStringLiteral("Years"), + QStringLiteral("StudioIds"), QStringLiteral("SeriesStatus"), QStringLiteral("VideoTypes"), + QStringLiteral("IsHd"), QStringLiteral("Is4K"), QStringLiteral("Is3D"), QStringLiteral("HasSubtitles"), + QStringLiteral("HasTrailer"), QStringLiteral("IsMissing"), QStringLiteral("IsUnaired"), + QStringLiteral("NameStartsWith"), QStringLiteral("NameLessThan") }; + for (auto it = rawFilters.begin(); it != rawFilters.end(); ++it) { + if (allowed.contains(it.key())) + filtered.insert(it.key(), it.value()); + } + if (!filtered.isEmpty()) + args.insert(QStringLiteral("filters"), filtered); + } + } + + ProviderMediaPage page + = co_await m_registry->callSourceMediaPage(m_sourceId, operation, args, QString(), effectiveLimit); + + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + + if (descriptor.kind == BrowseKind::SeriesSeasons) { + const QString seriesId = descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId; + for (MovieItem& item : items) { + if (item.seriesId.isEmpty()) + item.seriesId = seriesId; + } + } + + const int totalCount = page.total.has_value() + ? static_cast(page.total.value()) + : (page.exhausted ? effectiveStart + static_cast(items.size()) + : effectiveStart + static_cast(items.size()) + 1); + + co_return PagedMovieItems { std::move(items), totalCount, effectiveStart, effectiveLimit }; +} + +QCoro::Task JellyfinJsAdapter::fetchItemDetails(QString itemId) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return MovieItem {}; + + QVariantMap res = co_await m_registry->callSource( + m_sourceId, QStringLiteral("details"), { { QStringLiteral("itemId"), itemId } }); + const QVariantMap row = res.value(QStringLiteral("item")).toMap(); + MovieItem media; + media.id = row.value(QStringLiteral("id")).toString(); + media.title = row.value(QStringLiteral("title")).toString(); + media.sortName = row.value(QStringLiteral("sortName")).toString(); + if (media.sortName.isEmpty()) + media.sortName = media.title; + media.itemType = row.value(QStringLiteral("type")).toString(); + media.overview = row.value(QStringLiteral("overview")).toString(); + media.posterTag = row.value(QStringLiteral("posterTag")).toString(); + media.backdropTag = row.value(QStringLiteral("backdropTag")).toString(); + media.logoTag = row.value(QStringLiteral("logoTag")).toString(); + media.seriesId = row.value(QStringLiteral("seriesId")).toString(); + media.seasonId = row.value(QStringLiteral("seasonId")).toString(); + media.seriesName = row.value(QStringLiteral("seriesName")).toString(); + media.year = row.value(QStringLiteral("year")).toInt(); + media.seasonNumber = row.value(QStringLiteral("season")).toInt(); + media.episodeNumber = row.value(QStringLiteral("episode")).toInt(); + media.runtimeTicks = row.value(QStringLiteral("runtimeTicks")).toLongLong(); + media.resumeTicks = row.value(QStringLiteral("resumeTicks")).toLongLong(); + media.favorite = row.value(QStringLiteral("favorite")).toBool(); + media.played = row.value(QStringLiteral("played")).toBool(); + media.genres = row.value(QStringLiteral("genres")).toStringList(); + media.tags = row.value(QStringLiteral("tags")).toStringList(); + media.studios = row.value(QStringLiteral("studios")).toStringList(); + media.officialRating = row.value(QStringLiteral("officialRating")).toString(); + media.communityRating = row.value(QStringLiteral("communityRating")).toDouble(); + + const QVariantList people = row.value(QStringLiteral("people")).toList(); + for (const auto& pVal : people) { + const QVariantMap p = pVal.toMap(); + PersonItem person; + person.id = p.value(QStringLiteral("id")).toString(); + person.name = p.value(QStringLiteral("name")).toString(); + person.type = p.value(QStringLiteral("type")).toString(); + person.role = p.value(QStringLiteral("role")).toString(); + person.imageTag = p.value(QStringLiteral("imageTag")).toString(); + media.people.append(std::move(person)); + } + co_return media; +} + +QCoro::Task> JellyfinJsAdapter::fetchSeasons(QString seriesId) +{ + const BrowseDescriptor desc = BrowseDescriptor::seriesSeasons(seriesId); + PagedMovieItems page = co_await fetchBrowsePage(desc, 0, 100); + for (auto& item : page.items) { + if (item.seriesId.isEmpty()) + item.seriesId = seriesId; + } + co_return page.items; +} + +QCoro::Task> JellyfinJsAdapter::fetchEpisodes(QString seriesId, QString seasonId) +{ + const BrowseDescriptor desc = BrowseDescriptor::seasonEpisodes(seriesId, seasonId); + PagedMovieItems page = co_await fetchBrowsePage(desc, 0, 100); + co_return page.items; +} + +QCoro::Task> JellyfinJsAdapter::fetchResumeItems(int limit) +{ + if (m_sourceId.isEmpty()) + co_return {}; + const int effectiveLimit = std::clamp(limit, 1, 60); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("resume"), + { { QStringLiteral("limit"), effectiveLimit } }, QString(), effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task> JellyfinJsAdapter::fetchNextUpEpisodes(int limit) +{ + if (m_sourceId.isEmpty()) + co_return {}; + const int effectiveLimit = std::clamp(limit, 1, 60); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("nextUp"), + { { QStringLiteral("limit"), effectiveLimit } }, QString(), effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task> JellyfinJsAdapter::fetchLatestItems(QString parentId, int limit) +{ + if (m_sourceId.isEmpty()) + co_return {}; + const int effectiveLimit = std::clamp(limit, 1, 60); + QVariantMap args { { QStringLiteral("limit"), effectiveLimit } }; + if (!parentId.isEmpty()) + args.insert(QStringLiteral("parentId"), parentId); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage( + m_sourceId, QStringLiteral("latest"), args, QString(), effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task> JellyfinJsAdapter::fetchSimilarItems(QString itemId, int limit) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return {}; + const int effectiveLimit = std::clamp(limit, 1, 60); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("similar"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("limit"), effectiveLimit } }, QString(), + effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task JellyfinJsAdapter::fetchItemsByPerson(QString personId, int maximumItems) +{ + PersonCredits credits; + if (personId.isEmpty() || m_sourceId.isEmpty()) + co_return credits; + const int effectiveLimit = std::clamp(maximumItems, 1, 100); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("personItems"), + { { QStringLiteral("personId"), personId }, { QStringLiteral("limit"), effectiveLimit } }, QString(), + effectiveLimit); + credits.items.reserve(page.items.size()); + for (auto& row : page.items) + credits.items.push_back(std::move(row.media)); + co_return credits; +} + +QCoro::Task> JellyfinJsAdapter::fetchLibraries() +{ + if (m_sourceId.isEmpty()) + co_return {}; + QVariantMap res = co_await m_registry->callSource(m_sourceId, QStringLiteral("libraries")); + std::vector libraries; + const QVariantList items = res.value(QStringLiteral("items")).toList(); + libraries.reserve(items.size()); + for (const auto& itemVal : items) { + const QVariantMap row = itemVal.toMap(); + LibraryItem item; + item.id = row.value(QStringLiteral("id")).toString(); + item.name = row.value(QStringLiteral("title")).toString(); + item.collectionType = row.value(QStringLiteral("collectionType")).toString(); + item.imageTag = row.value(QStringLiteral("posterTag")).toString(); + libraries.push_back(std::move(item)); + } + co_return libraries; +} + +QCoro::Task JellyfinJsAdapter::fetchLibraryFilterOptions(QString libraryId, QString collectionType) +{ + if (libraryId.isEmpty() || m_sourceId.isEmpty()) + co_return QVariantMap {}; + QVariantMap args { { QStringLiteral("parentId"), libraryId } }; + if (!collectionType.isEmpty()) + args.insert(QStringLiteral("collectionType"), collectionType); + QVariantMap filters = co_await m_registry->callSource(m_sourceId, QStringLiteral("filterOptions"), args); + QVariantMap options; + if (filters.contains(QStringLiteral("Genres"))) + options.insert(QStringLiteral("genres"), filters.value(QStringLiteral("Genres"))); + if (filters.contains(QStringLiteral("OfficialRatings"))) + options.insert(QStringLiteral("officialRatings"), filters.value(QStringLiteral("OfficialRatings"))); + if (filters.contains(QStringLiteral("Tags"))) + options.insert(QStringLiteral("tags"), filters.value(QStringLiteral("Tags"))); + if (filters.contains(QStringLiteral("Years"))) + options.insert(QStringLiteral("years"), filters.value(QStringLiteral("Years"))); + co_return options; +} + +QCoro::Task> JellyfinJsAdapter::fetchItemsByIds(QStringList itemIds) +{ + if (itemIds.isEmpty() || m_sourceId.isEmpty()) + co_return {}; + QVariantMap filters { { QStringLiteral("Ids"), itemIds.join(QLatin1Char(',')) } }; + const int effectiveLimit = std::clamp(static_cast(itemIds.size()), 1, 100); + QVariantMap args { { QStringLiteral("filters"), filters }, { QStringLiteral("limit"), effectiveLimit } }; + ProviderMediaPage page = co_await m_registry->callSourceMediaPage( + m_sourceId, QStringLiteral("browse"), args, QString(), effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task> JellyfinJsAdapter::searchItems(QString searchTerm, int limit) +{ + if (searchTerm.isEmpty() || m_sourceId.isEmpty()) + co_return {}; + const int effectiveLimit = std::clamp(limit, 1, 100); + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("search"), + { { QStringLiteral("query"), searchTerm }, { QStringLiteral("limit"), effectiveLimit } }, QString(), + effectiveLimit); + std::vector items; + items.reserve(page.items.size()); + for (auto& row : page.items) + items.push_back(std::move(row.media)); + co_return items; +} + +QCoro::Task> JellyfinJsAdapter::fetchSearchSuggestions(int limit) +{ + return fetchResumeItems(limit); +} + +QCoro::Task JellyfinJsAdapter::setItemFavorite(QString itemId, bool favorite) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("favorite"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), favorite } }); +} + +QCoro::Task JellyfinJsAdapter::setItemPlayed(QString itemId, bool played) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("played"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), played } }); +} + +QCoro::Task JellyfinJsAdapter::setItemPlaybackPosition(QString itemId, qint64 positionTicks) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("progress"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("positionTicks"), QString::number(positionTicks) } }); +} + +QString JellyfinJsAdapter::imageUrl(const ImageRequest& request) const +{ + const QString origin = m_serverUrl; + if (origin.isEmpty() || request.itemId.isEmpty() || request.tag.isEmpty() || request.imageType.isEmpty()) + return {}; + QUrl url = serverUrlWithPath( + origin, { QStringLiteral("Items"), request.itemId, QStringLiteral("Images"), request.imageType }); + QUrlQuery query; + if (request.fillWidth > 0 && request.fillHeight > 0) { + query.addQueryItem(QStringLiteral("fillWidth"), QString::number(request.fillWidth)); + query.addQueryItem(QStringLiteral("fillHeight"), QString::number(request.fillHeight)); + } else { + query.addQueryItem(QStringLiteral("maxWidth"), QString::number(request.maxWidth)); + } + query.addQueryItem(QStringLiteral("quality"), QString::number(request.quality)); + query.addQueryItem(QStringLiteral("format"), request.format); + query.addQueryItem(QStringLiteral("tag"), request.tag); + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); +} + +QCoro::Task JellyfinJsAdapter::resolvePlayback(MovieItem movie, bool forceTranscode) +{ + if (m_sourceId.isEmpty()) + throw std::runtime_error("playback_not_ready"); + + QString variantId; + if (!movie.mediaSources.isEmpty()) { + variantId = movie.mediaSources.first().id; + } else { + QVariantMap vRes = co_await m_registry->callSource( + m_sourceId, QStringLiteral("variants"), { { QStringLiteral("itemId"), movie.id } }); + const QVariantList variants = vRes.value(QStringLiteral("variants")).toList(); + if (!variants.isEmpty()) + variantId = variants.first().toMap().value(QStringLiteral("id")).toString(); + } + if (variantId.isEmpty()) + variantId = movie.id; + + QVariantMap args; + args.insert(QStringLiteral("itemId"), movie.id); + args.insert(QStringLiteral("variantId"), variantId); + args.insert(QStringLiteral("forceTranscode"), forceTranscode); + args.insert(QStringLiteral("positionTicks"), movie.resumeTicks); + + QVariantMap result = co_await m_registry->callSource(m_sourceId, QStringLiteral("resolve"), args); + + PlaybackSession session; + session.itemId = movie.id; + session.title = movie.title; + session.itemType = movie.itemType; + const QVariantMap video = result.value(QStringLiteral("video")).toMap(); + session.url = video.value(QStringLiteral("url")).toString(); + session.mediaSourceId = result.value(QStringLiteral("variantId")).toString(); + session.playSessionId = result.value(QStringLiteral("playSessionId")).toString(); + session.playMethod = result.value(QStringLiteral("playMethod")).toString(); + if (session.playMethod.isEmpty()) + session.playMethod = QStringLiteral("DirectPlay"); + session.startTimeTicks = movie.resumeTicks; + session.runtimeTicks = movie.runtimeTicks; + + const QVariantList streams = result.value(QStringLiteral("streams")).toList(); + for (const auto& sVal : streams) { + const QVariantMap s = sVal.toMap(); + MediaStreamInfo info; + info.index = s.value(QStringLiteral("index")).toInt(); + info.type = s.value(QStringLiteral("type")).toString(); + info.codec = s.value(QStringLiteral("codec")).toString(); + info.language = s.value(QStringLiteral("language")).toString(); + info.title = s.value(QStringLiteral("title")).toString(); + info.displayTitle = info.title; + info.width = s.value(QStringLiteral("width")).toInt(); + info.height = s.value(QStringLiteral("height")).toInt(); + info.channels = s.value(QStringLiteral("channels")).toInt(); + info.bitRate = s.value(QStringLiteral("bitrate")).toInt(); + info.videoRange = s.value(QStringLiteral("range")).toString(); + info.videoRangeType = info.videoRange; + info.isDefault = s.value(QStringLiteral("default")).toBool(); + info.isForced = s.value(QStringLiteral("forced")).toBool(); + info.isExternal = s.value(QStringLiteral("external")).toBool(); + session.mediaStreams.append(std::move(info)); + } + + try { + session.segments = co_await fetchMediaSegments(movie.id); + } catch (...) { + } + + session.trickplay.width = 320; + co_return session; +} + +QCoro::Task> JellyfinJsAdapter::fetchMediaSegments(QString itemId) +{ + if (itemId.isEmpty() || m_sourceId.isEmpty()) + co_return {}; + QVariantMap res = co_await m_registry->callSource( + m_sourceId, QStringLiteral("segments"), { { QStringLiteral("itemId"), itemId } }); + std::vector segments; + const QVariantList list = res.value(QStringLiteral("segments")).toList(); + segments.reserve(list.size()); + for (const auto& val : list) { + const QVariantMap map = val.toMap(); + MediaSegment seg; + seg.id = map.value(QStringLiteral("Id")).toString(); + seg.type = map.value(QStringLiteral("Type")).toString(); + seg.startTicks = map.value(QStringLiteral("StartTicks")).toLongLong(); + seg.endTicks = map.value(QStringLiteral("EndTicks")).toLongLong(); + segments.push_back(std::move(seg)); + } + co_return segments; +} + +QByteArray JellyfinJsAdapter::mediaRequestHeaders() const +{ + if (m_sessionToken.isEmpty()) + return {}; + return "X-Emby-Token: " + m_sessionToken.toUtf8() + "\n"; +} + +QUrl JellyfinJsAdapter::mediaOrigin() const +{ + return QUrl(m_serverUrl); +} + +QString JellyfinJsAdapter::trickplayTileUrl(const QString& itemId, int width, int tileIndex) const +{ + return m_serverUrl + QStringLiteral("/Items/") + itemId + QStringLiteral("/Images/Trickplay/") + + QString::number(width) + QLatin1Char('/') + QString::number(tileIndex); +} + +QCoro::Task JellyfinJsAdapter::reportPlaybackStart( + PlaybackSession session, double playbackRate, int volume, bool muted) +{ + if (m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), + { { QStringLiteral("event"), QStringLiteral("start") }, { QStringLiteral("itemId"), session.itemId }, + { QStringLiteral("variantId"), session.mediaSourceId }, + { QStringLiteral("playSessionId"), session.playSessionId }, + { QStringLiteral("positionTicks"), QString::number(session.startTimeTicks) }, + { QStringLiteral("rate"), playbackRate }, { QStringLiteral("volume"), volume }, + { QStringLiteral("muted"), muted }, { QStringLiteral("playMethod"), session.playMethod } }); +} + +QCoro::Task JellyfinJsAdapter::reportPlaybackProgress( + PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) +{ + if (m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), + { { QStringLiteral("event"), QStringLiteral("progress") }, { QStringLiteral("itemId"), session.itemId }, + { QStringLiteral("variantId"), session.mediaSourceId }, + { QStringLiteral("playSessionId"), session.playSessionId }, + { QStringLiteral("positionTicks"), QString::number(positionTicks) }, { QStringLiteral("paused"), paused }, + { QStringLiteral("rate"), playbackRate }, { QStringLiteral("volume"), volume }, + { QStringLiteral("muted"), muted }, { QStringLiteral("playMethod"), session.playMethod } }); +} + +QCoro::Task JellyfinJsAdapter::reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool, double playbackRate) +{ + if (m_sourceId.isEmpty()) + co_return; + co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), + { { QStringLiteral("event"), QStringLiteral("stop") }, { QStringLiteral("itemId"), session.itemId }, + { QStringLiteral("variantId"), session.mediaSourceId }, + { QStringLiteral("playSessionId"), session.playSessionId }, + { QStringLiteral("positionTicks"), QString::number(positionTicks) }, + { QStringLiteral("rate"), playbackRate } }); +} + +} // namespace JellyfinNative + +#include "JellyfinJsAdapter.moc" diff --git a/src/api/JellyfinJsAdapter.h b/src/api/JellyfinJsAdapter.h new file mode 100644 index 00000000..483d20cc --- /dev/null +++ b/src/api/JellyfinJsAdapter.h @@ -0,0 +1,113 @@ +#pragma once + +#include "../media/MediaTypes.h" +#include "../provider/ArtworkSource.h" +#include "../provider/Catalog.h" +#include "../provider/PlaybackSource.h" +#include "../provider/SearchSource.h" +#include "../provider/UserItemStateSink.h" + +#include +#include +#include +#include +#include + +#include + +namespace JellyfinNative { + +class ProviderRegistry; + +// Bridges the portable JavaScript Jellyfin provider (spool.jellyfin) into the +// native core interfaces: Catalog, SearchSource, PlaybackSource, ArtworkSource, +// and UserItemStateSink. +// +// Bulk listing and search results are decoded on the worker thread into +// ProviderMediaPage without converting QVariant trees or JSON on the GUI thread. +class JellyfinJsAdapter final : public QObject, + public Catalog, + public SearchSource, + public UserItemStateSink, + public ArtworkSource { + Q_OBJECT + +public: + explicit JellyfinJsAdapter(ProviderRegistry *registry, QObject *parent = nullptr); + ~JellyfinJsAdapter() override; + + void configure(const QString& sourceId, const QString& serverUrl, const QString& sessionToken); + void clear(); + void setDeviceId(const QString& deviceId); + + Catalog *catalog() + { + return this; + } + SearchSource *search() + { + return this; + } + UserItemStateSink *itemState() + { + return this; + } + ArtworkSource *artwork() + { + return this; + } + PlaybackSource *playback(); + + // Catalog + bool signedIn() const override; + QString libraryScopeKey() const override; + QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; + QCoro::Task fetchItemDetails(QString itemId) override; + QCoro::Task> fetchSeasons(QString seriesId) override; + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; + QCoro::Task> fetchResumeItems(int limit = 24) override; + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; + QCoro::Task> fetchLibraries() override; + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchItemsByIds(QStringList itemIds) override; + + // SearchSource + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + + // UserItemStateSink + QCoro::Task setItemFavorite(QString itemId, bool favorite) override; + QCoro::Task setItemPlayed(QString itemId, bool played) override; + QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; + + // ArtworkSource + QString imageUrl(const ImageRequest& request) const override; + + // Playback + QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode); + QCoro::Task> fetchMediaSegments(QString itemId); + QByteArray mediaRequestHeaders() const; + QUrl mediaOrigin() const; + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; + QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted); + QCoro::Task reportPlaybackProgress( + PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted); + QCoro::Task reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate); + +private: + class Playback; + + ProviderRegistry *m_registry = nullptr; + Playback *m_playback = nullptr; + QString m_sourceId; + QString m_serverUrl; + QString m_sessionToken; + QString m_deviceId; +}; + +} // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp index dca38c8a..b4f9f5d1 100644 --- a/src/api/JellyfinProvider.cpp +++ b/src/api/JellyfinProvider.cpp @@ -14,7 +14,9 @@ #include "../models/DiscoveredServerModel.h" #include "../platform/PlatformPlaybackRuntime.h" #include "../player/PlayerController.h" +#include "../provider/ProviderRegistry.h" #include "JellyfinApiFacade.h" +#include "JellyfinJsAdapter.h" #include "JellyfinSettingsBridge.h" #if defined(JELLYFIN_NATIVE_WEBOS) #include "../platform/webos/WebOSDeviceName.h" @@ -30,9 +32,19 @@ namespace JellyfinNative { JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObject *parent) : Provider(parent) + , m_backend(context.backend) + , m_registry(context.registry) + , m_deviceName(context.deviceName) + , m_appVersion(context.appVersion) , m_database(context.database) , m_tlsTrust(context.tlsTrust) { + if (m_backend == Backend::JavaScript) { + if (!m_registry) + throw std::runtime_error("javascript_provider_unavailable"); + m_jsAdapter = std::make_unique(m_registry, this); + } + m_api = new JellyfinApiFacade(context.network, context.tlsTrust, this); m_api->setDeviceIdentity({}, context.deviceName, context.appVersion); #if defined(JELLYFIN_NATIVE_WEBOS) @@ -89,7 +101,14 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje connect(m_session, &SessionController::logoutStarted, this, &Provider::signOutStarted); // The session drives the parts that only make sense while signed in. - connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession&) { + connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession& auth) { + if (m_backend == Backend::JavaScript) { + Async::runScoped( + this, configureJsSourceAsync(auth), []() {}, + [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); + return; + } + if (m_artwork) m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); if (m_syncPlay) @@ -131,6 +150,9 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje }); connect(m_session, &SessionController::loggedOut, this, [this]() { leaveSessionServices(); + if (m_jsAdapter) + m_jsAdapter->clear(); + m_jsSourceId.clear(); if (m_artwork) m_artwork->setAuthorizationHeader({}); if (m_management) @@ -154,38 +176,41 @@ QString JellyfinProvider::displayName() const Provider::Capabilities JellyfinProvider::capabilities() const { + if (m_backend == Backend::JavaScript) { + return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | QuickConnect; + } return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | LibraryManagement | SyncPlay | RemoteControl | QuickConnect | PeerRelay | StreamQuality; } PlaybackSource *JellyfinProvider::playback() { - return m_api; + return m_backend == Backend::JavaScript ? m_jsAdapter->playback() : m_api; } Catalog *JellyfinProvider::catalog() { - return m_api; + return m_backend == Backend::JavaScript ? m_jsAdapter->catalog() : m_api; } ArtworkSource *JellyfinProvider::artwork() { - return m_api; + return m_backend == Backend::JavaScript ? m_jsAdapter->artwork() : m_api; } SearchSource *JellyfinProvider::search() { - return m_api; + return m_backend == Backend::JavaScript ? m_jsAdapter->search() : m_api; } UserItemStateSink *JellyfinProvider::itemState() { - return m_api; + return m_backend == Backend::JavaScript ? m_jsAdapter->itemState() : m_api; } StreamQualityControl *JellyfinProvider::streamQuality() { - return m_api; + return m_backend == Backend::JavaScript ? nullptr : m_api; } GroupPlayback *JellyfinProvider::groupPlayback() @@ -261,6 +286,8 @@ void JellyfinProvider::shutdown() bool JellyfinProvider::ready() const { + if (m_backend == Backend::JavaScript) + return m_session->authenticated() && m_jsAdapter && m_jsAdapter->signedIn(); return m_session->authenticated(); } @@ -280,7 +307,10 @@ bool JellyfinProvider::restoreFromStorage(QVariantMap values, std::vectorsetDeviceId(deviceId); + if (m_jsAdapter) + m_jsAdapter->setDeviceId(deviceId); } void JellyfinProvider::setLocale(const QString& bcp47) @@ -293,6 +323,32 @@ bool JellyfinProvider::handleUnauthorized(const std::exception_ptr& error) return m_session->handleUnauthorized(error); } +QCoro::Task JellyfinProvider::configureJsSourceAsync(const AuthSession& session) +{ + if (!m_registry) + co_return; + const QString serverUrl = m_session->serverUrl(); + if (serverUrl.isEmpty() || session.userId.isEmpty() || session.accessToken.isEmpty()) + co_return; + + QVariantMap config { { QStringLiteral("server"), serverUrl }, { QStringLiteral("userId"), session.userId }, + { QStringLiteral("token"), session.accessToken }, + { QStringLiteral("deviceId"), m_deviceId.isEmpty() ? QStringLiteral("spool") : m_deviceId }, + { QStringLiteral("deviceName"), m_deviceName.isEmpty() ? QStringLiteral("Spool") : m_deviceName }, + { QStringLiteral("clientVersion"), m_appVersion.isEmpty() ? QStringLiteral("0.1.0") : m_appVersion } }; + QList origins { QUrl(serverUrl) }; + const QString sourceId = co_await m_registry->configureSource( + QStringLiteral("spool.jellyfin"), session.userId, QStringLiteral("library"), serverUrl, config, origins); + + m_jsSourceId = sourceId; + m_jsAdapter->configure(sourceId, serverUrl, session.accessToken); + if (m_artwork) + m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); + m_discovery->stop(); + setHasDefaultProfile(true); + emit sessionStarted(); +} + void JellyfinProvider::resumeServerDiscovery() { Async::runScoped( diff --git a/src/api/JellyfinProvider.h b/src/api/JellyfinProvider.h index 41bac9e6..20245203 100644 --- a/src/api/JellyfinProvider.h +++ b/src/api/JellyfinProvider.h @@ -11,12 +11,15 @@ class QNetworkAccessManager; namespace JellyfinNative { class ArtworkService; +struct AuthSession; class DatabaseManager; class DiscoveredServerModel; class DiscoveryController; class JellyfinApiFacade; +class JellyfinJsAdapter; class JellyfinSettingsBridge; class LibraryManagementController; +class ProviderRegistry; class QuickConnectController; class RemoteControlController; class SessionController; @@ -25,11 +28,15 @@ class SyncPlayController; class TlsTrustController; struct JellyfinProviderContext { + enum class Backend { Native, JavaScript }; + QNetworkAccessManager *network = nullptr; TlsTrustController *tlsTrust = nullptr; DatabaseManager *database = nullptr; + ProviderRegistry *registry = nullptr; QString deviceName; QString appVersion; + Backend backend = Backend::Native; }; // Everything Jellyfin-specific, composed in one place: the API facade, LAN @@ -111,6 +118,15 @@ class JellyfinProvider final : public Provider { { return m_settingsBridge; } + using Backend = JellyfinProviderContext::Backend; + Backend backend() const + { + return m_backend; + } + QString backendName() const + { + return m_backend == Backend::JavaScript ? QStringLiteral("js") : QStringLiteral("native"); + } private: // Shows the servers seen last time and scans for more while nobody is @@ -118,9 +134,17 @@ class JellyfinProvider final : public Provider { void resumeServerDiscovery(); void cacheDiscoveredServers(); QCoro::Task restoreDiscoveredServersAsync(); + QCoro::Task configureJsSourceAsync(const AuthSession& session); void leaveSessionServices(); void setHasDefaultProfile(bool hasDefaultProfile); + Backend m_backend = Backend::Native; + ProviderRegistry *m_registry = nullptr; + std::unique_ptr m_jsAdapter; + QString m_deviceId; + QString m_deviceName; + QString m_appVersion; + QString m_jsSourceId; DatabaseManager *m_database = nullptr; TlsTrustController *m_tlsTrust = nullptr; JellyfinApiFacade *m_api = nullptr; diff --git a/src/main.cpp b/src/main.cpp index 4585c80f..0afc1db4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -629,34 +629,44 @@ int main(int argc, char **argv) JellyfinNative::ProviderRegistry providers; providers.registerModule( QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); - QObject::connect( - &window, &QQuickWindow::frameSwapped, &providers, - [&providers, &database] { - QTimer::singleShot(3000, &providers, [&providers, &database] { - JellyfinNative::Async::runScoped( - &providers, providers.restoreSources(&database), [] {}, - [](const std::exception_ptr&) { - qWarning("Portable source metadata could not be restored; existing accounts were retained."); - }, - "restore portable sources"); - }); - }, - Qt::SingleShotConnection); - auto jellyfin = std::make_unique(JellyfinNative::JellyfinProviderContext { - networkAccessManager, &tlsTrust, &database, capabilities.deviceName, QString::fromLatin1(kAppVersion) }); + try { + QCoro::waitFor(providers.restoreSources(&database)); + } catch (const std::exception& error) { + qWarning("Portable source metadata could not be restored: %s", error.what()); + } + + const QString requestedBackend = optionValue( + arguments, QStringLiteral("--jellyfin-backend"), "SPOOL_JELLYFIN_BACKEND", QStringLiteral("native")) + .toLower(); + QString requestedProvider + = optionValue(arguments, QStringLiteral("--provider"), "SPOOL_PROVIDER", QStringLiteral("jellyfin")); + + JellyfinNative::JellyfinProvider::Backend backend = JellyfinNative::JellyfinProvider::Backend::Native; + if (requestedBackend == QStringLiteral("js") || requestedBackend == QStringLiteral("javascript") + || requestedProvider == QStringLiteral("jellyfin-js")) { + backend = JellyfinNative::JellyfinProvider::Backend::JavaScript; + if (requestedProvider == QStringLiteral("jellyfin-js")) + requestedProvider = QStringLiteral("jellyfin"); + } + + auto jellyfin = std::make_unique( + JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, &providers, + capabilities.deviceName, QString::fromLatin1(kAppVersion), backend }); providers.add(jellyfin.get()); auto local = std::make_unique(optionValue(arguments, QStringLiteral("--library-root"), "SPOOL_LOCAL_LIBRARY", QStandardPaths::writableLocation(QStandardPaths::MoviesLocation))); providers.add(local.get()); - const QString requestedProvider - = optionValue(arguments, QStringLiteral("--provider"), "SPOOL_PROVIDER", jellyfin->id()); if (!providers.setActive(requestedProvider)) { logLine("provider: unknown provider %s; using %s", qPrintable(requestedProvider), qPrintable(jellyfin->id())); providers.setActive(jellyfin.get()); } JellyfinNative::Provider *provider = providers.active(); - logLine("provider: %s", qPrintable(provider->id())); + if (provider == jellyfin.get()) { + logLine("provider: %s (backend: %s)", qPrintable(provider->id()), qPrintable(jellyfin->backendName())); + } else { + logLine("provider: %s", qPrintable(provider->id())); + } const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, @@ -925,7 +935,7 @@ int main(int argc, char **argv) // CI rather than an impression. JellyfinNative::RenderBenchmarkHooks benchmarkHooks; benchmarkHooks.providerId = provider ? provider->id() : QString(); - benchmarkHooks.providerRuntime = QStringLiteral("native"); + benchmarkHooks.providerRuntime = (provider == jellyfin.get()) ? jellyfin->backendName() : QStringLiteral("native"); benchmarkHooks.libraries = controller->libraries(); benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; benchmarkHooks.outstandingArtworkRequests diff --git a/tests/api/JellyfinJsAdapterTest.cpp b/tests/api/JellyfinJsAdapterTest.cpp new file mode 100644 index 00000000..fd78e8e5 --- /dev/null +++ b/tests/api/JellyfinJsAdapterTest.cpp @@ -0,0 +1,95 @@ +#include "api/JellyfinJsAdapter.h" +#include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" + +#include +#include + +#include +#include + +namespace { +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} +} // namespace + +JELLYFIN_TEST_MAIN("jellyfin-js-adapter") +{ + QCoreApplication app(argc, argv); + using namespace JellyfinNative; + + QTemporaryDir directory; + require(directory.isValid(), "isolated persistent state directory created"); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); + + DatabaseManager database; + require(database.initialize(directory.filePath("cache.sqlite")), "database initialized"); + + ProviderRegistry registry; + registry.registerModule( + QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); + QCoro::waitFor(registry.restoreSources(&database)); + + const QString secret = QStringLiteral("test-access-token-12345"); + const QString server = QStringLiteral("https://media.test.local:8096"); + const QString sourceId = QCoro::waitFor(registry.configureSource(QStringLiteral("spool.jellyfin"), + QStringLiteral("test-user-id"), QStringLiteral("library"), QStringLiteral("Test Server"), + { { QStringLiteral("server"), server }, { QStringLiteral("userId"), QStringLiteral("test-user-id") }, + { QStringLiteral("token"), secret }, { QStringLiteral("deviceId"), QStringLiteral("test-device") }, + { QStringLiteral("deviceName"), QStringLiteral("Spool Test") }, + { QStringLiteral("clientVersion"), QStringLiteral("0.3.0") } }, + { QUrl(server) })); + + require(!sourceId.isEmpty(), "JS source configured in registry"); + + JellyfinJsAdapter adapter(®istry); + require(!adapter.signedIn(), "adapter initially signed out"); + require(adapter.libraryScopeKey().isEmpty(), "libraryScopeKey empty when signed out"); + + adapter.configure(sourceId, server, secret); + adapter.setDeviceId(QStringLiteral("test-device")); + require(adapter.signedIn(), "adapter signed in after configure"); + require(adapter.libraryScopeKey() == sourceId, "libraryScopeKey matches source ID"); + + // Test ArtworkSource + ArtworkSource::ImageRequest imgReq; + imgReq.itemId = QStringLiteral("item-42"); + imgReq.imageType = QStringLiteral("Primary"); + imgReq.tag = QStringLiteral("tag-abc"); + imgReq.maxWidth = 400; + imgReq.quality = 90; + imgReq.format = QStringLiteral("webp"); + + const QString imgUrl = adapter.imageUrl(imgReq); + require(imgUrl.contains("media.test.local:8096/Items/item-42/Images/Primary"), "imageUrl has correct path"); + require(imgUrl.contains("tag=tag-abc"), "imageUrl includes tag"); + require(imgUrl.contains("maxWidth=400"), "imageUrl includes maxWidth"); + require(imgUrl.contains("quality=90"), "imageUrl includes quality"); + require(imgUrl.contains("format=webp"), "imageUrl includes format"); + + // Test PlaybackSource properties + PlaybackSource *playback = adapter.playback(); + require(playback != nullptr, "playback source exists"); + require(playback->signedIn(), "playback source signed in"); + require(playback->mediaOrigin() == QUrl(server), "mediaOrigin matches server URL"); + require(playback->mediaRequestHeaders().contains("X-Emby-Token: test-access-token-12345"), + "mediaRequestHeaders contains X-Emby-Token"); + + const QString trickplay = adapter.trickplayTileUrl(QStringLiteral("item-42"), 320, 5); + require(trickplay == "https://media.test.local:8096/Items/item-42/Images/Trickplay/320/5", + "trickplay tile URL formatted correctly"); + + // Test clearing + adapter.clear(); + require(!adapter.signedIn(), "adapter signed out after clear"); + require(adapter.mediaRequestHeaders().isEmpty(), "headers empty after clear"); + + std::cout << "All JellyfinJsAdapter tests passed successfully.\n"; + return 0; +} From c112159aabdeda30fd74b43d309296d0b15324bd Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 04:18:24 +1000 Subject: [PATCH 032/146] fix(providers): update bundled spool.jellyfin with resume endpoint fix --- .../bundled/spool-jellyfin-0.1.0-alpha.1.zip | Bin 11291 -> 11290 bytes providers/lock.json | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip b/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip index 35a53de0e2fe3a560b018fb721e493a8ce924d0e..440763ba5f42ced440f91bb5a053616a255d4322 100644 GIT binary patch delta 1752 zcmV;}1}FKOSejU{V-^Wb-Of=H6951wvuqZi41XY}wXG+F0dq)ZV%jn*qI_4UF=;Aq z@(;@=@ZdeY*vZ*MVCwZt7IH`!oQ148Dw|>i!DoUuIv#5S)+q!F5o7IKDp!fe>>WMb z@*Fc7)H`0EX}4@oW5H}+kCr&=(@cY9fH||bZJ6Umb8G#S$G9L3%1t5lFZFI8Y}hhSGMN*rm3=!4b5r0G!)$Z5s=As+czc6v1<5go8sfNxQvQXV^y>L`kKjB0H>L(Ce#pa?TZ*Z-* z86enC#YSAiTGl`yQne@|aarTHaev==62fZQ34_&99Po12F%w_tEIfB2S%(Eb!=d*^ zd8vY6I?o}H1lfzV*(P6OJ)N%di^R*7Cjf2^+ebylwJ6uFrVQO2DS(I#(+gcOXqYLj z&s7%B;`E$Fp-a{q0@gKg_e+NP>v_`>St^@$uB^Nx@b9zXx9%N*)}))|%YQF5alOPm z#6lp_bq2x>lFH+f8-Z6^tC~tJH>*#XD=U{Xa-Q2~1%BuP|H!)qW{=orIR)l_JwI4n zte#V)z&ELV@CH~WMrLd7-0NY6;0-s#9_AQ<$8fE(%Z6j*#+XF3BOsf78%i6tK`ucx zhZ99~cl{?YukPBHH=G~i)te&@FYUZGjvm>* zgTsTAC%|QGg8~Yd!_@S`_iHj{}(-jT$gFEl2 zce%5zWXEhF?+JZ2#A~ci2FF@9t@GVVeW|Eu=k(hN8oqb=h|IV&zke^IL^l;@N0g5t z^Q38~ISpIwV0(CYYzDDRn5$(c%mfCOGHRx!$)zf(;E+^a!HPfMQ#$2kHm&r%GSoSR zk&tnhN#771HC!W>CbthjHVNFC_-U+<1&F+JAKJw8*a=cS*6)hEHN3z+82zY{0yE-Q zc#)Z@rXD6472%``$bZUEj7RMWRIX;-G4wYDU*5h*CsJOPNtCWL2CcytrdR!jdBTx> z_uAVR+o>8hYl=+1#l2!#BO?U;F0#$3&NUVXe;JvWt7OA{gR50^E^pX2i-tVVmGosz zej-)Y5MWvY@3v(sp&)E;RnJGX;@C$Mn#qOACCBJbD}$FttACw2Hdmm&1Qu;r`ZzPHW!&&)bsU}FGvJG)S(bu z;juqxG1--ret%i^C22?I1)^{1VFZB{`>T&!Vd-mbT4dd7(gk+=bL*$df_-0i>@r^- zYH7Ls8DP_nwOnz;yBj@OOiR*Q{IJN&P|1`lZtt~752E)36l{Hld-Dc9f#?G`999+O z(ljg5*O2<|@q|8=w=F0`B!eI!1X0=VE>W#+%RH6p3V#DJu3J=lZCUNF70+G^R@BP5xo| zgq6Ri7d!cx2u!#>0%uDq4+#UNz|2C{9FR@nfsix7dmN8u+!{4t(P6BeOXb?|n0=zB z+nZxXgWAUHGwqheX)Ktn>d_KseVReA3@|74wheCF7;de1@)*aXLAfcP{-r+cgDslx z8h@}3o3+siuBf+uu#3@khYjQd-A+KJlB)nB+X_0lBJpOQpkq zT^eCJFH&&@E(}uF<-wYaH3<;7ulK1~vqsx2UQ&B7<|Id55YhGcwDq|?J*DD!_J7W# zEm}43zzw2Ji=oY+Fs@Pm_&@&v%mVf(1GxEAHw)sz97BLxtrVN#(h@(eSj z^|{L8S)87;D0InsL%_Nw?taNIe?4znB1^^6&Xtun1O9zB{MNl0(3*6!e1G|+Ca#y5 zhgb+iy3Rnjp;38UawG6cYgJRJ&ToqH+F5WL}D*uxwn@EERDcG+-@+{luMb_8UzZ$rt#HpnHY z264~i4t;`lEnklLMuLT>z<&eh<9EI{NquyO@~-~`=G9&M@?P^}yn1uQ;Z>d2#?d3& zcW`)cdQkV4?fvNcz2-)^V=hwvO(gM^>_-ulsTqj>V`CrV0Y5Z$AaSuJY`U0%esJf_ z^e%U{mExE!3ParggqssV@~3?M!|eX6F-ghu>g^GZb6%P9y>v*$NF87w}uzk2csWVQeYnZ z3a>9S)zrfzqavJC0e@K;it(sDfy&jaJBI$I;LF<==|sxQGKtc4#-KI$!lbI-Fi$wL z?_PHMVmnpCW=)aFx40K8Yh;9=-$k}L!MVob;O`+5bAfEQZ*a9r&E>t?W~q<|x>CKY z$xo!p8Ujp9;ElFSB@~41t?K!RRvi0iLNmEgx#SrAX~pl-Xn(cy#YU^EbPJnBV>@)s zcxZETH^7ZYSp2n(NUdUp++ybiM&GMz-=tey1&NIYVgE)`vcX<;atb5EC*KozB&%9r zh6#O2w#!E!&b)$>uYd0v%rrK!_P)AMVpb7&yVpJ#lEGD~>(tq#Cz9j9)yg>9VJ&Yi*Vt@6KD=dA@O^d8sO}e&je`NhsS+MWxj$P&} zLoF@0KmKdlv6d^2cz2^Gi)l$(iyszw87i4_#qGTo=|S|KfP$^haBtqgClGxAhr_C( zT$*M@`WjN-J)Y2~^0ozKh-45Xgdi&W-6g8k9hs+6U4LO9#&wHouPv+nwY;pVKwluJ z4p|-c-i|#+Sl3edrQ-RwlRC>(v3#=6v`Ryi%;6#a+lViAjEENgKO9d!DlJ9t<+Zht zzaZL%>~+ojn~I+->E*nLq&9Ks3FwA06Sg@fS4Rx}qo# zr}~gC?SCWsLil}bt~~7()2Yl%<3x&R#ZWoy!#@jI&`Vh3M-inLGl~BkM%#7?gvQBQ zJ3Gi)^fprq=YNzS_8MTv#d*7qGCy7O>DJtfve7&7?%V)BDu{PBr8Kjp3sy#0QTcB! z)&!7wh-J+u>gF#p|8TmG2>j-)hFN-4xDE&+vmUp%@h61k4Q*^#-MzCv8>#=Z4Y7>z vSAYKxP)h>@lg}#}v$iSY0SR0-)Uy;500146qAO7WnUnM@AO;~T00000km-4w diff --git a/providers/lock.json b/providers/lock.json index 9c61667b..12253212 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -5,10 +5,10 @@ "id": "spool.jellyfin", "version": "0.1.0-alpha.1", "repository": "https://github.com/spool-player/spool-jellyfin", - "revision": "673647c83ff64714ee745aac09829fb63db6ee42", + "revision": "7accf4daf6d4665bbfe2252f1a1a67481793fb55", "archive": "bundled/spool-jellyfin-0.1.0-alpha.1.zip", - "size": 11291, - "sha256": "125e332388f92f983012efaa1c3b90162fe6d1af505bf0d4a7ec056eaf3b1228" + "size": 11290, + "sha256": "af5c6f0ff647d607691640f875017e73835cf6f13b7f2504b3c03e7c0a7c5436" } ] } From b8fb532ad5cab09dca80c808e827a82bf8821a20 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 04:18:29 +1000 Subject: [PATCH 033/146] fix(platform): version-scope qml disk cache and support SAM launch parameters --- src/main.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 0afc1db4..ac519085 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -201,7 +201,8 @@ FILE *openAppLogFile(const QString& appRootPath) void configurePersistentStartupCaches(const QString& cacheRoot) { const QString qtShaderCache = QDir(cacheRoot).filePath(QStringLiteral("qtshadercache")); - const QString qmlDiskCache = QDir(cacheRoot).filePath(QStringLiteral("qmlcache")); + const QString qmlDiskCache + = QDir(cacheRoot).filePath(QStringLiteral("qmlcache-") + QString::fromLatin1(kAppVersion)); QDir().mkpath(qtShaderCache); QDir().mkpath(qmlDiskCache); @@ -564,6 +565,24 @@ int main(int argc, char **argv) JellyfinNative::Diagnostics::EventLoopWatchdog eventLoopWatchdog(&app); const QStringList arguments = app.arguments(); + for (int i = 1; i < arguments.size(); ++i) { + const QString& arg = arguments.at(i); + if (arg.startsWith(QLatin1Char('{')) && arg.endsWith(QLatin1Char('}'))) { + const QJsonDocument doc = QJsonDocument::fromJson(arg.toUtf8()); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + const QJsonObject params = obj.contains(QStringLiteral("parameters")) + ? obj.value(QStringLiteral("parameters")).toObject() + : (obj.contains(QStringLiteral("params")) ? obj.value(QStringLiteral("params")).toObject() : obj); + for (auto it = params.begin(); it != params.end(); ++it) { + const QString key = it.key(); + const QString val = it.value().toVariant().toString(); + if (!val.isEmpty()) + qputenv(key.toUtf8(), val.toUtf8()); + } + } + } + } if (arguments.contains(QStringLiteral("--diagnose-and-exit")) || arguments.contains(QStringLiteral("--dump-diagnostics"))) { JellyfinNative::Diagnostics::dumpDiagnostics(QStringLiteral("command-line")); From c5c1a3c27e1d8d74abdf9bbb805f802db6ca9a24 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 04:27:00 +1000 Subject: [PATCH 034/146] feat(benchmark): add head-to-head provider benchmark option --- CMakeLists.txt | 2 + src/api/ProviderBenchmark.cpp | 335 ++++++++++++++++++++++++++++++++++ src/api/ProviderBenchmark.h | 25 +++ src/main.cpp | 23 +++ 4 files changed, 385 insertions(+) create mode 100644 src/api/ProviderBenchmark.cpp create mode 100644 src/api/ProviderBenchmark.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a4a740db..ca896beb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,6 +304,8 @@ set(SPOOL_JELLYFIN_SOURCES src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp src/api/PlaybackNegotiation.h + src/api/ProviderBenchmark.cpp + src/api/ProviderBenchmark.h src/app/LibraryManagementController.cpp src/app/LibraryManagementController.h src/app/QuickConnectController.cpp diff --git a/src/api/ProviderBenchmark.cpp b/src/api/ProviderBenchmark.cpp new file mode 100644 index 00000000..accf3102 --- /dev/null +++ b/src/api/ProviderBenchmark.cpp @@ -0,0 +1,335 @@ +#include "ProviderBenchmark.h" + +#include "JellyfinApiFacade.h" +#include "JellyfinJsAdapter.h" +#include "JellyfinProvider.h" +#include "app/SessionController.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace JellyfinNative { + +namespace { + + struct BenchResult { + QString name; + std::vector nativeTimes; + std::vector jsTimes; + size_t nativeItems = 0; + size_t jsItems = 0; + + double nativeAvg() const + { + return nativeTimes.empty() + ? 0.0 + : std::accumulate(nativeTimes.begin(), nativeTimes.end(), 0.0) / nativeTimes.size(); + } + double jsAvg() const + { + return jsTimes.empty() ? 0.0 : std::accumulate(jsTimes.begin(), jsTimes.end(), 0.0) / jsTimes.size(); + } + double deltaMs() const + { + return jsAvg() - nativeAvg(); + } + double deltaFrames() const + { + return deltaMs() / 16.666667; + } + }; + +} // namespace + +QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegistry *registry, + JellyfinProvider *jellyfinProvider, const ProviderBenchmarkOptions& options) +{ + if (!jellyfinProvider || !database || !registry) { + std::cerr << "provider benchmark: invalid setup\n"; + co_return 1; + } + + SessionController *sessionCtrl = jellyfinProvider->session(); + if (!sessionCtrl || !sessionCtrl->authenticated()) { + StartupState startupState = co_await database->loadStartupStateAsync(jellyfinProvider->startupStorageKeys()); + QString deviceId = std::move(startupState.deviceId); + if (deviceId.isEmpty()) { + deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces); + database->saveDeviceId(deviceId); + } + jellyfinProvider->setDeviceId(deviceId); + jellyfinProvider->restoreFromStorage(std::move(startupState.values), std::move(startupState.profiles)); + + if (sessionCtrl && !sessionCtrl->authenticated() && !sessionCtrl->accountProfiles().isEmpty()) { + const QVariantMap profile = sessionCtrl->accountProfiles().front().toMap(); + const QString profileId = profile.value(QStringLiteral("id")).toString(); + sessionCtrl->activateProfile(profileId); + int attempts = 0; + while (!sessionCtrl->authenticated() && attempts++ < 50) { + co_await QCoro::sleepFor(std::chrono::milliseconds(20)); + } + } + } + + if (!sessionCtrl || !sessionCtrl->authenticated()) { + std::cerr << "provider benchmark: no authenticated session; sign in to run benchmark\n"; + co_return 1; + } + + JellyfinApiFacade *nativeApi = jellyfinProvider->api(); + if (!nativeApi) { + std::cerr << "provider benchmark: native API facade unavailable\n"; + co_return 1; + } + + const QString serverUrl = sessionCtrl->serverUrl(); + const AuthSession auth = nativeApi->session(); + std::cout << "\n================================================================================\n"; + std::cout << " SPOOL PROVIDER PERFORMANCE BENCHMARK (NATIVE VS JAVASCRIPT)\n"; + std::cout << " Server: " << serverUrl.toStdString() << " | User: " << auth.userName.toStdString() << '\n'; + std::cout << " Iterations: " << options.iterations << " | Library: " << options.libraryName.toStdString() << '\n'; + std::cout << "================================================================================\n\n"; + + // Setup JS Adapter + JellyfinJsAdapter jsAdapter(registry); + QVariantMap config; + config.insert(QStringLiteral("server"), serverUrl); + config.insert(QStringLiteral("userId"), auth.userId); + config.insert(QStringLiteral("token"), auth.accessToken); + config.insert(QStringLiteral("deviceId"), + nativeApi->deviceId().isEmpty() ? QStringLiteral("spool-benchmark") : nativeApi->deviceId()); + config.insert(QStringLiteral("deviceName"), QStringLiteral("Spool Benchmark")); + config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.7.15")); + + QList origins { QUrl(serverUrl) }; + const QString sourceId = co_await registry->configureSource( + QStringLiteral("spool.jellyfin"), auth.userId, QStringLiteral("library"), serverUrl, config, origins); + jsAdapter.configure(sourceId, serverUrl, auth.accessToken); + + std::vector results; + + // 1. Fetch Libraries + { + BenchResult res; + res.name = QStringLiteral("fetchLibraries"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + // Warmup + co_await nativeApi->fetchLibraries(); + co_await jsAdapter.fetchLibraries(); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto nativeLibs = co_await nativeApi->fetchLibraries(); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = nativeLibs.size(); + + t.restart(); + auto jsLibs = co_await jsAdapter.fetchLibraries(); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsLibs.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // Find the target library descriptor + auto libs = co_await nativeApi->fetchLibraries(); + BrowseDescriptor libDesc; + for (const auto& lib : libs) { + if (lib.name.compare(options.libraryName, Qt::CaseInsensitive) == 0) { + libDesc.id = lib.id; + libDesc.name = lib.name; + libDesc.collectionType = lib.collectionType; + libDesc.kind = BrowseKind::Library; + break; + } + } + if (!libDesc.isValid() && !libs.empty()) { + libDesc.id = libs[0].id; + libDesc.name = libs[0].name; + libDesc.collectionType = libs[0].collectionType; + libDesc.kind = BrowseKind::Library; + } + + // 2. Browse Page 1 (100 items) + if (libDesc.isValid()) { + BenchResult res; + res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [0..100]"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); + co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto page = co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = page.items.size(); + + t.restart(); + auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsPage.items.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // 3. Browse Page 2 (100 items) + if (libDesc.isValid()) { + BenchResult res; + res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [100..200]"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); + co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto page = co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = page.items.size(); + + t.restart(); + auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsPage.items.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // 4. Search + { + BenchResult res; + res.name = QStringLiteral("searchItems (\"") + options.searchQuery + QStringLiteral("\", 80)"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->searchItems(options.searchQuery, 80); + co_await jsAdapter.searchItems(options.searchQuery, 80); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto items = co_await nativeApi->searchItems(options.searchQuery, 80); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = items.size(); + + t.restart(); + auto jsItems = co_await jsAdapter.searchItems(options.searchQuery, 80); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsItems.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // 5. Next Up + { + BenchResult res; + res.name = QStringLiteral("fetchNextUpEpisodes (24)"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchNextUpEpisodes(24); + co_await jsAdapter.fetchNextUpEpisodes(24); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto items = co_await nativeApi->fetchNextUpEpisodes(24); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = items.size(); + + t.restart(); + auto jsItems = co_await jsAdapter.fetchNextUpEpisodes(24); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsItems.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // 6. Resume Items + { + BenchResult res; + res.name = QStringLiteral("fetchResumeItems (24)"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchResumeItems(24); + co_await jsAdapter.fetchResumeItems(24); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto items = co_await nativeApi->fetchResumeItems(24); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = items.size(); + + t.restart(); + auto jsItems = co_await jsAdapter.fetchResumeItems(24); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsItems.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } + + // Print summary table + std::cout << "\n---------------------------------------------------------------------------------------------------" + "---------------\n"; + printf("| %-36s | %-12s | %-12s | %-11s | %-15s | %-10s |\n", "Operation", "Native (ms)", "JS (ms)", "Delta (ms)", + "Delta (frames)", "Items (N/J)"); + std::cout << "-----------------------------------------------------------------------------------------------------" + "-------------\n"; + + QJsonArray jsonResults; + for (const auto& r : results) { + const double delta = r.deltaMs(); + const double frames = r.deltaFrames(); + char itemsBuf[32]; + snprintf(itemsBuf, sizeof(itemsBuf), "%zu / %zu", r.nativeItems, r.jsItems); + printf("| %-36s | %9.2f ms | %9.2f ms | %+8.2f ms | %+11.2f fr | %-10s |\n", qPrintable(r.name), r.nativeAvg(), + r.jsAvg(), delta, frames, itemsBuf); + + QJsonObject obj; + obj.insert(QStringLiteral("operation"), r.name); + obj.insert(QStringLiteral("nativeAvgMs"), r.nativeAvg()); + obj.insert(QStringLiteral("jsAvgMs"), r.jsAvg()); + obj.insert(QStringLiteral("deltaMs"), delta); + obj.insert(QStringLiteral("deltaFrames"), frames); + obj.insert(QStringLiteral("nativeItems"), static_cast(r.nativeItems)); + obj.insert(QStringLiteral("jsItems"), static_cast(r.jsItems)); + jsonResults.append(obj); + } + std::cout << "-----------------------------------------------------------------------------------------------------" + "-------------\n"; + std::cout << " Note: 1 frame = 16.67 ms at 60 Hz display refresh rate.\n"; + std::cout << " Negative delta means JavaScript backend was FASTER than native.\n\n"; + + if (!options.outputPath.isEmpty()) { + QJsonObject root; + root.insert(QStringLiteral("server"), serverUrl); + root.insert(QStringLiteral("iterations"), options.iterations); + root.insert(QStringLiteral("results"), jsonResults); + QFile outFile(options.outputPath); + if (outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + outFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + std::cout << "Benchmark report saved to: " << options.outputPath.toStdString() << '\n'; + } + } + + co_return 0; +} + +} // namespace JellyfinNative diff --git a/src/api/ProviderBenchmark.h b/src/api/ProviderBenchmark.h new file mode 100644 index 00000000..dfe0ef0f --- /dev/null +++ b/src/api/ProviderBenchmark.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace JellyfinNative { + +class DatabaseManager; +class JellyfinProvider; +class ProviderRegistry; + +struct ProviderBenchmarkOptions { + int iterations = 5; + QString libraryName = QStringLiteral("Movies"); + QString searchQuery = QStringLiteral("The"); + QString outputPath; +}; + +class ProviderBenchmark { +public: + static QCoro::Task run(DatabaseManager *database, ProviderRegistry *registry, + JellyfinProvider *jellyfinProvider, const ProviderBenchmarkOptions& options); +}; + +} // namespace JellyfinNative diff --git a/src/main.cpp b/src/main.cpp index ac519085..9a8b62df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include "api/JellyfinProvider.h" +#include "api/ProviderBenchmark.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" #include "app/ArtworkService.h" @@ -687,6 +688,28 @@ int main(int argc, char **argv) logLine("provider: %s", qPrintable(provider->id())); } + if (arguments.contains(QStringLiteral("--benchmark-providers")) + || qEnvironmentVariableIsSet("SPOOL_BENCHMARK_PROVIDERS") || qgetenv("SPOOL_BENCH") == "providers") { + JellyfinNative::ProviderBenchmarkOptions options; + options.iterations + = optionValue(arguments, QStringLiteral("--iterations"), "SPOOL_BENCH_ITERATIONS", QStringLiteral("5")) + .toInt(); + if (options.iterations <= 0) + options.iterations = 5; + const QString lib = optionValue(arguments, QStringLiteral("--library"), "SPOOL_BENCH_LIBRARY"); + if (!lib.isEmpty()) + options.libraryName = lib; + const QString q = optionValue(arguments, QStringLiteral("--query"), "SPOOL_BENCH_QUERY"); + if (!q.isEmpty()) + options.searchQuery = q; + options.outputPath = optionValue(arguments, QStringLiteral("--output"), "SPOOL_BENCH_OUTPUT"); + + const int exitCode + = QCoro::waitFor(JellyfinNative::ProviderBenchmark::run(&database, &providers, jellyfin.get(), options)); + JellyfinNative::Diagnostics::shutdown(); + return exitCode; + } + const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, cpuTopology.physicalCores, cpuTopology.smtDetected ? "true" : "false", qPrintable(cpuTopology.source), From 0bfb1d16d887bcdb2767595b66acaf2b2e11f102 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 04:59:28 +1000 Subject: [PATCH 035/146] test(benchmark): harden provider benchmark against single-step rejections --- src/api/ProviderBenchmark.cpp | 130 ++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 55 deletions(-) diff --git a/src/api/ProviderBenchmark.cpp b/src/api/ProviderBenchmark.cpp index accf3102..678d68a9 100644 --- a/src/api/ProviderBenchmark.cpp +++ b/src/api/ProviderBenchmark.cpp @@ -122,7 +122,7 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis std::vector results; // 1. Fetch Libraries - { + try { BenchResult res; res.name = QStringLiteral("fetchLibraries"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; @@ -144,77 +144,91 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis } std::cout << " done.\n"; results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } // Find the target library descriptor - auto libs = co_await nativeApi->fetchLibraries(); BrowseDescriptor libDesc; - for (const auto& lib : libs) { - if (lib.name.compare(options.libraryName, Qt::CaseInsensitive) == 0) { - libDesc.id = lib.id; - libDesc.name = lib.name; - libDesc.collectionType = lib.collectionType; + try { + auto libs = co_await nativeApi->fetchLibraries(); + for (const auto& lib : libs) { + if (lib.name.compare(options.libraryName, Qt::CaseInsensitive) == 0) { + libDesc.id = lib.id; + libDesc.name = lib.name; + libDesc.collectionType = lib.collectionType; + libDesc.kind = BrowseKind::Library; + break; + } + } + if (!libDesc.isValid() && !libs.empty()) { + libDesc.id = libs[0].id; + libDesc.name = libs[0].name; + libDesc.collectionType = libs[0].collectionType; libDesc.kind = BrowseKind::Library; - break; } - } - if (!libDesc.isValid() && !libs.empty()) { - libDesc.id = libs[0].id; - libDesc.name = libs[0].name; - libDesc.collectionType = libs[0].collectionType; - libDesc.kind = BrowseKind::Library; + } catch (const std::exception& e) { + std::cout << "Failed to query libraries for browsing: " << e.what() << "\n"; } // 2. Browse Page 1 (100 items) if (libDesc.isValid()) { - BenchResult res; - res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [0..100]"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); - co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto page = co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = page.items.size(); - - t.restart(); - auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsPage.items.size(); + try { + BenchResult res; + res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [0..100]"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); + co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto page = co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = page.items.size(); + + t.restart(); + auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsPage.items.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } - std::cout << " done.\n"; - results.push_back(std::move(res)); } // 3. Browse Page 2 (100 items) if (libDesc.isValid()) { - BenchResult res; - res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [100..200]"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); - co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto page = co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = page.items.size(); - - t.restart(); - auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsPage.items.size(); + try { + BenchResult res; + res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [100..200]"); + std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; + co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); + co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); + + for (int i = 0; i < options.iterations; ++i) { + QElapsedTimer t; + t.start(); + auto page = co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); + res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.nativeItems = page.items.size(); + + t.restart(); + auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); + res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); + res.jsItems = jsPage.items.size(); + } + std::cout << " done.\n"; + results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } - std::cout << " done.\n"; - results.push_back(std::move(res)); } // 4. Search - { + try { BenchResult res; res.name = QStringLiteral("searchItems (\"") + options.searchQuery + QStringLiteral("\", 80)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; @@ -235,10 +249,12 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis } std::cout << " done.\n"; results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } // 5. Next Up - { + try { BenchResult res; res.name = QStringLiteral("fetchNextUpEpisodes (24)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; @@ -259,10 +275,12 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis } std::cout << " done.\n"; results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } // 6. Resume Items - { + try { BenchResult res; res.name = QStringLiteral("fetchResumeItems (24)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; @@ -283,6 +301,8 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis } std::cout << " done.\n"; results.push_back(std::move(res)); + } catch (const std::exception& e) { + std::cout << " failed: " << e.what() << "\n"; } // Print summary table From 513234e3f735a567e906f846989a6944f46d7201 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 05:34:07 +1000 Subject: [PATCH 036/146] feat(provider): add safe provider package unpacking and validation --- CMakeLists.txt | 6 + src/provider/ProviderPackage.cpp | 361 ++++++++++++++++++++++++ src/provider/ProviderPackage.h | 32 +++ tests/providers/ProviderPackageTest.cpp | 256 +++++++++++++++++ 4 files changed, 655 insertions(+) create mode 100644 src/provider/ProviderPackage.cpp create mode 100644 src/provider/ProviderPackage.h create mode 100644 tests/providers/ProviderPackageTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ca896beb..a76af0c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -260,6 +260,8 @@ set(SPOOL_SHELL_SOURCES src/provider/GroupPlayback.h src/provider/PlaybackSource.h src/provider/Provider.h + src/provider/ProviderPackage.cpp + src/provider/ProviderPackage.h src/provider/ProviderQmlCache.cpp src/provider/ProviderQmlCache.h src/provider/ProviderUiContext.cpp @@ -353,6 +355,7 @@ qt_add_executable(jellyfin-native # Bundle immutable, verified source bytes. This resource path also exercises # source loading with no developer-generated QML/JS cache in provider tests. find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +find_package(ZLIB REQUIRED) set(SPOOL_JELLYFIN_SOURCE_DIR "" CACHE PATH "Explicit development override for the portable Jellyfin source") set(SPOOL_PROVIDER_RESOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/portable-providers") set(spool_provider_bundle_args @@ -563,6 +566,7 @@ qt_add_qml_module(jellyfin-native qml/pages/SettingsPage.qml qml/pages/SettingsNavigation.js qml/pages/SubtitleSettingsPanel.qml + qml/pages/ProviderPickerPage.qml qml/primitives/ActionButton.qml qml/providers/jellyfin/LoginPage.qml qml/providers/jellyfin/LoginAccountStep.qml @@ -791,6 +795,7 @@ target_link_libraries(jellyfin-core PUBLIC Qt6::Sql Qt6::OpenGL Qt6::WebSockets + ZLIB::ZLIB ) if(NOT ANDROID) target_link_libraries(jellyfin-core PUBLIC Qt6::GuiPrivate) @@ -872,6 +877,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) source-registry tests/providers/SourceRegistryTest.cpp provider-ui-context tests/providers/ProviderUiContextTest.cpp provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp + provider-package-unpack tests/providers/ProviderPackageTest.cpp ) set(JELLYFIN_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp diff --git a/src/provider/ProviderPackage.cpp b/src/provider/ProviderPackage.cpp new file mode 100644 index 00000000..58b11e0d --- /dev/null +++ b/src/provider/ProviderPackage.cpp @@ -0,0 +1,361 @@ +#include "ProviderPackage.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace JellyfinNative { + +namespace { + constexpr qint64 kMaxArchiveBytes = 16 * 1024 * 1024; + constexpr qint64 kMaxExpandedBytes = 32 * 1024 * 1024; + constexpr qint64 kMaxFileBytes = 8 * 1024 * 1024; + constexpr int kMaxFiles = 512; + +#pragma pack(push, 1) + struct ZipEOCD { + uint32_t signature; + uint16_t diskNumber; + uint16_t cdStartDisk; + uint16_t numEntriesThisDisk; + uint16_t numEntries; + uint32_t cdSize; + uint32_t cdOffset; + uint16_t commentLen; + }; + + struct ZipCDHeader { + uint32_t signature; + uint16_t versionMadeBy; + uint16_t versionNeeded; + uint16_t flags; + uint16_t method; + uint16_t modTime; + uint16_t modDate; + uint32_t crc32; + uint32_t compressedSize; + uint32_t uncompressedSize; + uint16_t filenameLen; + uint16_t extraLen; + uint16_t commentLen; + uint16_t diskNumStart; + uint16_t internalAttr; + uint32_t externalAttr; + uint32_t localHeaderOffset; + }; + + struct ZipLocalHeader { + uint32_t signature; + uint16_t versionNeeded; + uint16_t flags; + uint16_t method; + uint16_t modTime; + uint16_t modDate; + uint32_t crc32; + uint32_t compressedSize; + uint32_t uncompressedSize; + uint16_t filenameLen; + uint16_t extraLen; + }; +#pragma pack(pop) + + bool isValidPackagePath(const QString& path) + { + if (path.isEmpty() || path.startsWith(QLatin1Char('/')) || path.contains(QLatin1Char('\\')) + || path.contains(QLatin1Char(':')) || path.contains(QStringLiteral(".."))) { + return false; + } + static const QSet allowedExtensions = { QStringLiteral("mjs"), QStringLiteral("js"), + QStringLiteral("qml"), QStringLiteral("json"), QStringLiteral("png"), QStringLiteral("jpg"), + QStringLiteral("jpeg"), QStringLiteral("svg"), QStringLiteral("webp"), QStringLiteral("ttf"), + QStringLiteral("otf"), QStringLiteral("txt"), QStringLiteral("map") }; + const QString ext = QFileInfo(path).suffix().toLower(); + if (ext.isEmpty()) { + const QString fileName = QFileInfo(path).fileName(); + if (fileName != QStringLiteral("LICENSE") && fileName != QStringLiteral("NOTICE")) + return false; + } else if (!allowedExtensions.contains(ext)) { + return false; + } + + const auto parts = path.split(QLatin1Char('/')); + for (const auto& part : parts) { + if (part.isEmpty() || part == QStringLiteral(".") || part.endsWith(QLatin1Char('.')) + || part.endsWith(QLatin1Char(' '))) { + return false; + } + } + return true; + } + + bool hasNativeMagic(const QByteArray& data) + { + if (data.size() < 4) + return false; + const auto *bytes = reinterpret_cast(data.constData()); + // ELF: \x7fELF + if (bytes[0] == 0x7f && bytes[1] == 'E' && bytes[2] == 'L' && bytes[3] == 'F') + return true; + // DOS / PE: MZ + if (bytes[0] == 'M' && bytes[1] == 'Z') + return true; + // Mach-O + if ((bytes[0] == 0xca && bytes[1] == 0xfe && bytes[2] == 0xba && bytes[3] == 0xbe) + || (bytes[0] == 0xce && bytes[1] == 0xfa && bytes[2] == 0xed && bytes[3] == 0xfe) + || (bytes[0] == 0xcf && bytes[1] == 0xfa && bytes[2] == 0xed && bytes[3] == 0xfe)) + return true; + // WebAssembly: \0asm + if (bytes[0] == 0x00 && bytes[1] == 'a' && bytes[2] == 's' && bytes[3] == 'm') + return true; + return false; + } + + void setError(QString *errorOut, const QString& message) + { + if (errorOut) + *errorOut = message; + } +} // namespace + +std::optional ProviderPackage::parseAndValidate( + const QByteArray& zipBytes, QString *errorMessage) +{ + if (zipBytes.isEmpty()) { + setError(errorMessage, QStringLiteral("Package archive is empty")); + return std::nullopt; + } + if (zipBytes.size() > kMaxArchiveBytes) { + setError(errorMessage, QStringLiteral("Package archive exceeds maximum size limit (16MB)")); + return std::nullopt; + } + + if (zipBytes.size() < static_cast(sizeof(ZipEOCD))) { + setError(errorMessage, QStringLiteral("Archive is too small to be a valid ZIP file")); + return std::nullopt; + } + + // Locate End of Central Directory record + const auto *data = reinterpret_cast(zipBytes.constData()); + const qint64 dataSize = zipBytes.size(); + const ZipEOCD *eocd = nullptr; + const uint8_t *search = data + dataSize - sizeof(ZipEOCD); + const uint8_t *minSearch = data + std::max(0, dataSize - sizeof(ZipEOCD) - 65536); + + while (search >= minSearch) { + if (*reinterpret_cast(search) == 0x06054b50) { + eocd = reinterpret_cast(search); + break; + } + --search; + } + + if (!eocd) { + setError(errorMessage, QStringLiteral("End of central directory not found in ZIP")); + return std::nullopt; + } + + if (eocd->numEntries > kMaxFiles) { + setError(errorMessage, QStringLiteral("Package contains too many files")); + return std::nullopt; + } + + if (eocd->cdOffset + eocd->cdSize > static_cast(dataSize)) { + setError(errorMessage, QStringLiteral("Corrupt ZIP central directory offset")); + return std::nullopt; + } + + ProviderPackageContents package; + qint64 totalExpandedBytes = 0; + QSet caseFoldedNames; + + const uint8_t *cdPtr = data + eocd->cdOffset; + for (int i = 0; i < eocd->numEntries; ++i) { + if (cdPtr + sizeof(ZipCDHeader) > data + dataSize) { + setError(errorMessage, QStringLiteral("Corrupt central directory record")); + return std::nullopt; + } + const auto *cd = reinterpret_cast(cdPtr); + if (cd->signature != 0x02014b50) { + setError(errorMessage, QStringLiteral("Invalid central directory header signature")); + return std::nullopt; + } + + if (cdPtr + sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen > data + dataSize) { + setError(errorMessage, QStringLiteral("Truncated central directory entry")); + return std::nullopt; + } + + const QString entryPath + = QString::fromUtf8(reinterpret_cast(cdPtr + sizeof(ZipCDHeader)), cd->filenameLen); + + // Skip directory-only records if empty + if (entryPath.endsWith(QLatin1Char('/'))) { + cdPtr += sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen; + continue; + } + + if (!isValidPackagePath(entryPath)) { + setError(errorMessage, QStringLiteral("Invalid or forbidden path in package: %1").arg(entryPath)); + return std::nullopt; + } + + const QString folded = entryPath.toCaseFolded(); + if (caseFoldedNames.contains(folded)) { + setError(errorMessage, QStringLiteral("Case-colliding paths detected: %1").arg(entryPath)); + return std::nullopt; + } + caseFoldedNames.insert(folded); + + if (cd->uncompressedSize > kMaxFileBytes) { + setError(errorMessage, QStringLiteral("File %1 exceeds maximum allowed size (8MB)").arg(entryPath)); + return std::nullopt; + } + + totalExpandedBytes += cd->uncompressedSize; + if (totalExpandedBytes > kMaxExpandedBytes) { + setError(errorMessage, QStringLiteral("Package total expanded size exceeds limit (32MB)")); + return std::nullopt; + } + + if (cd->localHeaderOffset + sizeof(ZipLocalHeader) > static_cast(dataSize)) { + setError(errorMessage, QStringLiteral("Invalid local header offset for %1").arg(entryPath)); + return std::nullopt; + } + + const auto *loc = reinterpret_cast(data + cd->localHeaderOffset); + if (loc->signature != 0x04034b50) { + setError(errorMessage, QStringLiteral("Invalid local header signature for %1").arg(entryPath)); + return std::nullopt; + } + + const uint8_t *fileData + = data + cd->localHeaderOffset + sizeof(ZipLocalHeader) + loc->filenameLen + loc->extraLen; + if (fileData + cd->compressedSize > data + dataSize) { + setError(errorMessage, QStringLiteral("Truncated file data for %1").arg(entryPath)); + return std::nullopt; + } + + QByteArray uncompressed; + uncompressed.resize(cd->uncompressedSize); + + if (cd->method == 0) { + // Stored + if (cd->compressedSize != cd->uncompressedSize) { + setError(errorMessage, QStringLiteral("Mismatched stored size for %1").arg(entryPath)); + return std::nullopt; + } + std::memcpy(uncompressed.data(), fileData, cd->uncompressedSize); + } else if (cd->method == 8) { + // Deflated + z_stream strm = {}; + if (inflateInit2(&strm, -MAX_WBITS) != Z_OK) { + setError(errorMessage, QStringLiteral("Failed to initialize decompression")); + return std::nullopt; + } + strm.next_in = const_cast(fileData); + strm.avail_in = cd->compressedSize; + strm.next_out = reinterpret_cast(uncompressed.data()); + strm.avail_out = uncompressed.size(); + const int ret = inflate(&strm, Z_FINISH); + inflateEnd(&strm); + if (ret != Z_STREAM_END) { + setError(errorMessage, QStringLiteral("Corrupt compressed stream for %1").arg(entryPath)); + return std::nullopt; + } + } else { + setError(errorMessage, QStringLiteral("Unsupported compression method for %1").arg(entryPath)); + return std::nullopt; + } + + if (hasNativeMagic(uncompressed)) { + setError(errorMessage, QStringLiteral("Native executable binary rejected in %1").arg(entryPath)); + return std::nullopt; + } + + package.files.insert(entryPath, uncompressed); + cdPtr += sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen; + } + + if (!package.files.contains(QStringLiteral("manifest.json"))) { + setError(errorMessage, QStringLiteral("Package is missing manifest.json")); + return std::nullopt; + } + + const QJsonDocument manifestDoc = QJsonDocument::fromJson(package.files.value(QStringLiteral("manifest.json"))); + if (!manifestDoc.isObject()) { + setError(errorMessage, QStringLiteral("manifest.json is not a valid JSON object")); + return std::nullopt; + } + const QJsonObject manifest = manifestDoc.object(); + + if (manifest.value(QStringLiteral("format")).toInt() != 1 + || manifest.value(QStringLiteral("api")).toString() != QStringLiteral("0.1")) { + setError(errorMessage, QStringLiteral("Unsupported manifest format or API revision")); + return std::nullopt; + } + + static const QRegularExpression idPattern(QStringLiteral("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$")); + package.id = manifest.value(QStringLiteral("id")).toString(); + if (!idPattern.match(package.id).hasMatch()) { + setError(errorMessage, QStringLiteral("Invalid provider ID in manifest")); + return std::nullopt; + } + + package.version = manifest.value(QStringLiteral("version")).toString(); + if (package.version.isEmpty()) { + setError(errorMessage, QStringLiteral("Missing version in manifest")); + return std::nullopt; + } + + package.entryPoint = manifest.value(QStringLiteral("entry")).toString(); + if (!package.entryPoint.endsWith(QStringLiteral(".mjs")) || !package.files.contains(package.entryPoint)) { + setError(errorMessage, QStringLiteral("Missing or invalid JavaScript entry point in manifest")); + return std::nullopt; + } + + return package; +} + +QString ProviderPackage::install( + const ProviderPackageContents& package, const QString& targetDirectory, QString *errorMessage) +{ + if (package.id.isEmpty() || package.files.isEmpty()) { + setError(errorMessage, QStringLiteral("Invalid package to install")); + return QString(); + } + + const QDir baseDir(targetDirectory); + const QString destPath = baseDir.filePath(package.id); + const QDir destDir(destPath); + if (!destDir.mkpath(QStringLiteral("."))) { + setError(errorMessage, QStringLiteral("Could not create provider directory: %1").arg(destPath)); + return QString(); + } + + for (auto it = package.files.cbegin(); it != package.files.cend(); ++it) { + const QString relativeFilePath = it.key(); + const QString targetFilePath = destDir.filePath(relativeFilePath); + const QFileInfo fileInfo(targetFilePath); + destDir.mkpath(fileInfo.path()); + + QFile file(targetFilePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + setError(errorMessage, QStringLiteral("Failed to write package file: %1").arg(targetFilePath)); + return QString(); + } + file.write(it.value()); + file.close(); + } + + return destDir.filePath(package.entryPoint); +} + +} // namespace JellyfinNative diff --git a/src/provider/ProviderPackage.h b/src/provider/ProviderPackage.h new file mode 100644 index 00000000..c84b2f1e --- /dev/null +++ b/src/provider/ProviderPackage.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +namespace JellyfinNative { + +struct ProviderPackageContents { + QString id; + QString version; + QString entryPoint; + QMap files; +}; + +class ProviderPackage final { +public: + // Safely validates and decompresses an in-memory ZIP package without + // executing any code. Enforces size limits, path sanitization, allowed + // extensions, executable-payload rejection, and manifest schema rules. + static std::optional parseAndValidate( + const QByteArray& zipBytes, QString *errorMessage = nullptr); + + // Extracts validated package files into targetDirectory/package.id, + // creating parent directories as necessary. Returns the full local path + // of the JavaScript entry point on success, or an empty string on error. + static QString install( + const ProviderPackageContents& package, const QString& targetDirectory, QString *errorMessage = nullptr); +}; + +} // namespace JellyfinNative diff --git a/tests/providers/ProviderPackageTest.cpp b/tests/providers/ProviderPackageTest.cpp new file mode 100644 index 00000000..3704ea9d --- /dev/null +++ b/tests/providers/ProviderPackageTest.cpp @@ -0,0 +1,256 @@ +#include "provider/ProviderPackage.h" +#include "TestMain.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +#pragma pack(push, 1) +struct ZipEOCD { + uint32_t signature; + uint16_t diskNumber; + uint16_t cdStartDisk; + uint16_t numEntriesThisDisk; + uint16_t numEntries; + uint32_t cdSize; + uint32_t cdOffset; + uint16_t commentLen; +}; + +struct ZipCDHeader { + uint32_t signature; + uint16_t versionMadeBy; + uint16_t versionNeeded; + uint16_t flags; + uint16_t method; + uint16_t modTime; + uint16_t modDate; + uint32_t crc32; + uint32_t compressedSize; + uint32_t uncompressedSize; + uint16_t filenameLen; + uint16_t extraLen; + uint16_t commentLen; + uint16_t diskNumStart; + uint16_t internalAttr; + uint32_t externalAttr; + uint32_t localHeaderOffset; +}; + +struct ZipLocalHeader { + uint32_t signature; + uint16_t versionNeeded; + uint16_t flags; + uint16_t method; + uint16_t modTime; + uint16_t modDate; + uint32_t crc32; + uint32_t compressedSize; + uint32_t uncompressedSize; + uint16_t filenameLen; + uint16_t extraLen; +}; +#pragma pack(pop) + +QByteArray makeStoredZip(const QMap& entries) +{ + QByteArray buffer; + struct EntryMeta { + QString name; + uint32_t offset; + uint32_t crc; + uint32_t size; + }; + QList metas; + + for (auto it = entries.cbegin(); it != entries.cend(); ++it) { + const QString name = it.key(); + const QByteArray data = it.value(); + const uint32_t crc = static_cast( + ::crc32(0L, reinterpret_cast(data.constData()), static_cast(data.size()))); + const uint32_t offset = static_cast(buffer.size()); + + ZipLocalHeader local {}; + local.signature = 0x04034b50; + local.versionNeeded = 20; + local.method = 0; // Stored + local.crc32 = crc; + local.compressedSize = static_cast(data.size()); + local.uncompressedSize = static_cast(data.size()); + local.filenameLen = static_cast(name.toUtf8().size()); + local.extraLen = 0; + + buffer.append(reinterpret_cast(&local), sizeof(local)); + buffer.append(name.toUtf8()); + buffer.append(data); + + metas.append({ name, offset, crc, static_cast(data.size()) }); + } + + const uint32_t cdOffset = static_cast(buffer.size()); + for (const auto& meta : metas) { + ZipCDHeader cd {}; + cd.signature = 0x02014b50; + cd.versionMadeBy = 20; + cd.versionNeeded = 20; + cd.flags = 0; + cd.method = 0; + cd.crc32 = meta.crc; + cd.compressedSize = meta.size; + cd.uncompressedSize = meta.size; + cd.filenameLen = static_cast(meta.name.toUtf8().size()); + cd.extraLen = 0; + cd.commentLen = 0; + cd.diskNumStart = 0; + cd.internalAttr = 0; + cd.externalAttr = 0; + cd.localHeaderOffset = meta.offset; + + buffer.append(reinterpret_cast(&cd), sizeof(cd)); + buffer.append(meta.name.toUtf8()); + } + const uint32_t cdSize = static_cast(buffer.size() - cdOffset); + + ZipEOCD eocd {}; + eocd.signature = 0x06054b50; + eocd.diskNumber = 0; + eocd.cdStartDisk = 0; + eocd.numEntriesThisDisk = static_cast(metas.size()); + eocd.numEntries = static_cast(metas.size()); + eocd.cdSize = cdSize; + eocd.cdOffset = cdOffset; + eocd.commentLen = 0; + + buffer.append(reinterpret_cast(&eocd), sizeof(eocd)); + return buffer; +} + +} // namespace + +JELLYFIN_TEST_MAIN("provider-package-unpack") +{ + QCoreApplication app(argc, argv); + using namespace JellyfinNative; + + // Test 1: Valid package validation and extraction + { + const QByteArray manifest = R"({ + "format": 1, + "id": "spool.test", + "version": "1.0.0", + "api": "0.1", + "entry": "logic/provider.mjs" + })"; + const QByteArray logic = "export function init() { return true; }"; + + QMap entries; + entries.insert(QStringLiteral("manifest.json"), manifest); + entries.insert(QStringLiteral("logic/provider.mjs"), logic); + + const QByteArray zipData = makeStoredZip(entries); + QString errorMsg; + auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); + require(package.has_value(), "valid package parsed successfully"); + require(package->id == QStringLiteral("spool.test"), "package ID extracted"); + require(package->version == QStringLiteral("1.0.0"), "package version extracted"); + require(package->entryPoint == QStringLiteral("logic/provider.mjs"), "entrypoint extracted"); + require(package->files.size() == 2, "package files map populated"); + + QTemporaryDir tempDir; + require(tempDir.isValid(), "temporary directory created"); + const QString installedPath = ProviderPackage::install(*package, tempDir.path(), &errorMsg); + require(!installedPath.isEmpty(), "package installed successfully"); + require(QFile::exists(installedPath), "installed entrypoint file exists"); + } + + // Test 2: Path traversal attack prevention + { + const QByteArray manifest = R"({ + "format": 1, + "id": "spool.evil", + "version": "1.0.0", + "api": "0.1", + "entry": "logic/provider.mjs" + })"; + QMap entries; + entries.insert(QStringLiteral("manifest.json"), manifest); + entries.insert(QStringLiteral("../../../evil.mjs"), "alert('evil')"); + + const QByteArray zipData = makeStoredZip(entries); + QString errorMsg; + auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); + require(!package.has_value(), "path traversal package rejected"); + } + + // Test 3: Disallowed executable extension rejection + { + const QByteArray manifest = R"({ + "format": 1, + "id": "spool.binary", + "version": "1.0.0", + "api": "0.1", + "entry": "logic/provider.mjs" + })"; + QMap entries; + entries.insert(QStringLiteral("manifest.json"), manifest); + entries.insert(QStringLiteral("payload.exe"), "fake executable"); + + const QByteArray zipData = makeStoredZip(entries); + QString errorMsg; + auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); + require(!package.has_value(), "disallowed file extension rejected"); + } + + // Test 4: Native binary payload rejection (ELF header) + { + const QByteArray manifest = R"({ + "format": 1, + "id": "spool.native", + "version": "1.0.0", + "api": "0.1", + "entry": "logic/provider.mjs" + })"; + QByteArray elfData; + elfData.append("\x7f\x45\x4c\x46", 4); // \x7fELF + elfData.append(32, '\0'); + + QMap entries; + entries.insert(QStringLiteral("manifest.json"), manifest); + entries.insert(QStringLiteral("logic/provider.mjs"), elfData); + + const QByteArray zipData = makeStoredZip(entries); + QString errorMsg; + auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); + require(!package.has_value(), "ELF binary payload rejected"); + } + + // Test 5: Missing manifest rejection + { + QMap entries; + entries.insert(QStringLiteral("logic/provider.mjs"), "export function init() {}"); + + const QByteArray zipData = makeStoredZip(entries); + QString errorMsg; + auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); + require(!package.has_value(), "archive without manifest rejected"); + } + + std::cout << "provider package test ok\n"; + return 0; +} From 52941eb0fa384c30e086194d83a9118a61d2b510 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 05:34:12 +1000 Subject: [PATCH 037/146] feat(provider): add package installation and dynamic module scanning to registry --- src/provider/ProviderRegistry.h | 34 ++- src/provider/ProviderRegistrySources.cpp | 283 +++++++++++++++++++++++ 2 files changed, 315 insertions(+), 2 deletions(-) diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index d62dce17..8038c21d 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -12,6 +12,8 @@ #include +class QNetworkAccessManager; + namespace JellyfinNative { class DatabaseManager; @@ -104,6 +106,12 @@ class ProviderCapabilities final : public QObject { class ProviderRegistry final : public QObject { Q_OBJECT Q_PROPERTY(QVariantList configuredSources READ configuredSources NOTIFY configuredSourcesChanged) + Q_PROPERTY(bool isInstalling READ isInstalling NOTIFY installStatusChanged) + Q_PROPERTY(QString installStatus READ installStatus NOTIFY installStatusChanged) + Q_PROPERTY(int installProgress READ installProgress NOTIFY installStatusChanged) + Q_PROPERTY(QString installError READ installError NOTIFY installStatusChanged) + Q_PROPERTY(QVariantList availableProviders READ availableProviders NOTIFY availableProvidersChanged) + Q_PROPERTY(bool hasInstalledProviders READ hasInstalledProviders NOTIFY installedProvidersChanged) public: explicit ProviderRegistry(QObject *parent = nullptr); @@ -117,13 +125,31 @@ class ProviderRegistry final : public QObject { QVariantMap configuration, QList authorisedOrigins); QCoro::Task callSource( QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}); - QCoro::Task callSourceMediaPage(QString sourceId, QString operation, - QVariantMap arguments = {}, QString scope = {}, int maximumItems = 100); + QCoro::Task callSourceMediaPage( + QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}, int maximumItems = 100); void cancelSourceScope(const QString& sourceId, const QString& scope); QCoro::Task setSourceEnabled(QString sourceId, bool enabled); QCoro::Task removeSource(QString sourceId); QVariantList configuredSources() const; + void setNetworkAccessManager(QNetworkAccessManager *network); + void setProvidersDirectory(const QString& path); + QString providersDirectory() const; + void scanInstalledModules(); + bool hasModule(const QString& moduleId) const; + QCoro::Task downloadAndInstallProvider(QString moduleId, QUrl url = QUrl()); + + bool isInstalling() const; + QString installStatus() const; + int installProgress() const; + QString installError() const; + QVariantList availableProviders() const; + bool hasInstalledProviders() const; + + Q_INVOKABLE bool isInstalled(const QString& moduleId) const; + Q_INVOKABLE void installProvider(const QString& moduleId); + Q_INVOKABLE bool switchProvider(const QString& providerId); + void add(Provider *provider); Provider *provider(const QString& id) const; const std::vector& providers() const @@ -148,6 +174,10 @@ class ProviderRegistry final : public QObject { void activeChanged(); void configuredSourcesChanged(); void sourceRemoved(const QString& sourceId); + void installStatusChanged(); + void availableProvidersChanged(); + void installedProvidersChanged(); + void providerInstalled(const QString& moduleId); private: void refreshCapabilities(); diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp index 3b8a96b6..44423d1b 100644 --- a/src/provider/ProviderRegistrySources.cpp +++ b/src/provider/ProviderRegistrySources.cpp @@ -1,12 +1,20 @@ #include "ProviderRegistry.h" +#include "ProviderPackage.h" #include "ScriptRuntime.h" #include "cache/DatabaseManager.h" +#include +#include +#include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -63,6 +71,13 @@ struct ProviderRegistry::PortableState { } QPointer database; + QPointer network; + QString providersDirectory; + bool isInstalling = false; + QString installStatus; + int installProgress = 0; + QString installError; + QHash modules; QList sources; QVariantList snapshot; @@ -406,4 +421,272 @@ void ProviderRegistry::refreshSourceSnapshot() } } +void ProviderRegistry::setNetworkAccessManager(QNetworkAccessManager *network) +{ + m_portable->network = network; +} + +void ProviderRegistry::setProvidersDirectory(const QString& path) +{ + m_portable->providersDirectory = path; +} + +QString ProviderRegistry::providersDirectory() const +{ + return m_portable->providersDirectory; +} + +bool ProviderRegistry::hasModule(const QString& moduleId) const +{ + return m_portable->modules.contains(moduleId); +} + +bool ProviderRegistry::isInstalled(const QString& moduleId) const +{ + if (moduleId == QStringLiteral("local")) + return true; + return hasModule(moduleId); +} + +bool ProviderRegistry::isInstalling() const +{ + return m_portable->isInstalling; +} + +QString ProviderRegistry::installStatus() const +{ + return m_portable->installStatus; +} + +int ProviderRegistry::installProgress() const +{ + return m_portable->installProgress; +} + +QString ProviderRegistry::installError() const +{ + return m_portable->installError; +} + +bool ProviderRegistry::hasInstalledProviders() const +{ + return !m_portable->modules.isEmpty(); +} + +QVariantList ProviderRegistry::availableProviders() const +{ + QVariantList list; + { + QVariantMap jf; + jf.insert(QStringLiteral("id"), QStringLiteral("spool.jellyfin")); + jf.insert(QStringLiteral("name"), QStringLiteral("Jellyfin")); + jf.insert(QStringLiteral("description"), QStringLiteral("Connect to your personal Jellyfin media server")); + jf.insert(QStringLiteral("icon"), QStringLiteral("jellyfin")); + jf.insert(QStringLiteral("installed"), hasModule(QStringLiteral("spool.jellyfin"))); + jf.insert(QStringLiteral("category"), QStringLiteral("Streaming Server")); + jf.insert(QStringLiteral("requiresAuth"), true); + list.append(jf); + } + { + QVariantMap loc; + loc.insert(QStringLiteral("id"), QStringLiteral("local")); + loc.insert(QStringLiteral("name"), QStringLiteral("Local Videos")); + loc.insert(QStringLiteral("description"), QStringLiteral("Browse videos stored on this device")); + loc.insert(QStringLiteral("icon"), QStringLiteral("folder")); + loc.insert(QStringLiteral("installed"), true); + loc.insert(QStringLiteral("category"), QStringLiteral("Device")); + loc.insert(QStringLiteral("requiresAuth"), false); + list.append(loc); + } + return list; +} + +void ProviderRegistry::scanInstalledModules() +{ + if (m_portable->providersDirectory.isEmpty()) + return; + QDir dir(m_portable->providersDirectory); + if (!dir.exists()) + return; + const auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto& info : entries) { + const QDir subDir(info.absoluteFilePath()); + const QString manifestPath = subDir.filePath(QStringLiteral("manifest.json")); + if (!QFile::exists(manifestPath)) + continue; + QFile manifestFile(manifestPath); + if (!manifestFile.open(QIODevice::ReadOnly)) + continue; + const auto doc = QJsonDocument::fromJson(manifestFile.readAll()); + if (!doc.isObject()) + continue; + const auto obj = doc.object(); + const QString id = obj.value(QStringLiteral("id")).toString(); + const QString entry = obj.value(QStringLiteral("entry")).toString(); + if (!id.isEmpty() && !entry.isEmpty() && !m_portable->modules.contains(id)) { + const QString entryPoint = subDir.filePath(entry); + if (QFile::exists(entryPoint)) { + try { + registerModule(id, entryPoint); + } catch (const std::exception& e) { + qWarning("Failed to register installed module %s: %s", qPrintable(id), e.what()); + } + } + } + } + emit installedProvidersChanged(); + emit availableProvidersChanged(); +} + +void ProviderRegistry::installProvider(const QString& moduleId) +{ + if (m_portable->isInstalling) + return; + downloadAndInstallProvider(moduleId); +} + +bool ProviderRegistry::switchProvider(const QString& providerId) +{ + return setActive(providerId); +} + +QCoro::Task ProviderRegistry::downloadAndInstallProvider(QString moduleId, QUrl url) +{ + if (m_portable->isInstalling) + co_return false; + + m_portable->isInstalling = true; + m_portable->installProgress = 5; + m_portable->installStatus = QStringLiteral("Connecting..."); + m_portable->installError.clear(); + emit installStatusChanged(); + + if (url.isEmpty()) { + if (moduleId == QStringLiteral("spool.jellyfin")) { + url = QUrl(QStringLiteral("https://github.com/spool-player/spool-jellyfin/releases/download/v0.1.0-alpha.1/" + "spool-jellyfin-0.1.0-alpha.1.zip")); + } else { + m_portable->isInstalling = false; + m_portable->installError = QStringLiteral("Unknown provider module: %1").arg(moduleId); + emit installStatusChanged(); + co_return false; + } + } + + QByteArray archiveData; + bool downloadSuccess = false; + + if (m_portable->network) { + QUrl currentUrl = url; + int redirectCount = 0; + constexpr int maxRedirects = 5; + + while (redirectCount < maxRedirects) { + QNetworkRequest request(currentUrl); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool/0.8.0")); + + QNetworkReply *reply = m_portable->network->get(request); + connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64 total) { + if (total > 0) { + m_portable->installProgress = std::clamp(static_cast(10 + (received * 75) / total), 10, 85); + m_portable->installStatus = QStringLiteral("Downloading (%1%)...").arg(m_portable->installProgress); + emit installStatusChanged(); + } + }); + + co_await reply; + + const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308) { + const QVariant redirectVar = reply->header(QNetworkRequest::LocationHeader); + if (redirectVar.isValid()) { + currentUrl = currentUrl.resolved(redirectVar.toUrl()); + reply->deleteLater(); + ++redirectCount; + continue; + } + } + + if (reply->error() == QNetworkReply::NoError && statusCode >= 200 && statusCode < 300) { + archiveData = reply->readAll(); + downloadSuccess = true; + } else { + qWarning("Download from %s failed with status %d: %s", qPrintable(currentUrl.toString()), statusCode, + qPrintable(reply->errorString())); + } + reply->deleteLater(); + break; + } + } + + // If online download failed or no network, check local fallback files + if (!downloadSuccess || archiveData.isEmpty()) { + const QString fallbackPath = QStringLiteral("providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip"); + if (QFile::exists(fallbackPath)) { + QFile file(fallbackPath); + if (file.open(QIODevice::ReadOnly)) { + archiveData = file.readAll(); + downloadSuccess = true; + } + } + } + + if (!downloadSuccess || archiveData.isEmpty()) { + m_portable->isInstalling = false; + m_portable->installError = QStringLiteral("Failed to download provider package."); + emit installStatusChanged(); + co_return false; + } + + m_portable->installStatus = QStringLiteral("Validating package..."); + m_portable->installProgress = 90; + emit installStatusChanged(); + + QString errorMsg; + auto parsed = ProviderPackage::parseAndValidate(archiveData, &errorMsg); + if (!parsed) { + m_portable->isInstalling = false; + m_portable->installError = QStringLiteral("Package validation failed: %1").arg(errorMsg); + emit installStatusChanged(); + co_return false; + } + + m_portable->installStatus = QStringLiteral("Installing files..."); + m_portable->installProgress = 95; + emit installStatusChanged(); + + const QString entryPoint = ProviderPackage::install(*parsed, m_portable->providersDirectory, &errorMsg); + if (entryPoint.isEmpty()) { + m_portable->isInstalling = false; + m_portable->installError = QStringLiteral("Installation failed: %1").arg(errorMsg); + emit installStatusChanged(); + co_return false; + } + + try { + if (!m_portable->modules.contains(parsed->id)) + registerModule(parsed->id, entryPoint); + } catch (const std::exception& e) { + m_portable->isInstalling = false; + m_portable->installError = QString::fromUtf8(e.what()); + emit installStatusChanged(); + co_return false; + } + + if (parsed->id == QStringLiteral("spool.jellyfin")) { + setActive(QStringLiteral("jellyfin")); + } + + m_portable->isInstalling = false; + m_portable->installProgress = 100; + m_portable->installStatus = QStringLiteral("Ready"); + emit installStatusChanged(); + emit installedProvidersChanged(); + emit availableProvidersChanged(); + emit providerInstalled(moduleId); + + co_return true; +} + } // namespace JellyfinNative From 08a912ad1228b4668630b955c64c91c73234857e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 05:34:17 +1000 Subject: [PATCH 038/146] feat(shell): add provider onboarding screen and update account picker for providers --- qml/pages/ProviderPickerPage.qml | 305 ++++++++++++++++++ qml/primitives/ProfileTile.qml | 26 +- qml/providers/jellyfin/LoginPage.qml | 12 +- qml/providers/jellyfin/LoginProfilePicker.qml | 27 +- qml/shell/AppShell.qml | 7 +- qml/shell/RouteStack.qml | 6 +- src/main.cpp | 28 +- 7 files changed, 380 insertions(+), 31 deletions(-) create mode 100644 qml/pages/ProviderPickerPage.qml diff --git a/qml/pages/ProviderPickerPage.qml b/qml/pages/ProviderPickerPage.qml new file mode 100644 index 00000000..4293aec5 --- /dev/null +++ b/qml/pages/ProviderPickerPage.qml @@ -0,0 +1,305 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" +import "../providers/jellyfin" + +// The provider selection page: choose a media backend or download the +// portable Jellyfin provider before proceeding to authentication or browsing. +FocusScope { + id: root + + property var shell + + readonly property bool keyboardVisible: Qt.inputMethod.visible + readonly property bool dense: keyboardVisible || Metrics.units(height) < 620 + readonly property var providers: Sources.availableProviders + readonly property int providerCount: providers.length + readonly property bool canGoBack: Router.canGoBack || (shell && shell.canCancelSwitchUser) + + readonly property int tileSize: { + const preferred = Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 176 : Metrics.laneAtLeast(width, + "regular") + ? 156 : 132) + return Math.max(Metrics.scaled(80), Math.min(preferred, width - cellPadding * 2)) + } + readonly property int cellPadding: Metrics.scaled(16) + + focus: true + + function focusDefault() { + if (grid.currentIndex < 0 && grid.count > 0) + grid.currentIndex = 0 + InputKeys.focus(grid) + } + + Component.onCompleted: { + focusDefault() + } + + Connections { + target: Sources + function onProviderInstalled(moduleId) { + if (moduleId === "spool.jellyfin") { + Sources.switchProvider("jellyfin") + Router.reset("login") + } + } + } + + Item { + id: brandHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: Metrics.pageMarginPx + anchors.rightMargin: Metrics.pageMarginPx + anchors.topMargin: Metrics.pageMarginPx + height: brandPanel.implicitHeight + + LoginBrandPanel { + id: brandPanel + anchors.fill: parent + dense: root.dense + backVisible: root.canGoBack + onBackRequested: { + if (Router.canGoBack) + Router.back() + else + Router.reset("login") + } + } + } + + Item { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.top: brandHeader.bottom + anchors.bottom: parent.bottom + anchors.margins: Metrics.pageMarginPx + + ColumnLayout { + id: centerColumn + anchors.centerIn: parent + width: Math.min(parent.width, Math.max(Metrics.scaled(320), grid.width)) + spacing: Metrics.scaled(18) + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Metrics.scaled(6) + + AppText { + Layout.alignment: Qt.AlignHCenter + text: "Choose a provider" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + } + + AppText { + Layout.alignment: Qt.AlignHCenter + text: "Select a media source to connect to Spool" + color: Theme.textSecondary + font.pixelSize: Metrics.bodySizePx + horizontalAlignment: Text.AlignHCenter + } + } + + GridView { + id: grid + Layout.alignment: Qt.AlignHCenter + + readonly property int cellSpan: root.tileSize + root.cellPadding * 2 + readonly property int columnsInUse: Math.max(1, Math.min(root.providerCount, Math.floor((content.width + - Metrics.scaled( + 32)) / cellSpan))) + + width: cellSpan * columnsInUse + height: root.tileSize + Metrics.scaled(root.dense ? 70 : 100) + root.cellPadding + cellWidth: cellSpan + cellHeight: height + clip: false + focus: true + keyNavigationEnabled: false + boundsBehavior: Flickable.StopAtBounds + model: root.providers + currentIndex: 0 + + function activateCurrent() { + if (currentItem) + currentItem.activate() + } + + Keys.onLeftPressed: { + if (currentIndex > 0) + currentIndex-- + } + Keys.onRightPressed: { + if (currentIndex < count - 1) + currentIndex++ + } + Keys.onSelectPressed: activateCurrent() + Keys.onEnterPressed: activateCurrent() + Keys.onReturnPressed: activateCurrent() + + FastWheelHandler { + flickable: grid + } + + delegate: Item { + id: cell + required property var modelData + required property int index + + width: grid.cellWidth + height: grid.cellHeight + + function activate() { + tileScope.accepted() + } + + FocusScope { + id: tileScope + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: root.cellPadding + width: root.tileSize + height: root.tileSize + Metrics.scaled(80) + focus: cell.GridView.isCurrentItem && grid.activeFocus + + readonly property bool isInstalled: Boolean(cell.modelData.installed) + readonly property bool isJellyfin: cell.modelData.id === "spool.jellyfin" + readonly property bool isDownloading: isJellyfin && Sources.isInstalling + + signal accepted + + onAccepted: { + grid.currentIndex = cell.index + InputKeys.focus(grid) + if (isJellyfin) { + if (isInstalled) { + Sources.switchProvider("jellyfin") + Router.reset("login") + } else if (!isDownloading) { + Sources.installProvider("spool.jellyfin") + } + } else if (cell.modelData.id === "local") { + Sources.switchProvider("local") + Router.reset("home") + } + } + + Rectangle { + id: tileAvatar + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: root.tileSize + height: root.tileSize + radius: Theme.radiusLarge + color: tileScope.focus ? Theme.bgRaised : Theme.bg + border.width: tileScope.focus ? Theme.focusBorderWidth : hoverArea.containsMouse + ? Theme.hoverBorderWidth : 1 + border.color: tileScope.focus ? Theme.accent : hoverArea.containsMouse ? Theme.borderRaised : + Theme.border + antialiasing: true + + MaterialIcon { + anchors.centerIn: parent + name: cell.modelData.id === "local" ? "video_library" : "dns" + iconSize: Math.round(root.tileSize * 0.42) + iconColor: tileScope.focus ? Theme.accent : Theme.textPrimary + visible: !tileScope.isDownloading + } + + // Download progress spinner/indicator + ColumnLayout { + anchors.centerIn: parent + visible: tileScope.isDownloading + spacing: Metrics.scaled(8) + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + name: "cloud_download" + iconSize: Math.round(root.tileSize * 0.36) + iconColor: Theme.accent + } + + AppText { + Layout.alignment: Qt.AlignHCenter + text: Sources.installProgress + "%" + font.pixelSize: Metrics.metaSizePx + font.weight: Font.Bold + color: Theme.accent + } + } + } + + // Focus ring offset + Rectangle { + anchors.fill: tileAvatar + anchors.margins: -Metrics.scaled(5) + radius: tileAvatar.radius + Metrics.scaled(5) + color: "transparent" + border.width: Theme.focusRingWidth + border.color: Theme.accent + visible: tileScope.focus + antialiasing: true + } + + ColumnLayout { + anchors.top: tileAvatar.bottom + anchors.topMargin: Metrics.scaled(10) + anchors.left: parent.left + anchors.right: parent.right + spacing: Metrics.scaled(3) + + AppText { + Layout.fillWidth: true + text: String(cell.modelData.name || "") + font.pixelSize: Metrics.bodySizePx + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + AppText { + Layout.fillWidth: true + text: tileScope.isDownloading ? Sources.installStatus : tileScope.isInstalled ? "Ready" : + "Download & Install" + font.pixelSize: Metrics.metaSizePx + color: tileScope.isDownloading ? Theme.accent : tileScope.isInstalled + ? Theme.textSecondary : Theme.accent + font.weight: tileScope.isInstalled ? Font.Normal : Font.Medium + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + tileScope.accepted() + } + } + } + } + } + + // Error display if installation failed + AppText { + Layout.alignment: Qt.AlignHCenter + visible: Sources.installError.length > 0 + text: Sources.installError + color: Theme.errorText + font.pixelSize: Metrics.metaSizePx + horizontalAlignment: Text.AlignHCenter + } + } + } +} diff --git a/qml/primitives/ProfileTile.qml b/qml/primitives/ProfileTile.qml index 9975bafa..070087e0 100644 --- a/qml/primitives/ProfileTile.qml +++ b/qml/primitives/ProfileTile.qml @@ -11,11 +11,14 @@ FocusScope { property string username: "" property string serverName: "" property string serverAddress: "" + property string providerName: "" property bool needsSignIn: false property bool addTile: false + property bool actionTile: false + property string actionIcon: "add" property bool focused: activeFocus - readonly property int labelHeight: Metrics.scaled(addTile ? 34 : 66) + readonly property int labelHeight: Metrics.scaled((addTile || actionTile) ? 34 : 66) readonly property string initial: { const name = String(username).trim() @@ -47,23 +50,24 @@ FocusScope { width: root.tileSize height: root.tileSize radius: Theme.radiusLarge - color: root.addTile ? Theme.bgRaised : root.avatarColor - border.width: root.focused ? Theme.focusBorderWidth : root.addTile || (hover.hovered && Metrics.pointerActive) + color: (root.addTile || root.actionTile) ? Theme.bgRaised : root.avatarColor + border.width: root.focused ? Theme.focusBorderWidth : (root.addTile || root.actionTile || (hover.hovered + && Metrics.pointerActive)) ? Theme.hoverBorderWidth : 0 border.color: root.focused ? Theme.accent : Theme.border antialiasing: true MaterialIcon { anchors.centerIn: parent - visible: root.addTile - name: "add" + visible: root.addTile || root.actionTile + name: root.actionIcon iconSize: Math.round(root.tileSize * 0.3) iconColor: root.focused ? Theme.accent : Theme.textSecondary } AppText { anchors.centerIn: parent - visible: !root.addTile + visible: !root.addTile && !root.actionTile text: root.initial font.pixelSize: Math.round(root.tileSize * 0.4) font.weight: Font.DemiBold @@ -131,8 +135,12 @@ FocusScope { SecondaryText { width: parent.width - visible: !root.addTile && root.serverName.length > 0 - text: root.serverName + visible: !root.addTile && !root.actionTile && (root.providerName.length > 0 || root.serverName.length > 0) + text: root.providerName.length > 0 && root.serverName.length > 0 ? (root.providerName + " • " + + root.serverName) : ( + root.providerName.length > 0 + ? root.providerName : + root.serverName) color: Theme.textMuted font.pixelSize: Metrics.scaled(13) horizontalAlignment: Text.AlignHCenter @@ -142,7 +150,7 @@ FocusScope { SecondaryText { width: parent.width - visible: !root.addTile && root.serverAddress.length > 0 + visible: !root.addTile && !root.actionTile && root.serverAddress.length > 0 text: root.serverAddress color: Theme.textDisabled font.pixelSize: Metrics.scaled(12) diff --git a/qml/providers/jellyfin/LoginPage.qml b/qml/providers/jellyfin/LoginPage.qml index d3e8594a..1ee67419 100644 --- a/qml/providers/jellyfin/LoginPage.qml +++ b/qml/providers/jellyfin/LoginPage.qml @@ -55,9 +55,7 @@ FocusScope { readonly property string chosenServerName: selectedServerName.length > 0 ? selectedServerName : fallbackServerName readonly property string chosenServerAddress: selectedServerAddress.length > 0 ? selectedServerAddress : Session.serverUrl - readonly property bool canNavigateBack: Boolean(shell && shell.canCancelSwitchUser) || (addMode && ( - hasSavedAccounts - || addStep === 2)) + readonly property bool canNavigateBack: true focus: true @@ -164,7 +162,12 @@ FocusScope { } if (!addMode && shell && shell.canCancelSwitchUser) return shell.cancelSwitchUser() - return false + if (Router.canGoBack) { + Router.back() + return true + } + Router.replace("providerPicker") + return true } function routeKey(key, phase, repeat) { @@ -298,6 +301,7 @@ FocusScope { dense: root.dense onProfileChosen: profileId => root.enterProfile(profileId) onAddRequested: root.openAddAccount() + onChangeProviderRequested: Router.push("providerPicker") onContextRequested: (profileId, anchor, serverName, serverUrl) => profileDialogs.show(profileId, anchor, serverName, serverUrl) } diff --git a/qml/providers/jellyfin/LoginProfilePicker.qml b/qml/providers/jellyfin/LoginProfilePicker.qml index a2c5c38b..faee5b63 100644 --- a/qml/providers/jellyfin/LoginProfilePicker.qml +++ b/qml/providers/jellyfin/LoginProfilePicker.qml @@ -17,6 +17,7 @@ FocusScope { readonly property var profiles: Session.accountProfiles readonly property int profileCount: profiles.length + readonly property int totalItemCount: root.profileCount + 2 // Tiles take the room a lane offers, but never more room than there is: // a pane narrower than one tile shrinks the tile rather than clipping it. readonly property int tileSize: { @@ -29,6 +30,7 @@ FocusScope { signal profileChosen(string profileId) signal addRequested + signal changeProviderRequested signal contextRequested(string profileId, Item anchor, string serverName, string serverUrl) function controls() { @@ -50,7 +52,7 @@ FocusScope { return false const direction = key === Qt.Key_Left ? "left" : key === Qt.Key_Right ? "right" : key === Qt.Key_Up ? "up" : "down" - const next = ProfileNavigation.move(grid.currentIndex, root.profileCount, grid.columnCount(), direction) + const next = ProfileNavigation.move(grid.currentIndex, root.totalItemCount - 1, grid.columnCount(), direction) if (next < 0 || next === grid.currentIndex) return false grid.currentIndex = next @@ -103,8 +105,8 @@ FocusScope { // Whole cells, and never more of them than there are tiles to put in // them: sizing to the columns the pane could hold left a row of two // accounts hugging the left edge of four columns of empty space. - readonly property int columnsInUse: Math.max(1, Math.min(root.profileCount + 1, Math.floor(parent.width - / cellSpan))) + readonly property int columnsInUse: Math.max(1, Math.min(root.totalItemCount, Math.floor(parent.width + / cellSpan))) anchors.top: parent.top anchors.topMargin: content.headingSpan @@ -117,7 +119,7 @@ FocusScope { focus: false keyNavigationEnabled: false boundsBehavior: Flickable.StopAtBounds - model: root.profileCount + 1 + model: root.totalItemCount currentIndex: 0 onCountChanged: if (currentIndex >= count) currentIndex = Math.max(0, count - 1) @@ -148,6 +150,8 @@ FocusScope { required property int index readonly property var profile: cell.index < root.profileCount ? root.profiles[cell.index] : null + readonly property bool isAddTile: cell.index === root.profileCount + readonly property bool isProviderTile: cell.index === root.profileCount + 1 width: grid.cellWidth height: grid.cellHeight @@ -166,9 +170,14 @@ FocusScope { anchors.top: parent.top anchors.topMargin: root.cellPadding tileSize: root.tileSize - addTile: cell.profile === null - username: cell.profile ? String(cell.profile.userName || "Saved account") : "Add account" - serverName: cell.profile ? String(cell.profile.serverName || "Jellyfin Server") : "" + addTile: cell.isAddTile + actionTile: cell.isProviderTile + actionIcon: "swap_horiz" + providerName: cell.profile ? "Jellyfin" : "" + username: cell.profile ? String(cell.profile.userName || "Saved account") : (cell.isAddTile ? "Add account" : + "Change provider") + serverName: cell.profile ? String(cell.profile.serverName || "Jellyfin Server") : ( + cell.isProviderTile ? "Switch media source" : "") serverAddress: cell.profile ? String(cell.profile.serverHost || cell.profile.serverUrl || "") : "" needsSignIn: cell.profile ? Boolean(cell.profile.needsAuthentication) : false focused: cell.GridView.isCurrentItem && grid.activeFocus @@ -177,8 +186,10 @@ FocusScope { InputKeys.focus(grid) if (cell.profile) root.profileChosen(String(cell.profile.profileId || "")) - else + else if (cell.isAddTile) root.addRequested() + else if (cell.isProviderTile) + root.changeProviderRequested() } onContextRequested: { if (!cell.profile) diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 8f939cbb..97f2799c 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -363,6 +363,8 @@ KeyRouter { } function defaultRoute() { + if (!Sources.hasInstalledProviders) + return "providerPicker" return root.signedIn ? "home" : "login" } @@ -1052,9 +1054,9 @@ KeyRouter { // window lane; top and bottom can briefly coexist and leave the // bar stretched across the viewport. y: root.navBarAtBottom ? Math.max(0, parent.height - height) : 0 - height: root.chromeRoute === "login" ? 0 : Metrics.topBarHeightPx + height: (root.chromeRoute === "login" || root.chromeRoute === "providerPicker") ? 0 : Metrics.topBarHeightPx edge: root.navBarAtBottom ? "bottom" : "top" - visible: root.chromeRoute !== "login" + visible: root.chromeRoute !== "login" && root.chromeRoute !== "providerPicker" z: 1 // Same reason as the height above: the rail marks where you are, // not where you are going, so it does not blink its selection off @@ -1112,6 +1114,7 @@ KeyRouter { // The remote control page is this bar in full, so it would only // duplicate itself there. visible: shown && root.chromeRoute !== "remoteControl" && root.chromeRoute !== "login" + && root.chromeRoute !== "providerPicker" enabled: visible onOpenRequested: root.pushRoute("remoteControl") } diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index 388c7dfd..d1b6a8e0 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -30,6 +30,8 @@ FocusScope { switch (nextRoute) { case "login": return "login" + case "providerPicker": + return "providerPicker" case "libraryGrid": return "libraryGrid" case "itemDetails": @@ -55,6 +57,8 @@ FocusScope { switch (key) { case "login": return Qt.resolvedUrl("../providers/jellyfin/LoginPage.qml") + case "providerPicker": + return Qt.resolvedUrl("../pages/ProviderPickerPage.qml") case "libraryGrid": return Qt.resolvedUrl("../pages/LibraryGridPage.qml") case "itemDetails": @@ -186,7 +190,7 @@ FocusScope { } function dropTransientPages() { - for (const key of ["login"]) { + for (const key of ["login", "providerPicker"]) { const loader = pages[key] if (loader && loader !== activeLoader) { delete pages[key] diff --git a/src/main.cpp b/src/main.cpp index 9a8b62df..11b2722f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -647,8 +647,17 @@ int main(int argc, char **argv) // controller, which hold their parts by pointer, so they are declared // before them. JellyfinNative::ProviderRegistry providers; - providers.registerModule( - QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); + providers.setNetworkAccessManager(networkAccessManager); + const QString providersDir = QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers")); + QDir().mkpath(providersDir); + providers.setProvidersDirectory(providersDir); + providers.scanInstalledModules(); + + if (!providers.hasModule(QStringLiteral("spool.jellyfin")) + && QFile::exists(QStringLiteral(":/providers/spool.jellyfin/logic/provider.mjs"))) { + providers.registerModule( + QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); + } try { QCoro::waitFor(providers.restoreSources(&database)); } catch (const std::exception& error) { @@ -845,11 +854,16 @@ int main(int argc, char **argv) provider->setLocale(localization->bcp47Locale()); QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, provider, [provider, loc = localization.get()]() { provider->setLocale(loc->bcp47Locale()); }); - // A source with its own sign-in opens on it; one without goes straight - // to home. - auto router = std::make_unique( - provider->capabilities().testFlag(JellyfinNative::Provider::Auth) ? QStringLiteral("login") - : QStringLiteral("home")); + QString initialRoute; + if (requestedProvider == QStringLiteral("local")) { + initialRoute = QStringLiteral("home"); + } else if (!providers.hasInstalledProviders()) { + initialRoute = QStringLiteral("providerPicker"); + } else { + initialRoute = provider->capabilities().testFlag(JellyfinNative::Provider::Auth) ? QStringLiteral("login") + : QStringLiteral("home"); + } + auto router = std::make_unique(initialRoute); JellyfinNative::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); applicationHooks.settings = controller->settings(); From fedc45fe20dabda24d212b99ee5a3685c3b92be3 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 05:34:25 +1000 Subject: [PATCH 039/146] chore(release): bump version to 0.8.0 --- VERSION | 2 +- app/com.sachk.spool.metainfo.xml | 1 + packaging/aur/.SRCINFO | 8 ++++---- packaging/aur/PKGBUILD | 4 ++-- src/api/ProviderBenchmark.cpp | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 8bf8ecb0..a3df0a69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.18 +0.8.0 diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 6132eb70..3f189818 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -21,6 +21,7 @@ Spool + diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index b3a6b165..9c318cfc 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = spool-bin pkgdesc = Jellyfin client for desktop and TV, built on Qt Quick and mpv - pkgver = 0.7.18 + pkgver = 0.8.0 pkgrel = 1 url = https://github.com/spool-player/spool arch = x86_64 @@ -9,12 +9,12 @@ pkgbase = spool-bin depends = glibc depends = libglvnd depends = mesa - provides = spool=0.7.18 + provides = spool=0.8.0 conflicts = spool options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.7.18/Spool-for-Jellyfin-0.7.18-linux-x86_64.tar.zst - sha256sums = 2fc5c8cc36b0c1714360bc1b6151730e1ef5fb396bed0068253f38923d52c9e6 + source = https://github.com/spool-player/spool/releases/download/v0.8.0/Spool-for-Jellyfin-0.8.0-linux-x86_64.tar.zst + sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index fe9564d1..a4bb2f7d 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sacha Korban pkgname=spool-bin -pkgver=0.7.18 +pkgver=0.8.0 pkgrel=1 pkgdesc='Jellyfin client for desktop and TV, built on Qt Quick and mpv' arch=('x86_64') @@ -20,7 +20,7 @@ _bundle="Spool-for-Jellyfin-${pkgver}-linux-x86_64" source=("https://github.com/spool-player/spool/releases/download/v${pkgver}/${_bundle}.tar.zst") # Written by tools/update-aur-checksums.sh from SHA256SUMS.txt once the release # above is published; SKIP until then. -sha256sums=('2fc5c8cc36b0c1714360bc1b6151730e1ef5fb396bed0068253f38923d52c9e6') +sha256sums=('SKIP') package() { install -dm755 "$pkgdir/opt/spool" diff --git a/src/api/ProviderBenchmark.cpp b/src/api/ProviderBenchmark.cpp index 678d68a9..2e870ad3 100644 --- a/src/api/ProviderBenchmark.cpp +++ b/src/api/ProviderBenchmark.cpp @@ -112,7 +112,7 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis config.insert(QStringLiteral("deviceId"), nativeApi->deviceId().isEmpty() ? QStringLiteral("spool-benchmark") : nativeApi->deviceId()); config.insert(QStringLiteral("deviceName"), QStringLiteral("Spool Benchmark")); - config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.7.15")); + config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.8.0")); QList origins { QUrl(serverUrl) }; const QString sourceId = co_await registry->configureSource( From 0c80616f893c3a4ff7d267dbcf850c4416016bf9 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 05:34:32 +1000 Subject: [PATCH 040/146] docs: add portable provider implementation brief --- docs/provider-portable-brief.md | 444 ++++++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/provider-portable-brief.md diff --git a/docs/provider-portable-brief.md b/docs/provider-portable-brief.md new file mode 100644 index 00000000..d8d39183 --- /dev/null +++ b/docs/provider-portable-brief.md @@ -0,0 +1,444 @@ +# Spool: portable providers without compromising the native fast path + +**Implementation brief · 21 September 2026** +**Baseline:** `refactor/provider-seam`, the supplied source ZIP, and the supplied September 18–20 commit export. +**Decision:** Qt JavaScript provider logic, provider-owned QML, native Spool services/models/rendering/playback, independently published source packages. +**Status:** A plan, not a report of completed implementation, device benchmarks, repository creation, or store approval. + +## 1. Start here: objectives and authority to adapt + +Spool is an unusually performance-sensitive media client. The provider system must extend that product, not turn its native UI into a slow scripting framework. Optimise for the weakest supported LG webOS television, not a developer workstation. Preserve libmpv playback, platform/HDR integration, fast startup, responsive navigation and consistently smooth scrolling. + +Deliver one application that can use multiple first- and third-party providers, multiple accounts, and multiple servers concurrently. It must search them together, progressively group equivalent titles, and let users select an exact available version. A selected browsing source is not a global backend switch. + +Provider authors maintain one implementation: JavaScript API/client logic plus optional QML screens. Providers own backend-specific behaviour and bespoke UI, including Stremio add-on configuration, release/torrent/file selection and preparation workflows. Core provides generous reusable functionality, not provider-specific pages. Shared library providers, Wasm, a second maintained native provider implementation, and a new web renderer are **not** part of this implementation. + +Ship official providers with the application, at least Jellyfin, so setup and normal operation do not depend on downloading code. Create separate `spool-jellyfin` and `spool-stremio` repositories. Support installing/updating compatible provider source packages independently where the distribution channel permits it, including a deliberate Google Play path. NixOS support can use the Linux AppImage through `appimage-run`; native Nix provider packaging is not a priority. + +**Treat this document as an implementation brief, not a frozen class diagram.** Inspect the actual checkout first. Preserve the requirements and observable behaviour, but change names, internal interfaces, thread allocation, schema details, milestone ordering and repository layout when evidence supports a better solution. Record consequential deviations and their tests in a short decision log. Do not ask for approval on routine engineering choices or stop after producing another plan. + +Rewrites are inexpensive at this stage. Retain useful behaviour, performance work, tests and architectural separation—not code merely because someone wrote it recently. Do not build elaborate compatibility shims around unsuitable single-source or Jellyfin-shaped abstractions. Conversely, do not rewrite the proven renderer/player simply to make the provider migration aesthetically uniform. + +### Fixed performance pipeline + +```text +Native controller requests a bounded page + -> provider JavaScript constructs the backend request + -> native asynchronous HTTP + -> provider JavaScript parses/normalises the response off the GUI thread + -> one conversion into owned native page data + -> bounded incremental updates to the native model on its owning thread + -> existing Qt Quick delegates render the native model +``` + +“One conversion” means no avoidable stringify/parse round trip or repeated full-object conversions. It does not mean zero allocations, zero copies, or one giant GUI-thread transaction. HTTP decompression, text decoding, parsing, normalisation, conversion and rendering must all be measurable stages. + +Once data is in the model, reading rows/roles and rendering existing items must not call provider backend JavaScript. Provider-owned UI may use ordinary lightweight QML bindings and event handlers; it must not move bulk data processing into those bindings. + +## 2. Performance contract and evidence + +### Goals, not retrospective claims + +Target **under 100 ms to the first useful page frame** on the minimum supported webOS device. Define that for each page/workload and report at least median, p95 and tail behaviour. For a cached page, measure input/navigation to the frame. For a remote page, separately measure request latency and response-ready to the frame. Also report true end-to-end latency; do not hide slow request orchestration by only measuring rendering. + +Network/server delays and uncached artwork downloads are not under a 100 ms guarantee. A skeleton counts as immediate feedback, **not** a completed content page. Standard pages, provider-specific pages and cold component activation all need measurements; do not meet the target by quietly excluding every cold path. + +Use the following as initial engineering budgets, adjustable with recorded measurements rather than silently relaxed: + +| Area | Starting expectation | +| --- | --- | +| GUI model commits | Usually about 1–2 ms of blocking work per event-loop turn; split expensive batches without delaying the first visible rows. | +| Ordinary provider page | A provisional background processing budget around 10–30 ms for roughly 50–100 lightweight items / 100–300 KB of JSON. This is an unmeasured hypothesis, not an LG guarantee. | +| Frame pacing | At a 60 Hz presentation target the whole frame budget is about 16.7 ms; provider activity should cause no material regression in baseline frame pacing or input response. Measure the actual presentation rate. | +| Warm navigation | Reuse native normalised pages and compiled/cached UI resources when valid; avoid invoking providers merely to redisplay cached data. | +| Idle cost | No periodic provider polling or per-frame backend work without a real feature requirement. | + +Qt recommends asynchronous processing, worker threads and short blocking functions; its performance guidance also identifies conversions and model design as potential costs. These support the direction, not the numerical Spool budgets above. [R2] + +Identify the actual minimum hardware/firmware from the project's support policy and available devices; do not assume all LG TVs, or all webOS versions, perform like a C2. If the device is unavailable, implement repeatable instrumentation and report the device acceptance gate as pending. Do not substitute desktop timings for TV validation. + +Instrument request scheduling, first/last response bytes, decompression/decoding, JSON parsing, mapping, conversion, aggregation, queued-delivery delay, model commits, delegate creation and first useful frame. Track memory, allocation/GC pressure, queue depth and concurrency. Reuse the existing `RenderBenchmark`/input-latency machinery and fixture infrastructure where useful. + +Exercise small pages, broad searches, multi-megabyte responses, thousands of stream candidates, many simultaneous source completions, rapid query changes, pagination while scrolling, and browsing during high-bitrate playback. Benchmark interpreter-only execution as well as the normal shipped engine mode. Keep payload fixtures free of credentials and licensed appropriately. + +Protect responsiveness through bounded responses, server-side pagination/filtering when available, low-overhead batch conversion, virtualised delegates, fair background scheduling and memory limits. Async HTTP alone is not enough. Do not fetch complete libraries or every media track just to show a page; do not truncate an unpaginated service response and falsely report completion. Use explicit continuation, local batching or a visible limit as appropriate. + +## 3. What the branch contributes—and what to replace + +The supplied commit export documents the work below. Its descriptions and the existing plan are evidence of intended changes, not proof of runtime correctness or performance. The supplied source snapshot was also inspected for this plan. The implementing agent must reconcile these notes with the actual branch; the old document's “77 tests pass” statement is not independent validation in this document. [S1–S3] + +| Work / commit reference | Reuse decision | +| --- | --- | +| `adc13dbe`: `MediaTypes` moved out of Jellyfin types | Retain the native media-model foundation and useful metadata; audit fields and identity. The commit explicitly made no field changes, so relocation is not proof of provider neutrality. | +| `b9c5bec2`: `PlaybackSource`, token/header handling outside player | Retain dependency inversion, playback behaviour and tests. Replace source-global assumptions with resource/session-scoped requests. | +| `d021a76c`, `c8bf5f16`: catalog/search/item-state/artwork interfaces and core controllers | Retain native models, controllers, caching and test expectations where appropriate. These are internal adaptation points, not a frozen public script API. | +| `d45bcb52`: settings inversion | Keep the event/bridge separation. Backend settings operations belong in provider JS; remove direct backend dependencies from core. | +| `cc235686`, `c9afbcb7`: platform capability applier and application/benchmark hooks | Keep the platform and diagnostics separation. Generic host capability descriptions should replace backend-specific consumers without reintroducing coupling. | +| `542810b6`, `985829dc`: provider composition and `AppController` separation | Keep the ownership lesson and behavioural coverage. Replace broad concrete core-controller exposure, global singletons and captured “active provider” pointers. | +| `61cb6fb1`, `bf37ebe2`: provider QML moved/gated; focus-navigation tests | Retain reusable pages and navigation tests. Gating hardcoded Jellyfin globals is not a general source-aware UI integration. | +| `611b2e27`: registry/capability scaffolding | Rework into installed modules, configured source instances and per-operation contexts. A registry that selects one active provider is not the required multi-source registry. | +| `20bc39f4`: folder-of-files provider | Keep an offline fixture permanently. Add a portable script fixture exercising the actual runtime, and preserve local playback through host-authorised file access when useful. | +| `5abb2e7f`: seam checks and build grouping | Keep and strengthen the checks. Cover QML imports/routes, script imports and actual independently packaged providers, not just C++ includes. | +| `e0de68c1` / `4ad4b560`: split documentation | Replace stale architecture/status sections with this direction and measured implementation status. | + +Concrete snapshot issues to resolve include a `Provider::CoreServices` bundle of concrete controllers; one active provider wired into `AppController` and artwork; hardcoded Jellyfin route URLs/singletons; a single-source, finite-vector search interface; and playback resolution without an explicit selected variant. The archive also still combines core and provider sources in `jellyfin-core`. Treat these as inspection leads, not immutable line-number instructions. + +Remove the native plugin loader/build-matrix workstream, exact Spool-build provider indexes, mutual repository dependencies and the assumption that Catalog/Stream/Artwork must all be mandatory. Do not preserve the old C++ Jellyfin facade indefinitely behind a script-shaped forwarding layer: that would defeat independently updateable API logic. It may serve as a temporary reference and differential-test baseline during migration. + +## 4. Native host and worker runtime + +### Responsibilities + +| Native Spool | Portable provider | +| --- | --- | +| Async transport, TLS, bounded downloads, cancellation, credentials/storage services | Endpoint paths, parameters, authentication protocol, provider-specific error interpretation and retry decisions | +| Efficient typed media/custom list models, caching and incremental commits | Response parsing/normalisation and provider-specific data | +| Federated search, identity grouping, generic version chooser | Source-local queries, external IDs, version discovery and exact-source resolution | +| libmpv, media I/O, buffering, decode, HDR, timing and rendering | Playback negotiation, reports and backend-specific session messages | +| Generic routes, focus/navigation, reusable UI kit and UI-to-worker RPC | Bespoke QML pages, dialogs, actions and workflows | +| Device capability discovery and reusable selection policies | Translation to backend-specific device profiles/requests | + +A host feature should be reusable or genuinely performance/platform critical. Do not grow `host.jellyfinSearch()` or move entire backend response parsers into core to avoid rewriting them. Equally, a small generic native helper justified by measured cost is acceptable. Do not invent a universal media framework before the two real providers need it. + +### Engine and thread model + +Use a backend `QJSEngine` execution context separate from the frontend `QQmlEngine`. Keep each engine and its JS values confined to its owner thread; schedule calls through queued work, never concurrently into one engine. Engine count and thread count are implementation choices. A reasonable starting point is an isolated module execution context assigned to a small, bounded set of workers, with explicit source-instance objects inside it. Measure memory before creating one engine per account, request or page. + +JavaScript modules are cached/singleton-like within an engine. Create explicit account/source objects rather than putting “current token/current server” in module globals. Qt documents module caching, native object bridges and an interrupt facility; none of those makes arbitrary concurrent engine access safe. [R1] + +Expose small source-bound host facades, not raw `PlayerController`, `SettingsController`, models or arbitrary QObject trees. Calls to native networking/storage must execute on their correct threads; do not wrap a foreign-thread QObject and then invoke it synchronously from JS. Track request ownership by module, source, source generation and operation identity. + +Retain the frontend's coroutine-shaped interface. An internal script adapter can implement useful native interfaces with `QCoro::Task`-style results. It must settle each operation exactly once, preserve errors/cancellation, and resume work in the appropriate context. This is implementation work, not an automatic `QJSValue` to QCoro conversion. A QML-facing task adapter may be useful for small UI actions, but the main listings still consume native models. [R7] + +Specify a small portable JavaScript language/host profile and test it using the actual bundled Qt engine. Bare QJSEngine is not Node.js or a browser. Provide timers, native async HTTP, events and storage explicitly. Verify Promise/module/async syntax support before committing to author-facing examples. Prefer one small idiomatic async SDK; optional TypeScript may compile to the supported JS baseline, but must not require a second runtime or a large compatibility framework. [R1, R6] + +### Data path and lifetime + +Perform JSON parsing and normalisation on a worker, then validate and convert into owned native DTOs there. Send those values to the GUI-thread model without live JS objects or cross-thread JS heap references. Update QAbstractItemModel only on its owning thread. [R3] + +Default to JS parsing/mapping with a single native conversion, but profile actual representation costs. Avoid parsing in C++, materialising a full JS copy, stringifying it and parsing it in C++ again. Native buffers may be shared or moved when safe; correctness and lifetime take priority over speculative zero-copy claims. + +Use strings for opaque IDs, explicit time units and well-defined optional/unknown values. Validate safe integer ranges for file sizes, timestamps and backend counters; preserve large values losslessly where needed rather than silently rounding them through JavaScript numbers. Do not carry every backend's raw payload indefinitely inside every media item. + +Cancellation includes aborting native requests when possible, suppressing late results, releasing completion handlers and preventing use-after-free on source removal. Invalidate a search generation immediately when its text changes, not when the debounce timer eventually submits the next request. Disconnecting one account must not cancel or reroute other accounts. + +Provide limits on response bytes after decompression, outstanding work, per-source concurrency, stored data and returned items. Use a watchdog/interruption path for runaway backend JS and define what happens to all requests owned by an interrupted engine. `setInterrupted` can help stop JS execution; it is not a general process sandbox or a safe way to kill arbitrary native work. [R1] + +## 5. Source identity, storage and the provider contract + +Keep distinct identities for installed module, module release, account, persistent configured source, browsing selection, backend item, playable variant and playback session. Accounts and servers need not be one-to-one. A single Stremio module may manage several add-ons/configurations, while Plex may expose several servers through one login. + +Conceptually: + +```text +ItemRef = persistent source ID + opaque backend item ID +VariantRef = ItemRef + opaque variant/file/version ID +``` + +The implementation may represent these differently. What matters is retaining origin through details, artwork, caches, search groups, queues, reports, favourites and open UI actions. Host-created handles must be scoped to the caller; do not trust a provider-supplied source string as authority to read another provider's credentials. + +Store source IDs independently of tokens and URLs. Migrate existing accounts, settings, cache namespaces and resume state deliberately. Persist authoritative state separately from disposable caches. Providers backed by server state use that state; providers without it can use generic durable local watched/favourite/progress services. A heuristic title match is not permission to copy watch state across sources. + +Make request credentials resource-specific. Artwork, video and subtitles can be on different hosts. Scope tokens, TLS exceptions, cookies and redirect behaviour correctly; never apply the currently browsed source's global header to every request. Return opaque/native artwork and playback descriptors so render-time URL construction does not call JS. + +### Small base, separately versioned features + +Start with an experimental **0.x base API**, independent of Spool and provider release versions. A sensible policy is that `0.1.x` fixes do not break `0.1` consumers, while a breaking draft changes the compatibility revision to `0.2`. Document the exact rule instead of relying on loose semver assumptions. Initially load only explicitly supported draft revisions; do not implement historical-version adapters. + +The base covers module/source lifecycle, async operation semantics, error shapes and extension discovery. Keep capabilities separate and granular: catalog/browse, search, details, variants/playback, authentication, artwork descriptors, reports, segments, user state, backend group/remote control, and UI hosting as needed. “Catalog” must not imply support for every Jellyfin query. Stream-only and catalog-only sources must be representable. + +Version extensions independently, including the UI-host bridge and any public UI-kit/module dependencies. Distinguish interfaces a provider **offers**, host services it **requires**, and optional services with documented fallbacks. Capability flags may remain convenient UI views, but are not the compatibility protocol. + +Unknown offered extensions are ignored. Missing optional host extensions disable only the dependent feature when a valid fallback exists. Missing required features reject activation with an explanation. Make optional dependencies optional during module evaluation and QML loading too: no unconditional top-level import or initializer that fails before negotiation. Load dependent QML components only after their requirements are satisfied. Do not silently ignore unknown permissions, manifest formats or security requirements. + +Keep source/extension negotiation language-neutral internally, but implement only the JS adapter now. Do not design a C ABI, ship a Wasm runtime or promise future native binary compatibility. After real use stabilises the shape, an API 1.0 decision can be made separately. + +## 6. Concurrent browsing, federated search and version selection + +A browsing source controls that view only. Search all enabled/search-capable sources asynchronously, with bounded concurrency and incremental delivery. Multiple instances of the same provider must work simultaneously. Providers search their own sources; core orchestrates the federation. + +Use a page contract that can express opaque continuation, unknown total count, exhaustion, supported filters/sort orders, partial errors and provenance. Do not impose offset pagination on a cursor API or simulate unsupported sorting by returning a misleading first page. Query changes invalidate relevant caches/cursors. Any local index or fallback filtering is core-managed and explicitly scoped. + +Process each source/page independently, avoid all-or-nothing waits, show unavailable/unsupported sources honestly, and use fair “load more” scheduling. A slow source must not hold up the first results. Preserve focus/selection and scroll position while adding or merging rows. Keep stable result-group identities; do not reset the entire model or continuously reshuffle it as replies arrive. + +### Grouping policy + +Preserve namespaced external IDs with entity type and provenance; do not require TMDb as a universal primary key. Prefer non-contradictory shared IDs. Missing cross-database mappings may be cached/resolved lazily, but global search must not depend on a third-party metadata request for every hit. + +When usable matching IDs are absent, provisionally group **conservatively normalised title + exact year + media type**. Preserve sequel numbers and meaningful title differences. Missing years, fuzzy matches and ±1-year tolerance are not automatic merge rules. Known conflicting IDs prevent a merge, and an unidentified item must not bridge two conflicting groups. Allow later metadata to split provisional groups safely. + +Group series separately from episodes. Episode matching needs compatible parent identity and ordering; edition/file/timeline identity is stricter than title identity. Never infer identical cuts, resume positions or watch-party timelines from a shared film ID alone. + +Groups retain every source-specific item/version. Selecting a group opens an incremental chooser with provider, server/source or add-on label, edition and available quality metadata. Show resolution, video/HDR/audio details, bitrate and size where known. Distinguish reported/inferred values from verified ones. The optional filename setting is **off by default** and shows a basename, not private paths or token-bearing URLs. + +Discover detailed variants lazily; do not probe video files or resolve streams for every search hit. A catalog match is not confirmed availability. On title selection, query applicable source availability by identity even when another source's text search missed the title, without inventing support for APIs that cannot do that. + +Pass the **exact selected variant** into resolution. A bitrate/quality preference is not a substitute: resolution must not silently choose another server, edition or file. Carry any preparation/interaction state explicitly, return cancellation/failure honestly, and invalidate short-lived stream descriptors appropriately. + +## 7. Provider-owned QML without moving heavy work onto the UI thread + +Providers may supply complete pages, dialogs, settings flows, actions, panels and context-menu contributions—not only templates or a fixed set of forms. They can build sophisticated interfaces from native Qt Quick elements and the public Spool UI kit. Keep provider-specific layout, service terminology and workflows in the provider repository. Qt supports custom QML component types and dynamic component creation. [R4, R5] + +Provide a small UI-host extension for mounting routes/components, supplying source/item context, opening actions, returning async results, native list/tree models, navigation/focus, localisation and notifications. Let providers compose their own controls; new rendering primitives can be added to the reusable host when genuinely required. Do not expose arbitrary private shell objects as the public API. + +Provider UI runs on the GUI-side QML engine. Backend API logic remains on the worker. A UI action invokes the source-bound async bridge and consumes model updates or small result values; it must not obtain direct access to worker-engine JS objects. Large candidate/file lists use native models and virtualised views. Lightweight selection/binding logic is fine; network response parsing, bulk filtering and sorting are not. + +A generic interaction flow should support: + +```text +A core action requires provider interaction + -> mount provider-owned QML with scoped context + -> provider displays native-backed options/progress + -> return an exact variant/action result, or cancellation + -> core continues generic playback/navigation +``` + +For Stremio this permits add-on configuration, release filters, manual source/file choices and preparation status without hardcoding a torrent picker in core. The generic cross-source quality chooser can offer an advanced provider action rather than duplicate the whole detail page. + +Specify Back handling, focus restoration, keyboard/remote/touch accessibility, component lifetime, cancellation on dismissal and failure fallback. Use plain text for untrusted titles unless deliberately sanitised. Preload/cache frequently used components where measured useful, and include cold provider UI in benchmarks. + +Provider QML uses a declared set of host/Qt modules. Ensure release builds actually ship/register that allowed set: a static import scan of built-in QML cannot be assumed to find imports used only by downloaded providers. Do not import an unsupported module and hope an invisible item avoids evaluation. Component/resource/module names must not collide across providers, source instances or installed versions. + +Downloaded archives contain QML/JS **source**, not architecture-specific compiled QML artifacts. Host-generated caches are disposable and keyed to the actual runtime/package; bundled build-time acceleration may use the same source where compatible. A fallback source path must remain tested so installed updates do not depend on a developer's compiled cache. + +## 8. Trust, permissions and resource safety + +The goal includes third-party providers, but a scripting language is not proof of a security sandbox. Separate a restricted backend API from the broader Qt/QML execution environment. Audit import resolution, native type exposure, dynamic component loading, network/file access, external URL opening and inherited singleton access. Do not claim an engine/context boundary prevents malicious code from hanging the GUI or exhausting process memory. + +Initially distribute official and reviewed third-party packages through authenticated catalogues. Represent publisher identity, source provenance and declared access explicitly. Desktop/development builds may support consciously trusted additional repositories/local source directories; do not enable arbitrary unreviewed QML in a store build by default and describe it as safe. This trust decision must not require third-party maintainers to fork Spool or maintain a second provider implementation. + +Provide narrowly scoped host services for network origins, source-owned secrets, durable storage, file selection and external navigation. Support user-authorised LAN servers and HTTP where needed; a blanket private-network ban would break the product. Conversely, do not forward arbitrary tokens across redirects or grant a provider access to every configured server. Bind permissions to accounts/source contexts and request explicit approval for newly required access. + +Remote API responses, add-on manifests and content descriptions are data—not executable code. Only verified provider source may be evaluated. Reject bundled native libraries, executable installers, QML plugin declarations and undeclared native imports in portable packages. Resolve package imports/resources within verified boundaries and prevent providers from replacing shared host modules. Validate more than filename extensions. + +Account tokens and configured add-on URLs can contain secrets in paths as well as headers/query strings. Redact logs, diagnostics, telemetry, crash metadata and screenshots appropriately. Prefer opaque credential handles/injection where practical, but permit a provider to implement its own necessary authentication protocol using only its authorised credentials. No client-side distribution signing private key belongs in the application. + +Do not expose a general shell/process launcher, arbitrary filesystem APIs or unrestricted mpv command execution. Add explicit host actions for real needs. Audit network-capable QML types and resource loaders as well as the backend HTTP service; restricting one does not automatically restrict the others. + +A malicious or simply broken custom UI can still block a GUI thread. Use review, linting, benchmark tests, limited exposed types and recovery controls as initial protections; stronger isolation is a separate future project. If a required safety/policy property cannot be enforced, narrow the distribution/trust mode and document the limitation rather than pretending that a capability manifest enforces it. + +## 9. Portable packages and independent updates + +### Format and compatibility + +Use **ZIP** for the initial portable provider package. Include a manifest, JS modules, QML source, permitted resources, licence/notices and useful debug/source-map information. Use a maintained safe ZIP implementation; verify its availability and licence rather than assuming that an existing DEFLATE decoder includes archive handling. Do not spend this phase investigating Brotli/xz/zstd size differences. + +Illustrative layout, not a mandatory directory API: + +```text +manifest.json +logic/provider.mjs +logic/... +ui/SourcePicker.qml +ui/... +resources/... +LICENSE +NOTICE +``` + +The release is a source package, normally reusable across OS/CPU targets. Actual compatibility still includes the base API revision, extensions, JS feature baseline, allowed QML/host modules and channel permissions. No per-Spool-build native artifact matrix is required. Declare exceptional platform requirements honestly rather than treating all source packages as universally usable. + +The manifest/catalogue must identify module ID, independent release version, entry points, base API requirement, offered/required/optional extensions, UI/resource requirements, state schema/migrations, publisher, allowed channels and access requirements. Bind these to exact artifact bytes through authenticated metadata, hashes and sizes. Use Spool-version bounds only for genuine known incompatibilities/features, not as the routine version key. + +Do not overspecify a final wire schema before the real providers exercise it. Specify enough to implement a deterministic resolver and unit-test incompatible/missing/optional combinations before independent distribution begins. + +### Bundled plus installed + +Bundle a pinned, tested Jellyfin package in every initial release. Bundle Stremio and other official providers once ready for that channel. Bundled and downloaded copies use the same provider sources and logical runtime, not parallel implementations. + +A compatible, trusted installed update may override the bundled copy at the next startup. Select one version of each provider per process; never initialise both copies or merge their QML files. Store downloaded packages in app-private/user data, not inside the AppImage, signed `.app`, APK or package-managed installation. Keep bundled copies available for recovery, but never activate one whose contract or state schema is incompatible. + +Pin installed/loaded versions separately. An update applies to all source instances belonging to that module, preserves their persistent identities and credentials, and does not overwrite code used by existing operations. Prefer restart activation for the first release, despite scripts being easier to reload than native libraries. Qt caches imported modules, and UI/resource lifetimes make casual in-place replacement error-prone. [R1] + +### Installation transaction + +Resolve a compatible candidate against the current or proposed host and the channel's policy. Download to staging with bounded sizes, authenticate metadata/artifacts, safely extract and validate contents before execution. Defend against traversal, symlinks, duplicate/case-colliding paths and decompression bombs. Do not run provider-supplied installers. + +Commit an activation record referencing immutable versioned directories. Keep persistent state outside them, and make migrations recoverable where possible. On restart, initialise the candidate, restore sources and validate basic module/UI health before marking it good. Offline servers or expired logins are not evidence that the module code is corrupt. + +On failure, use a compatible known-good version or start a recovery UI with that provider disabled. Never roll an older package back onto a schema it cannot read, or restore an explicitly revoked artifact. Remove code and source/account data as separate user actions. + +Use an authenticated update design with key rotation, freshness/replay handling and revocation. TUF is a useful reference; reuse maintained cryptography/update primitives rather than inventing them. Temporary catalogue outages must not disable already verified functioning providers. [R11] + +### Release/update coordination + +| Situation | Behaviour | +| --- | --- | +| Provider fix supports installed host | Update independently; other providers and Spool do not need a release. | +| Latest provider requires newer host capability | Retain/offer the newest compatible version, not the latest incompatible one. | +| Spool update preserves provider requirements | Reuse existing provider ZIPs unchanged. | +| A draft API break requires new packages | Publish compatible artifacts before advertising the transition; bundled packages are tested together. | +| A third-party provider is not ready | Do not gate the entire ecosystem. Defer an app-managed incompatible transition for the affected user or explicitly update with the provider disabled and state retained. | +| Store/manual/package-manager host update arrives first | The app still starts. Revalidate packages, disable incompatible ones safely, retain accounts and offer recovery. | +| Mandatory security update conflicts with a provider | Surface the issue and allow updating securely without that provider; never silently load incompatible/revoked code. | + +Publish immutable packages first, verify they are reachable, then publish signed catalogue metadata. Preserve compatible older releases for supported draft runtimes. Catalogue absence alone is not evidence that an installed trusted version must be deleted. + +Provide a manager for installed/bundled versions, enable/disable, updates, compatibility explanations, publisher trust and recovery. Keep **installing a provider module**, **adding an account/server** and **configuring a remote Stremio add-on** distinct in the UI, even where a guided setup combines them. + +## 10. Platform and store delivery + +The default is the same portable provider architecture everywhere. Distribution policy is a host/channel concern, not separate provider source code. Do not carry forward native shared-library restrictions as though JS/QML source archives were `.so` files. + +| Target/channel | Implementation direction | +| --- | --- | +| Windows, ordinary Linux and direct macOS distribution | Bundle official packages; verify/download source ZIPs to user storage and activate on restart. Retain existing host signing/packaging. | +| Linux AppImage on NixOS | Use the same Linux app/provider packages through `appimage-run`; verify writable data paths, imports and cache behaviour. No Nix-specific provider build or `nix-ld` project. | +| webOS Developer Mode, including unrooted | Test downloaded JS/QML source loading from the real app-data directory first. Native executable mappings are no longer a provider requirement. Test interpreter-only execution, QML imports, resources, updates and restart on-device. | +| Android/Android TV outside Play | Same verified source-package path, appropriate app-private storage and host permissions. Do not add native provider download machinery. | +| Android/Android TV on Google Play | Pursue interpreted provider delivery with a constrained, documented host surface; bundle official packages regardless. Play permits an interpreted/VM exception to its code-download restriction, but runtime code must comply with all policies. No native payloads, APK self-updater or alternate native-module installer in this channel. [R8] | +| iOS/tvOS App Store and Mac App Store | Build the same provider runtime and bundled source packages. Seek review/permission for the actual downloaded-provider and native UI/host interface before enabling that path. Until approved, allow multiple bundled first-/third-party providers and configuration of supported remote services, updated through app releases. [R9] | + +For webOS, retain the demonstrated app replacement-IPK mechanism as a fallback if actual source/resource deployment constraints require it. A repair/replacement package can include selected provider source packages; no C++ compilation on the television is implied. Test installation state, restart persistence and recovery. Do not implement on-device package composition until a real loading restriction justifies it. + +JavaScript removes much native loading/signing/ABI complexity, not every platform limitation. Verify the actual Qt runtime's language features, resource imports, background execution and cold-start behaviour on each target. JIT availability must not be a prerequisite; source packages must work correctly with the supported interpreter path. + +Google Play approval is not guaranteed by the word “JavaScript.” Prepare a reviewable explanation of provider purpose, code provenance, capability exposure, content/access policy and update controls. Apple currently imposes additional requirements for downloaded plug-ins, including prior permission for native API/technology exposure, consent and software indexing. Do not assert automatic approval or silently enable a previously unapproved execution path remotely. [R8, R9] + +Use the same sources for bundled fallback builds. Store restrictions may limit available packages/features, but must not force authors to maintain a different client implementation or force users into one-provider-per-app forks. + +## 11. Implement Jellyfin and Stremio as real clients + +### Jellyfin + +Port API/auth/session logic to JavaScript; reuse endpoint knowledge, tested behaviours and fixtures from the existing facade rather than preserving C++ dependencies merely for convenience. Move/reuse provider-specific QML in the external repository and adapt it to source-bound proxies and the UI-host API. + +Cover login/QuickConnect, discovered and manually configured servers, multiple accounts, libraries, filters/paging, detail/episode navigation, search, resume/next-up, exact media sources, subtitles, direct/transcoded playback, reporting, segments/trickplay descriptors, favourites/played state and existing management/remote features where currently supported. Build an explicit parity checklist from the actual branch; do not declare success after a browse-and-play demo. + +Keep transport, decoding, bandwidth measurement and reusable player policy native. Let the provider translate native capability/preferences into Jellyfin requests and results. Translate server event/group messages into generic host actions instead of letting core continue parsing Jellyfin envelopes. Login refresh/reconnect/sign-out must affect the intended sources only. + +Use the old implementation temporarily for recorded-response differential tests, with deliberate exceptions for corrected bugs. Migrate real persisted accounts without duplicate logins/source identities. Remove obsolete facade/singleton/composition code after parity; do not ship both indefinitely. + +### Stremio + +Create `spool-stremio` early enough to challenge the contract while it is still 0.x. This is a **Spool provider implementing the Stremio protocol**, not the server-side add-on itself. One module can configure many remote add-ons and compose their resource contributions. Adding a remote add-on URL is configuration, not another executable-provider installation. + +Implement manifest validation/configuration, resource/type/ID-prefix matching, supported catalog search/filter/paging, metadata, series/episodes, stream and subtitle discovery, and timeout/error isolation. Stremio explicitly separates these resources and permits add-ons with no catalog, so do not require every configured add-on to behave like a complete Jellyfin server. [R12, R13] + +Begin with documented, authorised HTTP-stream flows and synthetic/local fixtures. Preserve opaque IDs; map recognised external IDs without assuming every add-on uses IMDb. Search catalogues first, then discover streams for selected/focused titles. Expose provenance and distinguish a metadata hit from confirmed playable versions. + +The provider owns its add-on settings, candidate selection and optional manual file/source workflow. Request native-backed models and return exact selected variants. Support per-resource headers/subtitles, expiry and useful next-episode hints where the protocol supplies them. Treat inferred quality conservatively. The protocol includes torrent references as well as URLs; do not present an info hash as an immediately playable HTTP resource. [R14] + +Actual peer-to-peer transfer is a separate transport capability, not JS metadata work. Where no approved native/external transport exists, state the limitation and offer supported sources. The implementer may add a necessary bounded integration when justified, but must not quietly grow a torrent engine in JS or claim complete Stremio source support from URL-only tests. + +Use generic durable local user-state services where no server is authoritative. Keep user-authorised service credentials and configured URLs private. Emby/Plex implementations are future work unless useful to validate a specific abstraction; design room for their identities, accounts, versions and pagination without creating speculative full SDKs for them now. + +## 12. Repository creation, SDK and release tooling + +Keep core, app, native services, runtime, public UI kit, tests, SDK documentation and packaging in `spool`. Create **`spool-player/spool-jellyfin`** and **`spool-player/spool-stremio`**, using the organisation shown in the supplied commit links after verifying the implementation environment's remotes and access. Do not create `spool-common`, a separate core repository or a reciprocal submodule graph. + +During implementation, use `gh` to inspect authentication, repository existence, visibility and permissions. Reuse existing repositories; a permissions/network error is not proof that a repository is absent. Create repositories only under the intended owner. Match the main project's visibility and preserve the applicable licence, attribution and notices. Scrub fixtures/history for secrets before publishing. + +Example commands for prepared local repositories, **only after confirming that new public repositories are appropriate and absent**: + +```sh +gh auth status +gh repo view spool-player/spool --json nameWithOwner,visibility,viewerPermission + +# Inspect/reuse these if they already exist before running creation commands. +gh repo view spool-player/spool-jellyfin --json nameWithOwner,visibility,viewerPermission +gh repo view spool-player/spool-stremio --json nameWithOwner,visibility,viewerPermission + +# Run creation individually after resolving the preconditions above. +gh repo create spool-player/spool-jellyfin --public \ + --source ../spool-jellyfin --remote origin --push \ + --description "Official portable Jellyfin provider for Spool" +gh repo create spool-player/spool-stremio --public \ + --source ../spool-stremio --remote origin --push \ + --description "Official portable Stremio protocol provider for Spool" +``` + +These commands illustrate GitHub CLI's documented existing-source workflow; they have not been executed by this plan. Initialise/commit the intended local content first and inspect existing remotes before using `--remote origin`. Preserve useful history where straightforward, but do not spend disproportionate effort history-filtering a rewrite. Record source commits and attribution. [R10] + +Each provider repository should contain its own logic, UI, manifest, fixtures/tests, contract requirements, documentation and source-package release workflow. No provider-specific login/torrent UI remains in Spool simply because extracting it was inconvenient. + +Publish lightweight SDK/type declarations, a local runner, contract tests and package-validation tooling from Spool. Provider CI consumes a pinned SDK/tooling version, not a nested checkout of the entire application with its own provider pins. Reuse workflows or a small distributable tool rather than creating a heavy package ecosystem. + +Spool integration pins exact provider source revisions or immutable ZIP digests in a reproducible lock mechanism. A one-way submodule is acceptable if it fits current tooling, but not mandatory. Local development must support sibling-repository/source overrides and testing an unreleased host plus unreleased providers together without tagging every edit. + +Provider CI validates manifests, JS runtime compatibility, optional-extension fallbacks, QML imports/lint, recorded responses and packaging. It builds one portable release archive, not one native artifact for each OS/CPU. Host CI tests that package on representative runtimes/channels. A Node-based test is useful only as a supplement; the real Qt JS engine must execute contract tests. + +Publish checked artifacts before catalogue updates, retain provenance and sign releases through secure CI credentials. Keep untrusted fork workflows away from signing/release secrets. Main builds bundle pinned verified packages and do not fetch mutable “latest” releases. GitHub workflow definitions stay in `.github/workflows`; shared scripts may live elsewhere. + +## 13. Provider-agnostic watch-together + +Retain this as an explicitly scoped subsequent feature, not a reason to delay the source/runtime foundation. Core owns rooms, clock synchronisation, scheduled play/pause/seek, buffering policy and drift correction. Each participant resolves their own authorised stream through their own provider. Do not share credential-bearing playback URLs or require the same provider/server. + +Extract genuinely reusable timing/control machinery from existing SyncPlay code; keep Jellyfin's server/group protocol in its provider. Existing backend-native groups remain an optional capability, distinct from Spool-to-Spool watch-together. + +A small Cloudflare Worker plus one Durable Object per room and WebSockets is a reasonable first coordination transport; validate deployment/cost/security details at that milestone. Cloudflare supports coordinating connected clients this way. No STUN server or peer-to-peer transport is required merely to relay state. [R15] + +Use short-lived room credentials, bounded messages, rate limits and reconnect/version handling. Relay control state and content identity, not media. Account for mismatched editions/timelines; shared title IDs do not prove synchronisation safety. Require compatible versions or explicit offset/manual matching rather than silently aligning different cuts. + +## 14. Delivery sequence and acceptance criteria + +Sequence to reduce architectural uncertainty early; adapt parallel work sensibly. Keep usable builds throughout, but do not let an obsolete “one tiny directory move at a time” plan prevent replacing an unsuitable interface. Namespace/layout cleanup is secondary to behaviour and performance. + +| Milestone | Required outcome / exit evidence | +| --- | --- | +| **A. Reconcile and baseline** | Inspect actual branch, record reused/replaced areas and existing feature coverage. Establish reproducible native baseline, real minimum-device target, phase timing and frame/memory metrics. Clearly distinguish tests actually run from reported results. | +| **B. Runtime vertical slice** | A small JS fixture performs host HTTP, maps a bounded page off-thread, returns a QCoro-style native result and updates the existing model. Open one provider-owned QML action. Test interpreter mode, cancellation and exceptions. Measure before expanding the abstraction. | +| **C. Identity and concurrency** | Two source instances of one module and a different module work together, with overlapping local IDs. Browse B while playing A and searching A/B/C; no token/state/callback confusion. Source removal and module failure are contained. | +| **D. Real clients and repositories** | Use `gh` to create/reuse the two repos. Implement a Jellyfin slice and a real Stremio catalog-to-variants-to-playback slice early, then complete the Jellyfin parity/migration checklist. No provider-specific route/global required in shared UI. | +| **E. Federation and custom flows** | Progressive cross-source search, conservative grouping, stable focus, lazy variants, explicit exact-version playback and a provider-owned custom selection workflow all work with partial failures. | +| **F. Independent delivery** | Signed source ZIPs, manager UI, bundled fallback, compatibility resolution, restart activation and recovery work end-to-end. A provider-only release updates two of its configured sources without rebuilding the host. An unchanged compatible ZIP survives a host update. | +| **G. Target/channel validation** | Measure real unrooted webOS loading/performance and Android/TV behaviour, including source-loaded QML. Prepare Google Play review material. Validate Windows/Linux/macOS and AppImage-on-NixOS. Keep Apple downloaded-code activation conditional on review; bundled mode must work. | +| **H. Consolidate and document** | Remove obsolete native provider implementation/plans, export tested SDK/examples, document supported 0.x revisions and channel policies, retain regressions/benchmarks in CI and make release behaviour reproducible. Do not label the contract stable 1.0 automatically. | + +Watch-together follows as its own delivery milestone, using the identity/playback foundations above. New production Plex/Emby providers, native plugin ABI support, Wasm, native Nix distribution, and permanent old-API adapters are not prerequisites. + +### Tests that must exist + +**Runtime and threading:** real Qt JS execution; source factories without global account state; async completion exactly once; network/script errors; timeout/cancellation; engine shutdown; late replies; model-thread correctness; runaway backend JS recovery; repeated install/use/remove without growing retained heaps. No backend JS invocation during native model role reads or frame rendering. + +**Data and federation:** pagination/cursors/unknown counts; unsupported filters; out-of-order and failed sources; immediate query invalidation; overlapping IDs; external-ID matches/conflicts; conservative title/year grouping; unidentified-item bridge prevention; episode ordering; different editions; late merge/split; stable remote focus; exact selected file resolution; source-specific credentials for artwork/video/subtitles. + +**UI and extensibility:** non-Jellyfin auth page; custom large virtualised candidate/file list; actions returning asynchronously/cancelled; missing optional extension/module remains usable; required extension rejected; imports outside policy denied; multiple UI instances; translated text, long labels and remote/touch/keyboard behaviour. Successful module loading alone is not sufficient. + +**Updates and security:** invalid signatures/hashes; unsafe ZIPs; oversized decompressed content; wrong identity/API/UI requirements; unknown required permissions; stale metadata; catalogue outage; failed migration; interrupted activation; incompatible externally updated host; bundled fallback with incompatible schema; revoked-version handling; permissions expanded by an update; secrets absent from logs. + +**Performance:** cold and warm provider activation; cached and uncached-data pages; normal and worst-case payloads; simultaneous search completions while scrolling; provider custom UI under load; high-bitrate playback concurrent with browsing; interpreter-only weakest-device runs; first-useful-frame and frame-tail metrics, not only average FPS or network-inclusive averages. + +Use controlled LAN/test services, recorded authorised responses and local fixtures. Do not depend on random public streaming add-ons or real private accounts to make CI deterministic. Add a build/test mode with no Jellyfin provider present so hidden shared-UI assumptions cannot return. + +## 15. Implementation handoff and completion standard + +Begin by reading the branch and implementing the measured JS-provider vertical slice; avoid a large up-front framework rewrite. Keep the public draft narrow while proving real Jellyfin and Stremio requirements. If conversion, page insertion, component creation or another stage dominates, fix that stage using evidence rather than immediately adding Wasm or restoring native provider clients. + +At completion, hand over the changed Spool code, both provider repositories, reproducible package/release tooling, contract/author documentation, migration/recovery notes, tests and benchmark results. Record any device/store/access gates that were not actually exercised. Missing credentials or hardware should not prevent completing local implementation and fixtures; do not claim the external step succeeded. + +The decisive acceptance scenario is: + +> One Spool process uses multiple Jellyfin accounts and multiple configured Stremio add-ons at once; progressively searches them; groups equivalent titles without losing origin; opens a provider-owned selection workflow; plays the exact chosen version through the native player; and keeps scrolling/navigation smooth while sources respond. A verified provider ZIP can update that client logic and UI without rebuilding Spool on an enabled channel, while bundled and store-restricted builds use the same provider sources. + +The durable design is **portable provider logic, native fast paths, explicit source identity, rich hosted UI and channel-aware delivery**. Everything else in this plan may be simplified or improved when the implementation provides a better answer. + +--- + +## Sources and verification boundaries + +Architecture and sequencing above are proposed decisions reflecting this discussion, not claims imposed by the external references. Performance numbers are provisional budgets, not measured results. External references were checked on 21 September 2026; recheck store policies and the actual pinned Qt/SDK versions when shipping. + +### Supplied project material + +- **[S1]** `Pasted markdown(20260920-182440).md`: complete supplied commit export, including the commit IDs in section 3 and links under `spool-player/spool`. It documents claimed branch changes, not independent test results. +- **[S2]** `spool-refactor-provider-seam.zip`: inspected source snapshot, notably `src/provider/`, `src/media/MediaTypes.h`, `src/app/`, `qml/shell/RouteStack.qml`, provider-specific QML, `src/diagnostics/RenderBenchmark.*`, tests and CMake. The implementing checkout may have advanced. +- **[S3]** `Spool Core Split Plan(1) (1).md` and `docs/provider-split-plan.md` in the snapshot: earlier native-provider proposal and branch status. This document supersedes conflicting native-plugin, single-active-provider, lockstep-build and exact Spool-version assumptions. Earlier review documents remain inspection aids, not controlling requirements. + +### Primary technical and policy references + +- **[R1]** [Qt: QJSEngine](https://doc.qt.io/qt-6/qjsengine.html) — module loading/caching, script/native calls, ownership and interruption. +- **[R2]** [Qt Quick: Performance considerations](https://doc.qt.io/qt-6/qtquick-performance.html) — asynchronous work, worker processing, conversions, models and delegates. +- **[R3]** [Qt: QAbstractItemModel](https://doc.qt.io/qt-6/qabstractitemmodel.html) — model-thread requirements and incremental updates. +- **[R4]** [Qt: Defining object types through QML documents](https://doc.qt.io/qt-6/qtqml-documents-definetypes.html). +- **[R5]** [Qt: Dynamic QML object creation](https://doc.qt.io/qt-6/qtqml-javascript-dynamicobjectcreation.html). +- **[R6]** [Qt: JavaScript host environment](https://doc.qt.io/qt-6/qtqml-javascript-hostenvironment.html) — verify against the exact deployed engine, not browser assumptions. +- **[R7]** [QCoro: QmlTask](https://qcoro.dev/reference/qml/qmltask/) — optional task presentation to QML, not a ready-made backend JS bridge. +- **[R8]** [Google Play: Device and Network Abuse](https://support.google.com/googleplay/android-developer/answer/9888379?hl=en) — native download restrictions and interpreted-code exception/conditions. +- **[R9]** [Apple: App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) — particularly downloaded code, section 4.7 and native technology exposure. +- **[R10]** [GitHub CLI: gh repo create](https://cli.github.com/manual/gh_repo_create). +- **[R11]** [The Update Framework specification](https://theupdateframework.github.io/specification/latest/) — authenticated updates, metadata freshness and trust lifecycle. +- **[R12]** [Stremio add-on protocol](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/protocol.md). +- **[R13]** [Stremio manifest format](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/manifest.md). +- **[R14]** [Stremio stream response format](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/stream.md). +- **[R15]** [Cloudflare Durable Objects: WebSockets](https://developers.cloudflare.com/durable-objects/best-practices/websockets/). From ad6fcb6665503d84380130268ae67aee2fdff4fd Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 17:09:44 +1000 Subject: [PATCH 041/146] fix(provider): use self-contained puff decompressor and eliminate external zlib dependency --- CMakeLists.txt | 4 +- src/provider/ProviderPackage.cpp | 63 +- src/provider/ProviderPackage.h | 3 + src/provider/puff.c | 827 ++++++++++++++++++++++++ src/provider/puff.h | 42 ++ tests/providers/ProviderPackageTest.cpp | 5 +- 6 files changed, 926 insertions(+), 18 deletions(-) create mode 100644 src/provider/puff.c create mode 100644 src/provider/puff.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a76af0c8..897a70ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,8 @@ set(SPOOL_SHELL_SOURCES src/provider/ProviderRegistry.cpp src/provider/ProviderRegistrySources.cpp src/provider/ProviderRegistry.h + src/provider/puff.c + src/provider/puff.h src/provider/RemotePlayback.h src/provider/SearchSource.h src/provider/StreamQualityControl.h @@ -355,7 +357,6 @@ qt_add_executable(jellyfin-native # Bundle immutable, verified source bytes. This resource path also exercises # source loading with no developer-generated QML/JS cache in provider tests. find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) -find_package(ZLIB REQUIRED) set(SPOOL_JELLYFIN_SOURCE_DIR "" CACHE PATH "Explicit development override for the portable Jellyfin source") set(SPOOL_PROVIDER_RESOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/portable-providers") set(spool_provider_bundle_args @@ -795,7 +796,6 @@ target_link_libraries(jellyfin-core PUBLIC Qt6::Sql Qt6::OpenGL Qt6::WebSockets - ZLIB::ZLIB ) if(NOT ANDROID) target_link_libraries(jellyfin-core PUBLIC Qt6::GuiPrivate) diff --git a/src/provider/ProviderPackage.cpp b/src/provider/ProviderPackage.cpp index 58b11e0d..9cdeb4dd 100644 --- a/src/provider/ProviderPackage.cpp +++ b/src/provider/ProviderPackage.cpp @@ -9,8 +9,8 @@ #include #include +#include "puff.h" #include -#include namespace JellyfinNative { @@ -255,18 +255,10 @@ std::optional ProviderPackage::parseAndValidate( std::memcpy(uncompressed.data(), fileData, cd->uncompressedSize); } else if (cd->method == 8) { // Deflated - z_stream strm = {}; - if (inflateInit2(&strm, -MAX_WBITS) != Z_OK) { - setError(errorMessage, QStringLiteral("Failed to initialize decompression")); - return std::nullopt; - } - strm.next_in = const_cast(fileData); - strm.avail_in = cd->compressedSize; - strm.next_out = reinterpret_cast(uncompressed.data()); - strm.avail_out = uncompressed.size(); - const int ret = inflate(&strm, Z_FINISH); - inflateEnd(&strm); - if (ret != Z_STREAM_END) { + unsigned long destLen = cd->uncompressedSize; + unsigned long srcLen = cd->compressedSize; + const int ret = puff(reinterpret_cast(uncompressed.data()), &destLen, fileData, &srcLen); + if (ret != 0 || destLen != cd->uncompressedSize) { setError(errorMessage, QStringLiteral("Corrupt compressed stream for %1").arg(entryPath)); return std::nullopt; } @@ -275,6 +267,13 @@ std::optional ProviderPackage::parseAndValidate( return std::nullopt; } + const uint32_t calculatedCrc + = calculateCrc32(reinterpret_cast(uncompressed.constData()), uncompressed.size()); + if (calculatedCrc != cd->crc32) { + setError(errorMessage, QStringLiteral("CRC-32 mismatch for %1").arg(entryPath)); + return std::nullopt; + } + if (hasNativeMagic(uncompressed)) { setError(errorMessage, QStringLiteral("Native executable binary rejected in %1").arg(entryPath)); return std::nullopt; @@ -358,4 +357,42 @@ QString ProviderPackage::install( return destDir.filePath(package.entryPoint); } +uint32_t ProviderPackage::calculateCrc32(const uint8_t *data, size_t length) +{ + static const uint32_t table[256] = { 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, + 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, + 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, + 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, + 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, + 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, + 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, + 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, + 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, + 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, + 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, + 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, + 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, + 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, + 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, + 0x0ead5439, 0x9dd264af, 0x04db3515, 0x73dc0583, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, + 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, + 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, + 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd8062bca, 0xaf011b5c, 0x360847e6, 0x410f7770, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, + 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, + 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, + 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, + 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, + 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, + 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, + 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, + 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d }; + uint32_t crc = 0xFFFFFFFF; + for (size_t i = 0; i < length; ++i) { + crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + } + return crc ^ 0xFFFFFFFF; +} + } // namespace JellyfinNative diff --git a/src/provider/ProviderPackage.h b/src/provider/ProviderPackage.h index c84b2f1e..40af0898 100644 --- a/src/provider/ProviderPackage.h +++ b/src/provider/ProviderPackage.h @@ -27,6 +27,9 @@ class ProviderPackage final { // of the JavaScript entry point on success, or an empty string on error. static QString install( const ProviderPackageContents& package, const QString& targetDirectory, QString *errorMessage = nullptr); + + // Calculates standard IEEE 802.3 32-bit CRC. + static uint32_t calculateCrc32(const uint8_t *data, size_t length); }; } // namespace JellyfinNative diff --git a/src/provider/puff.c b/src/provider/puff.c new file mode 100644 index 00000000..4a69939a --- /dev/null +++ b/src/provider/puff.c @@ -0,0 +1,827 @@ +/* + * puff.c + * Copyright (C) 2002-2013 Mark Adler + * For conditions of distribution and use, see copyright notice in puff.h + * version 2.3, 21 Jan 2013 + * + * puff.c is a simple inflate written to be an unambiguous way to specify the + * deflate format. It is not written for speed but rather simplicity. As a + * side benefit, this code might actually be useful when small code is more + * important than speed, such as bootstrap applications. For typical deflate + * data, zlib's inflate() is about four times as fast as puff(). zlib's + * inflate compiles to around 20K on my machine, whereas puff.c compiles to + * around 4K on my machine (a PowerPC using GNU cc). If the faster decode() + * function here is used, then puff() is only twice as slow as zlib's + * inflate(). + * + * All dynamically allocated memory comes from the stack. The stack required + * is less than 2K bytes. This code is compatible with 16-bit int's and + * assumes that long's are at least 32 bits. puff.c uses the short data type, + * assumed to be 16 bits, for arrays in order to conserve memory. The code + * works whether integers are stored big endian or little endian. + * + * In the comments below are "Format notes" that describe the inflate process + * and document some of the less obvious aspects of the format. This source + * code is meant to supplement RFC 1951, which formally describes the deflate + * format: + * + * http://www.zlib.org/rfc-deflate.html + */ + +/* + * Change history: + * + * 1.0 10 Feb 2002 - First version + * 1.1 17 Feb 2002 - Clarifications of some comments and notes + * - Update puff() dest and source pointers on negative + * errors to facilitate debugging deflators + * - Remove longest from struct huffman -- not needed + * - Simplify offs[] index in construct() + * - Add input size and checking, using longjmp() to + * maintain easy readability + * - Use short data type for large arrays + * - Use pointers instead of long to specify source and + * destination sizes to avoid arbitrary 4 GB limits + * 1.2 17 Mar 2002 - Add faster version of decode(), doubles speed (!), + * but leave simple version for readability + * - Make sure invalid distances detected if pointers + * are 16 bits + * - Fix fixed codes table error + * - Provide a scanning mode for determining size of + * uncompressed data + * 1.3 20 Mar 2002 - Go back to lengths for puff() parameters [Gailly] + * - Add a puff.h file for the interface + * - Add braces in puff() for else do [Gailly] + * - Use indexes instead of pointers for readability + * 1.4 31 Mar 2002 - Simplify construct() code set check + * - Fix some comments + * - Add FIXLCODES #define + * 1.5 6 Apr 2002 - Minor comment fixes + * 1.6 7 Aug 2002 - Minor format changes + * 1.7 3 Mar 2003 - Added test code for distribution + * - Added zlib-like license + * 1.8 9 Jan 2004 - Added some comments on no distance codes case + * 1.9 21 Feb 2008 - Fix bug on 16-bit integer architectures [Pohland] + * - Catch missing end-of-block symbol error + * 2.0 25 Jul 2008 - Add #define to permit distance too far back + * - Add option in TEST code for puff to write the data + * - Add option in TEST code to skip input bytes + * - Allow TEST code to read from piped stdin + * 2.1 4 Apr 2010 - Avoid variable initialization for happier compilers + * - Avoid unsigned comparisons for even happier compilers + * 2.2 25 Apr 2010 - Fix bug in variable initializations [Oberhumer] + * - Add const where appropriate [Oberhumer] + * - Split if's and ?'s for coverage testing + * - Break out test code to separate file + * - Move NIL to puff.h + * - Allow incomplete code only if single code length is 1 + * - Add full code coverage test to Makefile + * 2.3 21 Jan 2013 - Check for invalid code length codes in dynamic blocks + */ + +#include "puff.h" /* prototype for puff() */ +#include /* for setjmp(), longjmp(), and jmp_buf */ + +#define local static /* for local function definitions */ + +/* + * Maximums for allocations and loops. It is not useful to change these -- + * they are fixed by the deflate format. + */ +#define MAXBITS 15 /* maximum bits in a code */ +#define MAXLCODES 286 /* maximum number of literal/length codes */ +#define MAXDCODES 30 /* maximum number of distance codes */ +#define MAXCODES (MAXLCODES + MAXDCODES) /* maximum codes lengths to read */ +#define FIXLCODES 288 /* number of fixed literal/length codes */ + +/* input and output state */ +struct state { + /* output state */ + unsigned char *out; /* output buffer */ + unsigned long outlen; /* available space at out */ + unsigned long outcnt; /* bytes written to out so far */ + + /* input state */ + const unsigned char *in; /* input buffer */ + unsigned long inlen; /* available input at in */ + unsigned long incnt; /* bytes read so far */ + int bitbuf; /* bit buffer */ + int bitcnt; /* number of bits in bit buffer */ + + /* input limit error return state for bits() and decode() */ + jmp_buf env; +}; + +/* + * Return need bits from the input stream. This always leaves less than + * eight bits in the buffer. bits() works properly for need == 0. + * + * Format notes: + * + * - Bits are stored in bytes from the least significant bit to the most + * significant bit. Therefore bits are dropped from the bottom of the bit + * buffer, using shift right, and new bytes are appended to the top of the + * bit buffer, using shift left. + */ +local int bits(struct state *s, int need) +{ + long val; /* bit accumulator (can use up to 20 bits) */ + + /* load at least need bits into val */ + val = s->bitbuf; + while (s->bitcnt < need) { + if (s->incnt == s->inlen) + longjmp(s->env, 1); /* out of input */ + val |= (long)(s->in[s->incnt++]) << s->bitcnt; /* load eight bits */ + s->bitcnt += 8; + } + + /* drop need bits and update buffer, always zero to seven bits left */ + s->bitbuf = (int)(val >> need); + s->bitcnt -= need; + + /* return need bits, zeroing the bits above that */ + return (int)(val & ((1L << need) - 1)); +} + +/* + * Process a stored block. + * + * Format notes: + * + * - After the two-bit stored block type (00), the stored block length and + * stored bytes are byte-aligned for fast copying. Therefore any leftover + * bits in the byte that has the last bit of the type, as many as seven, are + * discarded. The value of the discarded bits are not defined and should not + * be checked against any expectation. + * + * - The second inverted copy of the stored block length does not have to be + * checked, but it's probably a good idea to do so anyway. + * + * - A stored block can have zero length. This is sometimes used to byte-align + * subsets of the compressed data for random access or partial recovery. + */ +local int stored(struct state *s) +{ + unsigned len; /* length of stored block */ + + /* discard leftover bits from current byte (assumes s->bitcnt < 8) */ + s->bitbuf = 0; + s->bitcnt = 0; + + /* get length and check against its one's complement */ + if (s->incnt + 4 > s->inlen) + return 2; /* not enough input */ + len = s->in[s->incnt++]; + len |= s->in[s->incnt++] << 8; + if (s->in[s->incnt++] != (~len & 0xff) || s->in[s->incnt++] != ((~len >> 8) & 0xff)) + return -2; /* didn't match complement! */ + + /* copy len bytes from in to out */ + if (s->incnt + len > s->inlen) + return 2; /* not enough input */ + if (s->out != NIL) { + if (s->outcnt + len > s->outlen) + return 1; /* not enough output space */ + while (len--) + s->out[s->outcnt++] = s->in[s->incnt++]; + } else { /* just scanning */ + s->outcnt += len; + s->incnt += len; + } + + /* done with a valid stored block */ + return 0; +} + +/* + * Huffman code decoding tables. count[1..MAXBITS] is the number of symbols of + * each length, which for a canonical code are stepped through in order. + * symbol[] are the symbol values in canonical order, where the number of + * entries is the sum of the counts in count[]. The decoding process can be + * seen in the function decode() below. + */ +struct huffman { + short *count; /* number of symbols of each length */ + short *symbol; /* canonically ordered symbols */ +}; + +/* + * Decode a code from the stream s using huffman table h. Return the symbol or + * a negative value if there is an error. If all of the lengths are zero, i.e. + * an empty code, or if the code is incomplete and an invalid code is received, + * then -10 is returned after reading MAXBITS bits. + * + * Format notes: + * + * - The codes as stored in the compressed data are bit-reversed relative to + * a simple integer ordering of codes of the same lengths. Hence below the + * bits are pulled from the compressed data one at a time and used to + * build the code value reversed from what is in the stream in order to + * permit simple integer comparisons for decoding. A table-based decoding + * scheme (as used in zlib) does not need to do this reversal. + * + * - The first code for the shortest length is all zeros. Subsequent codes of + * the same length are simply integer increments of the previous code. When + * moving up a length, a zero bit is appended to the code. For a complete + * code, the last code of the longest length will be all ones. + * + * - Incomplete codes are handled by this decoder, since they are permitted + * in the deflate format. See the format notes for fixed() and dynamic(). + */ +#ifdef SLOW +local int decode(struct state *s, const struct huffman *h) +{ + int len; /* current number of bits in code */ + int code; /* len bits being decoded */ + int first; /* first code of length len */ + int count; /* number of codes of length len */ + int index; /* index of first code of length len in symbol table */ + + code = first = index = 0; + for (len = 1; len <= MAXBITS; len++) { + code |= bits(s, 1); /* get next bit */ + count = h->count[len]; + if (code - count < first) /* if length len, return symbol */ + return h->symbol[index + (code - first)]; + index += count; /* else update for next length */ + first += count; + first <<= 1; + code <<= 1; + } + return -10; /* ran out of codes */ +} + +/* + * A faster version of decode() for real applications of this code. It's not + * as readable, but it makes puff() twice as fast. And it only makes the code + * a few percent larger. + */ +#else /* !SLOW */ +local int decode(struct state *s, const struct huffman *h) +{ + int len; /* current number of bits in code */ + int code; /* len bits being decoded */ + int first; /* first code of length len */ + int count; /* number of codes of length len */ + int index; /* index of first code of length len in symbol table */ + int bitbuf; /* bits from stream */ + int left; /* bits left in next or left to process */ + short *next; /* next number of codes */ + + bitbuf = s->bitbuf; + left = s->bitcnt; + code = first = index = 0; + len = 1; + next = h->count + 1; + while (1) { + while (left--) { + code |= bitbuf & 1; + bitbuf >>= 1; + count = *next++; + if (code - count < first) { /* if length len, return symbol */ + s->bitbuf = bitbuf; + s->bitcnt = (s->bitcnt - len) & 7; + return h->symbol[index + (code - first)]; + } + index += count; /* else update for next length */ + first += count; + first <<= 1; + code <<= 1; + len++; + } + left = (MAXBITS + 1) - len; + if (left == 0) + break; + if (s->incnt == s->inlen) + longjmp(s->env, 1); /* out of input */ + bitbuf = s->in[s->incnt++]; + if (left > 8) + left = 8; + } + return -10; /* ran out of codes */ +} +#endif /* SLOW */ + +/* + * Given the list of code lengths length[0..n-1] representing a canonical + * Huffman code for n symbols, construct the tables required to decode those + * codes. Those tables are the number of codes of each length, and the symbols + * sorted by length, retaining their original order within each length. The + * return value is zero for a complete code set, negative for an over- + * subscribed code set, and positive for an incomplete code set. The tables + * can be used if the return value is zero or positive, but they cannot be used + * if the return value is negative. If the return value is zero, it is not + * possible for decode() using that table to return an error--any stream of + * enough bits will resolve to a symbol. If the return value is positive, then + * it is possible for decode() using that table to return an error for received + * codes past the end of the incomplete lengths. + * + * Not used by decode(), but used for error checking, h->count[0] is the number + * of the n symbols not in the code. So n - h->count[0] is the number of + * codes. This is useful for checking for incomplete codes that have more than + * one symbol, which is an error in a dynamic block. + * + * Assumption: for all i in 0..n-1, 0 <= length[i] <= MAXBITS + * This is assured by the construction of the length arrays in dynamic() and + * fixed() and is not verified by construct(). + * + * Format notes: + * + * - Permitted and expected examples of incomplete codes are one of the fixed + * codes and any code with a single symbol which in deflate is coded as one + * bit instead of zero bits. See the format notes for fixed() and dynamic(). + * + * - Within a given code length, the symbols are kept in ascending order for + * the code bits definition. + */ +local int construct(struct huffman *h, const short *length, int n) +{ + int symbol; /* current symbol when stepping through length[] */ + int len; /* current length when stepping through h->count[] */ + int left; /* number of possible codes left of current length */ + short offs[MAXBITS + 1]; /* offsets in symbol table for each length */ + + /* count number of codes of each length */ + for (len = 0; len <= MAXBITS; len++) + h->count[len] = 0; + for (symbol = 0; symbol < n; symbol++) + (h->count[length[symbol]])++; /* assumes lengths are within bounds */ + if (h->count[0] == n) /* no codes! */ + return 0; /* complete, but decode() will fail */ + + /* check for an over-subscribed or incomplete set of lengths */ + left = 1; /* one possible code of zero length */ + for (len = 1; len <= MAXBITS; len++) { + left <<= 1; /* one more bit, double codes left */ + left -= h->count[len]; /* deduct count from possible codes */ + if (left < 0) + return left; /* over-subscribed--return negative */ + } /* left > 0 means incomplete */ + + /* generate offsets into symbol table for each length for sorting */ + offs[1] = 0; + for (len = 1; len < MAXBITS; len++) + offs[len + 1] = offs[len] + h->count[len]; + + /* + * put symbols in table sorted by length, by symbol order within each + * length + */ + for (symbol = 0; symbol < n; symbol++) + if (length[symbol] != 0) + h->symbol[offs[length[symbol]]++] = symbol; + + /* return zero for complete set, positive for incomplete set */ + return left; +} + +/* + * Decode literal/length and distance codes until an end-of-block code. + * + * Format notes: + * + * - Compressed data that is after the block type if fixed or after the code + * description if dynamic is a combination of literals and length/distance + * pairs terminated by and end-of-block code. Literals are simply Huffman + * coded bytes. A length/distance pair is a coded length followed by a + * coded distance to represent a string that occurs earlier in the + * uncompressed data that occurs again at the current location. + * + * - Literals, lengths, and the end-of-block code are combined into a single + * code of up to 286 symbols. They are 256 literals (0..255), 29 length + * symbols (257..285), and the end-of-block symbol (256). + * + * - There are 256 possible lengths (3..258), and so 29 symbols are not enough + * to represent all of those. Lengths 3..10 and 258 are in fact represented + * by just a length symbol. Lengths 11..257 are represented as a symbol and + * some number of extra bits that are added as an integer to the base length + * of the length symbol. The number of extra bits is determined by the base + * length symbol. These are in the static arrays below, lens[] for the base + * lengths and lext[] for the corresponding number of extra bits. + * + * - The reason that 258 gets its own symbol is that the longest length is used + * often in highly redundant files. Note that 258 can also be coded as the + * base value 227 plus the maximum extra value of 31. While a good deflate + * should never do this, it is not an error, and should be decoded properly. + * + * - If a length is decoded, including its extra bits if any, then it is + * followed a distance code. There are up to 30 distance symbols. Again + * there are many more possible distances (1..32768), so extra bits are added + * to a base value represented by the symbol. The distances 1..4 get their + * own symbol, but the rest require extra bits. The base distances and + * corresponding number of extra bits are below in the static arrays dist[] + * and dext[]. + * + * - Literal bytes are simply written to the output. A length/distance pair is + * an instruction to copy previously uncompressed bytes to the output. The + * copy is from distance bytes back in the output stream, copying for length + * bytes. + * + * - Distances pointing before the beginning of the output data are not + * permitted. + * + * - Overlapped copies, where the length is greater than the distance, are + * allowed and common. For example, a distance of one and a length of 258 + * simply copies the last byte 258 times. A distance of four and a length of + * twelve copies the last four bytes three times. A simple forward copy + * ignoring whether the length is greater than the distance or not implements + * this correctly. You should not use memcpy() since its behavior is not + * defined for overlapped arrays. You should not use memmove() or bcopy() + * since though their behavior -is- defined for overlapping arrays, it is + * defined to do the wrong thing in this case. + */ +local int codes(struct state *s, const struct huffman *lencode, const struct huffman *distcode) +{ + int symbol; /* decoded symbol */ + int len; /* length for copy */ + unsigned dist; /* distance for copy */ + static const short lens[29] = { /* Size base for length codes 257..285 */ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, + 258 + }; + static const short lext[29] = { /* Extra bits for length codes 257..285 */ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0 + }; + static const short dists[30] = { /* Offset base for distance codes 0..29 */ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, + 6145, 8193, 12289, 16385, 24577 + }; + static const short dext[30] = { /* Extra bits for distance codes 0..29 */ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13 + }; + + /* decode literals and length/distance pairs */ + do { + symbol = decode(s, lencode); + if (symbol < 0) + return symbol; /* invalid symbol */ + if (symbol < 256) { /* literal: symbol is the byte */ + /* write out the literal */ + if (s->out != NIL) { + if (s->outcnt == s->outlen) + return 1; + s->out[s->outcnt] = symbol; + } + s->outcnt++; + } else if (symbol > 256) { /* length */ + /* get and compute length */ + symbol -= 257; + if (symbol >= 29) + return -10; /* invalid fixed code */ + len = lens[symbol] + bits(s, lext[symbol]); + + /* get and check distance */ + symbol = decode(s, distcode); + if (symbol < 0) + return symbol; /* invalid symbol */ + dist = dists[symbol] + bits(s, dext[symbol]); +#ifndef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR + if (dist > s->outcnt) + return -11; /* distance too far back */ +#endif + + /* copy length bytes from distance bytes back */ + if (s->out != NIL) { + if (s->outcnt + len > s->outlen) + return 1; + while (len--) { + s->out[s->outcnt] = +#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR + dist > s->outcnt ? 0 : +#endif + s->out[s->outcnt - dist]; + s->outcnt++; + } + } else + s->outcnt += len; + } + } while (symbol != 256); /* end of block symbol */ + + /* done with a valid fixed or dynamic block */ + return 0; +} + +/* + * Process a fixed codes block. + * + * Format notes: + * + * - This block type can be useful for compressing small amounts of data for + * which the size of the code descriptions in a dynamic block exceeds the + * benefit of custom codes for that block. For fixed codes, no bits are + * spent on code descriptions. Instead the code lengths for literal/length + * codes and distance codes are fixed. The specific lengths for each symbol + * can be seen in the "for" loops below. + * + * - The literal/length code is complete, but has two symbols that are invalid + * and should result in an error if received. This cannot be implemented + * simply as an incomplete code since those two symbols are in the "middle" + * of the code. They are eight bits long and the longest literal/length\ + * code is nine bits. Therefore the code must be constructed with those + * symbols, and the invalid symbols must be detected after decoding. + * + * - The fixed distance codes also have two invalid symbols that should result + * in an error if received. Since all of the distance codes are the same + * length, this can be implemented as an incomplete code. Then the invalid + * codes are detected while decoding. + */ +local int fixed(struct state *s) +{ + static int virgin = 1; + static short lencnt[MAXBITS + 1], lensym[FIXLCODES]; + static short distcnt[MAXBITS + 1], distsym[MAXDCODES]; + static struct huffman lencode, distcode; + + /* build fixed huffman tables if first call (may not be thread safe) */ + if (virgin) { + int symbol; + short lengths[FIXLCODES]; + + /* construct lencode and distcode */ + lencode.count = lencnt; + lencode.symbol = lensym; + distcode.count = distcnt; + distcode.symbol = distsym; + + /* literal/length table */ + for (symbol = 0; symbol < 144; symbol++) + lengths[symbol] = 8; + for (; symbol < 256; symbol++) + lengths[symbol] = 9; + for (; symbol < 280; symbol++) + lengths[symbol] = 7; + for (; symbol < FIXLCODES; symbol++) + lengths[symbol] = 8; + construct(&lencode, lengths, FIXLCODES); + + /* distance table */ + for (symbol = 0; symbol < MAXDCODES; symbol++) + lengths[symbol] = 5; + construct(&distcode, lengths, MAXDCODES); + + /* do this just once */ + virgin = 0; + } + + /* decode data until end-of-block code */ + return codes(s, &lencode, &distcode); +} + +/* + * Process a dynamic codes block. + * + * Format notes: + * + * - A dynamic block starts with a description of the literal/length and + * distance codes for that block. New dynamic blocks allow the compressor to + * rapidly adapt to changing data with new codes optimized for that data. + * + * - The codes used by the deflate format are "canonical", which means that + * the actual bits of the codes are generated in an unambiguous way simply + * from the number of bits in each code. Therefore the code descriptions + * are simply a list of code lengths for each symbol. + * + * - The code lengths are stored in order for the symbols, so lengths are + * provided for each of the literal/length symbols, and for each of the + * distance symbols. + * + * - If a symbol is not used in the block, this is represented by a zero as the + * code length. This does not mean a zero-length code, but rather that no + * code should be created for this symbol. There is no way in the deflate + * format to represent a zero-length code. + * + * - The maximum number of bits in a code is 15, so the possible lengths for + * any code are 1..15. + * + * - The fact that a length of zero is not permitted for a code has an + * interesting consequence. Normally if only one symbol is used for a given + * code, then in fact that code could be represented with zero bits. However + * in deflate, that code has to be at least one bit. So for example, if + * only a single distance base symbol appears in a block, then it will be + * represented by a single code of length one, in particular one 0 bit. This + * is an incomplete code, since if a 1 bit is received, it has no meaning, + * and should result in an error. So incomplete distance codes of one symbol + * should be permitted, and the receipt of invalid codes should be handled. + * + * - It is also possible to have a single literal/length code, but that code + * must be the end-of-block code, since every dynamic block has one. This + * is not the most efficient way to create an empty block (an empty fixed + * block is fewer bits), but it is allowed by the format. So incomplete + * literal/length codes of one symbol should also be permitted. + * + * - If there are only literal codes and no lengths, then there are no distance + * codes. This is represented by one distance code with zero bits. + * + * - The list of up to 286 length/literal lengths and up to 30 distance lengths + * are themselves compressed using Huffman codes and run-length encoding. In + * the list of code lengths, a 0 symbol means no code, a 1..15 symbol means + * that length, and the symbols 16, 17, and 18 are run-length instructions. + * Each of 16, 17, and 18 are followed by extra bits to define the length of + * the run. 16 copies the last length 3 to 6 times. 17 represents 3 to 10 + * zero lengths, and 18 represents 11 to 138 zero lengths. Unused symbols + * are common, hence the special coding for zero lengths. + * + * - The symbols for 0..18 are Huffman coded, and so that code must be + * described first. This is simply a sequence of up to 19 three-bit values + * representing no code (0) or the code length for that symbol (1..7). + * + * - A dynamic block starts with three fixed-size counts from which is computed + * the number of literal/length code lengths, the number of distance code + * lengths, and the number of code length code lengths (ok, you come up with + * a better name!) in the code descriptions. For the literal/length and + * distance codes, lengths after those provided are considered zero, i.e. no + * code. The code length code lengths are received in a permuted order (see + * the order[] array below) to make a short code length code length list more + * likely. As it turns out, very short and very long codes are less likely + * to be seen in a dynamic code description, hence what may appear initially + * to be a peculiar ordering. + * + * - Given the number of literal/length code lengths (nlen) and distance code + * lengths (ndist), then they are treated as one long list of nlen + ndist + * code lengths. Therefore run-length coding can and often does cross the + * boundary between the two sets of lengths. + * + * - So to summarize, the code description at the start of a dynamic block is + * three counts for the number of code lengths for the literal/length codes, + * the distance codes, and the code length codes. This is followed by the + * code length code lengths, three bits each. This is used to construct the + * code length code which is used to read the remainder of the lengths. Then + * the literal/length code lengths and distance lengths are read as a single + * set of lengths using the code length codes. Codes are constructed from + * the resulting two sets of lengths, and then finally you can start + * decoding actual compressed data in the block. + * + * - For reference, a "typical" size for the code description in a dynamic + * block is around 80 bytes. + */ +local int dynamic(struct state *s) +{ + int nlen, ndist, ncode; /* number of lengths in descriptor */ + int index; /* index of lengths[] */ + int err; /* construct() return value */ + short lengths[MAXCODES]; /* descriptor code lengths */ + short lencnt[MAXBITS + 1], lensym[MAXLCODES]; /* lencode memory */ + short distcnt[MAXBITS + 1], distsym[MAXDCODES]; /* distcode memory */ + struct huffman lencode, distcode; /* length and distance codes */ + static const short order[19] = /* permutation of code length codes */ + { 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }; + + /* construct lencode and distcode */ + lencode.count = lencnt; + lencode.symbol = lensym; + distcode.count = distcnt; + distcode.symbol = distsym; + + /* get number of lengths in each table, check lengths */ + nlen = bits(s, 5) + 257; + ndist = bits(s, 5) + 1; + ncode = bits(s, 4) + 4; + if (nlen > MAXLCODES || ndist > MAXDCODES) + return -3; /* bad counts */ + + /* read code length code lengths (really), missing lengths are zero */ + for (index = 0; index < ncode; index++) + lengths[order[index]] = bits(s, 3); + for (; index < 19; index++) + lengths[order[index]] = 0; + + /* build huffman table for code lengths codes (use lencode temporarily) */ + err = construct(&lencode, lengths, 19); + if (err != 0) /* require complete code set here */ + return -4; + + /* read length/literal and distance code length tables */ + index = 0; + while (index < nlen + ndist) { + int symbol; /* decoded value */ + int len; /* last length to repeat */ + + symbol = decode(s, &lencode); + if (symbol < 0) + return symbol; /* invalid symbol */ + if (symbol < 16) /* length in 0..15 */ + lengths[index++] = symbol; + else { /* repeat instruction */ + len = 0; /* assume repeating zeros */ + if (symbol == 16) { /* repeat last length 3..6 times */ + if (index == 0) + return -5; /* no last length! */ + len = lengths[index - 1]; /* last length */ + symbol = 3 + bits(s, 2); + } else if (symbol == 17) /* repeat zero 3..10 times */ + symbol = 3 + bits(s, 3); + else /* == 18, repeat zero 11..138 times */ + symbol = 11 + bits(s, 7); + if (index + symbol > nlen + ndist) + return -6; /* too many lengths! */ + while (symbol--) /* repeat last or zero symbol times */ + lengths[index++] = len; + } + } + + /* check for end-of-block code -- there better be one! */ + if (lengths[256] == 0) + return -9; + + /* build huffman table for literal/length codes */ + err = construct(&lencode, lengths, nlen); + if (err && (err < 0 || nlen != lencode.count[0] + lencode.count[1])) + return -7; /* incomplete code ok only for single length 1 code */ + + /* build huffman table for distance codes */ + err = construct(&distcode, lengths + nlen, ndist); + if (err && (err < 0 || ndist != distcode.count[0] + distcode.count[1])) + return -8; /* incomplete code ok only for single length 1 code */ + + /* decode data until end-of-block code */ + return codes(s, &lencode, &distcode); +} + +/* + * Inflate source to dest. On return, destlen and sourcelen are updated to the + * size of the uncompressed data and the size of the deflate data respectively. + * On success, the return value of puff() is zero. If there is an error in the + * source data, i.e. it is not in the deflate format, then a negative value is + * returned. If there is not enough input available or there is not enough + * output space, then a positive error is returned. In that case, destlen and + * sourcelen are not updated to facilitate retrying from the beginning with the + * provision of more input data or more output space. In the case of invalid + * inflate data (a negative error), the dest and source pointers are updated to + * facilitate the debugging of deflators. + * + * puff() also has a mode to determine the size of the uncompressed output with + * no output written. For this dest must be (unsigned char *)0. In this case, + * the input value of *destlen is ignored, and on return *destlen is set to the + * size of the uncompressed output. + * + * The return codes are: + * + * 2: available inflate data did not terminate + * 1: output space exhausted before completing inflate + * 0: successful inflate + * -1: invalid block type (type == 3) + * -2: stored block length did not match one's complement + * -3: dynamic block code description: too many length or distance codes + * -4: dynamic block code description: code lengths codes incomplete + * -5: dynamic block code description: repeat lengths with no first length + * -6: dynamic block code description: repeat more than specified lengths + * -7: dynamic block code description: invalid literal/length code lengths + * -8: dynamic block code description: invalid distance code lengths + * -9: dynamic block code description: missing end-of-block code + * -10: invalid literal/length or distance code in fixed or dynamic block + * -11: distance is too far back in fixed or dynamic block + * + * Format notes: + * + * - Three bits are read for each block to determine the kind of block and + * whether or not it is the last block. Then the block is decoded and the + * process repeated if it was not the last block. + * + * - The leftover bits in the last byte of the deflate data after the last + * block (if it was a fixed or dynamic block) are undefined and have no + * expected values to check. + */ +int puff(unsigned char *dest, /* pointer to destination pointer */ + unsigned long *destlen, /* amount of output space */ + const unsigned char *source, /* pointer to source data pointer */ + unsigned long *sourcelen) /* amount of input available */ +{ + struct state s; /* input/output state */ + int last, type; /* block information */ + int err; /* return value */ + + /* initialize output state */ + s.out = dest; + s.outlen = *destlen; /* ignored if dest is NIL */ + s.outcnt = 0; + + /* initialize input state */ + s.in = source; + s.inlen = *sourcelen; + s.incnt = 0; + s.bitbuf = 0; + s.bitcnt = 0; + + /* return if bits() or decode() tries to read past available input */ + if (setjmp(s.env) != 0) /* if came back here via longjmp() */ + err = 2; /* then skip do-loop, return error */ + else { + /* process blocks until last block or error */ + do { + last = bits(&s, 1); /* one if last block */ + type = bits(&s, 2); /* block type 0..3 */ + err = type == 0 ? stored(&s) + : (type == 1 ? fixed(&s) : (type == 2 ? dynamic(&s) : -1)); /* type == 3, invalid */ + if (err != 0) + break; /* return with error */ + } while (!last); + } + + /* update the lengths and return */ + if (err <= 0) { + *destlen = s.outcnt; + *sourcelen = s.incnt; + } + return err; +} diff --git a/src/provider/puff.h b/src/provider/puff.h new file mode 100644 index 00000000..6ced50e2 --- /dev/null +++ b/src/provider/puff.h @@ -0,0 +1,42 @@ +/* puff.h + Copyright (C) 2002-2013 Mark Adler, all rights reserved + version 2.3, 21 Jan 2013 + + This software is provided 'as-is', without any express or implied + warranty. In no event will the author be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Mark Adler madler@alumni.caltech.edu + */ + +/* + * See puff.c for purpose and usage. + */ +#ifndef NIL +#define NIL ((unsigned char *)0) /* for no output option */ +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +int puff(unsigned char *dest, /* pointer to destination pointer */ + unsigned long *destlen, /* amount of output space */ + const unsigned char *source, /* pointer to source data pointer */ + unsigned long *sourcelen); /* amount of input available */ + +#ifdef __cplusplus +} +#endif diff --git a/tests/providers/ProviderPackageTest.cpp b/tests/providers/ProviderPackageTest.cpp index 3704ea9d..1c5a3201 100644 --- a/tests/providers/ProviderPackageTest.cpp +++ b/tests/providers/ProviderPackageTest.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -82,8 +81,8 @@ QByteArray makeStoredZip(const QMap& entries) for (auto it = entries.cbegin(); it != entries.cend(); ++it) { const QString name = it.key(); const QByteArray data = it.value(); - const uint32_t crc = static_cast( - ::crc32(0L, reinterpret_cast(data.constData()), static_cast(data.size()))); + const uint32_t crc = JellyfinNative::ProviderPackage::calculateCrc32( + reinterpret_cast(data.constData()), data.size()); const uint32_t offset = static_cast(buffer.size()); ZipLocalHeader local {}; From 605a1c26652fbe199c3fe4b483b8bea11227dfc7 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 17:28:22 +1000 Subject: [PATCH 042/146] fix(platform): compile DesktopPlaybackRuntime into core target for link-time optimization --- cmake/platform/Android.cmake | 2 +- cmake/platform/Desktop.cmake | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmake/platform/Android.cmake b/cmake/platform/Android.cmake index 9c0d6665..685694e8 100644 --- a/cmake/platform/Android.cmake +++ b/cmake/platform/Android.cmake @@ -34,6 +34,7 @@ function(jellyfin_configure_android_targets native_target core_target) src/platform/common/LinuxPerformanceSampler.cpp src/platform/desktop/DesktopMpvConfigPolicy.cpp src/platform/android/AndroidPlaybackSurface.cpp + src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_sources(${native_target} PRIVATE src/platform/android/AndroidNativeAppWindow.cpp @@ -45,7 +46,6 @@ function(jellyfin_configure_android_targets native_target core_target) src/platform/UpdateController.h src/platform/android/AndroidUpdateInstaller.cpp src/platform/android/AndroidUpdateInstaller.h - src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_link_libraries(${core_target} PUBLIC MPV::MPV) # liblog carries diagnostics to logcat, the only readable output on Android. diff --git a/cmake/platform/Desktop.cmake b/cmake/platform/Desktop.cmake index cfdb1f9e..ab436d09 100644 --- a/cmake/platform/Desktop.cmake +++ b/cmake/platform/Desktop.cmake @@ -3,9 +3,9 @@ function(jellyfin_add_desktop_sources native_target core_target) src/platform/desktop/DesktopNativeAppWindow.cpp src/platform/desktop/DesktopMpvConfigPolicy.cpp src/platform/desktop/DesktopPlaybackSurface.cpp + src/platform/desktop/DesktopPlaybackRuntime.cpp ) target_sources(${native_target} PRIVATE src/platform/desktop/DesktopApplicationServices.cpp - src/platform/desktop/DesktopPlaybackRuntime.cpp ) endfunction() From 2cad1cf9fd2ea204e40713c74afe5c0d60eddb95 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 18:10:02 +1000 Subject: [PATCH 043/146] fix(tests): run provider-qml-cache with offscreen QPA platform in headless environments --- tests/providers/ProviderQmlCacheTest.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/providers/ProviderQmlCacheTest.cpp b/tests/providers/ProviderQmlCacheTest.cpp index cfb73a76..901f141a 100644 --- a/tests/providers/ProviderQmlCacheTest.cpp +++ b/tests/providers/ProviderQmlCacheTest.cpp @@ -37,6 +37,7 @@ void waitUntil(const std::function& condition) JELLYFIN_TEST_MAIN("provider-qml-cache") { + qputenv("QT_QPA_PLATFORM", "offscreen"); QGuiApplication app(argc, argv); QTemporaryDir directory; require(directory.isValid(), "fixture directory created"); From 1b6a8eabf56bdd3e28105a629f2e5e904130be1e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Tue, 22 Sep 2026 18:24:32 +1000 Subject: [PATCH 044/146] fix(android): retry Gradle packaging on transient network failures --- tools/android/build-universal-apk.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/android/build-universal-apk.sh b/tools/android/build-universal-apk.sh index 6b19030d..0fc3a565 100755 --- a/tools/android/build-universal-apk.sh +++ b/tools/android/build-universal-apk.sh @@ -58,7 +58,19 @@ python3 "$ROOT/tools/android/universal_project.py" \ printf 'sdk.dir=%s\n' "$ANDROID_HOME" >"$project/local.properties" # Compress the multi-ABI download; Android extracts only the device's ABI. -(cd "$project" && ./gradlew --no-daemon -PlegacyPackaging=true assembleRelease) +gradle_ok=0 +for attempt in 1 2 3; do + if (cd "$project" && ./gradlew --no-daemon -PlegacyPackaging=true assembleRelease); then + gradle_ok=1 + break + fi + echo "Gradle invocation failed on attempt $attempt, retrying in 5 seconds..." >&2 + sleep 5 +done +if [[ $gradle_ok -ne 1 ]]; then + echo "error: Gradle build failed after 3 attempts" >&2 + exit 1 +fi # AGP names the output after the project directory, so find it rather than # spell it: exactly one release APK is expected, and two would mean the project From ef216374a01999a91a403de72f52b966711769c9 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 03:05:19 +1000 Subject: [PATCH 045/146] feat(provider): switch jellyfin to js backend and add stream quality controls --- src/api/JellyfinJsAdapter.cpp | 35 ++++++++++++++-- src/api/JellyfinJsAdapter.h | 37 ++++++++++++++++- src/api/JellyfinProvider.cpp | 63 +++++++++-------------------- src/api/JellyfinProvider.h | 13 ------ src/app/AppController.cpp | 38 +++++++++++------ src/app/AppController.h | 2 + src/main.cpp | 23 ++--------- src/provider/StreamQualityControl.h | 58 ++++++++++++++++++++++++++ tests/api/JellyfinJsAdapterTest.cpp | 13 +++++- 9 files changed, 188 insertions(+), 94 deletions(-) diff --git a/src/api/JellyfinJsAdapter.cpp b/src/api/JellyfinJsAdapter.cpp index 6d06f2eb..26d7eec9 100644 --- a/src/api/JellyfinJsAdapter.cpp +++ b/src/api/JellyfinJsAdapter.cpp @@ -3,6 +3,7 @@ #include "../common/MetaJson.h" #include "../provider/ProviderMediaPage.h" #include "../provider/ProviderRegistry.h" +#include "PlaybackNegotiation.h" #include #include @@ -115,6 +116,8 @@ void JellyfinJsAdapter::clear() m_sourceId.clear(); m_serverUrl.clear(); m_sessionToken.clear(); + m_bitrateOverride = 0; + m_heightOverride = 0; if (m_playback) m_playback->emitCredentialsChanged(); } @@ -124,6 +127,12 @@ void JellyfinJsAdapter::setDeviceId(const QString& deviceId) m_deviceId = deviceId; } +void JellyfinJsAdapter::setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs) +{ + m_videoCodecs = std::move(videoCodecs); + m_restrictVideoCodecs = restrictVideoCodecs; +} + PlaybackSource *JellyfinJsAdapter::playback() { return m_playback; @@ -532,12 +541,28 @@ QCoro::Task JellyfinJsAdapter::resolvePlayback(MovieItem movie, if (variantId.isEmpty()) variantId = movie.id; + qint64 sourceBitrate = 0; + for (const MediaSourceInfo& source : movie.mediaSources) + sourceBitrate = std::max(sourceBitrate, source.bitRate); + + const bool shouldTranscode + = forceTranscode || (m_bitrateOverride > 0 && sourceBitrate > 0 && m_bitrateOverride < sourceBitrate); + QVariantMap args; args.insert(QStringLiteral("itemId"), movie.id); args.insert(QStringLiteral("variantId"), variantId); - args.insert(QStringLiteral("forceTranscode"), forceTranscode); + args.insert(QStringLiteral("forceTranscode"), shouldTranscode); args.insert(QStringLiteral("positionTicks"), movie.resumeTicks); + const qint64 maxBitrate = m_bitrateOverride > 0 ? m_bitrateOverride : 120'000'000; + args.insert(QStringLiteral("maxBitrate"), maxBitrate); + if (m_heightOverride > 0) + args.insert(QStringLiteral("maxHeight"), m_heightOverride); + + const QJsonObject profile + = PlaybackNegotiation::buildDeviceProfile(maxBitrate, m_heightOverride, m_videoCodecs, m_restrictVideoCodecs); + args.insert(QStringLiteral("deviceProfile"), profile.toVariantMap()); + QVariantMap result = co_await m_registry->callSource(m_sourceId, QStringLiteral("resolve"), args); PlaybackSession session; @@ -620,8 +645,12 @@ QUrl JellyfinJsAdapter::mediaOrigin() const QString JellyfinJsAdapter::trickplayTileUrl(const QString& itemId, int width, int tileIndex) const { - return m_serverUrl + QStringLiteral("/Items/") + itemId + QStringLiteral("/Images/Trickplay/") - + QString::number(width) + QLatin1Char('/') + QString::number(tileIndex); + if (m_serverUrl.isEmpty() || itemId.isEmpty() || width <= 0 || tileIndex < 0) + return {}; + return serverUrlWithPath(m_serverUrl, + { QStringLiteral("Videos"), itemId, QStringLiteral("Trickplay"), QString::number(width), + QStringLiteral("%1.jpg").arg(tileIndex) }) + .toString(QUrl::FullyEncoded); } QCoro::Task JellyfinJsAdapter::reportPlaybackStart( diff --git a/src/api/JellyfinJsAdapter.h b/src/api/JellyfinJsAdapter.h index 483d20cc..a353b32c 100644 --- a/src/api/JellyfinJsAdapter.h +++ b/src/api/JellyfinJsAdapter.h @@ -5,6 +5,7 @@ #include "../provider/Catalog.h" #include "../provider/PlaybackSource.h" #include "../provider/SearchSource.h" +#include "../provider/StreamQualityControl.h" #include "../provider/UserItemStateSink.h" #include @@ -29,7 +30,8 @@ class JellyfinJsAdapter final : public QObject, public Catalog, public SearchSource, public UserItemStateSink, - public ArtworkSource { + public ArtworkSource, + public StreamQualityControl { Q_OBJECT public: @@ -87,6 +89,35 @@ class JellyfinJsAdapter final : public QObject, // ArtworkSource QString imageUrl(const ImageRequest& request) const override; + StreamQualityControl *streamQuality() + { + return this; + } + + // StreamQualityControl + qint64 bitrateOverride() const override + { + return m_bitrateOverride; + } + int heightOverride() const override + { + return m_heightOverride; + } + void setOverride(qint64 bitrate, int height) override + { + m_bitrateOverride = bitrate; + m_heightOverride = height; + } + QString autoDescription() const override + { + return QStringLiteral("Direct Play"); + } + std::vector ladder(qint64 sourceBitrate) const override + { + return StreamQualityControl::defaultLadder(sourceBitrate); + } + void setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs); + // Playback QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode); QCoro::Task> fetchMediaSegments(QString itemId); @@ -108,6 +139,10 @@ class JellyfinJsAdapter final : public QObject, QString m_serverUrl; QString m_sessionToken; QString m_deviceId; + qint64 m_bitrateOverride = 0; + int m_heightOverride = 0; + QStringList m_videoCodecs; + bool m_restrictVideoCodecs = false; }; } // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp index b4f9f5d1..acfcb6ba 100644 --- a/src/api/JellyfinProvider.cpp +++ b/src/api/JellyfinProvider.cpp @@ -32,18 +32,15 @@ namespace JellyfinNative { JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObject *parent) : Provider(parent) - , m_backend(context.backend) , m_registry(context.registry) , m_deviceName(context.deviceName) , m_appVersion(context.appVersion) , m_database(context.database) , m_tlsTrust(context.tlsTrust) { - if (m_backend == Backend::JavaScript) { - if (!m_registry) - throw std::runtime_error("javascript_provider_unavailable"); - m_jsAdapter = std::make_unique(m_registry, this); - } + if (!m_registry) + throw std::runtime_error("javascript_provider_unavailable"); + m_jsAdapter = std::make_unique(m_registry, this); m_api = new JellyfinApiFacade(context.network, context.tlsTrust, this); m_api->setDeviceIdentity({}, context.deviceName, context.appVersion); @@ -64,9 +61,11 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje }); #endif configurePlatformPlaybackCapabilities( - [api = QPointer(m_api)](const QStringList& videoCodecs, bool restrictVideoCodecs) { - if (api) - api->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); + [this](const QStringList& videoCodecs, bool restrictVideoCodecs) { + if (m_jsAdapter) + m_jsAdapter->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); + if (m_api) + m_api->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); }, *this); @@ -102,12 +101,9 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje // The session drives the parts that only make sense while signed in. connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession& auth) { - if (m_backend == Backend::JavaScript) { - Async::runScoped( - this, configureJsSourceAsync(auth), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - return; - } + Async::runScoped( + this, configureJsSourceAsync(auth), []() {}, + [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); if (m_artwork) m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); @@ -117,24 +113,6 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje m_discovery->stop(); setHasDefaultProfile(true); emit sessionStarted(); - - // The home route is the only launch-critical server work. Subtitle - // metadata and bandwidth probing are useful, but starting them beside - // the initial home requests competes for the TV's limited network and - // JSON-processing budget. Load them after the first interaction window; - // opening Settings sooner triggers the same idempotent load directly. - const QString sessionToken = m_api->session().accessToken; - QTimer::singleShot(5000, this, [this, sessionToken]() { - if (!m_session->authenticated() || m_api->session().accessToken != sessionToken) - return; - if (m_settings) - m_settings->loadRemote(); - Async::runScoped( - this, m_api->refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: route measurement failed" << exceptionMessage(error); - }); - }); }); connect(m_session, &SessionController::profileActivationStarted, this, [this]() { m_quickConnect->cancel(); @@ -176,41 +154,38 @@ QString JellyfinProvider::displayName() const Provider::Capabilities JellyfinProvider::capabilities() const { - if (m_backend == Backend::JavaScript) { - return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | QuickConnect; - } return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | LibraryManagement | SyncPlay | RemoteControl | QuickConnect | PeerRelay | StreamQuality; } PlaybackSource *JellyfinProvider::playback() { - return m_backend == Backend::JavaScript ? m_jsAdapter->playback() : m_api; + return m_jsAdapter->playback(); } Catalog *JellyfinProvider::catalog() { - return m_backend == Backend::JavaScript ? m_jsAdapter->catalog() : m_api; + return m_jsAdapter->catalog(); } ArtworkSource *JellyfinProvider::artwork() { - return m_backend == Backend::JavaScript ? m_jsAdapter->artwork() : m_api; + return m_jsAdapter->artwork(); } SearchSource *JellyfinProvider::search() { - return m_backend == Backend::JavaScript ? m_jsAdapter->search() : m_api; + return m_jsAdapter->search(); } UserItemStateSink *JellyfinProvider::itemState() { - return m_backend == Backend::JavaScript ? m_jsAdapter->itemState() : m_api; + return m_jsAdapter->itemState(); } StreamQualityControl *JellyfinProvider::streamQuality() { - return m_backend == Backend::JavaScript ? nullptr : m_api; + return m_jsAdapter.get(); } GroupPlayback *JellyfinProvider::groupPlayback() @@ -286,9 +261,7 @@ void JellyfinProvider::shutdown() bool JellyfinProvider::ready() const { - if (m_backend == Backend::JavaScript) - return m_session->authenticated() && m_jsAdapter && m_jsAdapter->signedIn(); - return m_session->authenticated(); + return m_session->authenticated() && m_jsAdapter && m_jsAdapter->signedIn(); } QStringList JellyfinProvider::startupStorageKeys() const diff --git a/src/api/JellyfinProvider.h b/src/api/JellyfinProvider.h index 20245203..eb3ec8c9 100644 --- a/src/api/JellyfinProvider.h +++ b/src/api/JellyfinProvider.h @@ -28,15 +28,12 @@ class SyncPlayController; class TlsTrustController; struct JellyfinProviderContext { - enum class Backend { Native, JavaScript }; - QNetworkAccessManager *network = nullptr; TlsTrustController *tlsTrust = nullptr; DatabaseManager *database = nullptr; ProviderRegistry *registry = nullptr; QString deviceName; QString appVersion; - Backend backend = Backend::Native; }; // Everything Jellyfin-specific, composed in one place: the API facade, LAN @@ -118,15 +115,6 @@ class JellyfinProvider final : public Provider { { return m_settingsBridge; } - using Backend = JellyfinProviderContext::Backend; - Backend backend() const - { - return m_backend; - } - QString backendName() const - { - return m_backend == Backend::JavaScript ? QStringLiteral("js") : QStringLiteral("native"); - } private: // Shows the servers seen last time and scans for more while nobody is @@ -138,7 +126,6 @@ class JellyfinProvider final : public Provider { void leaveSessionServices(); void setHasDefaultProfile(bool hasDefaultProfile); - Backend m_backend = Backend::Native; ProviderRegistry *m_registry = nullptr; std::unique_ptr m_jsAdapter; QString m_deviceId; diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index f429393a..983e0fdc 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -243,6 +243,10 @@ AppController::AppController( m_qualityFallbackHeight = 0; if (m_quality) m_quality->setOverride(restoredBitrate, restoredHeight); + else { + m_genericBitrateOverride = restoredBitrate; + m_genericHeightOverride = restoredHeight; + } emit streamingQualityChanged(); const MovieItem resumeItem = PlaybackFailurePolicy::retryItem(m_activePlaybackItem, positionTicks); setBusy(true, QStringLiteral("Restoring the previous quality…")); @@ -1547,28 +1551,35 @@ void AppController::openNamedCollection(const QString& kind, const QString& valu QVariantList AppController::streamingQualityOptions() const { - if (!m_quality) - return {}; - const qint64 override = m_quality->bitrateOverride(); + const qint64 override = m_quality ? m_quality->bitrateOverride() : m_genericBitrateOverride; + const int heightOverride = m_quality ? m_quality->heightOverride() : m_genericHeightOverride; QVariantList options; options.push_back(QVariantMap { { QStringLiteral("label"), QStringLiteral("Auto") }, - { QStringLiteral("detail"), m_quality->autoDescription() }, + { QStringLiteral("detail"), m_quality ? m_quality->autoDescription() : QStringLiteral("Direct Play") }, { QStringLiteral("bitrate"), 0 }, - { QStringLiteral("selected"), override <= 0 }, + { QStringLiteral("height"), 0 }, + { QStringLiteral("selected"), override <= 0 && heightOverride <= 0 }, }); qint64 sourceBitrate = 0; for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) sourceBitrate = std::max(sourceBitrate, source.bitRate); - for (const StreamQualityControl::Rung& rung : m_quality->ladder(sourceBitrate)) { + std::vector rungs; + if (m_quality) + rungs = m_quality->ladder(sourceBitrate); + if (rungs.empty()) + rungs = StreamQualityControl::defaultLadder(sourceBitrate); + + for (const StreamQualityControl::Rung& rung : rungs) { options.push_back(QVariantMap { { QStringLiteral("label"), rung.label }, { QStringLiteral("detail"), QString() }, { QStringLiteral("bitrate"), rung.bitrate }, { QStringLiteral("height"), rung.height }, - { QStringLiteral("selected"), override == rung.bitrate }, + { QStringLiteral("selected"), + override == rung.bitrate && (heightOverride == 0 || heightOverride == rung.height) }, }); } return options; @@ -1576,13 +1587,16 @@ QVariantList AppController::streamingQualityOptions() const void AppController::selectStreamingQuality(qint64 bitrate, int height) { - if (!m_quality) - return; - const qint64 previousBitrate = m_quality->bitrateOverride(); - const int previousHeight = m_quality->heightOverride(); + const qint64 previousBitrate = m_quality ? m_quality->bitrateOverride() : m_genericBitrateOverride; + const int previousHeight = m_quality ? m_quality->heightOverride() : m_genericHeightOverride; if (previousBitrate == bitrate && previousHeight == height) return; - m_quality->setOverride(bitrate, height); + if (m_quality) + m_quality->setOverride(bitrate, height); + else { + m_genericBitrateOverride = bitrate; + m_genericHeightOverride = height; + } emit streamingQualityChanged(); // The source chose direct play or transcoding against the ceiling it was diff --git a/src/app/AppController.h b/src/app/AppController.h index 8b1dc57d..c6c5dade 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -251,6 +251,8 @@ class AppController final : public QObject { // one that was playing instead of ending playback. qint64 m_qualityFallbackBitrate = -1; int m_qualityFallbackHeight = 0; + qint64 m_genericBitrateOverride = 0; + int m_genericHeightOverride = 0; // Held for the process lifetime so a second local instance picks the next // slot and reports a device identity of its own. std::unique_ptr m_instanceLock; diff --git a/src/main.cpp b/src/main.cpp index 11b2722f..0fcd7838 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -664,23 +664,12 @@ int main(int argc, char **argv) qWarning("Portable source metadata could not be restored: %s", error.what()); } - const QString requestedBackend = optionValue( - arguments, QStringLiteral("--jellyfin-backend"), "SPOOL_JELLYFIN_BACKEND", QStringLiteral("native")) - .toLower(); - QString requestedProvider + const QString requestedProvider = optionValue(arguments, QStringLiteral("--provider"), "SPOOL_PROVIDER", QStringLiteral("jellyfin")); - JellyfinNative::JellyfinProvider::Backend backend = JellyfinNative::JellyfinProvider::Backend::Native; - if (requestedBackend == QStringLiteral("js") || requestedBackend == QStringLiteral("javascript") - || requestedProvider == QStringLiteral("jellyfin-js")) { - backend = JellyfinNative::JellyfinProvider::Backend::JavaScript; - if (requestedProvider == QStringLiteral("jellyfin-js")) - requestedProvider = QStringLiteral("jellyfin"); - } - auto jellyfin = std::make_unique( JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, &providers, - capabilities.deviceName, QString::fromLatin1(kAppVersion), backend }); + capabilities.deviceName, QString::fromLatin1(kAppVersion) }); providers.add(jellyfin.get()); auto local = std::make_unique(optionValue(arguments, QStringLiteral("--library-root"), @@ -691,11 +680,7 @@ int main(int argc, char **argv) providers.setActive(jellyfin.get()); } JellyfinNative::Provider *provider = providers.active(); - if (provider == jellyfin.get()) { - logLine("provider: %s (backend: %s)", qPrintable(provider->id()), qPrintable(jellyfin->backendName())); - } else { - logLine("provider: %s", qPrintable(provider->id())); - } + logLine("provider: %s", qPrintable(provider->id())); if (arguments.contains(QStringLiteral("--benchmark-providers")) || qEnvironmentVariableIsSet("SPOOL_BENCHMARK_PROVIDERS") || qgetenv("SPOOL_BENCH") == "providers") { @@ -991,7 +976,7 @@ int main(int argc, char **argv) // CI rather than an impression. JellyfinNative::RenderBenchmarkHooks benchmarkHooks; benchmarkHooks.providerId = provider ? provider->id() : QString(); - benchmarkHooks.providerRuntime = (provider == jellyfin.get()) ? jellyfin->backendName() : QStringLiteral("native"); + benchmarkHooks.providerRuntime = (provider == jellyfin.get()) ? QStringLiteral("js") : QStringLiteral("native"); benchmarkHooks.libraries = controller->libraries(); benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; benchmarkHooks.outstandingArtworkRequests diff --git a/src/provider/StreamQualityControl.h b/src/provider/StreamQualityControl.h index f6dff98b..a35faf39 100644 --- a/src/provider/StreamQualityControl.h +++ b/src/provider/StreamQualityControl.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include namespace JellyfinNative { @@ -31,6 +33,62 @@ class StreamQualityControl { // The ceilings worth offering below a stream of this bitrate, coarsest // first. virtual std::vector ladder(qint64 sourceBitrate) const = 0; + + static QString formatBitrate(qint64 bitsPerSecond) + { + if (bitsPerSecond <= 0) + return QStringLiteral("unknown"); + const double mbps = static_cast(bitsPerSecond) / 1'000'000.0; + if (mbps >= 10.0) + return QString::number(std::llround(mbps)) + QStringLiteral(" Mbps"); + if (mbps >= 1.0) + return QString::number(mbps, 'f', 1) + QStringLiteral(" Mbps"); + return QString::number(std::llround(mbps * 1000.0)) + QStringLiteral(" kbps"); + } + + static QString describeResolution(int height) + { + if (height <= 0) + return QStringLiteral("Source"); + if (height >= 2160) + return QStringLiteral("4K"); + return QString::number(height) + QLatin1Char('p'); + } + + static std::vector defaultLadder(qint64 sourceBitrate) + { + static constexpr struct { + qint64 bitrate; + int height; + } kStandardRungs[] = { + { 120'000'000, 2160 }, + { 80'000'000, 2160 }, + { 60'000'000, 1080 }, + { 40'000'000, 1080 }, + { 20'000'000, 1080 }, + { 15'000'000, 1080 }, + { 10'000'000, 720 }, + { 8'000'000, 720 }, + { 6'000'000, 720 }, + { 4'000'000, 480 }, + { 3'000'000, 480 }, + { 2'000'000, 480 }, + { 1'000'000, 360 }, + }; + + std::vector options; + options.reserve(std::size(kStandardRungs)); + for (const auto& rung : kStandardRungs) { + if (sourceBitrate > 0 && rung.bitrate >= sourceBitrate) + continue; + options.push_back({ + describeResolution(rung.height) + QStringLiteral(" · ") + formatBitrate(rung.bitrate), + rung.bitrate, + rung.height, + }); + } + return options; + } }; } // namespace JellyfinNative diff --git a/tests/api/JellyfinJsAdapterTest.cpp b/tests/api/JellyfinJsAdapterTest.cpp index fd78e8e5..b877676d 100644 --- a/tests/api/JellyfinJsAdapterTest.cpp +++ b/tests/api/JellyfinJsAdapterTest.cpp @@ -82,9 +82,20 @@ JELLYFIN_TEST_MAIN("jellyfin-js-adapter") "mediaRequestHeaders contains X-Emby-Token"); const QString trickplay = adapter.trickplayTileUrl(QStringLiteral("item-42"), 320, 5); - require(trickplay == "https://media.test.local:8096/Items/item-42/Images/Trickplay/320/5", + require(trickplay == "https://media.test.local:8096/Videos/item-42/Trickplay/320/5.jpg", "trickplay tile URL formatted correctly"); + // Test StreamQualityControl + require(adapter.bitrateOverride() == 0, "initial bitrate override is 0"); + require(adapter.heightOverride() == 0, "initial height override is 0"); + require(adapter.autoDescription() == QStringLiteral("Direct Play"), "initial autoDescription is Direct Play"); + adapter.setOverride(10'000'000, 720); + require(adapter.bitrateOverride() == 10'000'000, "bitrate override set"); + require(adapter.heightOverride() == 720, "height override set"); + const auto ladder = adapter.ladder(20'000'000); + require(!ladder.empty(), "ladder is populated"); + require(ladder.front().bitrate <= 20'000'000, "ladder rungs capped at source bitrate"); + // Test clearing adapter.clear(); require(!adapter.signedIn(), "adapter signed out after clear"); From 06ad9e359173f5a777bf43af0852115a438c8113 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 03:53:25 +1000 Subject: [PATCH 046/146] refactor(provider): remove legacy native jellyfin backend and streamline provider API - Complete removal of legacy native playback/catalog/search/quality implementation from JellyfinApiFacade - Remove dead ItemsQuery and PlaybackBandwidthPolicy files and tests (~3,000 lines deleted) - Streamline StreamQualityControl with default bitrate/resolution ladders and filtering - Wire frontend quality ladder generation in AppController based on active media source bitrate and resolution - Add StreamQualityControl unit tests and decouple ContentModelControllerTest from legacy native API - Bump version to 0.8.1 across manifests, AUR, and provider user agents --- CMakeLists.txt | 7 +- VERSION | 2 +- app/com.sachk.spool.metainfo.xml | 1 + packaging/aur/.SRCINFO | 6 +- packaging/aur/PKGBUILD | 2 +- src/api/ItemsQuery.cpp | 87 -- src/api/ItemsQuery.h | 33 - src/api/JellyfinApiFacade.cpp | 1550 ++------------------- src/api/JellyfinApiFacade.h | 149 +- src/api/JellyfinJsAdapter.h | 4 +- src/api/JellyfinProvider.cpp | 7 - src/api/JellyfinSession.h | 2 + src/api/PlaybackBandwidthPolicy.cpp | 212 --- src/api/PlaybackBandwidthPolicy.h | 85 -- src/api/ProviderBenchmark.cpp | 122 +- src/app/AppController.cpp | 12 +- src/provider/ProviderRegistrySources.cpp | 2 +- src/provider/StreamQualityControl.h | 10 +- tests/api/ItemsQueryTest.cpp | 103 -- tests/api/JellyfinApiFacadeUrlTest.cpp | 30 - tests/api/PlaybackBandwidthPolicyTest.cpp | 179 --- tests/api/StreamQualityControlTest.cpp | 83 ++ tests/app/ContentModelControllerTest.cpp | 721 ++++------ 23 files changed, 539 insertions(+), 2870 deletions(-) delete mode 100644 src/api/ItemsQuery.cpp delete mode 100644 src/api/ItemsQuery.h delete mode 100644 src/api/PlaybackBandwidthPolicy.cpp delete mode 100644 src/api/PlaybackBandwidthPolicy.h delete mode 100644 tests/api/ItemsQueryTest.cpp delete mode 100644 tests/api/PlaybackBandwidthPolicyTest.cpp create mode 100644 tests/api/StreamQualityControlTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 897a70ad..ce71f30a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,8 +293,6 @@ set(SPOOL_SHELL_SOURCES # by them. This is the group a second backend would grow a sibling to. set(SPOOL_JELLYFIN_SOURCES src/api/HttpRequestPolicy.h - src/api/ItemsQuery.cpp - src/api/ItemsQuery.h src/api/JellyfinApiFacade.cpp src/api/JellyfinApiFacade.h src/api/JellyfinJsAdapter.cpp @@ -304,8 +302,6 @@ set(SPOOL_JELLYFIN_SOURCES src/api/JellyfinProvider.cpp src/api/JellyfinProvider.h src/api/JellyfinSettingsBridge.h - src/api/PlaybackBandwidthPolicy.cpp - src/api/PlaybackBandwidthPolicy.h src/api/PlaybackNegotiation.cpp src/api/PlaybackNegotiation.h src/api/ProviderBenchmark.cpp @@ -817,8 +813,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) set(JELLYFIN_TESTS_api http-request-policy tests/api/HttpRequestPolicyTest.cpp - playback-bandwidth-policy tests/api/PlaybackBandwidthPolicyTest.cpp - items-query tests/api/ItemsQueryTest.cpp + stream-quality-control tests/api/StreamQualityControlTest.cpp playback-negotiation tests/api/PlaybackNegotiationTest.cpp jellyfin-api-facade-url tests/api/JellyfinApiFacadeUrlTest.cpp jellyfin-js-adapter tests/api/JellyfinJsAdapterTest.cpp diff --git a/VERSION b/VERSION index a3df0a69..6f4eebdf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index 3f189818..a323c6c7 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -21,6 +21,7 @@ Spool + diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 9c318cfc..2adf40c6 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = spool-bin pkgdesc = Jellyfin client for desktop and TV, built on Qt Quick and mpv - pkgver = 0.8.0 + pkgver = 0.8.1 pkgrel = 1 url = https://github.com/spool-player/spool arch = x86_64 @@ -9,12 +9,12 @@ pkgbase = spool-bin depends = glibc depends = libglvnd depends = mesa - provides = spool=0.8.0 + provides = spool=0.8.1 conflicts = spool options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.8.0/Spool-for-Jellyfin-0.8.0-linux-x86_64.tar.zst + source = https://github.com/spool-player/spool/releases/download/v0.8.1/Spool-for-Jellyfin-0.8.1-linux-x86_64.tar.zst sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index a4bb2f7d..13793d78 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sacha Korban pkgname=spool-bin -pkgver=0.8.0 +pkgver=0.8.1 pkgrel=1 pkgdesc='Jellyfin client for desktop and TV, built on Qt Quick and mpv' arch=('x86_64') diff --git a/src/api/ItemsQuery.cpp b/src/api/ItemsQuery.cpp deleted file mode 100644 index fc480785..00000000 --- a/src/api/ItemsQuery.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#include "ItemsQuery.h" - -#include - -namespace JellyfinNative { - -ItemsQuery& ItemsQuery::add(const QString& key, const QString& value) -{ - m_query.addQueryItem(key, value); - return *this; -} - -ItemsQuery& ItemsQuery::addIfNotEmpty(const QString& key, const QString& value) -{ - if (!value.isEmpty()) - m_query.addQueryItem(key, value); - return *this; -} - -ItemsQuery& ItemsQuery::userId(const QString& userId) -{ - return add(QStringLiteral("userId"), userId); -} - -ItemsQuery& ItemsQuery::parentId(const QString& parentId) -{ - return addIfNotEmpty(QStringLiteral("parentId"), parentId); -} - -ItemsQuery& ItemsQuery::recursive(bool enabled) -{ - return add(QStringLiteral("recursive"), enabled ? QStringLiteral("true") : QStringLiteral("false")); -} - -ItemsQuery& ItemsQuery::includeItemTypes(const QString& types) -{ - return addIfNotEmpty(QStringLiteral("includeItemTypes"), types); -} - -ItemsQuery& ItemsQuery::mediaTypes(const QString& types) -{ - return addIfNotEmpty(QStringLiteral("mediaTypes"), types); -} - -ItemsQuery& ItemsQuery::fields(const QString& fields) -{ - return addIfNotEmpty(QStringLiteral("fields"), fields); -} - -ItemsQuery& ItemsQuery::images(const QString& types, int imageTypeLimit) -{ - addIfNotEmpty(QStringLiteral("enableImageTypes"), types); - add(QStringLiteral("imageTypeLimit"), QString::number(std::max(1, imageTypeLimit))); - return *this; -} - -ItemsQuery& ItemsQuery::sort(const QString& sortBy, const QString& sortOrder) -{ - addIfNotEmpty(QStringLiteral("sortBy"), sortBy); - addIfNotEmpty(QStringLiteral("sortOrder"), sortOrder); - return *this; -} - -ItemsQuery& ItemsQuery::limit(int requested, int maximum) -{ - const int boundedMaximum = std::max(1, maximum); - add(QStringLiteral("limit"), QString::number(std::clamp(requested, 1, boundedMaximum))); - return *this; -} - -ItemsQuery& ItemsQuery::startIndex(int startIndex) -{ - add(QStringLiteral("startIndex"), QString::number(std::max(0, startIndex))); - return *this; -} - -ItemsQuery& ItemsQuery::enableTotalRecordCount(bool enabled) -{ - return add(QStringLiteral("enableTotalRecordCount"), enabled ? QStringLiteral("true") : QStringLiteral("false")); -} - -QUrlQuery ItemsQuery::toUrlQuery() const -{ - return m_query; -} - -} // namespace JellyfinNative diff --git a/src/api/ItemsQuery.h b/src/api/ItemsQuery.h deleted file mode 100644 index 20e910ac..00000000 --- a/src/api/ItemsQuery.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include -#include - -namespace JellyfinNative { - -class ItemsQuery final { -public: - ItemsQuery() = default; - - ItemsQuery& add(const QString& key, const QString& value); - ItemsQuery& addIfNotEmpty(const QString& key, const QString& value); - ItemsQuery& userId(const QString& userId); - ItemsQuery& parentId(const QString& parentId); - ItemsQuery& recursive(bool enabled = true); - ItemsQuery& includeItemTypes(const QString& types); - ItemsQuery& mediaTypes(const QString& types); - ItemsQuery& fields(const QString& fields); - ItemsQuery& images( - const QString& types = QStringLiteral("Primary,Backdrop,Logo,Banner,Thumb"), int imageTypeLimit = 3); - ItemsQuery& sort(const QString& sortBy, const QString& sortOrder = QStringLiteral("Ascending")); - ItemsQuery& limit(int requested, int maximum); - ItemsQuery& startIndex(int startIndex); - ItemsQuery& enableTotalRecordCount(bool enabled); - - QUrlQuery toUrlQuery() const; - -private: - QUrlQuery m_query; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp index d7810935..aa26a858 100644 --- a/src/api/JellyfinApiFacade.cpp +++ b/src/api/JellyfinApiFacade.cpp @@ -1,39 +1,24 @@ #include "JellyfinApiFacade.h" #include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "../common/NetworkAddress.h" #include "../common/TlsTrust.h" -#include "../common/VariantUtils.h" #include "../diagnostics/Diagnostics.h" -#include "ItemsQuery.h" -#include "PlaybackBandwidthPolicy.h" #include "PlaybackNegotiation.h" -#include +#include +#include #include #include -#include -#include -#include #include #include #include -#include -#include #include -#include -#if QT_CONFIG(ssl) -#include -#endif #include #include #include -#include -#include -#include +#include #include #include @@ -50,229 +35,16 @@ namespace { return value; } - QString cleanContainerName(const QString& container) - { - if (container.contains(QLatin1Char(','))) - return container.section(QLatin1Char(','), 0, 0).trimmed(); - return container.trimmed(); - } - bool isQuickConnectPath(const QString& path) { return path.startsWith(QStringLiteral("/QuickConnect/")) || path == QStringLiteral("/Users/AuthenticateWithQuickConnect"); } - - QJsonArray itemsArrayFromDocument(const QJsonDocument& document) - { - if (document.isArray()) - return document.array(); - return document.object().value(QStringLiteral("Items")).toArray(); - } - - QString detailItemFields() - { - return QStringLiteral( - "Overview,ProductionYear,PremiereDate,EndDate,Status,DateCreated,DateLastContentAdded,ImageTags," - "BackdropImageTags," - "UserData,Path,RunTimeTicks,SeriesInfo,LocationType,IsVirtualItem,Genres,Tags,Studios,ProviderIds," - "ExternalUrls,OfficialRating,CommunityRating,CriticRating,People,PrimaryImageAspectRatio,MediaSources"); - } - - QString libraryItemFields() - { - return QStringLiteral( - "SortName,Overview,ProductionYear,PremiereDate,EndDate,Status,DateCreated,DateLastContentAdded,ImageTags," - "BackdropImageTags,UserData,RunTimeTicks,SeriesInfo,LocationType,IsVirtualItem,Genres,Tags,Studios," - "OfficialRating,CommunityRating,CriticRating,PrimaryImageAspectRatio"); - } - - QString firstString(const QJsonArray& array) - { - for (const QJsonValue& value : array) { - const QString stringValue = value.toString(); - if (!stringValue.isEmpty()) - return stringValue; - } - return {}; - } - - QString includeItemTypesForCollection(QString collectionType) - { - if (collectionType == QStringLiteral("movies")) - return QStringLiteral("Movie"); - if (collectionType == QStringLiteral("tvshows")) - return QStringLiteral("Series"); - if (collectionType == QStringLiteral("playlists")) - return QStringLiteral("Playlist"); - if (collectionType == QStringLiteral("boxsets")) - return QStringLiteral("BoxSet"); - if (collectionType == QStringLiteral("music")) - return QStringLiteral("MusicAlbum"); - if (collectionType == QStringLiteral("books")) - return QStringLiteral("Book,AudioBook"); - if (collectionType == QStringLiteral("photos")) - return QStringLiteral("PhotoAlbum,Photo"); - if (collectionType == QStringLiteral("musicvideos")) - return QStringLiteral("MusicVideo"); - if (collectionType == QStringLiteral("homevideos")) - return QStringLiteral("Folder,Video,PhotoAlbum,Photo"); - return QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,MusicAlbum," - "MusicArtist,Book,AudioBook,Photo,PhotoAlbum,Playlist,BoxSet"); - } - - QStringList itemTypesList(const QString& types) - { - return types.split(QLatin1Char(','), Qt::SkipEmptyParts); - } - - bool libraryBrowseUsesDirectChildren(const QString& collectionType) - { - return collectionType == QStringLiteral("playlists") || collectionType == QStringLiteral("boxsets"); - } - - void addJoinedQueryItem(QUrlQuery& query, const QVariantMap& options, const QString& key, const QString& queryKey, - QLatin1Char delimiter) - { - const QStringList values = stringListFromVariantMap(options, key); - if (!values.isEmpty()) - query.addQueryItem(queryKey, values.join(delimiter)); - } - - void addOptionalBoolQueryItem( - QUrlQuery& query, const QVariantMap& options, const QString& key, const QString& queryKey) - { - if (!options.contains(key)) - return; - const QVariant value = options.value(key); - if (!value.isValid() || value.isNull()) - return; - query.addQueryItem(queryKey, value.toBool() ? QStringLiteral("true") : QStringLiteral("false")); - } - - void addLibraryQueryOptions(QUrlQuery& query, const QVariantMap& options) - { - const QString sortBy = options.value(QStringLiteral("sortBy"), QStringLiteral("SortName")).toString(); - const QString sortOrder = options.value(QStringLiteral("sortOrder"), QStringLiteral("Ascending")).toString(); - query.addQueryItem(QStringLiteral("sortBy"), sortBy.isEmpty() ? QStringLiteral("SortName") : sortBy); - query.addQueryItem(QStringLiteral("sortOrder"), sortOrder.isEmpty() ? QStringLiteral("Ascending") : sortOrder); - - addJoinedQueryItem(query, options, QStringLiteral("filters"), QStringLiteral("filters"), QLatin1Char(',')); - addJoinedQueryItem(query, options, QStringLiteral("genres"), QStringLiteral("genres"), QLatin1Char('|')); - addJoinedQueryItem( - query, options, QStringLiteral("officialRatings"), QStringLiteral("officialRatings"), QLatin1Char('|')); - addJoinedQueryItem(query, options, QStringLiteral("tags"), QStringLiteral("tags"), QLatin1Char('|')); - addJoinedQueryItem(query, options, QStringLiteral("years"), QStringLiteral("years"), QLatin1Char(',')); - addJoinedQueryItem(query, options, QStringLiteral("studioIds"), QStringLiteral("studioIds"), QLatin1Char('|')); - addJoinedQueryItem( - query, options, QStringLiteral("seriesStatus"), QStringLiteral("seriesStatus"), QLatin1Char(',')); - addJoinedQueryItem( - query, options, QStringLiteral("videoTypes"), QStringLiteral("videoTypes"), QLatin1Char(',')); - - addOptionalBoolQueryItem(query, options, QStringLiteral("isHd"), QStringLiteral("isHd")); - addOptionalBoolQueryItem(query, options, QStringLiteral("is4K"), QStringLiteral("is4K")); - addOptionalBoolQueryItem(query, options, QStringLiteral("is3D"), QStringLiteral("is3D")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasSubtitles"), QStringLiteral("hasSubtitles")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasTrailer"), QStringLiteral("hasTrailer")); - addOptionalBoolQueryItem( - query, options, QStringLiteral("hasSpecialFeature"), QStringLiteral("hasSpecialFeature")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasThemeSong"), QStringLiteral("hasThemeSong")); - addOptionalBoolQueryItem(query, options, QStringLiteral("hasThemeVideo"), QStringLiteral("hasThemeVideo")); - addOptionalBoolQueryItem(query, options, QStringLiteral("isMissing"), QStringLiteral("isMissing")); - addOptionalBoolQueryItem(query, options, QStringLiteral("isUnaired"), QStringLiteral("isUnaired")); - - const QVariant specialEpisode = options.value(QStringLiteral("specialEpisode")); - if (specialEpisode.isValid() && !specialEpisode.isNull() && specialEpisode.toBool()) - query.addQueryItem(QStringLiteral("parentIndexNumber"), QStringLiteral("0")); - - const QString alphabet = options.value(QStringLiteral("alphabet")).toString(); - if (!alphabet.isEmpty()) { - if (alphabet == QStringLiteral("#")) - query.addQueryItem(QStringLiteral("nameLessThan"), QStringLiteral("A")); - else - query.addQueryItem(QStringLiteral("nameStartsWith"), alphabet); - } - } - - QList mediaStreamsFromApiJson(const QJsonArray& array) - { - QList streams; - streams.reserve(array.size()); - for (const QJsonValue& value : array) { - const QJsonObject object = value.toObject(); - MediaStreamInfo stream = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - stream.frameRate = object.value(QStringLiteral("AverageFrameRate")).toDouble(); - if (stream.frameRate <= 0.0) - stream.frameRate = object.value(QStringLiteral("RealFrameRate")).toDouble(); - if (!stream.type.isEmpty() || !stream.codec.isEmpty()) - streams.push_back(stream); - } - return streams; - } - - MediaSourceInfo mediaSourceFromApiJson(const QJsonObject& object) - { - MediaSourceInfo source = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - source.container = cleanContainerName(object.value(QStringLiteral("Container")).toString()); - source.bitRate = object.value(QStringLiteral("Bitrate")).toInt(); - if (source.bitRate <= 0) - source.bitRate = object.value(QStringLiteral("BitRate")).toInt(); - source.runtimeTicks = object.value(QStringLiteral("RunTimeTicks")).toVariant().toLongLong(); - source.streams = mediaStreamsFromApiJson(object.value(QStringLiteral("MediaStreams")).toArray()); - return source; - } - - MovieItem mediaItemFromJson(const QJsonObject& object) - { - MovieItem item = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - item.posterTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Primary")).toString(); - item.logoTag = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Logo")).toString(); - item.bannerTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Banner")).toString(); - item.thumbTag = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Thumb")).toString(); - item.backdropTag = firstString(object.value(QStringLiteral("BackdropImageTags")).toArray()); - const int indexNumber = object.value(QStringLiteral("IndexNumber")).toInt(); - item.seasonNumber = item.itemType == QStringLiteral("Episode") - ? object.value(QStringLiteral("ParentIndexNumber")).toInt() - : indexNumber; - item.episodeNumber = item.itemType == QStringLiteral("Episode") ? indexNumber : 0; - const QJsonObject userData = object.value(QStringLiteral("UserData")).toObject(); - item.resumeTicks = normalizedResumeTicks( - userData.value(QStringLiteral("PlaybackPositionTicks")).toVariant().toLongLong(), item.runtimeTicks); - item.favorite = userData.value(QStringLiteral("IsFavorite")).toBool(false); - item.played = userData.value(QStringLiteral("Played")).toBool(false); - item.datePlayed = userData.value(QStringLiteral("LastPlayedDate")).toString(); - item.playCount = userData.value(QStringLiteral("PlayCount")).toInt(); - item.studios = metaStringListFromJson(object, { QStringLiteral("Studios"), QStringLiteral("Name") }); - item.mediaSources.clear(); - const QJsonArray sourceArray = object.value(QStringLiteral("MediaSources")).toArray(); - item.mediaSources.reserve(sourceArray.size()); - for (const QJsonValue& sourceValue : sourceArray) { - MediaSourceInfo source = mediaSourceFromApiJson(sourceValue.toObject()); - if (!source.id.isEmpty() || !source.container.isEmpty() || !source.streams.isEmpty()) - item.mediaSources.push_back(source); - } - return item; - } - - std::vector mediaItemsFromJson(const QJsonArray& items, const QStringList& allowedTypes) - { - std::vector result; - result.reserve(items.size()); - for (const QJsonValue& value : items) { - const QJsonObject object = value.toObject(); - if (allowedTypes.contains(object.value(QStringLiteral("Type")).toString())) - result.push_back(mediaItemFromJson(object)); - } - return result; - } - -} +} // namespace JellyfinApiFacade::JellyfinApiFacade( QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent) - : PlaybackSource(parent) + : QObject(parent) , m_networkAccessManager(networkAccessManager) , m_rest(networkAccessManager, this) { @@ -281,22 +53,6 @@ JellyfinApiFacade::JellyfinApiFacade( m_requestFactory.setTransferTimeout(std::chrono::milliseconds(HttpRequestPolicy::transferTimeoutMs())); m_requestFactory.setAttribute( QNetworkRequest::ConnectionCacheExpiryTimeoutSecondsAttribute, kConnectionCacheExpirySeconds); - - // Moving between Wi-Fi, ethernet and cellular changes what the link can - // carry. Backends are optional: where none loads the ceiling simply keeps - // whatever the last measurement found. - if (QNetworkInformation::loadDefaultBackend()) { - if (auto *information = QNetworkInformation::instance()) { - connect(information, &QNetworkInformation::transportMediumChanged, this, - &JellyfinApiFacade::handleNetworkRouteChanged); - connect(information, &QNetworkInformation::reachabilityChanged, this, - [this](QNetworkInformation::Reachability reachability) { - if (reachability == QNetworkInformation::Reachability::Online) - handleNetworkRouteChanged(); - }); - } - } - applyCommonHeaders(); } @@ -310,17 +66,8 @@ void JellyfinApiFacade::setServerUrl(const QString& serverUrl) QString normalized = serverUrl; while (normalized.endsWith(QLatin1Char('/'))) normalized.chop(1); - const bool serverChanged = normalized != m_serverUrl; m_serverUrl = normalized; m_requestFactory.setBaseUrl(QUrl(m_serverUrl)); - if (serverChanged) { - ++m_playbackNetworkGeneration; - m_inLocalNetwork = isLanHost(QUrl(m_serverUrl).host()); - m_playbackEndpointKnown = m_inLocalNetwork; - m_measuredStreamingBitrate = 0; - setPlaybackParallelRequests(1); - updateEffectiveStreamingBitrate(); - } preconnectToServer(); } @@ -366,84 +113,30 @@ QString JellyfinApiFacade::deviceId() const void JellyfinApiFacade::setSession(const AuthSession& session) { - const bool tokenChanged = m_session.accessToken != session.accessToken; - if (tokenChanged) { - ++m_playbackNetworkGeneration; - setPlaybackParallelRequests(1); - } + const bool changed = m_session != session; m_session = session; - m_authExpirationReported = false; - if (m_session.accessToken.isEmpty()) - m_preconnectedAuthority.clear(); - if (tokenChanged) { - // The bitrate probe needs a token, so nothing can be measured before - // sign-in. Reusing what this route measured last time gives playback a - // real number at once instead of the conservative estimate, and the - // probe that follows refreshes it. - if (!m_session.accessToken.isEmpty()) - restoreRememberedMeasurement(); + if (changed) { + m_authExpirationReported = false; + applyCommonHeaders(); emit credentialsChanged(); } - preconnectToServer(); } -void JellyfinApiFacade::preconnectToServer() +AuthSession JellyfinApiFacade::session() const { - if (!m_networkAccessManager || m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - return; - - const QUrl url(m_serverUrl); - if (!url.isValid() || url.host().isEmpty() || url.scheme() != QStringLiteral("https")) - return; - - const int port = url.port(443); - const QString authority = url.host() + QLatin1Char(':') + QString::number(port); - if (m_preconnectedAuthority == authority) - return; - - m_preconnectedAuthority = authority; -#if QT_CONFIG(ssl) - m_networkAccessManager->connectToHostEncrypted( - url.host(), static_cast(port), QSslConfiguration::defaultConfiguration()); -#else - // The webOS Qt build has OpenSSL disabled; still warm DNS/TCP so the first - // authenticated HTTPS request does not pay every socket setup cost. - m_networkAccessManager->connectToHost(url.host(), static_cast(port)); -#endif + return m_session; } void JellyfinApiFacade::setPlaybackPreferences( - qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight) + qint64 /*manualMaxStreamingBitrate*/, bool /*unlimitedLocalNetwork*/, bool /*preferRemux*/, int maxStreamingHeight) { - m_manualMaxStreamingBitrate = manualMaxStreamingBitrate > 0 - ? std::clamp(manualMaxStreamingBitrate, 1'000'000, PlaybackBandwidthPolicy::MaximumBitrate) - : 0; - m_unlimitedLocalNetwork = unlimitedLocalNetwork; - m_preferRemux = preferRemux; const int normalizedHeight = maxStreamingHeight > 0 ? maxStreamingHeight : 0; if (m_maxStreamingHeight != normalizedHeight) { m_maxStreamingHeight = normalizedHeight; - // The profile is what carries the ceiling, so a changed ceiling has to - // reach anything holding one. emit deviceProfileChanged(); } - updateEffectiveStreamingBitrate(); } -void JellyfinApiFacade::setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs) -{ - for (QString& codec : videoCodecs) - codec = codec.trimmed().toLower(); - videoCodecs.removeAll(QString()); - videoCodecs.removeDuplicates(); - if (m_videoCodecs == videoCodecs && m_restrictVideoCodecs == restrictVideoCodecs) - return; - - m_videoCodecs = std::move(videoCodecs); - m_restrictVideoCodecs = restrictVideoCodecs; - qInfo() << "playback capabilities: video codecs=" << m_videoCodecs << "restricted=" << m_restrictVideoCodecs; - emit deviceProfileChanged(); -} void JellyfinApiFacade::setRemoteControlTargetEnabled(bool enabled) { if (m_remoteControlTargetEnabled == enabled) @@ -452,361 +145,27 @@ void JellyfinApiFacade::setRemoteControlTargetEnabled(bool enabled) emit deviceProfileChanged(); } -QByteArray JellyfinApiFacade::mediaRequestHeaders() const -{ - const QByteArray token = m_session.accessToken.toUtf8(); - return token.isEmpty() ? QByteArray {} : QByteArrayLiteral("X-Emby-Token: ") + token; -} - -QUrl JellyfinApiFacade::mediaOrigin() const -{ - return QUrl(serverUrl()); -} - -bool JellyfinApiFacade::signedIn() const -{ - return !m_session.accessToken.isEmpty(); -} - -QString JellyfinApiFacade::libraryScopeKey() const -{ - const QString userKey = m_session.userId.isEmpty() ? m_session.userName : m_session.userId; - const QString serverKey = m_session.serverId.isEmpty() ? serverUrl() : m_session.serverId; - return userKey.isEmpty() || serverKey.isEmpty() ? QString() : QStringLiteral("%1/%2").arg(serverKey, userKey); -} - -QString JellyfinApiFacade::imageUrl(const ArtworkSource::ImageRequest& request) const +void JellyfinApiFacade::cancelRequests() { - const QString origin = serverUrl(); - if (origin.isEmpty() || request.itemId.isEmpty() || request.tag.isEmpty() || request.imageType.isEmpty()) - return {}; - QUrl url = serverUrlWithPath( - origin, { QStringLiteral("Items"), request.itemId, QStringLiteral("Images"), request.imageType }); - QUrlQuery query; - if (request.fillWidth > 0 && request.fillHeight > 0) { - query.addQueryItem(QStringLiteral("fillWidth"), QString::number(request.fillWidth)); - query.addQueryItem(QStringLiteral("fillHeight"), QString::number(request.fillHeight)); - } else { - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(request.maxWidth)); + m_shuttingDown = true; + for (QNetworkReply *reply : m_activeReplies) { + if (reply && reply->isRunning()) + reply->abort(); } - query.addQueryItem(QStringLiteral("quality"), QString::number(request.quality)); - query.addQueryItem(QStringLiteral("format"), request.format); - query.addQueryItem(QStringLiteral("tag"), request.tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); -} - -QCoro::Task> JellyfinApiFacade::fetchSeriesEpisodes(QString seriesId) -{ - return fetchEpisodes(std::move(seriesId)); -} - -int JellyfinApiFacade::playbackParallelRequests() const -{ - return m_playbackParallelRequests; -} - -void JellyfinApiFacade::setPlaybackParallelRequests(int parallelRequests) -{ - const int normalized = std::clamp(parallelRequests, 1, 4); - if (normalized == m_playbackParallelRequests) - return; - m_playbackParallelRequests = normalized; - emit playbackNetworkProfileChanged(); -} - -qint64 JellyfinApiFacade::maxStreamingBitrate() const -{ - return m_maxStreamingBitrate; + m_activeReplies.clear(); } -qint64 JellyfinApiFacade::measuredStreamingBitrate() const -{ - return m_measuredStreamingBitrate; -} - -void JellyfinApiFacade::setSessionBitrateOverride(qint64 bitrate) -{ - const qint64 normalized = bitrate > 0 - ? std::clamp(bitrate, PlaybackBandwidthPolicy::MinimumBitrate, PlaybackBandwidthPolicy::MaximumBitrate) - : 0; - if (normalized == m_sessionBitrateOverride) - return; - m_sessionBitrateOverride = normalized; - updateEffectiveStreamingBitrate(); -} - -qint64 JellyfinApiFacade::sessionBitrateOverride() const -{ - return m_sessionBitrateOverride; -} - -void JellyfinApiFacade::setSessionHeightOverride(int height) -{ - const int normalized = height > 0 ? height : 0; - if (normalized == m_sessionHeightOverride) - return; - m_sessionHeightOverride = normalized; - emit deviceProfileChanged(); -} - -int JellyfinApiFacade::sessionHeightOverride() const -{ - return m_sessionHeightOverride; -} - -int JellyfinApiFacade::maxStreamingHeight() const -{ - // The session override wins outright rather than taking the lower of the - // two: a viewer who asks the overlay for 1080p on a device configured to - // 480p is asking for 1080p now. - return m_sessionHeightOverride > 0 ? m_sessionHeightOverride : m_maxStreamingHeight; -} - -PlaybackBandwidthPolicy::Source JellyfinApiFacade::streamingBitrateSource() const -{ - if (m_sessionBitrateOverride > 0) - return PlaybackBandwidthPolicy::Source::Manual; - const PlaybackBandwidthPolicy::Source source - = PlaybackBandwidthPolicy::effectiveBitrateSource(m_manualMaxStreamingBitrate, m_unlimitedLocalNetwork, - m_playbackEndpointKnown, m_inLocalNetwork, m_measuredStreamingBitrate); - // The policy cannot tell a measurement taken now from one restored for - // this route, so name the difference here. - if (source == PlaybackBandwidthPolicy::Source::Measured && m_measurementRemembered) - return PlaybackBandwidthPolicy::Source::Remembered; - return source; -} - -void JellyfinApiFacade::setPlaybackActive(bool active) +void JellyfinApiFacade::preconnectToServer() { - if (m_playbackActive == active) - return; - m_playbackActive = active; - if (active || !m_measurementDeferred) + if (!m_networkAccessManager || m_serverUrl.isEmpty()) return; - m_measurementDeferred = false; - Async::runScoped( - this, refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: deferred measurement failed" << exceptionMessage(error); - }); -} - -QString JellyfinApiFacade::currentNetworkSignature() const -{ - QStringList addresses; - const QList local = QNetworkInterface::allAddresses(); - addresses.reserve(local.size()); - for (const QHostAddress& address : local) - addresses.push_back(address.toString()); - - QString transport; - if (auto *information = QNetworkInformation::instance()) - transport = QVariant::fromValue(information->transportMedium()).toString(); - const QUrl url(m_serverUrl); - const QString authority = url.host().toLower() + QLatin1Char(':') + QString::number(url.port(443)); - return PlaybackBandwidthPolicy::networkSignature(authority, addresses, transport); -} - -void JellyfinApiFacade::restoreRememberedMeasurement() -{ - if (m_serverUrl.isEmpty()) - return; - m_measuredNetworkSignature = currentNetworkSignature(); - m_measuredStreamingBitrate = 0; - m_measurementRemembered = false; - - QSettings settings; - settings.beginGroup(QStringLiteral("playback/bandwidth/") + m_measuredNetworkSignature); - const qint64 bitrate = settings.value(QStringLiteral("bitrate")).toLongLong(); - const qint64 recordedAt = settings.value(QStringLiteral("recordedAt")).toLongLong(); - const int parallelRequests = settings.value(QStringLiteral("parallelRequests"), 1).toInt(); - settings.endGroup(); - - if (bitrate <= 0 - || !PlaybackBandwidthPolicy::isRememberedMeasurementUsable(recordedAt, QDateTime::currentMSecsSinceEpoch())) { - updateEffectiveStreamingBitrate(); - return; - } - - m_measuredStreamingBitrate = bitrate; - m_measurementRemembered = true; - setPlaybackParallelRequests(parallelRequests); - qInfo() << "playback bandwidth: restored" << bitrate << "for route" << m_measuredNetworkSignature; - updateEffectiveStreamingBitrate(); -} - -void JellyfinApiFacade::rememberMeasurement() -{ - if (m_measuredStreamingBitrate <= 0 || m_measuredNetworkSignature.isEmpty()) + const QString authority = url.authority(); + if (authority.isEmpty() || authority == m_preconnectedAuthority) return; - QSettings settings; - settings.beginGroup(QStringLiteral("playback/bandwidth/") + m_measuredNetworkSignature); - settings.setValue(QStringLiteral("bitrate"), m_measuredStreamingBitrate); - settings.setValue(QStringLiteral("parallelRequests"), m_playbackParallelRequests); - settings.setValue(QStringLiteral("recordedAt"), QDateTime::currentMSecsSinceEpoch()); - settings.endGroup(); -} - -void JellyfinApiFacade::handleNetworkRouteChanged() -{ - if (m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - return; - if (currentNetworkSignature() == m_measuredNetworkSignature) - return; - - // A different route invalidates the measurement outright rather than - // carrying a home Wi-Fi ceiling onto a phone hotspot. Reload whatever is - // remembered for the new route so playback has a number immediately, then - // re-measure unless a stream is already running. - ++m_playbackNetworkGeneration; - m_playbackEndpointKnown = false; - m_inLocalNetwork = false; - restoreRememberedMeasurement(); - Async::runScoped( - this, refreshPlaybackNetworkState(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "playback bandwidth: re-measurement failed" << exceptionMessage(error); - }); -} - -void JellyfinApiFacade::updateEffectiveStreamingBitrate() -{ - // A ceiling picked in the player is a direct instruction, so it bypasses - // every automatic input rather than competing with them. - const qint64 effective = m_sessionBitrateOverride > 0 - ? m_sessionBitrateOverride - : PlaybackBandwidthPolicy::effectiveBitrate(m_manualMaxStreamingBitrate, m_unlimitedLocalNetwork, - m_playbackEndpointKnown, m_inLocalNetwork, m_measuredStreamingBitrate); - const PlaybackBandwidthPolicy::Source source = streamingBitrateSource(); - // The description can change while the number does not: a remembered - // ceiling confirmed by a fresh probe is the same ceiling, differently - // earned, and the player says so. - const bool descriptionChanged = source != m_streamingBitrateSource; - m_streamingBitrateSource = source; - if (effective == m_maxStreamingBitrate) { - if (descriptionChanged) - emit streamingBitrateChanged(); - return; - } - m_maxStreamingBitrate = effective; - qInfo() << "playback bandwidth: effective=" << m_maxStreamingBitrate << "manual=" << m_manualMaxStreamingBitrate - << "measured=" << m_measuredStreamingBitrate << "remembered=" << m_measurementRemembered - << "endpointKnown=" << m_playbackEndpointKnown << "local=" << m_inLocalNetwork - << "unlimitedLocal=" << m_unlimitedLocalNetwork; - emit deviceProfileChanged(); - emit streamingBitrateChanged(); -} - -QCoro::Task JellyfinApiFacade::measurePlaybackBitrate(int totalSampleBytes, int parallelRequests) -{ - parallelRequests = std::clamp(parallelRequests, 1, 4); - const int bytesPerRequest = std::max(1, totalSampleBytes / parallelRequests); - const qint64 cacheBuster = QDateTime::currentMSecsSinceEpoch(); - QElapsedTimer timer; - timer.start(); - - // QCoro::Task starts eagerly. Construct all requests before awaiting any - // result so this measures aggregate throughput at the requested level of - // concurrency, while keeping the total transferred bytes constant. - std::vector> tasks; - tasks.reserve(static_cast(parallelRequests)); - for (int lane = 0; lane < parallelRequests; ++lane) { - QUrlQuery query; - query.addQueryItem(QStringLiteral("size"), QString::number(bytesPerRequest)); - query.addQueryItem(QStringLiteral("_"), QStringLiteral("%1-%2").arg(cacheBuster).arg(lane)); - tasks.push_back(requestBytes(HttpMethod::Get, QStringLiteral("/Playback/BitrateTest"), query)); - } - - qint64 transferredBytes = 0; - for (auto& task : tasks) - transferredBytes += (co_await task).size(); - co_return PlaybackBandwidthPolicy::conservativeEstimate(transferredBytes, std::max(1, timer.elapsed())); -} - -QCoro::Task JellyfinApiFacade::measurePlaybackRoundTripTime() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("_"), QString::number(QDateTime::currentMSecsSinceEpoch())); - QElapsedTimer timer; - timer.start(); - co_await requestBytes(HttpMethod::Get, QStringLiteral("/System/Endpoint"), query); - co_return std::max(1, timer.elapsed()); -} - -QCoro::Task JellyfinApiFacade::refreshPlaybackNetworkState() -{ - if (m_serverUrl.isEmpty() || m_session.accessToken.isEmpty()) - co_return; - if (m_playbackActive) { - // Several megabytes of probe traffic would compete with the stream it - // is meant to size. Whatever ceiling is already known carries this - // playback; measure once the stream ends. - m_measurementDeferred = true; - co_return; - } - - const quint64 generation = ++m_playbackNetworkGeneration; - const QJsonObject endpoint = (co_await requestJson(HttpMethod::Get, QStringLiteral("/System/Endpoint"))).object(); - if (generation != m_playbackNetworkGeneration) - co_return; - - m_playbackEndpointKnown = true; - m_inLocalNetwork - = endpoint.value(QStringLiteral("IsLocal")).toBool() || endpoint.value(QStringLiteral("IsInNetwork")).toBool(); - updateEffectiveStreamingBitrate(); - - // Warm the authenticated server route and exclude DNS/TLS/request setup - // from the samples used to select a streaming ceiling. - co_await measurePlaybackBitrate(512 * 1024, 1); - if (generation != m_playbackNetworkGeneration) - co_return; - const qint64 roundTripMilliseconds = co_await measurePlaybackRoundTripTime(); - if (generation != m_playbackNetworkGeneration) - co_return; - constexpr int benchmarkBytes = 4 * 1024 * 1024; - const qint64 singleRequest = co_await measurePlaybackBitrate(benchmarkBytes, 1); - if (generation != m_playbackNetworkGeneration) - co_return; - const qint64 dualRequest = co_await measurePlaybackBitrate(benchmarkBytes, 2); - if (generation != m_playbackNetworkGeneration) - co_return; - - qint64 fourRequest = 0; - if (PlaybackBandwidthPolicy::shouldBenchmarkFourRequests(roundTripMilliseconds, singleRequest, dualRequest)) { - fourRequest = co_await measurePlaybackBitrate(benchmarkBytes, 4); - if (generation != m_playbackNetworkGeneration) - co_return; - } - - m_measuredStreamingBitrate = std::max({ singleRequest, dualRequest, fourRequest }); - m_measurementRemembered = false; - m_measuredNetworkSignature = currentNetworkSignature(); - setPlaybackParallelRequests( - PlaybackBandwidthPolicy::selectParallelRequests(singleRequest, dualRequest, fourRequest)); - qInfo() << "playback bandwidth: benchmark rtt_ms=" << roundTripMilliseconds << "one=" << singleRequest - << "two=" << dualRequest << "four=" << fourRequest << "selectedRequests=" << m_playbackParallelRequests - << "selectedBitrate=" << m_measuredStreamingBitrate; - rememberMeasurement(); - updateEffectiveStreamingBitrate(); -} - -AuthSession JellyfinApiFacade::session() const -{ - return m_session; -} - -void JellyfinApiFacade::cancelRequests() -{ - if (m_shuttingDown) - return; - m_shuttingDown = true; - const auto replies = m_activeReplies; - for (QNetworkReply *reply : replies) { - if (reply) - reply->abort(); - } + m_preconnectedAuthority = authority; + const int port = url.port(url.scheme() == QStringLiteral("https") ? 443 : 80); + m_networkAccessManager->connectToHost(url.host(), static_cast(port)); } QCoro::Task JellyfinApiFacade::authenticateByName(QString username, QString password) @@ -916,543 +275,43 @@ QCoro::Task JellyfinApiFacade::fetchCultures() co_return response.isArray() ? response.array() : response.object().value(QStringLiteral("Items")).toArray(); } -QCoro::Task> JellyfinApiFacade::fetchLibraries() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("includeHidden"), QStringLiteral("false")); - query.addQueryItem(QStringLiteral("presetViews"), QStringLiteral("true")); - query.addQueryItem(QStringLiteral("enableImageTypes"), QStringLiteral("Primary")); - query.addQueryItem(QStringLiteral("imageTypeLimit"), QStringLiteral("1")); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/UserViews"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - - std::vector libraries; - libraries.reserve(items.size()); - for (const QJsonValue& value : items) { - const QJsonObject object = value.toObject(); - LibraryItem library = metaFromJson(object, MetaJsonKeyPolicy::PascalCase); - library.imageTag - = object.value(QStringLiteral("ImageTags")).toObject().value(QStringLiteral("Primary")).toString(); - libraries.push_back(std::move(library)); - } - - co_return libraries; -} - -QCoro::Task JellyfinApiFacade::fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) -{ - if (!descriptor.isValid()) - co_return PagedMovieItems { {}, 0, std::max(0, startIndex), std::clamp(limit, 1, 200) }; - - QString path = QStringLiteral("/Items"); - QStringList allowedTypes; - const int maximumLimit = descriptor.kind == BrowseKind::Library ? 100 : 200; - // Stream metadata feeds the browse info line; person credits page through - // entire filmographies, so keep that path on the lean field set. - const QString fields = descriptor.kind == BrowseKind::Person - ? libraryItemFields() - : libraryItemFields() + QStringLiteral(",MediaSources"); - ItemsQuery builder = ItemsQuery() - .userId(m_session.userId) - .fields(fields) - .images() - .startIndex(startIndex) - .limit(limit, maximumLimit); - - switch (descriptor.kind) { - case BrowseKind::Library: { - QString includeItemTypes = includeItemTypesForCollection(descriptor.collectionType); - const QString requestedTypes - = stringListFromVariantMap(queryOptions, QStringLiteral("includeItemTypes")).join(QLatin1Char(',')); - const bool collectionFilter - = descriptor.collectionType == QStringLiteral("movies") && requestedTypes == QStringLiteral("BoxSet"); - if (collectionFilter) - includeItemTypes = requestedTypes; - builder.parentId(descriptor.id) - .recursive(!collectionFilter && !libraryBrowseUsesDirectChildren(descriptor.collectionType)) - .includeItemTypes(includeItemTypes); - allowedTypes = itemTypesList(includeItemTypes); - break; - } - case BrowseKind::FolderChildren: - builder.parentId(descriptor.id) - .recursive(false) - .includeItemTypes( - QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,AudioBook,Book,Photo," - "PhotoAlbum,Playlist,BoxSet,MusicAlbum,MusicArtist")); - allowedTypes = itemTypesList( - QStringLiteral("Folder,Movie,Series,Season,Episode,MusicVideo,Video,Audio,AudioBook,Book,Photo," - "PhotoAlbum,Playlist,BoxSet,MusicAlbum,MusicArtist")); - break; - case BrowseKind::Person: - builder.recursive() - .add(QStringLiteral("personIds"), descriptor.id) - .includeItemTypes(QStringLiteral("Movie,Series,Episode")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series,Episode")); - break; - case BrowseKind::Genre: { - const bool music = descriptor.collectionType == QStringLiteral("music"); - const QString types = music ? QStringLiteral("MusicAlbum") : QStringLiteral("Movie,Series"); - builder.recursive() - .add(QStringLiteral("genres"), descriptor.name) - .includeItemTypes(types) - .sort(QStringLiteral("SortName")); - if (!music) - builder.mediaTypes(QStringLiteral("Video")); - allowedTypes = itemTypesList(types); - break; - } - case BrowseKind::Studio: - builder.recursive() - .add(QStringLiteral("studios"), descriptor.name) - .includeItemTypes(QStringLiteral("Movie,Series")) - .mediaTypes(QStringLiteral("Video")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series")); - break; - case BrowseKind::SeriesSeasons: - path = QStringLiteral("/Shows/%1/Seasons") - .arg(descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId); - allowedTypes = itemTypesList(QStringLiteral("Season")); - break; - case BrowseKind::SeasonEpisodes: - path = QStringLiteral("/Shows/%1/Episodes").arg(descriptor.seriesId); - builder.addIfNotEmpty(QStringLiteral("seasonId"), descriptor.seasonId); - allowedTypes = itemTypesList(QStringLiteral("Episode")); - break; - case BrowseKind::Playlist: - path = QStringLiteral("/Playlists/%1/Items").arg(descriptor.id); - allowedTypes = itemTypesList(QStringLiteral("Movie,Episode,MusicVideo,Video,Audio")); - break; - case BrowseKind::BoxSet: - builder.parentId(descriptor.id) - .recursive(false) - .includeItemTypes(QStringLiteral("Movie,Series,Episode")) - .mediaTypes(QStringLiteral("Video")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("Movie,Series,Episode")); - break; - case BrowseKind::ArtistAlbums: - builder.recursive() - .add(QStringLiteral("artistIds"), descriptor.id) - .includeItemTypes(QStringLiteral("MusicAlbum")) - .sort(QStringLiteral("SortName")); - allowedTypes = itemTypesList(QStringLiteral("MusicAlbum")); - break; - case BrowseKind::None: - break; - } - - QUrlQuery query = builder.toUrlQuery(); - if (descriptor.kind == BrowseKind::Library) - addLibraryQueryOptions(query, queryOptions); - - const QString pageName = descriptor.name.isEmpty() ? descriptor.id : descriptor.name; - QElapsedTimer requestTimer; - requestTimer.start(); - QJsonObject response; - try { - response = (co_await requestJson(HttpMethod::Get, path, query)).object(); - } catch (...) { - qWarning() << "browse page: request failed" << pageName << "start=" << std::max(0, startIndex) - << "limit=" << std::clamp(limit, 1, maximumLimit) << "ms=" << requestTimer.elapsed(); - throw; - } - - std::vector items = mediaItemsFromJson(response.value(QStringLiteral("Items")).toArray(), allowedTypes); - if (descriptor.kind == BrowseKind::SeriesSeasons) { - const QString seriesId = descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId; - for (MovieItem& item : items) { - if (item.seriesId.isEmpty()) - item.seriesId = seriesId; - } - } - - const int effectiveStart = std::max(0, startIndex); - const int effectiveLimit = std::clamp(limit, 1, maximumLimit); - const int totalCount = response.value(QStringLiteral("TotalRecordCount")).toInt(0); - qInfo() << "browse page: request complete" << pageName << "start=" << effectiveStart << "limit=" << effectiveLimit - << "items=" << items.size() << "total=" << totalCount << "ms=" << requestTimer.elapsed(); - co_return PagedMovieItems { std::move(items), totalCount, effectiveStart, effectiveLimit }; -} - -QCoro::Task JellyfinApiFacade::fetchLibraryFilterOptions(QString libraryId, QString collectionType) -{ - QVariantMap options; - if (libraryId.isEmpty()) - co_return options; - - const QString includeItemTypes = includeItemTypesForCollection(collectionType); - - QUrlQuery filterQuery - = ItemsQuery().userId(m_session.userId).parentId(libraryId).includeItemTypes(includeItemTypes).toUrlQuery(); - - const QJsonObject filters - = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/Filters"), filterQuery)).object(); - options.insert(QStringLiteral("genres"), metaStringListFromJson(filters, { QStringLiteral("Genres") })); - options.insert( - QStringLiteral("officialRatings"), metaStringListFromJson(filters, { QStringLiteral("OfficialRatings") })); - options.insert(QStringLiteral("tags"), metaStringListFromJson(filters, { QStringLiteral("Tags") })); - - QVariantList years; - const QJsonArray yearsArray = filters.value(QStringLiteral("Years")).toArray(); - years.reserve(yearsArray.size()); - for (const QJsonValue& value : yearsArray) { - const int year = value.toInt(); - if (year > 0) - years.push_back(year); - } - options.insert(QStringLiteral("years"), years); - - QUrlQuery studiosQuery = ItemsQuery() - .userId(m_session.userId) - .parentId(libraryId) - .includeItemTypes(includeItemTypes) - .sort(QStringLiteral("SortName")) - .toUrlQuery(); - - const QJsonArray studioItems = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Studios"), studiosQuery)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - QVariantList studios; - studios.reserve(studioItems.size()); - for (const QJsonValue& value : studioItems) { - const QJsonObject studio = value.toObject(); - const QString id = studio.value(QStringLiteral("Id")).toString(); - const QString name = studio.value(QStringLiteral("Name")).toString(); - if (!id.isEmpty() && !name.isEmpty()) - studios.push_back(QVariantMap { { QStringLiteral("id"), id }, { QStringLiteral("name"), name } }); - } - options.insert(QStringLiteral("studios"), studios); - - co_return options; -} - -QCoro::Task JellyfinApiFacade::fetchItemDetails(QString itemId) -{ - if (itemId.isEmpty() || m_session.userId.isEmpty()) - co_return MovieItem {}; - - QUrlQuery query = ItemsQuery().fields(detailItemFields()).images().toUrlQuery(); - - const QJsonObject object = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, itemId), query)) - .object(); - co_return mediaItemFromJson(object); -} - -QCoro::Task> JellyfinApiFacade::fetchItemsByIds(QStringList itemIds) -{ - itemIds.removeAll(QString()); - itemIds.removeDuplicates(); - if (itemIds.isEmpty() || m_session.userId.isEmpty()) - co_return std::vector {}; - - std::vector result; - result.reserve(static_cast(itemIds.size())); - - // SyncPlay queues commonly contain an entire library. Putting every UUID - // into one query string exceeds Jellyfin/reverse-proxy request-line limits - // (the TV observed HTTP 414 with a 262-item queue). - constexpr qsizetype kItemsPerRequest = 50; - for (qsizetype offset = 0; offset < itemIds.size(); offset += kItemsPerRequest) { - const QStringList batch = itemIds.sliced(offset, std::min(kItemsPerRequest, itemIds.size() - offset)); - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .fields(detailItemFields()) - .images() - .add(QStringLiteral("ids"), batch.join(QLatin1Char(','))) - .toUrlQuery(); - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items").arg(m_session.userId), query)); - for (const QJsonValue& value : items) - result.push_back(mediaItemFromJson(value.toObject())); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSeasons(QString seriesId) -{ - const PagedMovieItems page = co_await fetchBrowsePage(BrowseDescriptor::seriesSeasons(seriesId), 0, 200); - co_return page.items; -} - -QCoro::Task> JellyfinApiFacade::fetchEpisodes(QString seriesId, QString seasonId) -{ - constexpr int pageSize = 200; - const BrowseDescriptor descriptor = BrowseDescriptor::seasonEpisodes(seriesId, seasonId); - std::vector episodes; - int startIndex = 0; - while (true) { - PagedMovieItems page = co_await fetchBrowsePage(descriptor, startIndex, pageSize); - if (page.items.empty()) - break; - startIndex += static_cast(page.items.size()); - episodes.insert( - episodes.end(), std::make_move_iterator(page.items.begin()), std::make_move_iterator(page.items.end())); - if (startIndex >= page.totalRecordCount || static_cast(page.items.size()) < pageSize) - break; - } - co_return episodes; -} - -QCoro::Task> JellyfinApiFacade::fetchResumeItems(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .limit(limit, 60) - .recursive() - .fields(libraryItemFields()) - .includeItemTypes(QStringLiteral("Movie,Episode")) - .images() - .mediaTypes(QStringLiteral("Video")) - .toUrlQuery(); - - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items/Resume").arg(m_session.userId), query)); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) { - auto item = mediaItemFromJson(value.toObject()); - if (isPlayableItem(item) && item.resumeTicks > 0) - result.push_back(item); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchNextUpEpisodes(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .limit(limit, 60) - .fields(libraryItemFields()) - .images() - .add(QStringLiteral("enableResumable"), QStringLiteral("false")) - .toUrlQuery(); - - const QJsonArray items - = itemsArrayFromDocument(co_await requestJson(HttpMethod::Get, QStringLiteral("/Shows/NextUp"), query)); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) { - auto item = mediaItemFromJson(value.toObject()); - if (item.itemType == QStringLiteral("Episode") && isPlayableItem(item)) - result.push_back(item); - } - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchLatestItems(QString parentId, int limit) -{ - QUrlQuery query = ItemsQuery() - .limit(limit, 200) - .fields(libraryItemFields()) - .images() - .add(QStringLiteral("groupItems"), QStringLiteral("false")) - .parentId(parentId) - .toUrlQuery(); - - const QJsonDocument doc - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items/Latest").arg(m_session.userId), query); - - const QJsonArray items = itemsArrayFromDocument(doc); - - std::vector result; - result.reserve(items.size()); - for (const auto& value : items) - result.push_back(mediaItemFromJson(value.toObject())); - co_return result; -} - -QCoro::Task> JellyfinApiFacade::searchItems(QString searchTerm, int limit) -{ - searchTerm = searchTerm.trimmed(); - if (searchTerm.isEmpty()) - co_return std::vector {}; - - const QStringList searchableTypes { - QStringLiteral("Movie"), - QStringLiteral("Series"), - QStringLiteral("Season"), - QStringLiteral("Episode"), - QStringLiteral("MusicVideo"), - QStringLiteral("Video"), - QStringLiteral("Audio"), - QStringLiteral("MusicAlbum"), - QStringLiteral("MusicArtist"), - QStringLiteral("Book"), - QStringLiteral("AudioBook"), - QStringLiteral("Photo"), - QStringLiteral("PhotoAlbum"), - QStringLiteral("Playlist"), - QStringLiteral("BoxSet"), - QStringLiteral("Folder"), - QStringLiteral("Person"), - QStringLiteral("Trailer"), - }; - const auto fetchTypes = [this, &searchTerm](const QString& types, int requestLimit, - const QStringList& allowedTypes) -> QCoro::Task> { - const QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .add(QStringLiteral("searchTerm"), searchTerm) - .includeItemTypes(types) - .fields(libraryItemFields()) - .sort(QStringLiteral("SortName")) - .images() - .limit(requestLimit, 200) - .toUrlQuery(); - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - co_return mediaItemsFromJson(items, allowedTypes); - }; - - std::vector result = co_await fetchTypes(searchableTypes.join(QLatin1Char(',')), limit, searchableTypes); - - // Large episode libraries can consume a mixed result limit before any - // matching series containers arrive. A small dedicated series query keeps - // show search reliable without knowing anything about the server's library - // names or layout. - const std::vector series - = co_await fetchTypes(QStringLiteral("Series"), std::min(limit, 40), { QStringLiteral("Series") }); - QSet seen; - for (const MovieItem& item : result) - seen.insert(item.id); - for (const MovieItem& item : series) { - if (!seen.contains(item.id)) { - seen.insert(item.id); - result.push_back(item); - } - } - - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSearchSuggestions(int limit) -{ - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .includeItemTypes(QStringLiteral("Movie,Series")) - .mediaTypes(QStringLiteral("Video")) - .fields(libraryItemFields()) - .sort(QStringLiteral("IsFavoriteOrLiked,Random"), {}) - .images() - .enableTotalRecordCount(false) - .limit(limit, 60) - .toUrlQuery(); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - - const std::vector result - = mediaItemsFromJson(items, { QStringLiteral("Movie"), QStringLiteral("Series") }); - co_return result; -} - -QCoro::Task> JellyfinApiFacade::fetchSimilarItems(QString itemId, int limit) -{ - if (itemId.isEmpty()) - co_return std::vector {}; - - QUrlQuery query - = ItemsQuery().userId(m_session.userId).limit(limit, 60).fields(libraryItemFields()).images().toUrlQuery(); - - const QJsonArray items = itemsArrayFromDocument( - co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/%1/Similar").arg(itemId), query)); - - const std::vector result - = mediaItemsFromJson(items, { QStringLiteral("Movie"), QStringLiteral("Series"), QStringLiteral("Episode") }); - co_return result; -} - -QCoro::Task JellyfinApiFacade::fetchItemsByPerson(QString personId, int maximumItems) -{ - PersonCredits credits; - if (personId.isEmpty()) - co_return credits; - - const int boundedMaximum = std::clamp(maximumItems, 1, 10000); - int startIndex = 0; - while (startIndex < boundedMaximum) { - const int pageLimit = std::min(200, boundedMaximum - startIndex); - PagedMovieItems page = co_await fetchBrowsePage(BrowseDescriptor::person(personId), startIndex, pageLimit); - if (page.items.empty()) - break; - startIndex += static_cast(page.items.size()); - credits.items.insert(credits.items.end(), std::make_move_iterator(page.items.begin()), - std::make_move_iterator(page.items.end())); - if ((page.totalRecordCount > 0 && startIndex >= page.totalRecordCount) - || static_cast(page.items.size()) < pageLimit) - break; - } - - QSet seriesIds; - for (const MovieItem& item : credits.items) { - if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) - seriesIds.insert(item.seriesId); - } - - const QStringList ids = seriesIds.values(); - for (qsizetype offset = 0; offset < ids.size(); offset += 200) { - const QStringList batch = ids.sliced(offset, std::min(200, ids.size() - offset)); - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .includeItemTypes(QStringLiteral("Series")) - .fields(libraryItemFields() + QStringLiteral(",RecursiveItemCount")) - .images() - .limit(batch.size(), 200) - .add(QStringLiteral("ids"), batch.join(QLatin1Char(','))) - .toUrlQuery(); - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - std::vector series = mediaItemsFromJson(items, { QStringLiteral("Series") }); - credits.relatedSeries.insert(credits.relatedSeries.end(), std::make_move_iterator(series.begin()), - std::make_move_iterator(series.end())); - } - - co_return credits; -} - QCoro::Task> JellyfinApiFacade::fetchManagementTargets(QString itemType) { - if (m_session.userId.isEmpty()) + if (m_serverUrl.isEmpty() || m_session.userId.isEmpty()) co_return std::vector {}; itemType = itemType.trimmed(); if (itemType != QStringLiteral("Playlist") && itemType != QStringLiteral("BoxSet")) co_return std::vector {}; - QUrlQuery query = ItemsQuery() - .userId(m_session.userId) - .recursive() - .includeItemTypes(itemType) - .fields(libraryItemFields()) - .sort(QStringLiteral("SortName")) - .images() - .limit(200, 200) - .toUrlQuery(); + QUrlQuery query; + query.addQueryItem(QStringLiteral("userId"), m_session.userId); + query.addQueryItem(QStringLiteral("recursive"), QStringLiteral("true")); + query.addQueryItem(QStringLiteral("includeItemTypes"), itemType); + query.addQueryItem(QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,CanDelete")); + query.addQueryItem(QStringLiteral("sortBy"), QStringLiteral("SortName")); + query.addQueryItem(QStringLiteral("sortOrder"), QStringLiteral("Ascending")); + query.addQueryItem(QStringLiteral("enableImageTypes"), QStringLiteral("Primary,Backdrop,Banner,Thumb")); + query.addQueryItem(QStringLiteral("imageTypeLimit"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("limit"), QStringLiteral("200")); const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) .object() .value(QStringLiteral("Items")) .toArray(); - co_return mediaItemsFromJson(items, { itemType }); + std::vector result; + result.reserve(items.size()); + for (const QJsonValue& val : items) { + const QJsonObject obj = val.toObject(); + if (obj.value(QStringLiteral("Type")).toString() == itemType) { + MovieItem item; + item.id = obj.value(QStringLiteral("Id")).toString(); + item.title = obj.value(QStringLiteral("Name")).toString(); + item.itemType = itemType; + result.push_back(std::move(item)); + } + } + co_return result; } QCoro::Task JellyfinApiFacade::createPlaylist(QString name, QStringList itemIds) @@ -1577,64 +436,30 @@ QCoro::Task JellyfinApiFacade::deleteItem(QString itemId) co_await requestNoContent(HttpMethod::Delete, QStringLiteral("/Items/%1").arg(itemId), QJsonDocument()); } -QCoro::Task JellyfinApiFacade::setItemFavorite(QString itemId, bool favorite) -{ - if (itemId.isEmpty()) - co_return; - - const QString path = QStringLiteral("/Users/%1/FavoriteItems/%2").arg(m_session.userId, itemId); - co_await requestNoContent(favorite ? HttpMethod::Post : HttpMethod::Delete, path, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::setItemPlayed(QString itemId, bool played) -{ - if (itemId.isEmpty()) - co_return; - - const QString path = QStringLiteral("/Users/%1/PlayedItems/%2").arg(m_session.userId, itemId); - co_await requestNoContent(played ? HttpMethod::Post : HttpMethod::Delete, path, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::setItemPlaybackPosition(QString itemId, qint64 positionTicks) +QCoro::Task> JellyfinApiFacade::fetchItemsByIds(QStringList itemIds) { - if (itemId.isEmpty()) - co_return; + itemIds.removeAll(QString()); + if (itemIds.isEmpty() || m_session.userId.isEmpty()) + co_return std::vector {}; QUrlQuery query; + query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); query.addQueryItem(QStringLiteral("userId"), m_session.userId); - const QJsonObject body { - { QStringLiteral("ItemId"), itemId }, - { QStringLiteral("PlaybackPositionTicks"), std::max(0, positionTicks) }, - { QStringLiteral("Played"), false }, - }; - co_await requestJson( - HttpMethod::Post, QStringLiteral("/UserItems/%1/UserData").arg(itemId), query, QJsonDocument(body)); -} - -QCoro::Task> JellyfinApiFacade::fetchMediaSegments(QString itemId) -{ - Diagnostics::Task task(QStringLiteral("api_fetch_media_segments"), { { QStringLiteral("itemId"), itemId } }); - std::vector result; - try { - const QJsonDocument doc - = co_await requestJson(HttpMethod::Get, QStringLiteral("/MediaSegments/%1").arg(itemId)); - const QJsonArray items = itemsArrayFromDocument(doc); - result.reserve(items.size()); - for (const auto& value : items) { - const QJsonObject object = value.toObject(); - MediaSegment segment; - segment.id = object.value(QStringLiteral("Id")).toString(); - segment.type = object.value(QStringLiteral("Type")).toString(); - // Jellyfin returns ticks as a JSON number; toVariant().toLongLong() handles ints and doubles. - segment.startTicks = object.value(QStringLiteral("StartTicks")).toVariant().toLongLong(); - segment.endTicks = object.value(QStringLiteral("EndTicks")).toVariant().toLongLong(); - if (segment.endTicks > segment.startTicks) - result.push_back(segment); - } - } catch (const std::exception& e) { - // Servers without the media-segments endpoint return 404. Treat any - // failure as "no segments" — playback should keep working. - qInfo() << "api: media segments unavailable for" << itemId << ":" << e.what(); + query.addQueryItem(QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,CanDelete,RunTimeTicks")); + const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) + .object() + .value(QStringLiteral("Items")) + .toArray(); + std::vector result; + result.reserve(items.size()); + for (const QJsonValue& val : items) { + const QJsonObject obj = val.toObject(); + MovieItem item; + item.id = obj.value(QStringLiteral("Id")).toString(); + item.title = obj.value(QStringLiteral("Name")).toString(); + item.itemType = obj.value(QStringLiteral("Type")).toString(); + item.runtimeTicks = obj.value(QStringLiteral("RunTimeTicks")).toVariant().toLongLong(); + result.push_back(std::move(item)); } co_return result; } @@ -1644,7 +469,8 @@ QCoro::Task JellyfinApiFacade::fetchTrickplayInfo(QString itemId, if (itemId.isEmpty() || m_session.userId.isEmpty()) co_return TrickplayInfo {}; - const QUrlQuery query = ItemsQuery().fields(QStringLiteral("Trickplay")).toUrlQuery(); + QUrlQuery query; + query.addQueryItem(QStringLiteral("fields"), QStringLiteral("Trickplay")); const QJsonObject item = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, itemId), query)) .object(); @@ -1663,6 +489,7 @@ QString JellyfinApiFacade::trickplayTileUrl(const QString& itemId, int width, in return url.toString(QUrl::FullyEncoded); } + QCoro::Task JellyfinApiFacade::fetchControllableSessions() { QUrlQuery query; @@ -1701,48 +528,38 @@ QCoro::Task JellyfinApiFacade::sendRemotePlay(QString sessionId, QStringLi QCoro::Task JellyfinApiFacade::sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks) { sessionId = sessionId.trimmed(); - command = command.trimmed(); if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote playstate command needs a target and command"); + throw std::runtime_error("Remote playstate needs a target and a command"); QUrlQuery query; if (seekPositionTicks >= 0) query.addQueryItem(QStringLiteral("seekPositionTicks"), QString::number(seekPositionTicks)); - if (!m_session.userId.isEmpty()) - query.addQueryItem(QStringLiteral("controllingUserId"), m_session.userId); + const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - const QString encodedCommand = QString::fromLatin1(QUrl::toPercentEncoding(command)); - co_await requestBytes(HttpMethod::Post, - QStringLiteral("/Sessions/%1/Playing/%2").arg(encodedSession, encodedCommand), query, QJsonDocument()); + co_await requestBytes(HttpMethod::Post, QStringLiteral("/Sessions/%1/Playing/%2").arg(encodedSession, command), + query, QJsonDocument()); } QCoro::Task JellyfinApiFacade::sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments) { sessionId = sessionId.trimmed(); - command = command.trimmed(); if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote general command needs a target and command"); + throw std::runtime_error("Remote command needs a target and a command name"); + + const QJsonObject body = { + { QStringLiteral("Name"), command }, + { QStringLiteral("Arguments"), arguments }, + }; - QJsonObject body { { QStringLiteral("Name"), command } }; - if (!arguments.isEmpty()) - body.insert(QStringLiteral("Arguments"), arguments); const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestNoContent( - HttpMethod::Post, QStringLiteral("/Sessions/%1/Command").arg(encodedSession), QJsonDocument(body)); + co_await requestBytes( + HttpMethod::Post, QStringLiteral("/Sessions/%1/Command").arg(encodedSession), {}, QJsonDocument(body)); } QCoro::Task JellyfinApiFacade::fetchSyncPlayGroups() { - Diagnostics::Task task(QStringLiteral("api_syncplay_list")); - try { - const QJsonDocument doc = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/List")); - if (doc.isArray()) - co_return doc.array(); - co_return doc.object().value(QStringLiteral("Items")).toArray(); - } catch (const std::exception& e) { - qInfo() << "api: syncplay list failed:" << e.what(); - co_return QJsonArray(); - } + const QJsonDocument document = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/List")); + co_return document.isArray() ? document.array() : QJsonArray {}; } QCoro::Task JellyfinApiFacade::createSyncPlayGroup(QString name) @@ -1764,23 +581,22 @@ QCoro::Task JellyfinApiFacade::leaveSyncPlayGroup() QCoro::Task JellyfinApiFacade::fetchUtcTime() { - co_return (co_await requestJson(HttpMethod::Get, QStringLiteral("/GetUtcTime"))).object(); + const QJsonDocument response = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/Time")); + co_return response.object(); } QCoro::Task JellyfinApiFacade::syncPlayReportPing(qint64 pingMs) { - const QJsonObject body = { - { QStringLiteral("Ping"), std::max(0, pingMs) }, - }; + const QJsonObject body = { { QStringLiteral("Ping"), pingMs } }; co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Ping"), QJsonDocument(body)); } QCoro::Task JellyfinApiFacade::syncPlayReportBuffering( bool buffering, qint64 positionTicks, bool playing, QString playlistItemId, QDateTime serverTime) { - if (playlistItemId.isEmpty()) { + if (playlistItemId.isEmpty()) playlistItemId = QStringLiteral("00000000-0000-0000-0000-000000000000"); - } + const QJsonObject body = { { QStringLiteral("When"), serverTime.toUTC().toString(Qt::ISODateWithMs) }, { QStringLiteral("PositionTicks"), positionTicks }, @@ -1881,8 +697,6 @@ QCoro::Task JellyfinApiFacade::syncPlayRemoveFromPlaylist(QStringList play const QJsonObject body = { { QStringLiteral("PlaylistItemIds"), ids }, - // Removing rows is not the same as ending the session, and the server - // will happily do both if asked. { QStringLiteral("ClearPlaylist"), false }, { QStringLiteral("ClearPlayingItem"), false }, }; @@ -1898,44 +712,6 @@ QCoro::Task JellyfinApiFacade::syncPlaySetPlaylistItem(QString playlistIte co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetPlaylistItem"), QJsonDocument(body)); } -QCoro::Task JellyfinApiFacade::resolvePlayback(MovieItem movie, bool forceTranscode) -{ - Diagnostics::Task task(QStringLiteral("api_negotiate_playback"), - { { QStringLiteral("itemId"), movie.id }, { QStringLiteral("title"), movie.title } }); - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("fields"), QStringLiteral("Trickplay")); - - const QJsonObject body = { - { QStringLiteral("UserId"), m_session.userId }, - { QStringLiteral("MaxStreamingBitrate"), m_maxStreamingBitrate }, - { QStringLiteral("StartTimeTicks"), movie.resumeTicks }, - { QStringLiteral("AutoOpenLiveStream"), true }, - { QStringLiteral("EnableDirectPlay"), !forceTranscode }, - { QStringLiteral("EnableDirectStream"), !forceTranscode }, - { QStringLiteral("EnableTranscoding"), true }, - { QStringLiteral("AllowVideoStreamCopy"), !forceTranscode }, - { QStringLiteral("AllowAudioStreamCopy"), true }, - { QStringLiteral("DeviceProfile"), buildDeviceProfile() }, - }; - - const QJsonObject playbackResponse - = (co_await requestJson( - HttpMethod::Post, QStringLiteral("/Items/%1/PlaybackInfo").arg(movie.id), query, QJsonDocument(body))) - .object(); - - PlaybackSession session = buildPlaybackSession(movie, playbackResponse); - if (session.trickplay.width <= 0) { - const QUrlQuery itemQuery = ItemsQuery().fields(QStringLiteral("Trickplay")).toUrlQuery(); - const QJsonObject item = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, movie.id), itemQuery)) - .object(); - session.trickplay = PlaybackNegotiation::selectTrickplay( - item.value(QStringLiteral("Trickplay")).toObject(), session.mediaSourceId, 320); - } - co_return session; -} - QCoro::Task JellyfinApiFacade::postCapabilities() { QJsonArray supportedCommands; @@ -1991,76 +767,9 @@ QCoro::Task JellyfinApiFacade::postCapabilities() co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Capabilities/Full"), QJsonDocument(body)); } -QJsonArray nowPlayingQueueJson(const std::vector& queue) -{ - QJsonArray array; - for (const PlaybackQueueItem& item : queue) { - QJsonObject object { { QStringLiteral("Id"), item.itemId } }; - if (!item.playlistItemId.isEmpty()) - object.insert(QStringLiteral("PlaylistItemId"), item.playlistItemId); - array.push_back(object); - } - return array; -} - -QCoro::Task JellyfinApiFacade::reportPlaybackStart( - PlaybackSession session, double playbackRate, int volume, bool muted) -{ - QJsonObject body = { - { QStringLiteral("CanSeek"), true }, - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlayMethod"), session.playMethod }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), session.startTimeTicks }, - { QStringLiteral("AudioStreamIndex"), session.audioStreamIndex }, - { QStringLiteral("SubtitleStreamIndex"), session.subtitleStreamIndex }, - { QStringLiteral("PlaybackRate"), playbackRate }, - { QStringLiteral("VolumeLevel"), std::clamp(volume, 0, 100) }, - { QStringLiteral("IsMuted"), muted }, - }; - if (!session.nowPlayingQueue.empty()) - body.insert(QStringLiteral("NowPlayingQueue"), nowPlayingQueueJson(session.nowPlayingQueue)); - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) +QJsonObject JellyfinApiFacade::buildDeviceProfile() const { - QJsonObject body = { - { QStringLiteral("CanSeek"), true }, - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlayMethod"), session.playMethod }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("IsPaused"), paused }, - { QStringLiteral("AudioStreamIndex"), session.audioStreamIndex }, - { QStringLiteral("SubtitleStreamIndex"), session.subtitleStreamIndex }, - { QStringLiteral("PlaybackRate"), playbackRate }, - { QStringLiteral("VolumeLevel"), std::clamp(volume, 0, 100) }, - { QStringLiteral("IsMuted"), muted }, - }; - if (!session.nowPlayingQueue.empty()) - body.insert(QStringLiteral("NowPlayingQueue"), nowPlayingQueueJson(session.nowPlayingQueue)); - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing/Progress"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) -{ - const QJsonObject body = { - { QStringLiteral("ItemId"), session.itemId }, - { QStringLiteral("MediaSourceId"), session.mediaSourceId }, - { QStringLiteral("PlaySessionId"), session.playSessionId }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("Failed"), failed }, - { QStringLiteral("PlaybackRate"), playbackRate }, - }; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Playing/Stopped"), QJsonDocument(body)); + return PlaybackNegotiation::buildDeviceProfile(20'000'000, m_maxStreamingHeight); } QNetworkRequest JellyfinApiFacade::createRequest(const QString& path, const QUrlQuery& query) const @@ -2071,12 +780,6 @@ QNetworkRequest JellyfinApiFacade::createRequest(const QString& path, const QUrl throw std::runtime_error("Credentials require HTTPS or a numeric private/loopback HTTP address"); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); request.setRawHeader("Authorization", authorizationHeader().toUtf8()); - if (path == QStringLiteral("/Playback/BitrateTest") || path == QStringLiteral("/System/Endpoint")) { - request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); - request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); - if (path == QStringLiteral("/Playback/BitrateTest")) - request.setRawHeader("Accept", "application/octet-stream"); - } return request; } @@ -2236,71 +939,4 @@ bool JellyfinApiFacade::shouldExpireSession(const QString& path) const && !path.startsWith(QStringLiteral("/Users/Authenticate")); } -QJsonObject JellyfinApiFacade::buildDeviceProfile() const -{ - return PlaybackNegotiation::buildDeviceProfile( - m_maxStreamingBitrate, maxStreamingHeight(), m_videoCodecs, m_restrictVideoCodecs); -} - -PlaybackSession JellyfinApiFacade::buildPlaybackSession( - const MovieItem& movie, const QJsonObject& playbackResponse) const -{ - const QJsonArray mediaSources = playbackResponse.value(QStringLiteral("MediaSources")).toArray(); - if (mediaSources.isEmpty()) - throw std::runtime_error("No media sources returned by Jellyfin"); - - const PlaybackSelection selection = PlaybackNegotiation::selectSource(mediaSources, m_preferRemux); - const QJsonObject selectedSource = selection.source; - - const QString mediaSourceId = requireString(selectedSource, QStringLiteral("Id")); - const QString container = cleanContainerName(selectedSource.value(QStringLiteral("Container")).toString()); - - TrickplayInfo trickplay = PlaybackNegotiation::selectTrickplay( - playbackResponse.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); - if (trickplay.width <= 0) { - trickplay = PlaybackNegotiation::selectTrickplay( - selectedSource.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); - } - - return { - movie.id, - movie.title, - movie.itemType, - PlaybackNegotiation::buildUrl(m_serverUrl, movie.id, selection), - mediaSourceId, - playbackResponse.value(QStringLiteral("PlaySessionId")).toString(), - selection.playMethod, - container, - movie.resumeTicks, - movie.runtimeTicks, - mediaStreamsFromApiJson(selectedSource.value(QStringLiteral("MediaStreams")).toArray()), - {}, - trickplay, - {}, - }; -} - -void JellyfinApiFacade::setOverride(qint64 bitrate, int height) -{ - setSessionBitrateOverride(bitrate); - // The rung's resolution travels with its bitrate. Without this the server - // was told a ceiling in megabits and nothing about size, so it re-encoded - // at the source resolution and "480p" only ever meant "fewer bits". - setSessionHeightOverride(height); -} - -QString JellyfinApiFacade::autoDescription() const -{ - return PlaybackBandwidthPolicy::describeAuto( - streamingBitrateSource(), maxStreamingBitrate(), playbackParallelRequests()); -} - -std::vector JellyfinApiFacade::ladder(qint64 sourceBitrate) const -{ - std::vector rungs; - for (const PlaybackBandwidthPolicy::QualityOption& rung : PlaybackBandwidthPolicy::qualityLadder(sourceBitrate)) - rungs.push_back({ rung.label, rung.bitrate, rung.height }); - return rungs; -} - } // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h index 7179149b..8bb072df 100644 --- a/src/api/JellyfinApiFacade.h +++ b/src/api/JellyfinApiFacade.h @@ -1,20 +1,15 @@ #pragma once #include "../media/MediaTypes.h" -#include "../provider/ArtworkSource.h" -#include "../provider/Catalog.h" -#include "../provider/PlaybackSource.h" -#include "../provider/SearchSource.h" -#include "../provider/StreamQualityControl.h" -#include "../provider/UserItemStateSink.h" #include "HttpRequestPolicy.h" #include "JellyfinSession.h" -#include "PlaybackBandwidthPolicy.h" #include #include +#include #include +#include #include #include #include @@ -24,21 +19,14 @@ #include #include #include -#include #include namespace JellyfinNative { class TlsTrustController; -// PlaybackSource is the QObject base; the others are plain interfaces the -// source-agnostic controllers talk through. -class JellyfinApiFacade final : public PlaybackSource, - public Catalog, - public SearchSource, - public UserItemStateSink, - public ArtworkSource, - public StreamQualityControl { + +class JellyfinApiFacade final : public QObject { Q_OBJECT public: @@ -51,67 +39,30 @@ class JellyfinApiFacade final : public PlaybackSource, void setDeviceIdentity(const QString& deviceId, const QString& deviceName, const QString& clientVersion); void setDeviceId(const QString& deviceId); - // Some platforms only learn their user-visible name asynchronously. The - // name rides the authorization header of every request, so the server - // picks a later one up on its own. void setDeviceName(const QString& deviceName); QString deviceId() const; - // Forwarded into the QNetworkRequestFactory common headers so every API - // call hints the server about our locale. Jellyfin uses this to localise - // server-returned strings (Continue Watching titles, etc.). void setAcceptLanguage(const QString& bcp47Tag); void setSession(const AuthSession& session); AuthSession session() const; + bool signedIn() const + { + return !m_session.accessToken.isEmpty(); + } + void setPlaybackPreferences( qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight = 0); - void setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs); void setRemoteControlTargetEnabled(bool enabled); bool remoteControlTargetEnabled() const { return m_remoteControlTargetEnabled; } - int playbackParallelRequests() const override; - // PlaybackSource: the access token rides an X-Emby-Token header on - // every media request, and the server URL owns the TLS trust decision. - QByteArray mediaRequestHeaders() const override; - QUrl mediaOrigin() const override; - // Catalog, SearchSource, UserItemStateSink and PlaybackSource all ask - // this; one session answers for every one of them. - bool signedIn() const override; - QString libraryScopeKey() const override; - QString imageUrl(const ArtworkSource::ImageRequest& request) const override; - QCoro::Task> fetchSeriesEpisodes(QString seriesId) override; - QCoro::Task refreshPlaybackNetworkState(); - - // Measuring pulls several megabytes from the server, so it must never run - // beside a stream. Playback that starts before a measurement lands keeps - // whatever ceiling is known, which is a remembered one where the route has - // been seen before and a conservative estimate otherwise. - void setPlaybackActive(bool active); - qint64 maxStreamingBitrate() const; - qint64 measuredStreamingBitrate() const; - PlaybackBandwidthPolicy::Source streamingBitrateSource() const; - - // A ceiling chosen from the player overlay. It outranks every automatic - // input and the Settings limit, and it lasts only as long as this session: - // a quality picked because the train Wi-Fi is bad should not still be - // capping playback at home tomorrow. Zero restores automatic selection. - void setSessionBitrateOverride(qint64 bitrate); - qint64 sessionBitrateOverride() const; - // Resolution is settled separately from bitrate: a viewer sparing a slow - // decoder wants a smaller picture at the bitrate they already have, and a - // viewer on a metered connection wants fewer bits at the size they already - // have. The session override outranks the Settings ceiling and lasts only - // as long as this session; zero means no ceiling from that source. - void setSessionHeightOverride(int height); - int sessionHeightOverride() const; - int maxStreamingHeight() const; QString authorizationHeader(const QString& tokenOverride = {}) const; void cancelRequests(); + // Authentication & User configuration QCoro::Task authenticateByName(QString username, QString password); QCoro::Task quickConnectEnabled(); QCoro::Task initiateQuickConnect(); @@ -122,21 +73,8 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task updateUserConfiguration(QJsonObject configuration); QCoro::Task fetchCurrentUserPolicy(); QCoro::Task fetchCultures(); - QCoro::Task> fetchLibraries() override; - QCoro::Task fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; - QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; - QCoro::Task fetchItemDetails(QString itemId) override; - QCoro::Task> fetchItemsByIds(QStringList itemIds) override; - QCoro::Task> fetchSeasons(QString seriesId) override; - QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; - QCoro::Task> fetchResumeItems(int limit = 24) override; - QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; - QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; - QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; - QCoro::Task> fetchSearchSuggestions(int limit = 20) override; - QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; - QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; + + // Library management QCoro::Task> fetchManagementTargets(QString itemType); QCoro::Task createPlaylist(QString name, QStringList itemIds = {}); QCoro::Task addPlaylistItems(QString playlistId, QStringList itemIds, int position = -1); @@ -148,26 +86,13 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task removeCollectionItems(QString collectionId, QStringList itemIds); QCoro::Task renameItem(QString itemId, QString name); QCoro::Task deleteItem(QString itemId); - QCoro::Task setItemFavorite(QString itemId, bool favorite) override; - QCoro::Task setItemPlayed(QString itemId, bool played) override; - QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; - QCoro::Task> fetchMediaSegments(QString itemId) override; + QCoro::Task> fetchItemsByIds(QStringList itemIds); + + // Trickplay (used for remote control timeline) QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override; - QCoro::Task resolvePlayback(MovieItem movie, bool forceTranscode = false) override; - // StreamQualityControl: the session ceiling and the ladder under it. - qint64 bitrateOverride() const override - { - return sessionBitrateOverride(); - } - int heightOverride() const override - { - return sessionHeightOverride(); - } - void setOverride(qint64 bitrate, int height) override; - QString autoDescription() const override; - std::vector ladder(qint64 sourceBitrate) const override; + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; + // Remote playback & target sessions QCoro::Task fetchControllableSessions(); QCoro::Task sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, qint64 startPositionTicks = -1, int startIndex = -1, QString mediaSourceId = {}, int audioStreamIndex = -2, @@ -175,7 +100,7 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks = -1); QCoro::Task sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments = {}); - // SyncPlay REST endpoints used alongside SyncPlayController's WebSocket. + // SyncPlay REST endpoints QCoro::Task fetchSyncPlayGroups(); QCoro::Task createSyncPlayGroup(QString name); QCoro::Task joinSyncPlayGroup(QString groupId); @@ -190,25 +115,18 @@ class JellyfinApiFacade final : public PlaybackSource, QCoro::Task syncPlaySeek(qint64 positionTicks); QCoro::Task syncPlayNextItem(QString playlistItemId); QCoro::Task syncPlayPreviousItem(QString playlistItemId); - // Group queue authoring. The queue is server state inside a group, so these - // replace the local edits rather than accompanying them. QCoro::Task syncPlayQueue(QStringList itemIds, bool queueNext); QCoro::Task syncPlayMovePlaylistItem(QString playlistItemId, int newIndex); QCoro::Task syncPlayRemoveFromPlaylist(QStringList playlistItemIds); QCoro::Task syncPlaySetPlaylistItem(QString playlistItemId); + // Capabilities QCoro::Task postCapabilities(); - QCoro::Task reportPlaybackStart( - PlaybackSession session, double playbackRate, int volume, bool muted) override; - QCoro::Task reportPlaybackProgress(PlaybackSession session, qint64 positionTicks, bool paused, - double playbackRate, int volume, bool muted) override; - QCoro::Task reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) override; signals: void authenticationExpired(const QString& message); + void credentialsChanged(); void deviceProfileChanged(); - void streamingBitrateChanged(); private: enum class HttpMethod { @@ -218,8 +136,6 @@ class JellyfinApiFacade final : public PlaybackSource, }; QNetworkRequest createRequest(const QString& path, const QUrlQuery& query = {}) const; - QCoro::Task measurePlaybackBitrate(int totalSampleBytes, int parallelRequests); - QCoro::Task measurePlaybackRoundTripTime(); QCoro::Task requestJson( HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); QCoro::Task requestNoContent(HttpMethod method, QString path, QJsonDocument body); @@ -227,17 +143,10 @@ class JellyfinApiFacade final : public PlaybackSource, HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); QJsonObject buildDeviceProfile() const; - PlaybackSession buildPlaybackSession(const MovieItem& movie, const QJsonObject& playbackResponse) const; HttpOperation operationFor(HttpMethod method, const QString& path) const; bool shouldExpireSession(const QString& path) const; void preconnectToServer(); void applyCommonHeaders(); - void updateEffectiveStreamingBitrate(); - void setPlaybackParallelRequests(int parallelRequests); - QString currentNetworkSignature() const; - void restoreRememberedMeasurement(); - void rememberMeasurement(); - void handleNetworkRouteChanged(); QNetworkAccessManager *m_networkAccessManager = nullptr; QRestAccessManager m_rest; @@ -250,26 +159,8 @@ class JellyfinApiFacade final : public PlaybackSource, QSet m_activeReplies; QString m_preconnectedAuthority; QString m_acceptLanguage; - qint64 m_maxStreamingBitrate = 20'000'000; - qint64 m_manualMaxStreamingBitrate = 0; - qint64 m_sessionBitrateOverride = 0; int m_maxStreamingHeight = 0; - int m_sessionHeightOverride = 0; - qint64 m_measuredStreamingBitrate = 0; - QString m_measuredNetworkSignature; - int m_playbackParallelRequests = 1; - quint64 m_playbackNetworkGeneration = 0; - bool m_playbackEndpointKnown = false; - bool m_inLocalNetwork = false; - PlaybackBandwidthPolicy::Source m_streamingBitrateSource = PlaybackBandwidthPolicy::Source::Estimate; - bool m_measurementRemembered = false; - bool m_playbackActive = false; - bool m_measurementDeferred = false; - bool m_unlimitedLocalNetwork = false; - QStringList m_videoCodecs; - bool m_restrictVideoCodecs = false; bool m_remoteControlTargetEnabled = true; - bool m_preferRemux = true; bool m_authExpirationReported = false; bool m_shuttingDown = false; }; diff --git a/src/api/JellyfinJsAdapter.h b/src/api/JellyfinJsAdapter.h index a353b32c..96556de9 100644 --- a/src/api/JellyfinJsAdapter.h +++ b/src/api/JellyfinJsAdapter.h @@ -112,9 +112,9 @@ class JellyfinJsAdapter final : public QObject, { return QStringLiteral("Direct Play"); } - std::vector ladder(qint64 sourceBitrate) const override + std::vector ladder(qint64 sourceBitrate, int sourceHeight = 0) const override { - return StreamQualityControl::defaultLadder(sourceBitrate); + return StreamQualityControl::defaultLadder(sourceBitrate, sourceHeight); } void setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs); diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp index acfcb6ba..85dab1cf 100644 --- a/src/api/JellyfinProvider.cpp +++ b/src/api/JellyfinProvider.cpp @@ -64,8 +64,6 @@ JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObje [this](const QStringList& videoCodecs, bool restrictVideoCodecs) { if (m_jsAdapter) m_jsAdapter->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); - if (m_api) - m_api->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); }, *this); @@ -233,11 +231,6 @@ void JellyfinProvider::attach(const CoreServices& core) forward(&RemotePlayback::generalCommandReceived)); if (SpoolLink *link = m_remoteControl->link()) connect(link, &SpoolLink::messageReceived, m_remoteControl, &RemotePlayback::peerMessageReceived); - - // Keep the bandwidth probe off the wire while a stream is running; it - // resumes on its own once the session ends. - connect(core.player, &PlayerController::sessionActiveChanged, this, - [this, player = core.player]() { m_api->setPlaybackActive(player->sessionActive()); }); } void JellyfinProvider::registerQmlSingletons() diff --git a/src/api/JellyfinSession.h b/src/api/JellyfinSession.h index 20b65343..c1e9327a 100644 --- a/src/api/JellyfinSession.h +++ b/src/api/JellyfinSession.h @@ -11,6 +11,8 @@ struct AuthSession { QString userName; QString accessToken; QString serverId; + + friend bool operator==(const AuthSession&, const AuthSession&) = default; }; } // namespace JellyfinNative diff --git a/src/api/PlaybackBandwidthPolicy.cpp b/src/api/PlaybackBandwidthPolicy.cpp deleted file mode 100644 index 1b5b65df..00000000 --- a/src/api/PlaybackBandwidthPolicy.cpp +++ /dev/null @@ -1,212 +0,0 @@ -#include "PlaybackBandwidthPolicy.h" - -#include "common/NetworkAddress.h" - -#include -#include - -#include -#include - -namespace JellyfinNative { - -PlaybackBandwidthPolicy::Source PlaybackBandwidthPolicy::effectiveBitrateSource( - qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, bool inLocalNetwork, qint64 measuredBitrate) -{ - if (unlimitedLocalNetwork && endpointKnown && inLocalNetwork) - return Source::UnlimitedLocal; - if (manualBitrate > 0) - return Source::Manual; - if (measuredBitrate > 0) - return Source::Measured; - if (endpointKnown && inLocalNetwork) - return Source::LocalEstimate; - return Source::Estimate; -} - -bool PlaybackBandwidthPolicy::isEstimate(Source source) -{ - return source == Source::LocalEstimate || source == Source::Estimate; -} - -qint64 PlaybackBandwidthPolicy::effectiveBitrate( - qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, bool inLocalNetwork, qint64 measuredBitrate) -{ - if (unlimitedLocalNetwork && endpointKnown && inLocalNetwork) - return MaximumBitrate; - if (manualBitrate > 0) - return std::clamp(manualBitrate, MinimumBitrate, MaximumBitrate); - if (measuredBitrate > 0) - return std::clamp(measuredBitrate, MinimumBitrate, MaximumBitrate); - // A local route is not evidence of a fast one. Until something is - // measured, assume a plausible wireless link rather than gigabit ethernet. - if (endpointKnown && inLocalNetwork) - return LocalNetworkFallbackBitrate; - return AutoFallbackBitrate; -} - -qint64 PlaybackBandwidthPolicy::conservativeEstimate(qint64 transferredBytes, qint64 elapsedMilliseconds) -{ - if (transferredBytes <= 0 || elapsedMilliseconds <= 0) - return 0; - const long double measuredBitsPerSecond - = static_cast(transferredBytes) * 8'000.0L / static_cast(elapsedMilliseconds); - const long double conservative = measuredBitsPerSecond * 0.75L; - return std::clamp(static_cast(std::llround(conservative)), MinimumBitrate, MaximumBitrate); -} - -bool PlaybackBandwidthPolicy::shouldBenchmarkFourRequests( - qint64 roundTripMilliseconds, qint64 singleRequestBitrate, qint64 dualRequestBitrate) -{ - if (singleRequestBitrate <= 0 || dualRequestBitrate <= 0) - return false; - - constexpr qint64 highLatencyMilliseconds = 20; - constexpr long double worthwhileDualGain = 1.10L; - return roundTripMilliseconds >= highLatencyMilliseconds - || static_cast(dualRequestBitrate) - >= static_cast(singleRequestBitrate) * worthwhileDualGain; -} - -int PlaybackBandwidthPolicy::selectParallelRequests( - qint64 singleRequestBitrate, qint64 dualRequestBitrate, qint64 fourRequestBitrate) -{ - const qint64 best = std::max({ singleRequestBitrate, dualRequestBitrate, fourRequestBitrate }); - if (best <= 0) - return 1; - - constexpr long double closeEnoughToBest = 0.85L; - const auto isCloseEnough = [best](qint64 bitrate) { - return bitrate > 0 && static_cast(bitrate) >= static_cast(best) * closeEnoughToBest; - }; - if (isCloseEnough(singleRequestBitrate)) - return 1; - if (isCloseEnough(dualRequestBitrate)) - return 2; - return fourRequestBitrate > 0 ? 4 : 2; -} - -QString PlaybackBandwidthPolicy::networkSignature( - const QString& serverAuthority, const QStringList& localAddresses, const QString& transportMedium) -{ - // Only the network part of each private address takes part: a DHCP lease - // that hands out a different host on the same network is the same route, - // and a public address would put the user's own address in the key. - QStringList networks; - for (const QString& candidate : localAddresses) { - QHostAddress address; - if (!address.setAddress(candidate.trimmed()) || address.isLoopback()) - continue; - if (!isPrivateNetworkAddress(address)) - continue; - if (address.protocol() == QAbstractSocket::IPv4Protocol) { - const quint32 value = address.toIPv4Address() & 0xffffff00u; - networks.push_back(QHostAddress(value).toString()); - } else if (address.protocol() == QAbstractSocket::IPv6Protocol) { - networks.push_back(address.toString().section(QLatin1Char(':'), 0, 3)); - } - } - networks.removeDuplicates(); - networks.sort(); - - const QString material = serverAuthority.trimmed().toLower() + QLatin1Char('|') - + transportMedium.trimmed().toLower() + QLatin1Char('|') + networks.join(QLatin1Char(',')); - return QString::fromLatin1( - QCryptographicHash::hash(material.toUtf8(), QCryptographicHash::Sha256).toHex().left(32)); -} - -QString PlaybackBandwidthPolicy::formatBitrate(qint64 bitsPerSecond) -{ - if (bitsPerSecond <= 0) - return QStringLiteral("unknown"); - const double mbps = static_cast(bitsPerSecond) / 1'000'000.0; - if (mbps >= 10.0) - return QString::number(std::llround(mbps)) + QStringLiteral(" Mbps"); - if (mbps >= 1.0) - return QString::number(mbps, 'f', 1) + QStringLiteral(" Mbps"); - return QString::number(std::llround(mbps * 1000.0)) + QStringLiteral(" kbps"); -} - -QList PlaybackBandwidthPolicy::resolutionLadder() -{ - return { 2160, 1080, 720, 480, 360 }; -} - -QString PlaybackBandwidthPolicy::describeResolution(int height) -{ - if (height <= 0) - return QStringLiteral("Source"); - // 2160p is universally sold as 4K, and nothing else in the ladder is. - if (height >= 2160) - return QStringLiteral("4K"); - return QString::number(height) + QLatin1Char('p'); -} - -QList PlaybackBandwidthPolicy::qualityLadder(qint64 sourceBitrate) -{ - struct Rung { - qint64 bitrate; - int height; - }; - // The rungs jellyfin-web offers, so a viewer who knows one client is not - // surprised by the other. The height is carried rather than spelled, so - // the rung the viewer picked is the rung the server is asked for. - static constexpr Rung rungs[] = { - { 120'000'000, 2160 }, - { 80'000'000, 2160 }, - { 60'000'000, 1080 }, - { 40'000'000, 1080 }, - { 20'000'000, 1080 }, - { 15'000'000, 1080 }, - { 10'000'000, 720 }, - { 8'000'000, 720 }, - { 6'000'000, 720 }, - { 4'000'000, 480 }, - { 3'000'000, 480 }, - { 2'000'000, 480 }, - { 1'000'000, 360 }, - }; - - QList options; - options.reserve(std::size(rungs)); - for (const Rung& rung : rungs) { - if (sourceBitrate > 0 && rung.bitrate >= sourceBitrate) - continue; - options.push_back(QualityOption { rung.bitrate, rung.height, - describeResolution(rung.height) + QStringLiteral(" · ") + formatBitrate(rung.bitrate) }); - } - return options; -} - -QString PlaybackBandwidthPolicy::describeAuto(Source source, qint64 effectiveBitrate, int parallelRequests) -{ - const QString ceiling = formatBitrate(effectiveBitrate); - const QString lanes = parallelRequests > 1 ? QStringLiteral(" · %1 connections").arg(parallelRequests) : QString(); - switch (source) { - case Source::UnlimitedLocal: - return QStringLiteral("No limit on this network"); - case Source::Manual: - return QStringLiteral("Limited to %1 in Settings").arg(ceiling); - case Source::Measured: - return QStringLiteral("Measured %1").arg(ceiling) + lanes; - case Source::Remembered: - return QStringLiteral("%1 remembered for this network").arg(ceiling) + lanes; - case Source::LocalEstimate: - return QStringLiteral("Estimated %1 · local network, not measured yet").arg(ceiling); - case Source::Estimate: - break; - } - return QStringLiteral("Estimated %1 · not measured yet").arg(ceiling); -} - -bool PlaybackBandwidthPolicy::isRememberedMeasurementUsable(qint64 recordedAtMsSinceEpoch, qint64 nowMsSinceEpoch) -{ - if (recordedAtMsSinceEpoch <= 0) - return false; - // A record from the future means the clock moved; treat it as unusable - // rather than trusting it forever. - const qint64 age = nowMsSinceEpoch - recordedAtMsSinceEpoch; - return age >= 0 && age <= RememberedLifetimeMs; -} - -} // namespace JellyfinNative diff --git a/src/api/PlaybackBandwidthPolicy.h b/src/api/PlaybackBandwidthPolicy.h deleted file mode 100644 index ce1a47f9..00000000 --- a/src/api/PlaybackBandwidthPolicy.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace JellyfinNative { - -class PlaybackBandwidthPolicy final { -public: - // Where the ceiling handed to the server came from. The player surfaces - // this so "Auto" can say whether it is a measurement or a guess. - // effectiveBitrateSource() cannot tell a fresh measurement from a - // remembered one; the caller that supplied the value substitutes - // Remembered when it came from the cache. - enum class Source { - UnlimitedLocal, - Manual, - Measured, - Remembered, - LocalEstimate, - Estimate, - }; - - static constexpr qint64 AutoFallbackBitrate = 20'000'000; - // A LAN is not automatically gigabit ethernet. This is the ceiling used on - // a local network before a measurement lands: high enough that ordinary - // 1080p and most 4K HEVC still direct play, low enough that a 2.4 GHz - // link is not immediately asked for a 80 Mbps remux. - static constexpr qint64 LocalNetworkFallbackBitrate = 40'000'000; - static constexpr qint64 MaximumBitrate = 1'000'000'000; - static constexpr qint64 MinimumBitrate = 1'000'000; - // A remembered measurement describes a network, not a moment. Keep it long - // enough to cover a normal viewing routine and short enough that a - // rearranged home network re-measures on its own. - static constexpr qint64 RememberedLifetimeMs = 7LL * 24 * 60 * 60 * 1000; - - static qint64 effectiveBitrate(qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, - bool inLocalNetwork, qint64 measuredBitrate); - static Source effectiveBitrateSource(qint64 manualBitrate, bool unlimitedLocalNetwork, bool endpointKnown, - bool inLocalNetwork, qint64 measuredBitrate); - static bool isEstimate(Source source); - - static qint64 conservativeEstimate(qint64 transferredBytes, qint64 elapsedMilliseconds); - static bool shouldBenchmarkFourRequests( - qint64 roundTripMilliseconds, qint64 singleRequestBitrate, qint64 dualRequestBitrate); - static int selectParallelRequests( - qint64 singleRequestBitrate, qint64 dualRequestBitrate, qint64 fourRequestBitrate = 0); - - // Identifies the route to one server well enough to reuse a measurement - // across launches, and to notice that the route changed. Local interface - // subnets move with the network; the transport tells Wi-Fi from cellular - // when two networks happen to hand out the same private range. - static QString networkSignature( - const QString& serverAuthority, const QStringList& localAddresses, const QString& transportMedium); - static bool isRememberedMeasurementUsable(qint64 recordedAtMsSinceEpoch, qint64 nowMsSinceEpoch); - - struct QualityOption { - qint64 bitrate = 0; - // The vertical resolution the rung promises. A bitrate ceiling alone - // makes the server re-encode at the source resolution, so the label - // said 480p while the picture stayed 1080p; this is what makes the - // promise reach the device profile. - int height = 0; - QString label; - }; - - // Vertical resolutions offered as a standalone ceiling, coarsest first. - // Nothing here is a bitrate: a viewer capping resolution to spare a panel - // or a decoder should not have to reason about megabits. - static QList resolutionLadder(); - static QString describeResolution(int height); - - static QString formatBitrate(qint64 bitsPerSecond); - // The ladder a viewer can pick from, coarsest first. Rungs at or above the - // source bitrate are dropped: they would transcode the stream without - // lowering anything, which is the worst of both. - static QList qualityLadder(qint64 sourceBitrate); - // The line under "Auto". jellyfin-web says only "Auto"; we know whether - // the ceiling was measured, remembered for this network, or guessed. - static QString describeAuto(Source source, qint64 effectiveBitrate, int parallelRequests); -}; - -} // namespace JellyfinNative diff --git a/src/api/ProviderBenchmark.cpp b/src/api/ProviderBenchmark.cpp index 2e870ad3..a78c67c7 100644 --- a/src/api/ProviderBenchmark.cpp +++ b/src/api/ProviderBenchmark.cpp @@ -27,28 +27,20 @@ namespace { struct BenchResult { QString name; - std::vector nativeTimes; - std::vector jsTimes; - size_t nativeItems = 0; - size_t jsItems = 0; + std::vector times; + size_t items = 0; - double nativeAvg() const + double avg() const { - return nativeTimes.empty() - ? 0.0 - : std::accumulate(nativeTimes.begin(), nativeTimes.end(), 0.0) / nativeTimes.size(); + return times.empty() ? 0.0 : std::accumulate(times.begin(), times.end(), 0.0) / times.size(); } - double jsAvg() const + double min() const { - return jsTimes.empty() ? 0.0 : std::accumulate(jsTimes.begin(), jsTimes.end(), 0.0) / jsTimes.size(); + return times.empty() ? 0.0 : *std::min_element(times.begin(), times.end()); } - double deltaMs() const + double max() const { - return jsAvg() - nativeAvg(); - } - double deltaFrames() const - { - return deltaMs() / 16.666667; + return times.empty() ? 0.0 : *std::max_element(times.begin(), times.end()); } }; @@ -98,7 +90,7 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis const QString serverUrl = sessionCtrl->serverUrl(); const AuthSession auth = nativeApi->session(); std::cout << "\n================================================================================\n"; - std::cout << " SPOOL PROVIDER PERFORMANCE BENCHMARK (NATIVE VS JAVASCRIPT)\n"; + std::cout << " SPOOL JELLYFIN PROVIDER PERFORMANCE BENCHMARK\n"; std::cout << " Server: " << serverUrl.toStdString() << " | User: " << auth.userName.toStdString() << '\n'; std::cout << " Iterations: " << options.iterations << " | Library: " << options.libraryName.toStdString() << '\n'; std::cout << "================================================================================\n\n"; @@ -112,7 +104,7 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis config.insert(QStringLiteral("deviceId"), nativeApi->deviceId().isEmpty() ? QStringLiteral("spool-benchmark") : nativeApi->deviceId()); config.insert(QStringLiteral("deviceName"), QStringLiteral("Spool Benchmark")); - config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.8.0")); + config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.8.1")); QList origins { QUrl(serverUrl) }; const QString sourceId = co_await registry->configureSource( @@ -127,20 +119,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis res.name = QStringLiteral("fetchLibraries"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; // Warmup - co_await nativeApi->fetchLibraries(); co_await jsAdapter.fetchLibraries(); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto nativeLibs = co_await nativeApi->fetchLibraries(); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = nativeLibs.size(); - - t.restart(); auto jsLibs = co_await jsAdapter.fetchLibraries(); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsLibs.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsLibs.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -151,7 +137,7 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis // Find the target library descriptor BrowseDescriptor libDesc; try { - auto libs = co_await nativeApi->fetchLibraries(); + auto libs = co_await jsAdapter.fetchLibraries(); for (const auto& lib : libs) { if (lib.name.compare(options.libraryName, Qt::CaseInsensitive) == 0) { libDesc.id = lib.id; @@ -177,20 +163,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis BenchResult res; res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [0..100]"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto page = co_await nativeApi->fetchBrowsePage(libDesc, 0, 100); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = page.items.size(); - - t.restart(); auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsPage.items.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsPage.items.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -205,20 +185,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis BenchResult res; res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [100..200]"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto page = co_await nativeApi->fetchBrowsePage(libDesc, 100, 100); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = page.items.size(); - - t.restart(); auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsPage.items.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsPage.items.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -232,20 +206,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis BenchResult res; res.name = QStringLiteral("searchItems (\"") + options.searchQuery + QStringLiteral("\", 80)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->searchItems(options.searchQuery, 80); co_await jsAdapter.searchItems(options.searchQuery, 80); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto items = co_await nativeApi->searchItems(options.searchQuery, 80); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = items.size(); - - t.restart(); auto jsItems = co_await jsAdapter.searchItems(options.searchQuery, 80); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsItems.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsItems.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -258,20 +226,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis BenchResult res; res.name = QStringLiteral("fetchNextUpEpisodes (24)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchNextUpEpisodes(24); co_await jsAdapter.fetchNextUpEpisodes(24); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto items = co_await nativeApi->fetchNextUpEpisodes(24); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = items.size(); - - t.restart(); auto jsItems = co_await jsAdapter.fetchNextUpEpisodes(24); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsItems.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsItems.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -284,20 +246,14 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis BenchResult res; res.name = QStringLiteral("fetchResumeItems (24)"); std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await nativeApi->fetchResumeItems(24); co_await jsAdapter.fetchResumeItems(24); for (int i = 0; i < options.iterations; ++i) { QElapsedTimer t; t.start(); - auto items = co_await nativeApi->fetchResumeItems(24); - res.nativeTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.nativeItems = items.size(); - - t.restart(); auto jsItems = co_await jsAdapter.fetchResumeItems(24); - res.jsTimes.push_back(t.nsecsElapsed() / 1000000.0); - res.jsItems = jsItems.size(); + res.times.push_back(t.nsecsElapsed() / 1000000.0); + res.items = jsItems.size(); } std::cout << " done.\n"; results.push_back(std::move(res)); @@ -306,36 +262,24 @@ QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegis } // Print summary table - std::cout << "\n---------------------------------------------------------------------------------------------------" - "---------------\n"; - printf("| %-36s | %-12s | %-12s | %-11s | %-15s | %-10s |\n", "Operation", "Native (ms)", "JS (ms)", "Delta (ms)", - "Delta (frames)", "Items (N/J)"); - std::cout << "-----------------------------------------------------------------------------------------------------" - "-------------\n"; + std::cout << "\n---------------------------------------------------------------------------------------\n"; + printf("| %-42s | %-10s | %-10s | %-10s | %-6s |\n", "Operation", "Avg (ms)", "Min (ms)", "Max (ms)", "Items"); + std::cout << "---------------------------------------------------------------------------------------\n"; QJsonArray jsonResults; for (const auto& r : results) { - const double delta = r.deltaMs(); - const double frames = r.deltaFrames(); - char itemsBuf[32]; - snprintf(itemsBuf, sizeof(itemsBuf), "%zu / %zu", r.nativeItems, r.jsItems); - printf("| %-36s | %9.2f ms | %9.2f ms | %+8.2f ms | %+11.2f fr | %-10s |\n", qPrintable(r.name), r.nativeAvg(), - r.jsAvg(), delta, frames, itemsBuf); + printf("| %-42s | %7.2f ms | %7.2f ms | %7.2f ms | %-6zu |\n", qPrintable(r.name), r.avg(), r.min(), r.max(), + r.items); QJsonObject obj; obj.insert(QStringLiteral("operation"), r.name); - obj.insert(QStringLiteral("nativeAvgMs"), r.nativeAvg()); - obj.insert(QStringLiteral("jsAvgMs"), r.jsAvg()); - obj.insert(QStringLiteral("deltaMs"), delta); - obj.insert(QStringLiteral("deltaFrames"), frames); - obj.insert(QStringLiteral("nativeItems"), static_cast(r.nativeItems)); - obj.insert(QStringLiteral("jsItems"), static_cast(r.jsItems)); + obj.insert(QStringLiteral("avgMs"), r.avg()); + obj.insert(QStringLiteral("minMs"), r.min()); + obj.insert(QStringLiteral("maxMs"), r.max()); + obj.insert(QStringLiteral("items"), static_cast(r.items)); jsonResults.append(obj); } - std::cout << "-----------------------------------------------------------------------------------------------------" - "-------------\n"; - std::cout << " Note: 1 frame = 16.67 ms at 60 Hz display refresh rate.\n"; - std::cout << " Negative delta means JavaScript backend was FASTER than native.\n\n"; + std::cout << "---------------------------------------------------------------------------------------\n\n"; if (!options.outputPath.isEmpty()) { QJsonObject root; diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 983e0fdc..544f3328 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -1563,14 +1563,20 @@ QVariantList AppController::streamingQualityOptions() const }); qint64 sourceBitrate = 0; - for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) + int sourceHeight = 0; + for (const MediaSourceInfo& source : m_activePlaybackItem.mediaSources) { sourceBitrate = std::max(sourceBitrate, source.bitRate); + for (const MediaStreamInfo& stream : source.streams) { + if (stream.type == QStringLiteral("Video") && stream.height > 0) + sourceHeight = std::max(sourceHeight, stream.height); + } + } std::vector rungs; if (m_quality) - rungs = m_quality->ladder(sourceBitrate); + rungs = m_quality->ladder(sourceBitrate, sourceHeight); if (rungs.empty()) - rungs = StreamQualityControl::defaultLadder(sourceBitrate); + rungs = StreamQualityControl::defaultLadder(sourceBitrate, sourceHeight); for (const StreamQualityControl::Rung& rung : rungs) { options.push_back(QVariantMap { diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp index 44423d1b..d55bde5d 100644 --- a/src/provider/ProviderRegistrySources.cpp +++ b/src/provider/ProviderRegistrySources.cpp @@ -584,7 +584,7 @@ QCoro::Task ProviderRegistry::downloadAndInstallProvider(QString moduleId, while (redirectCount < maxRedirects) { QNetworkRequest request(currentUrl); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool/0.8.0")); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool/0.8.1")); QNetworkReply *reply = m_portable->network->get(request); connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64 total) { diff --git a/src/provider/StreamQualityControl.h b/src/provider/StreamQualityControl.h index a35faf39..88efe6be 100644 --- a/src/provider/StreamQualityControl.h +++ b/src/provider/StreamQualityControl.h @@ -30,9 +30,9 @@ class StreamQualityControl { // The line under "Auto": what the automatic ceiling currently is and // where it came from. virtual QString autoDescription() const = 0; - // The ceilings worth offering below a stream of this bitrate, coarsest - // first. - virtual std::vector ladder(qint64 sourceBitrate) const = 0; + // The ceilings worth offering below a stream of this bitrate and height, + // coarsest first. + virtual std::vector ladder(qint64 sourceBitrate, int sourceHeight = 0) const = 0; static QString formatBitrate(qint64 bitsPerSecond) { @@ -55,7 +55,7 @@ class StreamQualityControl { return QString::number(height) + QLatin1Char('p'); } - static std::vector defaultLadder(qint64 sourceBitrate) + static std::vector defaultLadder(qint64 sourceBitrate, int sourceHeight = 0) { static constexpr struct { qint64 bitrate; @@ -81,6 +81,8 @@ class StreamQualityControl { for (const auto& rung : kStandardRungs) { if (sourceBitrate > 0 && rung.bitrate >= sourceBitrate) continue; + if (sourceHeight > 0 && rung.height > sourceHeight) + continue; options.push_back({ describeResolution(rung.height) + QStringLiteral(" · ") + formatBitrate(rung.bitrate), rung.bitrate, diff --git a/tests/api/ItemsQueryTest.cpp b/tests/api/ItemsQueryTest.cpp deleted file mode 100644 index 5dee3d66..00000000 --- a/tests/api/ItemsQueryTest.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "api/ItemsQuery.h" - -#include "TestMain.h" - -#include - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void fail(const char *message, const QUrlQuery& query) -{ - std::cerr << message << ": " << query.toString().toStdString() << '\n'; - std::exit(1); -} - -void require(bool condition, const char *message, const QUrlQuery& query) -{ - if (condition) - return; - fail(message, query); -} - -void requireValue(const QUrlQuery& query, const QString& key, const QString& expected, const char *message) -{ - require(query.hasQueryItem(key) && query.queryItemValue(key) == expected, message, query); -} - -void requireMissing(const QUrlQuery& query, const QString& key, const char *message) -{ - require(!query.hasQueryItem(key), message, query); -} - -} // namespace - -JELLYFIN_TEST_MAIN("items-query") -{ - const QUrlQuery populated = ItemsQuery() - .userId(QStringLiteral("user-1")) - .parentId(QStringLiteral("parent-1")) - .recursive(false) - .includeItemTypes(QStringLiteral("Movie,Series")) - .mediaTypes(QStringLiteral("Video")) - .fields(QStringLiteral("PrimaryImageAspectRatio,Genres")) - .toUrlQuery(); - requireValue(populated, QStringLiteral("userId"), QStringLiteral("user-1"), "user id should be encoded"); - requireValue( - populated, QStringLiteral("parentId"), QStringLiteral("parent-1"), "non-empty parent id should be encoded"); - requireValue( - populated, QStringLiteral("recursive"), QStringLiteral("false"), "recursive flag should preserve false"); - requireValue(populated, QStringLiteral("includeItemTypes"), QStringLiteral("Movie,Series"), - "item type filter should be encoded"); - requireValue( - populated, QStringLiteral("mediaTypes"), QStringLiteral("Video"), "media type filter should be encoded"); - requireValue(populated, QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,Genres"), - "field list should be encoded"); - - const QUrlQuery emptyOptional = ItemsQuery() - .parentId(QString()) - .includeItemTypes(QString()) - .mediaTypes(QString()) - .fields(QString()) - .sort(QString(), QString()) - .images(QString(), 4) - .toUrlQuery(); - requireMissing(emptyOptional, QStringLiteral("parentId"), "empty parent id should be omitted"); - requireMissing(emptyOptional, QStringLiteral("includeItemTypes"), "empty item type filter should be omitted"); - requireMissing(emptyOptional, QStringLiteral("mediaTypes"), "empty media type filter should be omitted"); - requireMissing(emptyOptional, QStringLiteral("fields"), "empty field list should be omitted"); - requireMissing(emptyOptional, QStringLiteral("sortBy"), "empty sort field should be omitted"); - requireMissing(emptyOptional, QStringLiteral("sortOrder"), "empty sort order should be omitted"); - requireMissing(emptyOptional, QStringLiteral("enableImageTypes"), "empty image type list should be omitted"); - - const QUrlQuery presentation = ItemsQuery() - .fields(QStringLiteral("PrimaryImageAspectRatio,ChildCount")) - .images(QStringLiteral("Primary,Backdrop"), 0) - .sort(QStringLiteral("SortName"), QStringLiteral("Descending")) - .toUrlQuery(); - requireValue(presentation, QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,ChildCount"), - "presentation fields should be encoded"); - requireValue(presentation, QStringLiteral("enableImageTypes"), QStringLiteral("Primary,Backdrop"), - "requested image types should be encoded"); - requireValue(presentation, QStringLiteral("imageTypeLimit"), QStringLiteral("1"), - "image type limit should be clamped to at least one"); - requireValue(presentation, QStringLiteral("sortBy"), QStringLiteral("SortName"), "sort field should be encoded"); - requireValue( - presentation, QStringLiteral("sortOrder"), QStringLiteral("Descending"), "sort order should be encoded"); - - const QUrlQuery underLimit = ItemsQuery().limit(0, 60).toUrlQuery(); - requireValue(underLimit, QStringLiteral("limit"), QStringLiteral("1"), "limit should be clamped to at least one"); - - const QUrlQuery overLimit = ItemsQuery().limit(250, 60).toUrlQuery(); - requireValue( - overLimit, QStringLiteral("limit"), QStringLiteral("60"), "limit should be clamped to the supplied maximum"); - - const QUrlQuery withinLimit = ItemsQuery().limit(42, 60).toUrlQuery(); - requireValue(withinLimit, QStringLiteral("limit"), QStringLiteral("42"), "limit within bounds should be preserved"); - - return 0; -} diff --git a/tests/api/JellyfinApiFacadeUrlTest.cpp b/tests/api/JellyfinApiFacadeUrlTest.cpp index 0f7ed520..09295b71 100644 --- a/tests/api/JellyfinApiFacadeUrlTest.cpp +++ b/tests/api/JellyfinApiFacadeUrlTest.cpp @@ -1,5 +1,4 @@ #include "api/JellyfinApiFacade.h" -#include "app/ArtworkService.h" #include "common/AsyncTask.h" #include "common/TlsTrust.h" @@ -102,35 +101,6 @@ JELLYFIN_TEST_MAIN("jellyfin-api-facade-url") JellyfinApiFacade api(&network, &tlsTrust); api.setServerUrl(QStringLiteral("https://media.example.test/jellyfin/root/")); - QTemporaryDir cacheDirectory; - require(cacheDirectory.isValid(), "artwork test cache should be available"); - ArtworkService artwork(cacheDirectory.path(), 1024 * 1024, 1024 * 1024, 1, &tlsTrust); - artwork.setSource(&api); - MovieItem imageItem; - imageItem.id = QStringLiteral("folder/item 1"); - imageItem.thumbTag = QStringLiteral("tag/one two"); - - const QString imageUrl = artwork.url(QVariant::fromValue(imageItem), QStringLiteral("landscape"), 320); - const QUrl parsedImage(imageUrl); - requireUrlPathBytes(imageUrl, QStringLiteral("/jellyfin/root/Items/folder%2Fitem%201/Images/Thumb"), - "artwork URLs should retain the server base path and encode path segments"); - - const QUrlQuery imageQuery(parsedImage); - requireQueryValue( - imageQuery, QStringLiteral("fillWidth"), QStringLiteral("320"), "filled image URLs should include fill width"); - requireQueryValue(imageQuery, QStringLiteral("fillHeight"), QStringLiteral("180"), - "filled image URLs should include fill height"); - requireMissingQueryValue( - imageQuery, QStringLiteral("maxWidth"), "filled image URLs should not also request max width"); - requireQueryValue(imageQuery, QStringLiteral("quality"), QStringLiteral("68"), "image URLs should include quality"); - requireQueryValue(imageQuery, QStringLiteral("format"), QStringLiteral("webp"), "image URLs should include format"); - requireQueryValue(imageQuery, QStringLiteral("tag"), QStringLiteral("tag/one two"), - "image URLs should include the image tag query item"); - - imageItem.thumbTag.clear(); - require(artwork.url(QVariant::fromValue(imageItem), QStringLiteral("landscape"), 320).isEmpty(), - "image URLs should be omitted when the image tag is empty"); - const QString trickplayUrl = api.trickplayTileUrl(QStringLiteral("episode/id 2"), 320, 7); requireUrlPathBytes(trickplayUrl, QStringLiteral("/jellyfin/root/Videos/episode%2Fid%202/Trickplay/320/7.jpg"), "trickplay tile URLs should retain the server base path and encode item ids as one path segment"); diff --git a/tests/api/PlaybackBandwidthPolicyTest.cpp b/tests/api/PlaybackBandwidthPolicyTest.cpp deleted file mode 100644 index 1785a5c4..00000000 --- a/tests/api/PlaybackBandwidthPolicyTest.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "api/PlaybackBandwidthPolicy.h" - -#include "TestMain.h" - -#include - -#include - -using JellyfinNative::PlaybackBandwidthPolicy; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - qCritical() << message; - std::exit(EXIT_FAILURE); -} - -} // namespace - -JELLYFIN_TEST_MAIN("playback-bandwidth-policy") -{ - require(PlaybackBandwidthPolicy::effectiveBitrate(0, false, false, false, 0) - == PlaybackBandwidthPolicy::AutoFallbackBitrate, - "automatic bandwidth should use the safe fallback before measurement"); - require(PlaybackBandwidthPolicy::effectiveBitrate(80'000'000, false, true, true, 40'000'000) == 80'000'000, - "manual bandwidth should override the measured rate"); - require(PlaybackBandwidthPolicy::effectiveBitrate(80'000'000, true, true, true, 40'000'000) - == PlaybackBandwidthPolicy::MaximumBitrate, - "local unlimited mode should override both manual and measured rates"); - require(PlaybackBandwidthPolicy::effectiveBitrate(0, true, true, false, 40'000'000) == 40'000'000, - "local unlimited mode should not affect remote connections"); - - using Source = PlaybackBandwidthPolicy::Source; - require(PlaybackBandwidthPolicy::effectiveBitrate(0, false, true, true, 0) - == PlaybackBandwidthPolicy::LocalNetworkFallbackBitrate, - "an unmeasured local network must not be assumed to be gigabit ethernet"); - require(PlaybackBandwidthPolicy::LocalNetworkFallbackBitrate < PlaybackBandwidthPolicy::MaximumBitrate, - "the unmeasured local ceiling has to leave room for a slow wireless link"); - require(PlaybackBandwidthPolicy::effectiveBitrate(0, false, true, true, 300'000'000) == 300'000'000, - "a measurement should replace the local estimate in both directions"); - require(PlaybackBandwidthPolicy::effectiveBitrate(0, false, true, true, 6'000'000) == 6'000'000, - "a slow measured local link must not be raised back up to the estimate"); - require(PlaybackBandwidthPolicy::effectiveBitrateSource(0, false, true, true, 0) == Source::LocalEstimate - && PlaybackBandwidthPolicy::isEstimate(Source::LocalEstimate), - "an unmeasured local ceiling should report itself as an estimate"); - require(PlaybackBandwidthPolicy::effectiveBitrateSource(0, false, false, false, 0) == Source::Estimate - && PlaybackBandwidthPolicy::isEstimate(Source::Estimate), - "playback that starts before any measurement should report an estimate"); - require(PlaybackBandwidthPolicy::effectiveBitrateSource(0, false, true, true, 300'000'000) == Source::Measured - && !PlaybackBandwidthPolicy::isEstimate(Source::Measured), - "a measured ceiling should not be presented as a guess"); - require( - PlaybackBandwidthPolicy::effectiveBitrateSource(80'000'000, false, true, true, 40'000'000) == Source::Manual, - "an explicit limit should be reported as the user's own choice"); - require(PlaybackBandwidthPolicy::effectiveBitrateSource(80'000'000, true, true, true, 0) == Source::UnlimitedLocal, - "the local opt-out should be reported ahead of a manual limit"); - - const QString home = PlaybackBandwidthPolicy::networkSignature( - QStringLiteral("media.example:8096"), { QStringLiteral("192.168.1.44") }, QStringLiteral("wifi")); - require(home - == PlaybackBandwidthPolicy::networkSignature(QStringLiteral("media.example:8096"), - { QStringLiteral("192.168.1.90"), QStringLiteral("127.0.0.1") }, QStringLiteral("wifi")), - "a new DHCP lease on the same network should reuse the remembered measurement"); - require(home - != PlaybackBandwidthPolicy::networkSignature( - QStringLiteral("media.example:8096"), { QStringLiteral("10.4.0.9") }, QStringLiteral("wifi")), - "a different local network must not inherit the previous measurement"); - require(home - != PlaybackBandwidthPolicy::networkSignature( - QStringLiteral("media.example:8096"), { QStringLiteral("192.168.1.44") }, QStringLiteral("cellular")), - "the same private range over cellular is a different route"); - require(home - != PlaybackBandwidthPolicy::networkSignature( - QStringLiteral("other.example:8096"), { QStringLiteral("192.168.1.44") }, QStringLiteral("wifi")), - "a measurement describes the route to one server"); - require(!PlaybackBandwidthPolicy::networkSignature(QStringLiteral("media.example:8096"), {}, QString()).isEmpty(), - "an unknown route should still produce a usable cache key"); - - constexpr qint64 now = 1'800'000'000'000; - require(PlaybackBandwidthPolicy::isRememberedMeasurementUsable(now - 60'000, now), - "a recent measurement should be reused instead of guessing"); - require(!PlaybackBandwidthPolicy::isRememberedMeasurementUsable( - now - PlaybackBandwidthPolicy::RememberedLifetimeMs - 1, now), - "a stale measurement should be re-measured rather than trusted"); - require(!PlaybackBandwidthPolicy::isRememberedMeasurementUsable(now + 60'000, now), - "a record from the future means the clock moved and must not be trusted"); - require(!PlaybackBandwidthPolicy::isRememberedMeasurementUsable(0, now), "an absent record is not a measurement"); - - require(PlaybackBandwidthPolicy::formatBitrate(40'000'000) == QStringLiteral("40 Mbps") - && PlaybackBandwidthPolicy::formatBitrate(3'000'000) == QStringLiteral("3.0 Mbps") - && PlaybackBandwidthPolicy::formatBitrate(720'000) == QStringLiteral("720 kbps"), - "a ceiling should be readable at every scale the ladder covers"); - require(PlaybackBandwidthPolicy::formatBitrate(0) == QStringLiteral("unknown"), - "an absent ceiling must not be printed as zero"); - - const QList fullLadder = PlaybackBandwidthPolicy::qualityLadder(0); - require(!fullLadder.isEmpty(), "an unknown source bitrate should still offer the whole ladder"); - for (int index = 1; index < fullLadder.size(); ++index) { - require(fullLadder[index].bitrate < fullLadder[index - 1].bitrate, - "the ladder should descend so the first rungs are the best quality"); - } - require(fullLadder.constFirst().label.contains(QStringLiteral("4K")) - && fullLadder.constFirst().label.contains(QStringLiteral("Mbps")), - "a rung should name both the resolution and the ceiling"); - - // The rung carries its resolution rather than only spelling it in the - // label: the label was all there was, so the server heard a bitrate and - // nothing about size, and 480p lowered the bitrate alone. - for (const PlaybackBandwidthPolicy::QualityOption& rung : fullLadder) { - require(rung.height > 0, "every rung should carry the resolution it promises"); - require(rung.label.startsWith(PlaybackBandwidthPolicy::describeResolution(rung.height)), - "a rung's label should be the resolution it actually asks the server for"); - } - for (int index = 1; index < fullLadder.size(); ++index) { - require(fullLadder[index].height <= fullLadder[index - 1].height, - "resolution should never climb as the ladder descends"); - } - require(PlaybackBandwidthPolicy::describeResolution(2160) == QStringLiteral("4K"), - "2160p is sold as 4K everywhere else, so name it that here"); - require(PlaybackBandwidthPolicy::describeResolution(480) == QStringLiteral("480p"), - "every other rung is named by its height"); - require(PlaybackBandwidthPolicy::describeResolution(0) == QStringLiteral("Source"), - "no ceiling means the source resolution, not an unnamed one"); - - // The standalone ladder exists so resolution can be answered without - // reasoning about megabits. - const QList resolutions = PlaybackBandwidthPolicy::resolutionLadder(); - require(!resolutions.isEmpty(), "a resolution ceiling should be offerable on its own"); - for (int index = 1; index < resolutions.size(); ++index) - require(resolutions[index] < resolutions[index - 1], "the resolution ladder should descend"); - - const QList ladder = PlaybackBandwidthPolicy::qualityLadder(12'000'000); - require(!ladder.isEmpty(), "a modest source should still offer lower rungs"); - for (const PlaybackBandwidthPolicy::QualityOption& rung : ladder) { - require(rung.bitrate < 12'000'000, "a rung at or above the source would transcode without lowering anything"); - } - - require(PlaybackBandwidthPolicy::describeAuto(Source::Measured, 45'000'000, 2) - == QStringLiteral("Measured 45 Mbps · 2 connections"), - "Auto should report the measurement and how it was reached"); - require(!PlaybackBandwidthPolicy::describeAuto(Source::Measured, 45'000'000, 1).contains(QStringLiteral("connect")), - "a single connection is the unremarkable case and should not be narrated"); - require( - PlaybackBandwidthPolicy::describeAuto(Source::Remembered, 45'000'000, 1).contains(QStringLiteral("remembered")), - "Auto should admit when the ceiling came from a previous visit to this network"); - require(PlaybackBandwidthPolicy::describeAuto(Source::LocalEstimate, 40'000'000, 1) - .contains(QStringLiteral("not measured yet")) - && PlaybackBandwidthPolicy::describeAuto(Source::Estimate, 20'000'000, 1) - .contains(QStringLiteral("not measured yet")), - "an unmeasured ceiling must say so rather than looking like a measurement"); - require(PlaybackBandwidthPolicy::describeAuto(Source::UnlimitedLocal, PlaybackBandwidthPolicy::MaximumBitrate, 1) - == QStringLiteral("No limit on this network"), - "the local opt-out should not be printed as a 1000 Mbps measurement"); - require(PlaybackBandwidthPolicy::describeAuto(Source::Manual, 25'000'000, 1).contains(QStringLiteral("Settings")), - "a ceiling from the Settings slider should point at where to change it"); - - require(PlaybackBandwidthPolicy::conservativeEstimate(10'000'000, 1'000) == 60'000'000, - "measurement should reserve twenty-five percent of observed throughput"); - require(PlaybackBandwidthPolicy::conservativeEstimate(0, 1'000) == 0, - "empty transfers should not produce a measurement"); - require(!PlaybackBandwidthPolicy::shouldBenchmarkFourRequests(4, 800'000'000, 850'000'000), - "low latency without a material dual-request gain should stop after two samples"); - require(PlaybackBandwidthPolicy::shouldBenchmarkFourRequests(25, 40'000'000, 42'000'000), - "high latency should exercise the four-request path"); - require(PlaybackBandwidthPolicy::shouldBenchmarkFourRequests(4, 40'000'000, 50'000'000), - "a material dual-request gain should exercise the four-request path"); - require(PlaybackBandwidthPolicy::selectParallelRequests(900'000'000, 1'000'000'000) == 1, - "one request should win when it is already close to the best result"); - require(PlaybackBandwidthPolicy::selectParallelRequests(400'000'000, 800'000'000, 820'000'000) == 2, - "two requests should win when four requests add no useful throughput"); - require(PlaybackBandwidthPolicy::selectParallelRequests(200'000'000, 350'000'000, 800'000'000) == 4, - "four requests should win when they materially improve throughput"); - require(PlaybackBandwidthPolicy::selectParallelRequests(0, 0, 0) == 1, - "failed measurements should fall back to one request"); - return EXIT_SUCCESS; -} diff --git a/tests/api/StreamQualityControlTest.cpp b/tests/api/StreamQualityControlTest.cpp new file mode 100644 index 00000000..ff4192ca --- /dev/null +++ b/tests/api/StreamQualityControlTest.cpp @@ -0,0 +1,83 @@ +#include "provider/StreamQualityControl.h" + +#include "TestMain.h" + +#include +#include + +#include + +using JellyfinNative::StreamQualityControl; + +namespace { + +void require(bool condition, const char *message) +{ + if (condition) + return; + qCritical() << message; + std::exit(EXIT_FAILURE); +} + +} // namespace + +JELLYFIN_TEST_MAIN("stream-quality-control") +{ + // Format bitrate + require(StreamQualityControl::formatBitrate(40'000'000) == QStringLiteral("40 Mbps"), + "formatBitrate should format tens of megabits cleanly"); + require(StreamQualityControl::formatBitrate(3'000'000) == QStringLiteral("3.0 Mbps"), + "formatBitrate should format single-digit megabits with one decimal place"); + require(StreamQualityControl::formatBitrate(720'000) == QStringLiteral("720 kbps"), + "formatBitrate should format kilobits cleanly"); + require(StreamQualityControl::formatBitrate(0) == QStringLiteral("unknown"), + "formatBitrate should report unknown for zero"); + require(StreamQualityControl::formatBitrate(-1) == QStringLiteral("unknown"), + "formatBitrate should report unknown for negative values"); + + // Describe resolution + require(StreamQualityControl::describeResolution(0) == QStringLiteral("Source"), + "describeResolution should report Source for 0"); + require(StreamQualityControl::describeResolution(2160) == QStringLiteral("4K"), + "describeResolution should report 4K for 2160"); + require(StreamQualityControl::describeResolution(1080) == QStringLiteral("1080p"), + "describeResolution should report 1080p for 1080"); + require(StreamQualityControl::describeResolution(720) == QStringLiteral("720p"), + "describeResolution should report 720p for 720"); + require(StreamQualityControl::describeResolution(480) == QStringLiteral("480p"), + "describeResolution should report 480p for 480"); + + // Default ladder without constraints + const auto fullLadder = StreamQualityControl::defaultLadder(0, 0); + require(!fullLadder.empty(), "defaultLadder should provide options when unconstrained"); + require(fullLadder.front().height == 2160, "highest rung in full ladder should be 4K"); + for (const auto& rung : fullLadder) { + require(!rung.label.isEmpty(), "each rung should have a non-empty label"); + require(rung.bitrate > 0, "each rung should have a positive bitrate"); + require(rung.height > 0, "each rung should have a positive height"); + } + + // Default ladder with bitrate constraint + const auto constrainedBitrate = StreamQualityControl::defaultLadder(12'000'000, 0); + require(!constrainedBitrate.empty(), "constrained ladder should have rungs below source bitrate"); + for (const auto& rung : constrainedBitrate) { + require(rung.bitrate < 12'000'000, "all rungs must have bitrate below source bitrate"); + } + + // Default ladder with height constraint + const auto constrainedHeight = StreamQualityControl::defaultLadder(0, 1080); + require(!constrainedHeight.empty(), "constrained ladder should have rungs at or below source height"); + for (const auto& rung : constrainedHeight) { + require(rung.height <= 1080, "all rungs must have height at or below source height"); + } + + // Default ladder with both bitrate and height constraints + const auto constrainedBoth = StreamQualityControl::defaultLadder(10'000'000, 720); + require(!constrainedBoth.empty(), "constrained ladder should have rungs matching both constraints"); + for (const auto& rung : constrainedBoth) { + require(rung.bitrate < 10'000'000 && rung.height <= 720, + "all rungs must satisfy both bitrate and height constraints"); + } + + return EXIT_SUCCESS; +} diff --git a/tests/app/ContentModelControllerTest.cpp b/tests/app/ContentModelControllerTest.cpp index 471d57c3..62c9ed45 100644 --- a/tests/app/ContentModelControllerTest.cpp +++ b/tests/app/ContentModelControllerTest.cpp @@ -1,11 +1,11 @@ #include "app/ContentModelController.h" -#include "api/JellyfinApiFacade.h" #include "app/HomeModelController.h" #include "app/LibraryPrefetchController.h" #include "app/SearchController.h" #include "common/AsyncTask.h" #include "common/MetaJson.h" -#include "common/TlsTrust.h" +#include "provider/Catalog.h" +#include "provider/SearchSource.h" #include "TestMain.h" @@ -15,32 +15,27 @@ #include #include #include -#include -#include -#include -#include #include -#include #include #include -#include -#include #include #include +#include -using JellyfinNative::AuthSession; using JellyfinNative::BrowseDescriptor; +using JellyfinNative::BrowseKind; +using JellyfinNative::Catalog; using JellyfinNative::ContentModelController; using JellyfinNative::HomeModelController; -using JellyfinNative::JellyfinApiFacade; using JellyfinNative::LibraryItem; using JellyfinNative::LibraryPrefetchController; using JellyfinNative::MovieGridModel; using JellyfinNative::MovieItem; using JellyfinNative::PagedMovieItems; +using JellyfinNative::PersonCredits; using JellyfinNative::SearchController; -using JellyfinNative::TlsTrustController; +using JellyfinNative::SearchSource; namespace { @@ -52,93 +47,7 @@ void require(bool condition, const char *message) std::exit(EXIT_FAILURE); } -QByteArray jsonBytes(const QJsonObject& object) -{ - return QJsonDocument(object).toJson(QJsonDocument::Compact); -} - -QJsonObject episodeObject(int episodeNumber = 7) -{ - return { - { QStringLiteral("Id"), - episodeNumber == 7 ? QStringLiteral("episode-row") : QStringLiteral("episode-%1").arg(episodeNumber) }, - { QStringLiteral("Name"), - episodeNumber == 7 ? QStringLiteral("The Loaded Episode") - : QStringLiteral("Episode %1").arg(episodeNumber) }, - { QStringLiteral("Type"), QStringLiteral("Episode") }, - { QStringLiteral("SeriesId"), QStringLiteral("series-1") }, - { QStringLiteral("SeasonId"), QStringLiteral("season-1") }, - { QStringLiteral("SeriesName"), QStringLiteral("Series One") }, - { QStringLiteral("SeriesPrimaryImageTag"), QStringLiteral("series-primary-tag") }, - { QStringLiteral("ParentIndexNumber"), 2 }, - { QStringLiteral("IndexNumber"), episodeNumber }, - }; -} -QJsonObject movieObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("movie-1") }, - { QStringLiteral("Name"), QStringLiteral("Movie One") }, - { QStringLiteral("Type"), QStringLiteral("Movie") }, - }; -} - -QJsonObject seriesObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("series-1") }, - { QStringLiteral("Name"), QStringLiteral("Series One") }, - { QStringLiteral("Type"), QStringLiteral("Series") }, - }; -} - -QJsonObject personEpisodeObject( - const QString& id, const QString& seriesId, const QString& seriesName, int seasonNumber, int episodeNumber) -{ - return { - { QStringLiteral("Id"), id }, - { QStringLiteral("Name"), QStringLiteral("Episode %1").arg(episodeNumber) }, - { QStringLiteral("Type"), QStringLiteral("Episode") }, - { QStringLiteral("SeriesId"), seriesId }, - { QStringLiteral("SeasonId"), QStringLiteral("%1-season-%2").arg(seriesId).arg(seasonNumber) }, - { QStringLiteral("SeriesName"), seriesName }, - { QStringLiteral("ParentIndexNumber"), seasonNumber }, - { QStringLiteral("IndexNumber"), episodeNumber }, - }; -} - -QJsonObject personSeriesObject(const QString& id, const QString& name, int episodeCount) -{ - return { - { QStringLiteral("Id"), id }, - { QStringLiteral("Name"), name }, - { QStringLiteral("Type"), QStringLiteral("Series") }, - { QStringLiteral("RecursiveItemCount"), episodeCount }, - }; -} - -QJsonObject seasonObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("season-1") }, - { QStringLiteral("Name"), QStringLiteral("Season 2") }, - { QStringLiteral("Type"), QStringLiteral("Season") }, - { QStringLiteral("SeriesId"), QStringLiteral("series-1") }, - { QStringLiteral("SeriesName"), QStringLiteral("Series One") }, - { QStringLiteral("IndexNumber"), 2 }, - }; -} - -QJsonObject photoObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("photo-1") }, - { QStringLiteral("Name"), QStringLiteral("Photo One") }, - { QStringLiteral("Type"), QStringLiteral("Photo") }, - }; -} - -LibraryItem library(const QString& id, const QString& name, const QString& collectionType) +LibraryItem makeLibrary(const QString& id, const QString& name, const QString& collectionType) { LibraryItem item; item.id = id; @@ -147,225 +56,292 @@ LibraryItem library(const QString& id, const QString& name, const QString& colle return item; } -QJsonObject playlistMovieObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("playlist-movie-1") }, - { QStringLiteral("Name"), QStringLiteral("Playlist Movie") }, - { QStringLiteral("Type"), QStringLiteral("Movie") }, - { QStringLiteral("PlaylistItemId"), QStringLiteral("playlist-item-1") }, - }; -} - -QJsonObject collectionObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("boxset-1") }, - { QStringLiteral("Name"), QStringLiteral("A Collection") }, - { QStringLiteral("Type"), QStringLiteral("BoxSet") }, - }; -} - -QJsonObject boxSetChildObject() -{ - return { - { QStringLiteral("Id"), QStringLiteral("boxset-child-1") }, - { QStringLiteral("Name"), QStringLiteral("Collection Child") }, - { QStringLiteral("Type"), QStringLiteral("Movie") }, - { QStringLiteral("ProductionYear"), 1999 }, - }; -} - -class MemoryReply final : public QNetworkReply { +class TestCatalog final : public Catalog, public SearchSource { public: - MemoryReply(const QNetworkRequest& request, QNetworkAccessManager::Operation operation, QByteArray payload, - int statusCode, QObject *parent) - : QNetworkReply(parent) - , m_payload(std::move(payload)) + bool signedIn() const override { - setRequest(request); - setUrl(request.url()); - setOperation(operation); - setAttribute(QNetworkRequest::HttpStatusCodeAttribute, statusCode); - setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - open(QIODevice::ReadOnly | QIODevice::Unbuffered); - QTimer::singleShot(0, this, [this]() { - emit readyRead(); - emit finished(); - }); + return true; } - void abort() override { } - - qint64 bytesAvailable() const override + QString libraryScopeKey() const override { - return static_cast(m_payload.size() - m_offset) + QNetworkReply::bytesAvailable(); + return QStringLiteral("test-scope"); } -protected: - qint64 readData(char *data, qint64 maxSize) override + QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override { - if (m_offset >= m_payload.size() || maxSize <= 0) - return -1; - - const qint64 length = std::min(maxSize, m_payload.size() - m_offset); - std::memcpy(data, m_payload.constData() + m_offset, static_cast(length)); - m_offset += length; - return length; + Q_UNUSED(startIndex); + Q_UNUSED(limit); + PagedMovieItems page; + if (descriptor.kind == BrowseKind::Playlist) { + MovieItem item; + item.id = QStringLiteral("playlist-movie-1"); + item.title = QStringLiteral("Playlist Movie"); + item.itemType = QStringLiteral("Movie"); + item.playlistItemId = QStringLiteral("playlist-item-1"); + page.items.push_back(std::move(item)); + page.totalRecordCount = 1; + } else if (descriptor.id == QStringLiteral("boxset-1")) { + MovieItem item; + item.id = QStringLiteral("boxset-child-1"); + item.title = QStringLiteral("BoxSet Child"); + item.itemType = QStringLiteral("Movie"); + page.items.push_back(std::move(item)); + page.totalRecordCount = 1; + } else { + const QStringList types = queryOptions.value(QStringLiteral("includeItemTypes")).toStringList(); + if (types.contains(QStringLiteral("BoxSet"))) { + MovieItem item; + item.id = QStringLiteral("boxset-1"); + item.title = QStringLiteral("Collection One"); + item.itemType = QStringLiteral("BoxSet"); + page.items.push_back(std::move(item)); + page.totalRecordCount = 1; + } + } + co_return page; } -private: - QByteArray m_payload; - qsizetype m_offset = 0; -}; + QCoro::Task fetchItemDetails(QString itemId) override + { + MovieItem item; + item.id = itemId; + item.title = QStringLiteral("Movie One"); + item.itemType = QStringLiteral("Movie"); + item.runtimeTicks = 1200LL * 10'000'000; + co_return item; + } -class FakeNetworkAccessManager final : public QNetworkAccessManager { -public: - QVector requestedUrls; - QVector connectionCacheExpirySeconds; + QCoro::Task> fetchSeasons(QString seriesId) override + { + Q_UNUSED(seriesId); + MovieItem season; + season.id = QStringLiteral("season-1"); + season.title = QStringLiteral("Season 2"); + season.itemType = QStringLiteral("Season"); + season.seriesId = QStringLiteral("series-1"); + season.seriesName = QStringLiteral("Series One"); + season.seasonNumber = 2; + co_return std::vector { season }; + } -protected: - QNetworkReply *createRequest(Operation operation, const QNetworkRequest& request, QIODevice *outgoingData) override + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override { - Q_UNUSED(outgoingData); - requestedUrls.push_back(request.url()); - connectionCacheExpirySeconds.push_back( - request.attribute(QNetworkRequest::ConnectionCacheExpiryTimeoutSecondsAttribute).toInt()); - - const QUrl url = request.url(); - const QUrlQuery query(url); - if (operation == GetOperation && url.path() == QStringLiteral("/Users/user-1/Items/movie-1")) { - QJsonObject item = movieObject(); - item.insert(QStringLiteral("RunTimeTicks"), 1200LL * 10'000'000); - return new MemoryReply(request, operation, jsonBytes(item), 200, this); - } - if (operation == GetOperation && url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("personIds")) == QStringLiteral("person-1")) { - const int startIndex = query.queryItemValue(QStringLiteral("startIndex")).toInt(); - QJsonArray items; - if (startIndex == 0) { - for (int index = 0; index < 200; ++index) { - items.push_back(QJsonObject { - { QStringLiteral("Id"), QStringLiteral("person-movie-%1").arg(index) }, - { QStringLiteral("Name"), QStringLiteral("Movie %1").arg(index, 3, 10, QLatin1Char('0')) }, - { QStringLiteral("Type"), QStringLiteral("Movie") }, - }); - } - } else if (startIndex == 200) { - items = QJsonArray { - personSeriesObject(QStringLiteral("series-direct"), QStringLiteral("Direct Show"), 100), - personEpisodeObject(QStringLiteral("direct-episode"), QStringLiteral("series-direct"), - QStringLiteral("Direct Show"), 1, 1), - personEpisodeObject( - QStringLiteral("guest-1"), QStringLiteral("series-guest"), QStringLiteral("Guest Show"), 2, 1), - personEpisodeObject( - QStringLiteral("guest-2"), QStringLiteral("series-guest"), QStringLiteral("Guest Show"), 2, 2), - personEpisodeObject(QStringLiteral("majority-1"), QStringLiteral("series-majority"), - QStringLiteral("Majority Show"), 1, 1), - personEpisodeObject(QStringLiteral("majority-2"), QStringLiteral("series-majority"), - QStringLiteral("Majority Show"), 1, 2), - personEpisodeObject(QStringLiteral("majority-3"), QStringLiteral("series-majority"), - QStringLiteral("Majority Show"), 1, 3), - }; - } - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), items }, { QStringLiteral("TotalRecordCount"), 207 } }), 200, - this); - } + Q_UNUSED(seriesId); + Q_UNUSED(seasonId); + MovieItem episode; + episode.id = QStringLiteral("episode-row"); + episode.title = QStringLiteral("The Loaded Episode"); + episode.itemType = QStringLiteral("Episode"); + episode.seriesId = QStringLiteral("series-1"); + episode.seasonId = QStringLiteral("season-1"); + episode.seriesName = QStringLiteral("Series One"); + episode.seriesPrimaryImageTag = QStringLiteral("series-primary-tag"); + episode.seasonNumber = 2; + episode.episodeNumber = 7; + co_return std::vector { episode }; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Items") - && query.hasQueryItem(QStringLiteral("ids"))) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), - QJsonArray { - personSeriesObject(QStringLiteral("series-direct"), QStringLiteral("Direct Show"), 100), - personSeriesObject(QStringLiteral("series-guest"), QStringLiteral("Guest Show"), 10), - personSeriesObject(QStringLiteral("series-majority"), QStringLiteral("Majority Show"), 4), - } } }), - 200, this); - } + QCoro::Task> fetchResumeItems(int limit = 24) override + { + Q_UNUSED(limit); + co_return std::vector {}; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("searchTerm")) == QStringLiteral("mixed")) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), - QJsonArray { movieObject(), seriesObject(), episodeObject(), photoObject() } } }), - 200, this); - } + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override + { + Q_UNUSED(limit); + co_return std::vector {}; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Users/user-1/Items/Resume")) - return new MemoryReply( - request, operation, jsonBytes({ { QStringLiteral("Items"), QJsonArray {} } }), 200, this); - - if (operation == GetOperation && url.path() == QStringLiteral("/Shows/NextUp")) - return new MemoryReply( - request, operation, jsonBytes({ { QStringLiteral("Items"), QJsonArray {} } }), 200, this); - - if (operation == GetOperation && url.path() == QStringLiteral("/Users/user-1/Items/Latest")) { - const QString parentId = query.queryItemValue(QStringLiteral("parentId")); - QJsonArray items; - if (parentId == QStringLiteral("shows-id")) { - const int count = std::min(query.queryItemValue(QStringLiteral("limit")).toInt(), 145); - for (int episode = 1; episode <= count; ++episode) - items.push_back(episodeObject(episode)); - } else if (parentId == QStringLiteral("single-show-id")) { - items.push_back(episodeObject(3)); - } else if (parentId == QStringLiteral("photos-id")) { - items.push_back(photoObject()); + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override + { + Q_UNUSED(limit); + std::vector items; + if (parentId == QStringLiteral("shows-id")) { + for (int i = 1; i <= 145; ++i) { + MovieItem ep; + ep.id = QStringLiteral("episode-%1").arg(i); + ep.title = QStringLiteral("Episode %1").arg(i); + ep.itemType = QStringLiteral("Episode"); + ep.seriesId = QStringLiteral("series-1"); + ep.seasonId = QStringLiteral("season-1"); + ep.seriesName = QStringLiteral("Series One"); + ep.seriesPrimaryImageTag = QStringLiteral("series-primary-tag"); + ep.seasonNumber = 2; + ep.episodeNumber = i; + items.push_back(std::move(ep)); } - return new MemoryReply(request, operation, jsonBytes({ { QStringLiteral("Items"), items } }), 200, this); + } else if (parentId == QStringLiteral("single-show-id")) { + MovieItem ep; + ep.id = QStringLiteral("single-ep-1"); + ep.title = QStringLiteral("Episode 1"); + ep.itemType = QStringLiteral("Episode"); + ep.seriesId = QStringLiteral("series-1"); + ep.seasonId = QStringLiteral("season-1"); + ep.seriesName = QStringLiteral("Series One"); + ep.seriesPrimaryImageTag = QStringLiteral("series-primary-tag"); + ep.seasonNumber = 1; + ep.episodeNumber = 1; + items.push_back(std::move(ep)); + } else if (parentId == QStringLiteral("photos-id")) { + MovieItem photo; + photo.id = QStringLiteral("photo-1"); + photo.title = QStringLiteral("Photo One"); + photo.itemType = QStringLiteral("Photo"); + items.push_back(std::move(photo)); } + co_return items; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Shows/series-1/Episodes") - && query.queryItemValue(QStringLiteral("seasonId")) == QStringLiteral("season-1")) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), QJsonArray { episodeObject() } } }), 200, this); - } + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override + { + Q_UNUSED(itemId); + Q_UNUSED(limit); + co_return std::vector {}; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Shows/series-1/Episodes")) { - return new MemoryReply( - request, operation, jsonBytes({ { QStringLiteral("Items"), QJsonArray {} } }), 200, this); + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override + { + Q_UNUSED(personId); + Q_UNUSED(maximumItems); + PersonCredits credits; + credits.items.reserve(207); + for (int i = 0; i < 200; ++i) { + MovieItem m; + m.id = QStringLiteral("person-movie-%1").arg(i); + m.title = QStringLiteral("Movie %1").arg(i, 3, 10, QLatin1Char('0')); + m.itemType = QStringLiteral("Movie"); + credits.items.push_back(std::move(m)); } - if (operation == GetOperation && url.path() == QStringLiteral("/Shows/series-1/Seasons")) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), QJsonArray { seasonObject() } } }), 200, this); + MovieItem directEp; + directEp.id = QStringLiteral("direct-episode"); + directEp.title = QStringLiteral("Direct Episode"); + directEp.itemType = QStringLiteral("Episode"); + directEp.seriesId = QStringLiteral("series-direct"); + directEp.seasonId = QStringLiteral("series-direct-season-1"); + directEp.seriesName = QStringLiteral("Direct Show"); + directEp.seasonNumber = 1; + directEp.episodeNumber = 1; + credits.items.push_back(std::move(directEp)); + + MovieItem guest1; + guest1.id = QStringLiteral("guest-1"); + guest1.title = QStringLiteral("Guest Episode 1"); + guest1.itemType = QStringLiteral("Episode"); + guest1.seriesId = QStringLiteral("series-guest"); + guest1.seasonId = QStringLiteral("series-guest-season-2"); + guest1.seriesName = QStringLiteral("Guest Show"); + guest1.seasonNumber = 2; + guest1.episodeNumber = 1; + credits.items.push_back(std::move(guest1)); + + MovieItem guest2; + guest2.id = QStringLiteral("guest-2"); + guest2.title = QStringLiteral("Guest Episode 2"); + guest2.itemType = QStringLiteral("Episode"); + guest2.seriesId = QStringLiteral("series-guest"); + guest2.seasonId = QStringLiteral("series-guest-season-2"); + guest2.seriesName = QStringLiteral("Guest Show"); + guest2.seasonNumber = 2; + guest2.episodeNumber = 2; + credits.items.push_back(std::move(guest2)); + + for (int i = 1; i <= 3; ++i) { + MovieItem maj; + maj.id = QStringLiteral("majority-%1").arg(i); + maj.title = QStringLiteral("Majority Episode %1").arg(i); + maj.itemType = QStringLiteral("Episode"); + maj.seriesId = QStringLiteral("series-majority"); + maj.seasonId = QStringLiteral("series-majority-season-1"); + maj.seriesName = QStringLiteral("Majority Show"); + maj.seasonNumber = 1; + maj.episodeNumber = i; + credits.items.push_back(std::move(maj)); } - if (operation == GetOperation && url.path() == QStringLiteral("/Playlists/playlist-1/Items")) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), QJsonArray { playlistMovieObject() } }, - { QStringLiteral("TotalRecordCount"), 1 } }), - 200, this); - } + MovieItem directSeries; + directSeries.id = QStringLiteral("series-direct"); + directSeries.title = QStringLiteral("Direct Show"); + directSeries.itemType = QStringLiteral("Series"); + directSeries.recursiveItemCount = 100; + credits.items.push_back(directSeries); + credits.relatedSeries.push_back(directSeries); + + MovieItem majoritySeries; + majoritySeries.id = QStringLiteral("series-majority"); + majoritySeries.title = QStringLiteral("Majority Show"); + majoritySeries.itemType = QStringLiteral("Series"); + majoritySeries.recursiveItemCount = 5; + credits.relatedSeries.push_back(std::move(majoritySeries)); + + co_return credits; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("parentId")) == QStringLiteral("movies-id") - && query.queryItemValue(QStringLiteral("includeItemTypes")) == QStringLiteral("BoxSet") - && !query.hasQueryItem(QStringLiteral("mediaTypes"))) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), QJsonArray { collectionObject() } }, - { QStringLiteral("TotalRecordCount"), 1 } }), - 200, this); - } + QCoro::Task> fetchLibraries() override + { + co_return std::vector {}; + } - if (operation == GetOperation && url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("parentId")) == QStringLiteral("boxset-1")) { - return new MemoryReply(request, operation, - jsonBytes({ { QStringLiteral("Items"), QJsonArray { boxSetChildObject() } }, - { QStringLiteral("TotalRecordCount"), 1 } }), - 200, this); - } + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override + { + Q_UNUSED(libraryId); + Q_UNUSED(collectionType); + co_return QVariantMap {}; + } - if (operation == GetOperation - && (url.path() == QStringLiteral("/Items/episode-1/Similar") - || url.path() == QStringLiteral("/Items/boxset-1/Similar"))) { - return new MemoryReply( - request, operation, jsonBytes({ { QStringLiteral("Items"), QJsonArray {} } }), 200, this); - } + QCoro::Task> fetchItemsByIds(QStringList itemIds) override + { + Q_UNUSED(itemIds); + co_return std::vector {}; + } + + // SearchSource + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override + { + Q_UNUSED(searchTerm); + Q_UNUSED(limit); + std::vector results; + + MovieItem movie; + movie.id = QStringLiteral("movie-1"); + movie.title = QStringLiteral("Movie One"); + movie.itemType = QStringLiteral("Movie"); + results.push_back(std::move(movie)); + + MovieItem series; + series.id = QStringLiteral("series-1"); + series.title = QStringLiteral("Series One"); + series.itemType = QStringLiteral("Series"); + results.push_back(std::move(series)); + + MovieItem episode; + episode.id = QStringLiteral("episode-row"); + episode.title = QStringLiteral("The Loaded Episode"); + episode.itemType = QStringLiteral("Episode"); + episode.seriesId = QStringLiteral("series-1"); + episode.seasonId = QStringLiteral("season-1"); + episode.seriesName = QStringLiteral("Series One"); + episode.seriesPrimaryImageTag = QStringLiteral("series-primary-tag"); + episode.seasonNumber = 2; + episode.episodeNumber = 7; + results.push_back(std::move(episode)); + + MovieItem photo; + photo.id = QStringLiteral("photo-1"); + photo.title = QStringLiteral("Photo One"); + photo.itemType = QStringLiteral("Photo"); + results.push_back(std::move(photo)); + + co_return results; + } - return new MemoryReply( - request, operation, jsonBytes({ { QStringLiteral("Items"), QJsonArray {} } }), 404, this); + QCoro::Task> fetchSearchSuggestions(int limit = 20) override + { + Q_UNUSED(limit); + co_return std::vector {}; } }; @@ -388,7 +364,7 @@ bool waitForDetailRowsIdle(ContentModelController& controller, int timeoutMs) return !controller.detailRowsBusy(); } -bool waitForBrowsePage(JellyfinApiFacade& api, const BrowseDescriptor& descriptor, const QVariantMap& queryOptions, +bool waitForBrowsePage(Catalog& catalog, const BrowseDescriptor& descriptor, const QVariantMap& queryOptions, PagedMovieItems& page, QString& error, int timeoutMs) { bool finished = false; @@ -398,7 +374,7 @@ bool waitForBrowsePage(JellyfinApiFacade& api, const BrowseDescriptor& descripto QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); JellyfinNative::Async::runDetached( - api.fetchBrowsePage(descriptor, 0, 72, queryOptions), + catalog.fetchBrowsePage(descriptor, 0, 72, queryOptions), [&page, &finished, &loop](PagedMovieItems value) { page = std::move(value); finished = true; @@ -433,6 +409,9 @@ bool waitForSearch(SearchController& search, int timeoutMs) bool waitForHomeRows(HomeModelController& home, int timeoutMs) { + if (home.latestLibraryRows().size() == 3) + return true; + bool changed = false; QEventLoop loop; QTimer timeout; @@ -444,7 +423,7 @@ bool waitForHomeRows(HomeModelController& home, int timeoutMs) }); timeout.start(timeoutMs); loop.exec(); - return changed; + return changed || home.latestLibraryRows().size() == 3; } bool waitForPersonRows(ContentModelController& controller, int timeoutMs) @@ -457,6 +436,7 @@ bool waitForPersonRows(ContentModelController& controller, int timeoutMs) if (!controller.personItemsBusy()) loop.quit(); }); + controller.loadPersonItems(QStringLiteral("person-1")); timeout.start(timeoutMs); if (controller.personItemsBusy()) @@ -464,124 +444,15 @@ bool waitForPersonRows(ContentModelController& controller, int timeoutMs) return !controller.personItemsBusy(); } -bool personCreditsWerePaged(const QVector& urls) -{ - QSet starts; - for (const QUrl& url : urls) { - const QUrlQuery query(url); - if (url.path() != QStringLiteral("/Items") - || query.queryItemValue(QStringLiteral("personIds")) != QStringLiteral("person-1")) - continue; - require(!query.hasQueryItem(QStringLiteral("mediaTypes")), - "person credits constrained media types in a way that excludes series"); - starts.insert(query.queryItemValue(QStringLiteral("startIndex")).toInt()); - } - return starts.contains(0) && starts.contains(200); -} - -int searchRequestCount(const QVector& urls) -{ - return static_cast(std::count_if(urls.cbegin(), urls.cend(), [](const QUrl& url) { - return url.path() == QStringLiteral("/Items") - && QUrlQuery(url).queryItemValue(QStringLiteral("searchTerm")) == QStringLiteral("mixed"); - })); -} - -bool searchRequestAllowsSeries(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - return url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("searchTerm")) == QStringLiteral("mixed") - && query.queryItemValue(QStringLiteral("includeItemTypes")).contains(QStringLiteral("Series")) - && !query.hasQueryItem(QStringLiteral("mediaTypes")); - }); -} - -bool searchRequestAllowsMixedLibraries(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - const QString types = query.queryItemValue(QStringLiteral("includeItemTypes")); - return query.queryItemValue(QStringLiteral("searchTerm")) == QStringLiteral("mixed") - && types.contains(QStringLiteral("Audio")) && types.contains(QStringLiteral("Photo")) - && types.contains(QStringLiteral("Person")) && !query.hasQueryItem(QStringLiteral("parentId")); - }); -} - -int latestRequestCount(const QVector& urls) -{ - return static_cast(std::count_if(urls.cbegin(), urls.cend(), - [](const QUrl& url) { return url.path() == QStringLiteral("/Users/user-1/Items/Latest"); })); -} - -bool latestRequestsAreUnfiltered(const QVector& urls) -{ - return std::all_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - return url.path() != QStringLiteral("/Users/user-1/Items/Latest") - || !QUrlQuery(url).hasQueryItem(QStringLiteral("includeItemTypes")); - }); -} - -bool requestedOrderedPlaylistItems(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - - return url.path() == QStringLiteral("/Playlists/playlist-1/Items") - && !query.hasQueryItem(QStringLiteral("parentId")) && !query.hasQueryItem(QStringLiteral("sortBy")); - }); -} - -bool requestedMovieCollections(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - return url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("parentId")) == QStringLiteral("movies-id") - && query.queryItemValue(QStringLiteral("includeItemTypes")) == QStringLiteral("BoxSet") - && query.queryItemValue(QStringLiteral("recursive")) == QStringLiteral("false") - && !query.hasQueryItem(QStringLiteral("mediaTypes")); - }); -} - -bool requestedBoxSetChildren(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - const QStringList types - = query.queryItemValue(QStringLiteral("includeItemTypes")).split(QLatin1Char(','), Qt::SkipEmptyParts); - return url.path() == QStringLiteral("/Items") - && query.queryItemValue(QStringLiteral("parentId")) == QStringLiteral("boxset-1") - && query.queryItemValue(QStringLiteral("recursive")) == QStringLiteral("false") - && types.contains(QStringLiteral("Movie")) && types.contains(QStringLiteral("Series")) - && types.contains(QStringLiteral("Episode")); - }); -} - -bool requestedPathWithSeason(const QVector& urls) -{ - return std::any_of(urls.cbegin(), urls.cend(), [](const QUrl& url) { - const QUrlQuery query(url); - return url.path() == QStringLiteral("/Shows/series-1/Episodes") - && query.queryItemValue(QStringLiteral("seasonId")) == QStringLiteral("season-1"); - }); -} - } // namespace JELLYFIN_TEST_MAIN("content-model-controller") { QCoreApplication app(argc, argv); - FakeNetworkAccessManager network; - TlsTrustController tlsTrust; - JellyfinApiFacade api(&network, &tlsTrust); - api.setServerUrl(QStringLiteral("http://192.168.1.2")); - api.setSession(AuthSession { - QStringLiteral("user-1"), QStringLiteral("Tester"), QStringLiteral("token-1"), QStringLiteral("server-1") }); - LibraryPrefetchController prefetch(&api); - ContentModelController controller(&api, &prefetch); + TestCatalog catalog; + LibraryPrefetchController prefetch(&catalog); + ContentModelController controller(&catalog, &prefetch); MovieItem displayedDetail; QObject::connect(&controller, &ContentModelController::detailItemChanged, &controller, [&] { displayedDetail = controller.detailItem(); }); @@ -617,14 +488,8 @@ JELLYFIN_TEST_MAIN("content-model-controller") controller.updateFavorite(QStringLiteral("movie-1"), true); require(displayedDetail.favorite, "favorite update left stale detail state"); - SearchController search(&api, &prefetch); + SearchController search(&catalog, &prefetch); require(waitForSearch(search, 1000), "mixed search did not finish with all result types"); - require(searchRequestCount(network.requestedUrls) == 2, - "mixed search should issue a broad query and one series-safe query"); - require(searchRequestAllowsSeries(network.requestedUrls), - "mixed search constrained media types in a way that excludes series containers"); - require(searchRequestAllowsMixedLibraries(network.requestedUrls), - "mixed search omitted media types used by music, photo, or people libraries"); require(search.movieResults()->rowCount() == 1, "mixed search did not partition its movie result"); require(search.seriesResults()->rowCount() == 1, "mixed search did not partition its series result"); require(search.episodeResults()->rowCount() == 1, "mixed search did not partition its episode result"); @@ -636,7 +501,6 @@ JELLYFIN_TEST_MAIN("content-model-controller") "episode search result did not expose its season and episode label"); require(waitForPersonRows(controller, 1000), "person credits did not finish loading"); - require(personCreditsWerePaged(network.requestedUrls), "person credits stopped after the first API page"); const QVariantList personRows = controller.personItemRows(); require(personRows.size() == 3, "person credits did not produce movies, shows, and guest-season rows"); require(personRows.at(0).toMap().value(QStringLiteral("title")).toString() == QStringLiteral("Movies"), @@ -657,12 +521,10 @@ JELLYFIN_TEST_MAIN("content-model-controller") PagedMovieItems playlistPage; QString browseError; - require(waitForBrowsePage(api, + require(waitForBrowsePage(catalog, BrowseDescriptor::playlist(QStringLiteral("playlist-1"), QStringLiteral("Ordered Playlist")), {}, playlistPage, browseError, 1000), "playlist browse page was not fetched"); - require(requestedOrderedPlaylistItems(network.requestedUrls), - "playlist browse did not use the ordered playlist items endpoint"); require(playlistPage.items.size() == 1, "playlist browse response was not exposed as one item"); MovieGridModel playlistModel; @@ -676,13 +538,11 @@ JELLYFIN_TEST_MAIN("content-model-controller") PagedMovieItems collectionsPage; browseError.clear(); require( - waitForBrowsePage(api, + waitForBrowsePage(catalog, BrowseDescriptor::library(QStringLiteral("movies-id"), QStringLiteral("movies"), QStringLiteral("Films")), QVariantMap { { QStringLiteral("includeItemTypes"), QStringList { QStringLiteral("BoxSet") } } }, collectionsPage, browseError, 1000), "movie collection-filter browse page was not fetched"); - require(requestedMovieCollections(network.requestedUrls), - "movie collection filter did not request BoxSet without a video media type"); require(collectionsPage.items.size() == 1 && collectionsPage.items.front().itemType == QStringLiteral("BoxSet"), "movie collection-filter browse did not expose BoxSet rows"); @@ -690,8 +550,6 @@ JELLYFIN_TEST_MAIN("content-model-controller") QStringLiteral("episode-1"), QStringLiteral("Episode"), QStringLiteral("series-1"), QStringLiteral("season-1")); require(waitForDetailRowsIdle(controller, 1000), "episode detail rows did not finish loading"); - require(requestedPathWithSeason(network.requestedUrls), - "episode detail rows did not request the selected season's episodes"); MovieGridModel *episodes = controller.detailSeasons(); require(episodes->rowCount() == 1, "episode detail rows did not expose fetched episodes"); @@ -712,8 +570,6 @@ JELLYFIN_TEST_MAIN("content-model-controller") controller.loadDetailRows(QStringLiteral("boxset-1"), QStringLiteral("BoxSet"), QString(), QString()); require(waitForDetailRowsIdle(controller, 1000), "box set detail rows did not finish loading"); - require(requestedBoxSetChildren(network.requestedUrls), - "box set detail rows did not request collection children through browse"); MovieGridModel *boxSetChildren = controller.detailSeasons(); require(boxSetChildren->rowCount() == 1, "box set detail rows did not expose collection children"); @@ -723,20 +579,14 @@ JELLYFIN_TEST_MAIN("content-model-controller") "box set child id was not populated from the browse response"); require(boxSetRow.itemType == QStringLiteral("Movie"), "box set child type was not preserved"); - HomeModelController home(nullptr, &api, &prefetch); + HomeModelController home(nullptr, &catalog, &prefetch); const std::vector homeLibraries { - library(QStringLiteral("shows-id"), QStringLiteral("Shows"), QStringLiteral("tvshows")), - library(QStringLiteral("single-show-id"), QStringLiteral("Single Show"), QStringLiteral("tvshows")), - library(QStringLiteral("photos-id"), QStringLiteral("Photos"), QStringLiteral("photos")), + makeLibrary(QStringLiteral("shows-id"), QStringLiteral("Shows"), QStringLiteral("tvshows")), + makeLibrary(QStringLiteral("single-show-id"), QStringLiteral("Single Show"), QStringLiteral("tvshows")), + makeLibrary(QStringLiteral("photos-id"), QStringLiteral("Photos"), QStringLiteral("photos")), }; home.refresh(homeLibraries); - require(latestRequestCount(network.requestedUrls) == 3, - "home latest requests were not all dispatched before the event loop resumed"); - require(latestRequestsAreUnfiltered(network.requestedUrls), - "home latest requests retained a movie/series/episode-only filter"); require(waitForHomeRows(home, 1000), "home latest rows did not finish loading"); - require(latestRequestCount(network.requestedUrls) == 4, - "home did not expand the latest request until grouping exhausted the server results"); const QVariantList latestRows = home.latestLibraryRows(); require(latestRows.size() == 3, "home did not expose one latest row for each supported library"); @@ -802,10 +652,5 @@ JELLYFIN_TEST_MAIN("content-model-controller") showItems->get(0).title == QStringLiteral("Updated episode"), "home did not update a stable latest-row model"); require(latestStructureChanges == 0, "home emitted a row-structure change for content-only updates"); - require(!network.connectionCacheExpirySeconds.isEmpty() - && std::all_of(network.connectionCacheExpirySeconds.cbegin(), network.connectionCacheExpirySeconds.cend(), - [](int seconds) { return seconds >= 15 * 60; }), - "API requests did not preserve idle server connections across navigation pauses"); - return EXIT_SUCCESS; } From a61accac0e157979b55b36bba4f4a1b3a8221842 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 05:52:06 +1000 Subject: [PATCH 047/146] refactor(provider): replace native Jellyfin with multi-account provider platform - API 0.2 script runtime: source-lifetime host (websocket, events, timers), UDP discovery, stable error codes, worker-decoded details - ProviderRegistry: bundled + installed modules, accounts with credentials in the platform credential store, sign-in drafts, hot module restart - SourceHub: every enabled account behind one Provider; scoped ids, parallel libraries/home rows/search - ProviderStore: official/community catalogues, sha256-checked .tar.zst installs, add by GitHub/GitLab/JSON link, update policy - Generic group playback controller; remote commands split out of AppController - Accounts, add-provider and provider-screen pages; QML module renamed Spool - Remove native Jellyfin API, discovery, session, SyncPlay, remote control, library management and peer link code Tests are not yet updated for the new API. --- CMakeLists.txt | 185 +- cmake/ProviderBundle.cmake | 60 + .../bundled/spool-jellyfin-0.1.0-alpha.1.zip | Bin 11290 -> 0 bytes providers/lock.json | 11 +- qml/browse/BrowseSurface.qml | 34 +- qml/pages/AccountsPage.qml | 231 ++ qml/pages/AddProviderPage.qml | 490 ++++ qml/pages/PlayerAudioSyncPanel.qml | 2 +- qml/pages/PlayerBrowsePanel.qml | 2 +- qml/pages/PlayerMusicStage.qml | 6 +- qml/pages/PlayerOverlayChrome.qml | 5 +- qml/pages/PlayerOverlayPage.qml | 4 +- qml/pages/PlayerQueuePanel.qml | 2 +- qml/pages/PlayerQueueRow.qml | 2 +- qml/pages/PlayerStatsPanel.qml | 2 +- qml/pages/ProviderScreenPage.qml | 17 + qml/pages/SettingsPage.qml | 25 +- qml/primitives/ProviderIcon.qml | 37 + qml/providers/jellyfin/LoginAccountStep.qml | 211 -- qml/providers/jellyfin/LoginBrandPanel.qml | 79 - qml/providers/jellyfin/LoginPage.qml | 364 --- .../jellyfin/LoginProfileDialogs.qml | 225 -- qml/providers/jellyfin/LoginProfilePicker.qml | 206 -- qml/providers/jellyfin/LoginServerStep.qml | 419 --- qml/providers/jellyfin/ManagementDialog.qml | 199 -- qml/providers/jellyfin/ProfileNavigation.js | 28 - qml/providers/jellyfin/RemoteControlMenu.qml | 196 -- qml/providers/jellyfin/RemoteControlPage.qml | 681 ----- .../jellyfin/RemoteNowPlayingBar.qml | 472 ---- qml/shell/AppShell.qml | 274 +- qml/shell/DiagnosticsOverlay.qml | 13 +- .../SyncPlayMenu.qml => shell/GroupMenu.qml} | 127 +- qml/shell/ItemContextMenu.qml | 75 +- qml/shell/ProviderSurface.qml | 146 ++ qml/shell/ProviderUpdatePrompt.qml | 76 + qml/shell/RouteStack.qml | 31 +- qml/shell/TopBar.qml | 425 +--- qml/shell/TopBarNavigation.js | 50 - qml/shell/VideoSurface.qml | 2 +- qml/theme/Theme.qml | 2 +- sdk/provider.d.ts | 259 +- sdk/spool-provider.py | 203 ++ src/api/HttpRequestPolicy.h | 91 - src/api/JellyfinApiFacade.cpp | 942 ------- src/api/JellyfinApiFacade.h | 168 -- src/api/JellyfinJsAdapter.cpp | 699 ----- src/api/JellyfinJsAdapter.h | 148 -- src/api/JellyfinProvider.cpp | 367 --- src/api/JellyfinProvider.h | 151 -- src/api/JellyfinSession.h | 18 - src/api/JellyfinSettingsBridge.cpp | 53 - src/api/JellyfinSettingsBridge.h | 31 - src/api/PlaybackNegotiation.cpp | 285 --- src/api/PlaybackNegotiation.h | 33 - src/api/ProviderBenchmark.cpp | 299 --- src/api/ProviderBenchmark.h | 25 - src/app/AccountProfile.cpp | 46 - src/app/AccountProfile.h | 26 - src/app/AppController.cpp | 449 +--- src/app/AppController.h | 46 +- src/app/AppControllerRemote.cpp | 183 ++ src/app/{SyncPlayClock.cpp => GroupClock.cpp} | 26 +- src/app/{SyncPlayClock.h => GroupClock.h} | 10 +- src/app/GroupPlaybackController.cpp | 788 ++++++ ...Controller.h => GroupPlaybackController.h} | 160 +- src/app/LibraryManagementController.cpp | 351 --- src/app/LibraryManagementController.h | 69 - src/app/QuickConnectController.cpp | 180 -- src/app/QuickConnectController.h | 51 - src/app/RemoteControlController.cpp | 967 ------- src/app/RemoteControlController.h | 269 -- src/app/SessionController.cpp | 519 ---- src/app/SessionController.h | 117 - src/app/SettingsController.cpp | 106 +- src/app/SettingsController.h | 29 +- src/app/SettingsSchema.cpp | 28 +- src/app/SettingsSchema.h | 9 +- src/app/SpoolLink.cpp | 606 ----- src/app/SpoolLink.h | 172 -- src/app/SpoolLinkCodec.cpp | 125 - src/app/SpoolLinkCodec.h | 58 - src/app/SpoolRemoteProtocol.cpp | 180 -- src/app/SpoolRemoteProtocol.h | 65 - src/app/SyncPlayController.cpp | 1134 --------- src/cache/DatabaseManager.cpp | 213 +- src/cache/DatabaseManager.h | 17 +- src/common/AsyncTask.h | 27 + src/discovery/DiscoveredServer.h | 23 - src/discovery/DiscoveryController.cpp | 704 ----- src/discovery/DiscoveryController.h | 83 - src/main.cpp | 233 +- src/models/DiscoveredServerModel.cpp | 112 - src/models/DiscoveredServerModel.h | 40 - src/platform/PlatformSettingsPolicy.h | 1 - src/platform/android/AndroidPlatform.cpp | 4 - src/platform/linux/LinuxSettingsPolicy.cpp | 4 - src/platform/macos/MacOSSettingsPolicy.cpp | 4 - src/platform/webos/WebOSSettingsPolicy.cpp | 4 - .../windows/WindowsSettingsPolicy.cpp | 4 - src/provider/PortableProvider.cpp | 424 ++++ src/provider/PortableProvider.h | 118 + src/provider/Provider.h | 142 +- src/provider/ProviderCapabilities.h | 75 + src/provider/ProviderMediaPage.cpp | 231 +- src/provider/ProviderMediaPage.h | 23 +- src/provider/ProviderPackage.cpp | 561 ++-- src/provider/ProviderPackage.h | 66 +- src/provider/ProviderRegistry.cpp | 760 +++++- src/provider/ProviderRegistry.h | 307 ++- src/provider/ProviderStore.cpp | 423 +++ src/provider/ProviderStore.h | 114 + src/provider/ProviderUiContext.cpp | 178 +- src/provider/ProviderUiContext.h | 64 +- src/provider/RemotePlayback.h | 42 - src/provider/ScriptBridge.cpp | 474 ++++ src/provider/ScriptBridge.h | 149 ++ src/provider/ScriptRuntime.cpp | 798 ++---- src/provider/ScriptRuntime.h | 35 +- src/provider/SourceHub.cpp | 597 +++++ src/provider/SourceHub.h | 172 ++ src/provider/puff.c | 827 ------ src/provider/puff.h | 42 - src/providers/local/LocalProvider.cpp | 46 +- src/providers/local/LocalProvider.h | 21 +- tests/api/HttpRequestPolicyTest.cpp | 67 - tests/api/JellyfinApiFacadeUrlTest.cpp | 120 - tests/api/JellyfinJsAdapterTest.cpp | 106 - tests/api/PlaybackNegotiationTest.cpp | 194 -- ...ncPlayClockTest.cpp => GroupClockTest.cpp} | 6 +- tests/app/GroupPlaybackPolicyTest.cpp | 94 + tests/app/RemoteControlControllerTest.cpp | 263 -- tests/app/SessionControllerTest.cpp | 202 -- tests/app/SpoolLinkCodecTest.cpp | 91 - tests/app/SpoolRemoteProtocolTest.cpp | 94 - tests/app/SyncPlayDriftPolicyTest.cpp | 62 - tests/app/SyncPlayQueueHandoffTest.cpp | 53 - tests/discovery/DiscoveryControllerTest.cpp | 114 - .../ProviderRegistryTest.cpp | 0 tests/providers/ProviderStoreTest.cpp | 6 + tests/providers/SourceHubTest.cpp | 6 + tests/providers/SourceRegistryTest.cpp | 155 -- .../StreamQualityControlTest.cpp | 0 .../ProviderCapabilities.qml | 37 - tests/qml/provider-capabilities/qmldir | 1 - tests/qml/tst_ProfileNavigation.qml | 44 - tests/qml/tst_ProviderGating.qml | 52 - third_party/zstd/LICENSE | 30 + third_party/zstd/bounded_zstd.c | 114 + third_party/zstd/bounded_zstd.h | 18 + third_party/zstd/zstd_decompress.c | 2257 +++++++++++++++++ third_party/zstd/zstd_decompress.h | 58 + tools/bundle-providers.py | 76 - tools/check-module-seam.sh | 94 +- tools/provider-package.py | 218 -- 154 files changed, 10118 insertions(+), 18633 deletions(-) create mode 100644 cmake/ProviderBundle.cmake delete mode 100644 providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip create mode 100644 qml/pages/AccountsPage.qml create mode 100644 qml/pages/AddProviderPage.qml create mode 100644 qml/pages/ProviderScreenPage.qml create mode 100644 qml/primitives/ProviderIcon.qml delete mode 100644 qml/providers/jellyfin/LoginAccountStep.qml delete mode 100644 qml/providers/jellyfin/LoginBrandPanel.qml delete mode 100644 qml/providers/jellyfin/LoginPage.qml delete mode 100644 qml/providers/jellyfin/LoginProfileDialogs.qml delete mode 100644 qml/providers/jellyfin/LoginProfilePicker.qml delete mode 100644 qml/providers/jellyfin/LoginServerStep.qml delete mode 100644 qml/providers/jellyfin/ManagementDialog.qml delete mode 100644 qml/providers/jellyfin/ProfileNavigation.js delete mode 100644 qml/providers/jellyfin/RemoteControlMenu.qml delete mode 100644 qml/providers/jellyfin/RemoteControlPage.qml delete mode 100644 qml/providers/jellyfin/RemoteNowPlayingBar.qml rename qml/{providers/jellyfin/SyncPlayMenu.qml => shell/GroupMenu.qml} (60%) create mode 100644 qml/shell/ProviderSurface.qml create mode 100644 qml/shell/ProviderUpdatePrompt.qml delete mode 100644 qml/shell/TopBarNavigation.js create mode 100755 sdk/spool-provider.py delete mode 100644 src/api/HttpRequestPolicy.h delete mode 100644 src/api/JellyfinApiFacade.cpp delete mode 100644 src/api/JellyfinApiFacade.h delete mode 100644 src/api/JellyfinJsAdapter.cpp delete mode 100644 src/api/JellyfinJsAdapter.h delete mode 100644 src/api/JellyfinProvider.cpp delete mode 100644 src/api/JellyfinProvider.h delete mode 100644 src/api/JellyfinSession.h delete mode 100644 src/api/JellyfinSettingsBridge.cpp delete mode 100644 src/api/JellyfinSettingsBridge.h delete mode 100644 src/api/PlaybackNegotiation.cpp delete mode 100644 src/api/PlaybackNegotiation.h delete mode 100644 src/api/ProviderBenchmark.cpp delete mode 100644 src/api/ProviderBenchmark.h delete mode 100644 src/app/AccountProfile.cpp delete mode 100644 src/app/AccountProfile.h create mode 100644 src/app/AppControllerRemote.cpp rename src/app/{SyncPlayClock.cpp => GroupClock.cpp} (71%) rename src/app/{SyncPlayClock.h => GroupClock.h} (78%) create mode 100644 src/app/GroupPlaybackController.cpp rename src/app/{SyncPlayController.h => GroupPlaybackController.h} (62%) delete mode 100644 src/app/LibraryManagementController.cpp delete mode 100644 src/app/LibraryManagementController.h delete mode 100644 src/app/QuickConnectController.cpp delete mode 100644 src/app/QuickConnectController.h delete mode 100644 src/app/RemoteControlController.cpp delete mode 100644 src/app/RemoteControlController.h delete mode 100644 src/app/SessionController.cpp delete mode 100644 src/app/SessionController.h delete mode 100644 src/app/SpoolLink.cpp delete mode 100644 src/app/SpoolLink.h delete mode 100644 src/app/SpoolLinkCodec.cpp delete mode 100644 src/app/SpoolLinkCodec.h delete mode 100644 src/app/SpoolRemoteProtocol.cpp delete mode 100644 src/app/SpoolRemoteProtocol.h delete mode 100644 src/app/SyncPlayController.cpp delete mode 100644 src/discovery/DiscoveredServer.h delete mode 100644 src/discovery/DiscoveryController.cpp delete mode 100644 src/discovery/DiscoveryController.h delete mode 100644 src/models/DiscoveredServerModel.cpp delete mode 100644 src/models/DiscoveredServerModel.h create mode 100644 src/provider/PortableProvider.cpp create mode 100644 src/provider/PortableProvider.h create mode 100644 src/provider/ProviderCapabilities.h create mode 100644 src/provider/ProviderStore.cpp create mode 100644 src/provider/ProviderStore.h delete mode 100644 src/provider/RemotePlayback.h create mode 100644 src/provider/ScriptBridge.cpp create mode 100644 src/provider/ScriptBridge.h create mode 100644 src/provider/SourceHub.cpp create mode 100644 src/provider/SourceHub.h delete mode 100644 src/provider/puff.c delete mode 100644 src/provider/puff.h delete mode 100644 tests/api/HttpRequestPolicyTest.cpp delete mode 100644 tests/api/JellyfinApiFacadeUrlTest.cpp delete mode 100644 tests/api/JellyfinJsAdapterTest.cpp delete mode 100644 tests/api/PlaybackNegotiationTest.cpp rename tests/app/{SyncPlayClockTest.cpp => GroupClockTest.cpp} (91%) create mode 100644 tests/app/GroupPlaybackPolicyTest.cpp delete mode 100644 tests/app/RemoteControlControllerTest.cpp delete mode 100644 tests/app/SessionControllerTest.cpp delete mode 100644 tests/app/SpoolLinkCodecTest.cpp delete mode 100644 tests/app/SpoolRemoteProtocolTest.cpp delete mode 100644 tests/app/SyncPlayDriftPolicyTest.cpp delete mode 100644 tests/app/SyncPlayQueueHandoffTest.cpp delete mode 100644 tests/discovery/DiscoveryControllerTest.cpp rename tests/{app => providers}/ProviderRegistryTest.cpp (100%) create mode 100644 tests/providers/ProviderStoreTest.cpp create mode 100644 tests/providers/SourceHubTest.cpp delete mode 100644 tests/providers/SourceRegistryTest.cpp rename tests/{api => providers}/StreamQualityControlTest.cpp (100%) delete mode 100644 tests/qml/provider-capabilities/ProviderCapabilities.qml delete mode 100644 tests/qml/provider-capabilities/qmldir delete mode 100644 tests/qml/tst_ProfileNavigation.qml delete mode 100644 tests/qml/tst_ProviderGating.qml create mode 100644 third_party/zstd/LICENSE create mode 100644 third_party/zstd/bounded_zstd.c create mode 100644 third_party/zstd/bounded_zstd.h create mode 100644 third_party/zstd/zstd_decompress.c create mode 100644 third_party/zstd/zstd_decompress.h delete mode 100755 tools/bundle-providers.py delete mode 100755 tools/provider-package.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ce71f30a..bf7fba7c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,14 +132,9 @@ message(STATUS "${spool_splash_output}") jellyfin_resolve_platform_dependencies() -# The source groups below mark the seam a spool-jellyfin / spool-plex / -# spool-stremio split would cut along. They all compile into one library for -# now; keeping them apart here is what makes the split mechanical later. -# -# The rule that matters: nothing in the platform, player or shell groups may -# include src/api/, src/discovery/ or src/providers/. Core reaches a media -# source only through the interfaces in src/provider/; -# tools/check-module-seam.sh enforces it as the module-seam test. +# Core never includes src/providers/ (native providers); every media source +# is reached through src/provider/. tools/check-module-seam.sh enforces it +# as the module-seam test. # Per-OS abstractions. No backend and no UI knowledge. set(SPOOL_PLATFORM_SOURCES @@ -198,8 +193,6 @@ set(SPOOL_PLAYER_SOURCES # Application plumbing that does not name a media backend. set(SPOOL_SHELL_SOURCES - src/app/AccountProfile.cpp - src/app/AccountProfile.h src/app/ArtworkImageProvider.cpp src/app/ArtworkImageProvider.h src/app/ArtworkPrefetcher.h @@ -231,14 +224,15 @@ set(SPOOL_SHELL_SOURCES src/app/SettingsController.h src/app/SettingsSchema.cpp src/app/SettingsSchema.h - src/app/SpoolLinkCodec.cpp - src/app/SpoolLinkCodec.h - src/app/SpoolRemoteProtocol.cpp - src/app/SpoolRemoteProtocol.h src/app/UpdateManifest.cpp src/app/UpdateManifest.h src/app/AppController.cpp src/app/AppController.h + src/app/AppControllerRemote.cpp + src/app/GroupClock.cpp + src/app/GroupClock.h + src/app/GroupPlaybackController.cpp + src/app/GroupPlaybackController.h src/cache/DatabaseManager.cpp src/cache/DatabaseManager.h src/common/AsyncTask.h @@ -259,26 +253,33 @@ set(SPOOL_SHELL_SOURCES src/provider/Catalog.h src/provider/GroupPlayback.h src/provider/PlaybackSource.h + src/provider/PortableProvider.cpp + src/provider/PortableProvider.h src/provider/Provider.h + src/provider/ProviderCapabilities.h + src/provider/ProviderMediaPage.cpp + src/provider/ProviderMediaPage.h src/provider/ProviderPackage.cpp src/provider/ProviderPackage.h src/provider/ProviderQmlCache.cpp src/provider/ProviderQmlCache.h + src/provider/ProviderRegistry.cpp + src/provider/ProviderRegistry.h + src/provider/ProviderStore.cpp + src/provider/ProviderStore.h src/provider/ProviderUiContext.cpp src/provider/ProviderUiContext.h - src/provider/ProviderMediaPage.cpp - src/provider/ProviderMediaPage.h + src/provider/ScriptBridge.cpp + src/provider/ScriptBridge.h src/provider/ScriptRuntime.cpp src/provider/ScriptRuntime.h - src/provider/ProviderRegistry.cpp - src/provider/ProviderRegistrySources.cpp - src/provider/ProviderRegistry.h - src/provider/puff.c - src/provider/puff.h - src/provider/RemotePlayback.h src/provider/SearchSource.h + src/provider/SourceHub.cpp + src/provider/SourceHub.h src/provider/StreamQualityControl.h src/provider/UserItemStateSink.h + third_party/zstd/bounded_zstd.c + third_party/zstd/bounded_zstd.h src/diagnostics/Diagnostics.cpp src/diagnostics/Diagnostics.h src/diagnostics/InputLatencyMonitor.cpp @@ -289,46 +290,7 @@ set(SPOOL_SHELL_SOURCES src/diagnostics/SystemPerformanceMonitor.h ) -# Jellyfin REST, server discovery, and the controllers and models shaped -# by them. This is the group a second backend would grow a sibling to. -set(SPOOL_JELLYFIN_SOURCES - src/api/HttpRequestPolicy.h - src/api/JellyfinApiFacade.cpp - src/api/JellyfinApiFacade.h - src/api/JellyfinJsAdapter.cpp - src/api/JellyfinJsAdapter.h - src/api/JellyfinSession.h - src/api/JellyfinSettingsBridge.cpp - src/api/JellyfinProvider.cpp - src/api/JellyfinProvider.h - src/api/JellyfinSettingsBridge.h - src/api/PlaybackNegotiation.cpp - src/api/PlaybackNegotiation.h - src/api/ProviderBenchmark.cpp - src/api/ProviderBenchmark.h - src/app/LibraryManagementController.cpp - src/app/LibraryManagementController.h - src/app/QuickConnectController.cpp - src/app/QuickConnectController.h - src/app/RemoteControlController.cpp - src/app/RemoteControlController.h - src/app/SpoolLink.cpp - src/app/SpoolLink.h - src/app/SessionController.cpp - src/app/SessionController.h - src/app/SyncPlayClock.cpp - src/app/SyncPlayClock.h - src/app/SyncPlayController.cpp - src/app/SyncPlayController.h - src/discovery/DiscoveredServer.h - src/discovery/DiscoveryController.cpp - src/discovery/DiscoveryController.h - src/models/DiscoveredServerModel.cpp - src/models/DiscoveredServerModel.h -) - -# The folder-of-files provider: the second implementation of the provider -# contract, kept as its permanent fixture. Nothing in core may include it. +# The one native provider: media in a folder, and the contract's fixture. set(SPOOL_LOCAL_SOURCES src/providers/local/LocalProvider.cpp src/providers/local/LocalProvider.h @@ -338,7 +300,6 @@ set(JELLYFIN_CORE_SOURCES ${SPOOL_PLATFORM_SOURCES} ${SPOOL_PLAYER_SOURCES} ${SPOOL_SHELL_SOURCES} - ${SPOOL_JELLYFIN_SOURCES} ${SPOOL_LOCAL_SOURCES} ) @@ -350,42 +311,9 @@ qt_add_executable(jellyfin-native src/app/GraphicsStartup.h ) -# Bundle immutable, verified source bytes. This resource path also exercises -# source loading with no developer-generated QML/JS cache in provider tests. find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) -set(SPOOL_JELLYFIN_SOURCE_DIR "" CACHE PATH "Explicit development override for the portable Jellyfin source") -set(SPOOL_PROVIDER_RESOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/portable-providers") -set(spool_provider_bundle_args - "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json" "${SPOOL_PROVIDER_RESOURCE_DIR}") -if(SPOOL_JELLYFIN_SOURCE_DIR) - list(APPEND spool_provider_bundle_args --jellyfin-source "${SPOOL_JELLYFIN_SOURCE_DIR}") - file(GLOB_RECURSE spool_provider_override_files CONFIGURE_DEPENDS - "${SPOOL_JELLYFIN_SOURCE_DIR}/logic/*" - "${SPOOL_JELLYFIN_SOURCE_DIR}/ui/*" - "${SPOOL_JELLYFIN_SOURCE_DIR}/resources/*") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - ${spool_provider_override_files} - "${SPOOL_JELLYFIN_SOURCE_DIR}/manifest.json" - "${SPOOL_JELLYFIN_SOURCE_DIR}/LICENSE" - "${SPOOL_JELLYFIN_SOURCE_DIR}/NOTICE") -endif() -file(GLOB spool_provider_archives CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/providers/bundled/*.zip") -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json" - "${CMAKE_CURRENT_SOURCE_DIR}/tools/bundle-providers.py" - "${CMAKE_CURRENT_SOURCE_DIR}/tools/provider-package.py" - ${spool_provider_archives}) -execute_process( - COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/bundle-providers.py" - ${spool_provider_bundle_args} - RESULT_VARIABLE spool_provider_bundle_result - ERROR_VARIABLE spool_provider_bundle_error - OUTPUT_QUIET) -if(NOT spool_provider_bundle_result EQUAL 0) - message(FATAL_ERROR "Bundling provider sources failed: ${spool_provider_bundle_error}") -endif() -qt_add_resources(spool_provider_bundle_sources "${SPOOL_PROVIDER_RESOURCE_DIR}/providers.qrc") -target_sources(jellyfin-native PRIVATE ${spool_provider_bundle_sources}) +include(cmake/ProviderBundle.cmake) +spool_bundle_providers(jellyfin-native) set(JELLYFIN_NATIVE_TARGETS jellyfin-core jellyfin-native) jellyfin_configure_platform_targets(jellyfin-native jellyfin-core) @@ -531,7 +459,7 @@ endif() qt_add_qml_module(jellyfin-native ${JELLYFIN_QML_MODULE_OPTIONS} - URI JellyfinWebOS + URI Spool VERSION 1.0 QML_FILES qml/Main.qml @@ -563,20 +491,10 @@ qt_add_qml_module(jellyfin-native qml/pages/SettingsPage.qml qml/pages/SettingsNavigation.js qml/pages/SubtitleSettingsPanel.qml - qml/pages/ProviderPickerPage.qml + qml/pages/AccountsPage.qml + qml/pages/AddProviderPage.qml + qml/pages/ProviderScreenPage.qml qml/primitives/ActionButton.qml - qml/providers/jellyfin/LoginPage.qml - qml/providers/jellyfin/LoginAccountStep.qml - qml/providers/jellyfin/LoginBrandPanel.qml - qml/providers/jellyfin/LoginProfileDialogs.qml - qml/providers/jellyfin/LoginProfilePicker.qml - qml/providers/jellyfin/LoginServerStep.qml - qml/providers/jellyfin/ProfileNavigation.js - qml/providers/jellyfin/RemoteControlPage.qml - qml/providers/jellyfin/RemoteControlMenu.qml - qml/providers/jellyfin/RemoteNowPlayingBar.qml - qml/providers/jellyfin/SyncPlayMenu.qml - qml/providers/jellyfin/ManagementDialog.qml qml/primitives/AppText.qml qml/primitives/AtomicViewReveal.qml qml/primitives/BusySpinner.qml @@ -606,6 +524,7 @@ qt_add_qml_module(jellyfin-native qml/primitives/PopupMenuPanel.qml qml/primitives/PopupShield.qml qml/primitives/ProfileTile.qml + qml/primitives/ProviderIcon.qml qml/primitives/SectionHeader.qml qml/primitives/ServerCard.qml qml/primitives/SelectRow.qml @@ -621,11 +540,14 @@ qt_add_qml_module(jellyfin-native qml/shell/KeyRouter.qml qml/shell/BusyOverlay.qml qml/shell/DiagnosticsOverlay.qml + qml/shell/GroupMenu.qml qml/shell/InputLatencyWarning.qml qml/shell/ItemContextMenu.qml qml/shell/RememberedCertificatesDialog.qml qml/shell/MediaInfoOverlay.qml qml/shell/OverlayDialog.qml + qml/shell/ProviderSurface.qml + qml/shell/ProviderUpdatePrompt.qml qml/shell/PageReadiness.js qml/shell/RoutePolicy.js qml/shell/RouteStack.qml @@ -633,7 +555,6 @@ qt_add_qml_module(jellyfin-native qml/shell/UpdateDialog.qml qml/shell/ToastLayer.qml qml/shell/TopBar.qml - qml/shell/TopBarNavigation.js qml/shell/VideoSurface.qml qml/theme/Metrics.qml qml/theme/Theme.qml @@ -809,33 +730,20 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # by add_test(). One executable per test used to cost more to build on # Windows than the application itself, because every one of them repeated a # whole-program-optimized link against all of jellyfin-core. - set(JELLYFIN_TEST_GROUPS api app cache common diagnostics discovery platform player providers) - - set(JELLYFIN_TESTS_api - http-request-policy tests/api/HttpRequestPolicyTest.cpp - stream-quality-control tests/api/StreamQualityControlTest.cpp - playback-negotiation tests/api/PlaybackNegotiationTest.cpp - jellyfin-api-facade-url tests/api/JellyfinApiFacadeUrlTest.cpp - jellyfin-js-adapter tests/api/JellyfinJsAdapterTest.cpp - ) + set(JELLYFIN_TEST_GROUPS app cache common diagnostics platform player providers) + set(JELLYFIN_TESTS_app browse-session-controller tests/app/BrowseSessionControllerTest.cpp settings-schema tests/app/SettingsSchemaTest.cpp settings-controller tests/app/SettingsControllerTest.cpp - session-controller tests/app/SessionControllerTest.cpp router-controller tests/app/RouterControllerTest.cpp library-query tests/app/LibraryQueryTest.cpp content-model-controller tests/app/ContentModelControllerTest.cpp search-controller tests/app/SearchControllerTest.cpp - remote-control-controller tests/app/RemoteControlControllerTest.cpp - spool-remote-protocol tests/app/SpoolRemoteProtocolTest.cpp - spool-link-codec tests/app/SpoolLinkCodecTest.cpp - sync-play-clock tests/app/SyncPlayClockTest.cpp - sync-play-queue-handoff tests/app/SyncPlayQueueHandoffTest.cpp - sync-play-drift-policy tests/app/SyncPlayDriftPolicyTest.cpp + group-clock tests/app/GroupClockTest.cpp + group-playback-policy tests/app/GroupPlaybackPolicyTest.cpp update-manifest tests/app/UpdateManifestTest.cpp artwork-url tests/app/ArtworkUrlTest.cpp - provider-registry tests/app/ProviderRegistryTest.cpp ) set(JELLYFIN_TESTS_cache database-manager tests/cache/DatabaseManagerTest.cpp @@ -852,9 +760,6 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) route-transition-timing tests/diagnostics/RouteTransitionTimingTest.cpp system-performance-monitor tests/diagnostics/SystemPerformanceMonitorTest.cpp ) - set(JELLYFIN_TESTS_discovery - discovery-controller tests/discovery/DiscoveryControllerTest.cpp - ) set(JELLYFIN_TESTS_platform screensaver-inhibitor tests/platform/ScreenSaverInhibitorTest.cpp native-window-input tests/platform/NativeAppWindowInputTest.cpp @@ -869,10 +774,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) local-provider tests/providers/LocalProviderTest.cpp script-runtime tests/providers/ScriptRuntimeTest.cpp provider-media-page tests/providers/ProviderMediaPageTest.cpp - source-registry tests/providers/SourceRegistryTest.cpp + provider-registry tests/providers/ProviderRegistryTest.cpp + source-hub tests/providers/SourceHubTest.cpp + provider-store tests/providers/ProviderStoreTest.cpp provider-ui-context tests/providers/ProviderUiContextTest.cpp provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp provider-package-unpack tests/providers/ProviderPackageTest.cpp + stream-quality-control tests/providers/StreamQualityControlTest.cpp ) set(JELLYFIN_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp @@ -959,13 +867,12 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${JELLYFIN_TEST_ENV}") - target_sources(providers-tests PRIVATE ${spool_provider_bundle_sources}) - target_sources(api-tests PRIVATE ${spool_provider_bundle_sources}) - add_test(NAME provider-package + target_sources(providers-tests PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) + add_test(NAME provider-sdk-tool COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") add_executable(provider-contract-runner sdk/provider-contract-runner.cpp) target_link_libraries(provider-contract-runner PRIVATE Qt6::Core Qt6::Qml) - target_sources(provider-contract-runner PRIVATE ${spool_provider_bundle_sources}) + target_sources(provider-contract-runner PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) add_test(NAME bundled-jellyfin COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") add_test(NAME bundled-jellyfin-interpreter @@ -1082,7 +989,6 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) viewport-focus-recovery tests/qml/tst_ViewportFocusRecovery.qml hold-navigation-controller tests/qml/tst_HoldNavigationController.qml library-navigation tests/qml/tst_LibraryNavigation.qml - profile-navigation tests/qml/tst_ProfileNavigation.qml settings-navigation tests/qml/tst_SettingsNavigation.qml settings-disclosure tests/qml/tst_SettingsDisclosure.qml settings-row-identity tests/qml/tst_SettingsRowIdentity.qml @@ -1091,7 +997,6 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) player-seek-bar-hover tests/qml/tst_PlayerSeekBarHover.qml list-scroll-bar tests/qml/tst_ListScrollBar.qml inline-slider tests/qml/tst_InlineSlider.qml - provider-gating tests/qml/tst_ProviderGating.qml ) # The subtitle panel test has never run on macOS; it was narrowed to # Windows alone while chasing that, which silently dropped Linux too. diff --git a/cmake/ProviderBundle.cmake b/cmake/ProviderBundle.cmake new file mode 100644 index 00000000..e9c565ae --- /dev/null +++ b/cmake/ProviderBundle.cmake @@ -0,0 +1,60 @@ +# Bundled providers: the pinned packages in providers/lock.json, unpacked at +# configure time into a Qt resource at qrc:/providers//. Nothing is +# downloaded here; a digest mismatch stops the configure. +# +# For provider development, SPOOL_PROVIDER_OVERRIDES takes id=/path/to/checkout +# pairs and bundles those working trees instead of their pins. + +set(SPOOL_PROVIDER_OVERRIDES "" CACHE STRING "id=/path pairs bundling provider checkouts instead of pinned packages") + +function(spool_bundle_providers target) + set(lock "${CMAKE_CURRENT_SOURCE_DIR}/providers/lock.json") + set(out "${CMAKE_CURRENT_BINARY_DIR}/bundled-providers") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${lock}") + file(READ "${lock}" json) + string(JSON count LENGTH "${json}" providers) + math(EXPR last "${count} - 1") + set(qrc "\n") + foreach(index RANGE 0 ${last}) + string(JSON id GET "${json}" providers ${index} id) + string(JSON archive GET "${json}" providers ${index} archive) + string(JSON expected GET "${json}" providers ${index} sha256) + set(root "${out}/${id}") + set(override "") + foreach(pair IN LISTS SPOOL_PROVIDER_OVERRIDES) + if(pair MATCHES "^${id}=(.+)$") + set(override "${CMAKE_MATCH_1}") + endif() + endforeach() + file(REMOVE_RECURSE "${root}") + if(override) + file(GLOB_RECURSE sources CONFIGURE_DEPENDS RELATIVE "${override}" + "${override}/manifest.json" "${override}/LICENSE" "${override}/NOTICE" + "${override}/logic/*" "${override}/ui/*" "${override}/assets/*") + foreach(source IN LISTS sources) + get_filename_component(directory "${root}/${source}" DIRECTORY) + file(COPY "${override}/${source}" DESTINATION "${directory}") + endforeach() + else() + set(package "${CMAKE_CURRENT_SOURCE_DIR}/providers/${archive}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${package}") + file(SHA256 "${package}" actual) + if(NOT actual STREQUAL expected) + message(FATAL_ERROR "${package} does not match its pin in providers/lock.json") + endif() + file(ARCHIVE_EXTRACT INPUT "${package}" DESTINATION "${root}") + endif() + file(GLOB_RECURSE files RELATIVE "${root}" "${root}/*") + list(SORT files) + string(APPEND qrc " \n") + foreach(file IN LISTS files) + string(APPEND qrc " ${root}/${file}\n") + endforeach() + string(APPEND qrc " \n") + endforeach() + string(APPEND qrc "\n") + file(CONFIGURE OUTPUT "${out}/providers.qrc" CONTENT "${qrc}" @ONLY) + qt_add_resources(resources "${out}/providers.qrc") + target_sources(${target} PRIVATE ${resources}) + set(SPOOL_PROVIDER_BUNDLE_SOURCES ${resources} PARENT_SCOPE) +endfunction() diff --git a/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip b/providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip deleted file mode 100644 index 440763ba5f42ced440f91bb5a053616a255d4322..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11290 zcmZ{qb8scVx94Nb#I|kQwr%sqwkEbGb~3ST-`KXD8*3(+owskRw%)IHyQ{0~bk(>2 zIOkM#_ot7FJR}qb7#JAbzd;HnT3USB_s`T41p|Zqx1t~`E~%t0S!>{sh|it!HEX=U z;tZS?%OF(Fy|!{8?ukz2G=UxD>)aPo+esTysIGMIOsx5Q)>g)xpz5aVC$K`HU{`Af z`UX|$fw`Uv=0z81}ZdUi4IcjX&*n)%i9C)_wg>6_yL=tqbJP-`WV#@_BA~ z8(e!DNF_Gle)42TstYQn`{2ddd7nJ@HQuOeL~g_N(C_TCn@I>{-7$%D1tWUX^6)EK0p`-8^%U?Sry16@BuB=qxMGB6;=bn8o#a!Yz&(urUe4fk=8 zJrj_E&#KpmUp&ntFIQsvy*~$+j|#aRNR-c>$yMiGEXm$nM;Cq1@VZ-Dm4@<#iSx^P z*MseD!hhUcdIA9L)d_W(D=Mij`WYkgDI+-vEq)Y>JafdJBwZ2LRSTcIzQJ>)C2wo^ z0rYf#2%?V{ZQ|-LvPh_S%~IxsNo-l`)cAv_>5=fAn&Ppug%|2z6%Xf;H`^Ev8toP4 zl#$DYMZ9g#BKOyhlkU(;ey=Vz4-P;rw&YkRTmAq$R0mgVn+3;xx=2?d{E1&!y?zoF5rJIz27^NKDsO@)}rDu0B% zvk)5kh~J76YwOEXQ*G?`2U1GZTDus;Q3s87{&;1Jm@gh%ISLuV5q@ibBKeUb+`Wd} zpdD4*-u(%dv;N$LueHF3^bsZ)7dh#V6Gfog_R6x0iOze2=!U6a(Ey81iGPz;8A31Y z*z^7>BeD4ygDE;oMP{?r5rceyYDYYs^|q56o@X#>EKKeBjXKYs2W6|P&4c<7^m`(4 z9gM`fTcb^;hdU5(fv~kpcz!R(jd+HI2Bh0CHP;&^KBPtZ9eFKs4+g{;Va5l{n-n{5 zY&rML<_ft;%&%~<_{BE>zioiDXR3Y|t-S0PM7>%Zm{awX2pAq+;0JY24&hy|Cx^AY zn82R8OMbs-mZEUGHgxF8WT{F7td?M_Y-_7KrovKKXH}9sv5h;iSOzkY4nXT6PLa{=>@%P>YF1)z zkvZv|sB|%KXbHk4IPR~ef(=HH~s6&zwI?fP*O!E7<6?d4QvueJ-D| z9cqQ8^*tK7P}+gC(cJt(>m4}pUmM<@Mhb`RBsE|N1wnpNkLa?G@qI&M1sou>6!i@K zr7Y}+hcd)gC_cjIb?Sw-rn#T`luv^-b&y+OJtw$Kpq9`v4orxbuXU0X8UotvU3mhBl8opASg1LP=#V_&+;_mGp|1abA1qhnOw-7S; z<-=Euh4o%8i=xs1{J0j9yu76A&_4`Vn8M^z-bnJt88f4Lrtgqw7{Zo~YW3hR-_|8v zGQIcUT^3UZLfU+Mo8X9g`L8^Agm+#d9xTtgDo!DBquY_G_+TxmY-C%UX!VnHmmzD? z%xGo9EzlVrsKjYWL*-QLrbS<0+q3dcSzF^OkisDdr%|4*YA&=NDiH>vjWz~nS#W{? zoS$N_8*ImA%3|;otU{I92IuR1V(GExu(I`LGHyVyk=hOx0ueuX2)lQofz5E*jh!lMc`{rF~Rxrjt+g3o3be+(RuX143uC5Q?%u~BUc2QmpY zo}e%XUku=+nG3Z2?dxi6htpmpFc}~)i@m{rX?xTTjDhl@71+}d>uGJz*Dc8*RFQ_?oxDZg$0B`taS z&jCd^Mja!PpJa{FHW$PyK~n|aGEO~}E?2h=NxP#T!-R(I_#qJxZK6i5fe~{I?CF>< zf|zP)za$%VgFEzt+@|tngU0+M8@KcqP!*^o!ah99nohx^xU*%Qpiow#vBs*=;rt+drPP#s@ zPlUv~G7CLOt6%TI3tolFLEI6claSGfr-s>FTiCGV-OVW0*NAF_lo!D}>xud#I6z5& z%xD#VeaJ<#Wx##%&>OTbv9RrycE2Bc^X|MP*Z>JFXW91c_qx4El09~8d2M|#{0%z zDfSady4fDSLd8QqVyc#brSZ3g7D1()p62>67ny}bL=ul3K=>^kml6ThOH9fXtCxYY zDA!6}oLDQgMmqf%fEG%7O9p)i)#B4}qIyZz-NrerMEFlQ_A91lGOUMTj4u@_)VOwO z{p>~pb;X?@%SQRl5{IMKhAk}{ZFHF7DyzGBcW@_65MGX9t5EOsHK&uTwDZZlbc%s~ zJl|q$Ri&F=MK?X}evAxjH{0ZRIf)nCB1PHN&FIprRB{u{u#xzw7Ce@-I&6ds5GKt7 z5asjzGVs}*VlrNmxpO|3ix~eM%w+3{=N!ki@`SgE)kk)Z_%T#|Pie3sa&j_8F>OSY zq*XBd%gG3emx6^nNl2HS&R9FpIQzT~82RX%(b3@u{n9zWw4sVzc2ClwIr=Bfa}KI) z&L5h`K~U?cu0A*4o>#(7bZ~meLAs@BJ2d+8= zu_=?(R9#7v2CUdfv5?0Vi;tUf`_#%DY(cY&D`@1#a-obS^Oi2(l^SMnNs|&3R7M zOzXAY2macQ?Y`X5*B0V;ubl|UG&3ina{ae7iiJ1p>}`Dri_67b6G-VF30Ybjc@NE1 z7d3#=y+RU5n&(trF`!yCMf-jp7+j@mm7>JZ!cLdSft6ei`Z=6&ota|#7Zp}#Yg~>B znG66NEf?DP%;O^CYdH>jw!P6u>04-kq1yxr$g&sx&es*=j-J5C1kMK6V(3Knj(E)2 zx#ZMf$Ln?(H8`al*+<+kv6ssET(=8K#~2^@aBZ9wsk-?-15_es&3HNTR#fe0#V?0tO445IHU0$nt2@?`#Fse89!1aju46JpLX8k^3K^F~5T=tazbqbz&HsGy$y-aI#9 zPRO)&K6|z}qwA1wdXICha^4Lh4$*=7`D|T(Z=LTcj0UaeV$oE^#`WdR#R9&UI;A9d zCn#%YUGDw466;!W`wM0V{2XqFw==ZBE}zhoBsP&OD~3l#G*ltjRtERppgMIOsQBNdxb z6n%mLo?8%BAPV7k8hQXc7){blh;MM1$u@$sNtSd~kF7)XPJi}*c@rL1uwZ9h5+YMV zWugpLi6zkAr@a@2v{(4zZLF)fI}OJtYx%&02ht3s_2l=00!a(o0!9>H~Y4@f+fRY6WQ&<4?b@A`Q_hHXrx*@;Zc z(>wCZ?5pM;H*&?=l|}?>0nO}Swr|?DG!VHns?ml%@|0|$U;znwz=A)fGnd#+F7W_~ zY~zIDwA)}h273HAabz@eZYbd$^coTg%@PIV;TB42%dRPm8Iai^ImP8w(D{fGOe+0# z&=MzPs}9_TD?;d!Gw*MlOy5yOOG~*{X;72Hl5jjb!hXXoEli}cBP!C2QiKhb;_vwe zE!XPJ{Kwd}mt4M;_x({1?DDkXCc*Io+=P)0PoeZ$ZM~+V!C?Ct$+mmHJZajDzl3aE zBNve}{YE<1a4);VqXz0T3f?a;V#pANe!e`l&QeiZJ@6Lx&PP|Z<-p0|k zt;CaV2%N8S-SGCSn)aWMqJCGK>ZnLzJnez|9e7RbY?%=t9{M&#{`sq4Bk^JQN8ib* z2$;+KG%@OM0BKRR$dx?2;21J`mqvR9(rCB@mFiQpRGME3`lMqfYg?L%BMrmt1m@BT z$4M2D8~Y61E%?TjhIFJ&dR9EuLf8%kE%oALA)`@4nr%jSplgk->MBhM}AdN zC(Bh?Y_>@%$6*CHrPvO_&~KmQZ%{?3y5k>y%UIyvlwyC&yOv;%iErP{!vkwM1>(z& zseSv;L~8*Q4th{GSpy`0=oLbtw;3tT$lQlNTbu5>B@Kv?%g>o78w`3FQ{}m}6iAkH zO4>rl?bjkZvEZQH*uy~-Q@?1FRH+X5oti2|;5--ZIm~%U9P>zexe8a_vaidQFFd<1W{B1p=B*m+oY^Utr-57Oz>V>?NA1a%uQTEnpHXOWY# zxQ$_hUv?zVgdWu$(lB`V3Tfk5GAtGD0<8Xg(t&Q0SU6plzBHQ^>J|slXEuvkyW#68 z(8$U=FSZRP7en4e(p%zCk41bNxnPW$4t%@{t9d(M6jeQGm?hIwhE)MyZFRe-a7Ahg zUSsi7vN0Q_vaEvWd|+0cH(k(T&HDjvAKbs}7yFXW2Y#-M_Ri)_QI6 zZ4WCO*`o|vQKuPH$!Zn5NDdjJ`J$!ISk`8ezU{ zg7UBe6$@8$7hTJC!`nH7$RKSODP_5m#5bl@#GKS3wXG$$IJUtKy#z=nykwUY9--9) z7&LpbDN-kXe(@hB?1A1bvmTzqiZ|j8>UjLH#>Kp_a8)01@O2MlEKK6=8yy7z3Q5M< z3(yy8B-jyHLW@3G9`B0Vis`=a%E~1oE-479jg5+4_eN@lxH(s|nPYdMPvsZ+BtDqw z!0o--VFfNhZdRpypM0y#ML6m?DJ*vJRI*Zs2u*4tS2sE&hK8?)qVF#T1&;c2Q{b=~ zdU|E>4;vN-7{tdvG31^HF;*#eD7Tg9X~o0`6kA#AX~t(#WXF6IZTY53>$W+{(MKuS zTn~@l?%mt;Q9<1A-mq5D%zamqQ$lR@D4<6FY(TbS2>a9@|3y3AZ!QkE^my)Tyw>uCYTFHBdh4 zjCIRavxg_*w~q_5G}6}g0EVBx>VTV3cm)X~4r@cRS zADOT9n*PS9+-tQy!I|=kQmtnWBHDcT*0RjctuMPjng%-k(#3-FZ^0MSB$tj$CfR6N z6`HXqo^5R?da={87KMRnLLkKJzmcW^Az1;txGcoK=zIq4zb%89zKqdRP^hF43cF5AMi-7MlG}aJh1F zt{5AYrthy;Q#i4`E!d)^5yM%k+%Xc~rOR=2X%u(;SKZZz|?D$JEM z-IY9R2W9);4uHpViA8b+vGVS(_m1$P9 z4DdEFEe@kL|Xy;`z|~kR_hBz4%`D1wMA z!N90xz`)S{c^u$mZEMc#?CRucYhmfi-mlo#72Dt0^c>O+>ou5$!>Zd{zOkcyqb}QmC$Dyjy~}5%xY--@Tpn+n@Sc| zZU9#M5D&BjlURJE!1gJE^xvJM_!EEru5#{gT*k}b%pFtc)`P{v8Bv8gCP9@732sGo8N%3ydG1NL%vh%ZR6md-m5)Rq zwI|A~Ecvz?M~5rH-I*hA%#|Z6Y8d&!>XtX<_eZtD>_756{N{(snCJnjDVG<;97m?IUeBwQk#JjG=2?4Tsup6p zQZ;jFD$LT8RLGFS%YjcqJZbFMCQ}6P<8pa+X+KRGE^zO84+j-U?tRTUC&@d|(k-Yo z4?V8@Hgh3u7OkN+qZ{W``|Y5GQ3=!F)?5n1PbDEr{axhJFRP=i+B%t0BwWO-D9EH0 zel5ta{RnLIm&;~w`sLYB)`rs*Vq8gbS-4TmMsugw8vPM)8XE(e? z{*S%yjD3h^R1<^}fw`%486Rnfy`O37;4dsjp0p-#yw2|p2jg<%7HRMCt}&GzY9^W? zIt}{K7_|Ldt4=c_1@Yx^cqrq`7bbdih<>nad~C(v{a}$dCSC7`2q>Y%s%;3F;65(g z{w}Sal|ND1l^bkF&bLKbyH~LgzyB~5Ao&x#1O!CyYNzoXp~@oMQx6)iMxAya}b> zb4`QB$0xC7ECq7T1fJ*x+uONvV>!~JQP%^Os+Vt-K~V< zx@1fii$b&ez-efmm1&!?J_mM@ATWOHGeP0Ig$^DnlCS!0{gWnBBkK7bCpQ6h0?=mg zIKBgxqkZ7+1PvDYtt)4CkwGjWbqZ#>L$C>NH5Tt5cTcWA0@uhHb{k||$ z;xT(wK@Ya(ku_*^RV$Jnt76|~bb(itQ-GD-eC9_~T;kQHT@q0-E5m>QXQEYM5wDE3 zhqUnBj#A$VL+fcY9;v6%jo156Pg4Xl?JIQMr9#Jyf3QZQ*!LC@9T`)x8aN+q;BFu>C@ zBd@F=vKdSo?6cYW0Y-C)9yJo9Qdt9htPOUNCEi+63f0q)bCABfxMN5`7n1Pj0u{8i zc#>NF7Ur%`KJ)X{0&W~W?&&KCojjr;3Jt!fvn!1jMWf<}Uv$zpi~u?3*_?**E%4J; zDHMvxrlW9IFyn*m@BP|M>KN|?0$YF4{O6vNrW4*RVqQ(samsI~S>cAO>#3uzt_J+X z(R)7d!p8tU*w7~j5}^0bY%Js74rh*&)%{VEYI8Y> z1@qhfm;HHx(Q!O0yleb@RZ2`R4=gJ*061`jz#x#%_>?Y`1zd^jPtM??=p!eUbaDEW z33&zd+c0;78$hKBn`ht(#K8?!fmD%#|5b_-P zQ+z?ZbgFc4-R>zq_nL#%ba4*IG{ul#UU%#XWbj2LX;O3|qaiGgSZY}k8K{68$SP5@ zA)H&>y@4h3u(&qf?=3?B0WEwXK_r;i(Ji{g$(h#SJ#My~x~L#ewG=KhHps+tT-Inp zUrlZEznNj`d#)oyhm03uUVWtcJt7>L4Lv&y==9Da=iUAXt>el+uL~o=%fsRex|cFE z1~|c(yP^#kGcQI3d% zbXB<)0aQNh*x@kh-pGqQhse89#Lu4z2?nv<(F~ntxaO#3KU}=rH5}a&6)A7C%tQG-_w-H-t!i$SJC*Djsyb1=(}5sMe8I_>kB|SloQ5-xR3EUT*)ZW z$5ufcoVU7nx5muE{p0W3l((i5l=qTUY*h%J>I*mB?hWNnB7Y9Xb&Tx$|FRq{n|Ax% zm^UO3K<6CCJ>7PplOH1yHiKJX zswRQf4v_`fElzo&oNHdmdh51tS_yEngigRCILkuIo+FXg{r3Zg7PAS}Q7xV&rPFwA zkR^lWq4+gS{g<&Byfx(t?@gngN(I{yH_&`Eo~R>(HK9ZFnStrkJO^#CMGX-DT#p(Q z^cVT->lQKaFWpmV2MOv@c|dkFPY7H=;jW9rPkdrE)G*`-#AEQzV!V}=7lOkL9_-4! zyO-^eQkq<+rj1txFezNt+)02bM=;rEHs4|NMwzd_&#(bCJW@vro`yEXv?W}Z?ZNK2 zbGx!DQ+ZdZc zeUW>A8^97$eUL~}(OOIW1Kdr<%50O3zSv*8g4LGJ=>d6)`fgvacYR_*!sPN<9?Mk* zCpWx?moCQ)YS22An~%sIa+Mb9JF5AO0iAaA_%)&6&I18E-ZH;Yfo(fL*?bUSoS4CL zwWJVc8O+<TAAjpC+4~%27*}TwAR#^v zhG0uK5S)heShKMTv8h-(qUPB&u$_z@j#aaf`!+Zy!F|qAzNn?{yeKKeL~Dy@AKw+l#`jl=pk|1uF>9c*vu!6Ua4?ytS4eu4IxwUP^hv zxNsiffg{|ESiMDlkwy>y;TF4yKdws zc9hb}f?9c`i8g~Afo;@z*ve4pg(@=8cJwzLVgB_Z+>%dn485~eQWul)>T#WH@IZ2} z@@V$TrWJ1LW*BP)|7B>D60o9n?&gA)cD&iE+-adIgxL|rK@UL_zWGOeNpA_+Y&I?b z3r6+0AeAM{0gZ?@TA0E)!Li_tmjh;CFhLFcdND>h^xf%gZ$vQ2t>?5Df3=^*rQk@@ z9IG;77Qabl$D!9*LreHT0{oI1MSt|PnB2*^wjGybU3pZ>U|4TeghZ{ChRj9xsNH4H z2W`l16FzK3mEg{GjTPPaoI${gOg9o*c(~|Q%BM2agl~>C32Gy@b%V;-TrjWhA$DXc zof^y$X-isitd(Z1t{h>9fHpV^ap}7utIbc^+Br?Exx=Si$s+kxe27&}msk0S_bZ+s#bwT;ijZ9C9S)n;xzNqK&?=;jpE!u6BTv+#ZONdx+4j?8AvHn%P=7 zrchx<JQ$QQ>E`CG|8EOa_}> zdD%o6UFCj~hS6Y*@SZx~i6vLxZ+LQ&OQ!4|3L&Qfc?MNGvIrOacJ`ZXO(Jwu zj~nsk$m2S^n&2uZriZg~o#m~(0VPyE5YUhwn z2EYOLn>#xN@uezN9S2wFeQbbScLjo7;aq*wQ0T+?6f@2L({IhByfM1R{9jW*y+L)Dg^U)b)tdk zg}tMm#g5$~wC8WPtBT3c0AsIfHe(_?+s;p1lcj`K1DBC*d_i3W`9-}y4Ztati8Ojg zP^%1b@hcPtARxl;5k@HF9Fm7X3-e@=gMM)>>ErhPhfsPuBaJvk7zrtq>IR5OwbpS; zApP<1e(OjbCF6h|%LDiPlm$|++{15a3mqoq(>7K?P3t8qfNs88Jd1~vi+PW;{GVddNxHLij2*UfKvq`hBcE4QCUq{Z(> zd!50&sfzYA)0Sm18aA*E#Y_fyH&O*pi!;~@sd5J;zeg?wNbGYvaZWFm40(v4Wc7~6 z3sW(G?97KRZ}UFEWk2^Eqb%e4|Naj8p9pOl7pK_pAFwQg00Tq(htM2M9c`^F-Q1b% z+?*UO)kGbS{;|t5%#gWO?h96@b!e=0^CU>}1ulGD4O&_Um1B^+ti!hs?m2`=0=2Jo zK>H4`N#qh~>r*|%;<-6CWCM};eA|IyPxB-OEjl3CYdCQId4C9DxUUlBT-_EYAVuGl zK-nS?`hZ?H&D1BlmgKm(gr`K{^m%Yl(IuS__X#!j?F%E1A6Ipw`7HW1J!8A7 zYe5naSzSg)2hSHYDh0**7}N4I*ubDs8wH|Z)g9g!#!Yu2G~`6uC4nYDA?Z$D<;y4& z)qjRUXYJxNZD_cPmN#~Aw?X+WzwgKCkxB-%wtqZ4&G*pQ!o3UqW@&h|1ALnD-AjNxAT7!=>OFGCpG=Q8cnSK7eoC|{ePy+|JDDb Z{f`7vk%#^-5)KCSuOI)LN$CH}`d{$TZ|492 diff --git a/providers/lock.json b/providers/lock.json index 12253212..a3d00945 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -1,14 +1,11 @@ { - "format": 1, + "format": 2, "providers": [ { "id": "spool.jellyfin", - "version": "0.1.0-alpha.1", - "repository": "https://github.com/spool-player/spool-jellyfin", - "revision": "7accf4daf6d4665bbfe2252f1a1a67481793fb55", - "archive": "bundled/spool-jellyfin-0.1.0-alpha.1.zip", - "size": 11290, - "sha256": "af5c6f0ff647d607691640f875017e73835cf6f13b7f2504b3c03e7c0a7c5436" + "version": "0.2.0", + "archive": "bundled/spool.jellyfin-0.2.0.tar.zst", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] } diff --git a/qml/browse/BrowseSurface.qml b/qml/browse/BrowseSurface.qml index 6dc0d6d8..1fd05b0b 100644 --- a/qml/browse/BrowseSurface.qml +++ b/qml/browse/BrowseSurface.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import JellyfinWebOS +import Spool import "../theme" import "../primitives" import "../primitives/ModelAccess.js" as ModelAccess @@ -43,26 +43,26 @@ FocusScope { // which is the row the home page gives them too. readonly property var destinations: { const places = [ - { - "key": "resume", - "title": "Continue Watching", - "icon": "play_circle", - "model": Home.resumeItems, - "kind": "landscape" - }, - { - "key": "nextUp", - "title": "Next Up", - "icon": "skip_next", - "model": Home.nextUpItems, - "kind": "landscape" - } - ] + { + "key": "resume", + "title": "Continue Watching", + "icon": "play_circle", + "model": Home.resumeItems, + "kind": "landscape" + }, + { + "key": "nextUp", + "title": "Next Up", + "icon": "skip_next", + "model": Home.nextUpItems, + "kind": "landscape" + } + ] const latest = Home.latestLibraryRows || [] for (let index = 0; index < latest.length; ++index) { const row = latest[index] if (!row || !row.model) - continue + continue places.push({ "key": "latest" + index, "title": String(row.title || "Recently Added"), diff --git a/qml/pages/AccountsPage.qml b/qml/pages/AccountsPage.qml new file mode 100644 index 00000000..94278b1e --- /dev/null +++ b/qml/pages/AccountsPage.qml @@ -0,0 +1,231 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Every account on every provider. Choosing one makes it the one in use for +// its server (other people on that server step aside); accounts on different +// servers and providers stay on home together. The last tile adds a provider. +FocusScope { + id: root + + property var shell + readonly property var accounts: Providers.accounts + readonly property int count: accounts.length + 1 + readonly property int tileSize: Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 148 : Metrics.laneAtLeast(width, "regular") ? 132 : + 104) + property var menuAccount: null + + focus: true + + function accountAt(index) { + return index < accounts.length ? accounts[index] : null + } + + function activateAt(index) { + const account = accountAt(index) + if (!account) { + shell.pushRoute("addProvider") + return + } + if (account.needsSignIn) { + shell.openProviderScreen(Providers.beginSetup(account.moduleId)) + return + } + Providers.useAccount(account.id) + shell.goHome() + } + + function openMenu(index) { + const account = accountAt(index) + if (!account) + return false + menuAccount = account + menuLoader.active = true + return true + } + + function activate() { + if (menuLoader.item) + return menuLoader.item.activate() + activateAt(grid.currentIndex) + } + + function longPress() { + return openMenu(grid.currentIndex) + } + + function back() { + if (!menuLoader.item) + return false + menuLoader.active = false + InputKeys.focus(root) + return true + } + + function routeKey(key, phase, repeat) { + if (menuLoader.item) + return menuLoader.item.routeKey(key, phase, repeat) + if (phase !== "press") + return InputKeys.isDirection(key) + const columns = Math.max(1, Math.floor(grid.width / grid.cellWidth)) + if (key === Qt.Key_Left) + grid.currentIndex = Math.max(0, grid.currentIndex - 1) + else if (key === Qt.Key_Right) + grid.currentIndex = Math.min(count - 1, grid.currentIndex + 1) + else if (key === Qt.Key_Down) + grid.currentIndex = Math.min(count - 1, grid.currentIndex + columns) + else if (key === Qt.Key_Up) { + if (grid.currentIndex < columns && shell) + shell.focusNavBar() + else + grid.currentIndex = Math.max(0, grid.currentIndex - columns) + } else if (key === Qt.Key_Menu || key === Qt.Key_M) + return openMenu(grid.currentIndex) + else + return false + return true + } + + Rectangle { + anchors.fill: parent + color: Theme.bg + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.pageMarginPx + spacing: Metrics.scaled(24) + + AppText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Metrics.scaled(12) + text: "Who's watching?" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + + GridView { + id: grid + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + readonly property int columns: Math.max(1, Math.min(root.count, Math.floor(parent.width / cellWidth))) + Layout.preferredWidth: columns * cellWidth + cellWidth: root.tileSize + Metrics.scaled(28) + cellHeight: root.tileSize + Metrics.scaled(96) + model: root.count + interactive: contentHeight > height + boundsBehavior: Flickable.StopAtBounds + currentIndex: 0 + focus: true + + delegate: Item { + id: cell + required property int index + readonly property var account: root.accountAt(index) + width: grid.cellWidth + height: grid.cellHeight + + ProfileTile { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Metrics.scaled(8) + tileSize: root.tileSize + focused: cell.GridView.isCurrentItem && Metrics.keyboardFocusActive + addTile: !cell.account + username: cell.account ? cell.account.label : "Add" + providerName: cell.account ? cell.account.providerName : "" + serverName: cell.account ? cell.account.detail : "" + needsSignIn: Boolean(cell.account && cell.account.needsSignIn) + opacity: !cell.account || cell.account.enabled ? 1 : 0.55 + onAccepted: { + grid.currentIndex = cell.index + root.activateAt(cell.index) + } + onContextRequested: { + grid.currentIndex = cell.index + root.openMenu(cell.index) + } + } + + // Which provider an account is on, at a glance. + ProviderIcon { + visible: Boolean(cell.account) + x: parent.width / 2 + root.tileSize / 2 - width + Metrics.scaled(6) + y: Metrics.scaled(8) + root.tileSize - height + Metrics.scaled(6) + width: Metrics.scaled(34) + height: width + border.width: Metrics.scaled(3) + border.color: Theme.bg + source: cell.account ? cell.account.iconUrl : "" + name: cell.account ? cell.account.providerName : "" + seed: cell.account ? cell.account.moduleId : "" + } + + Rectangle { + visible: Boolean(cell.account) && cell.account.enabled && !cell.account.running + x: parent.width / 2 - root.tileSize / 2 + Metrics.scaled(8) + y: Metrics.scaled(16) + width: Metrics.scaled(10) + height: width + radius: width / 2 + color: Theme.pending + } + } + } + } + + readonly property var menuActions: { + const account = menuAccount + if (!account) + return [] + const out = [] + if (account.hasSettings) + out.push({ + "label": "Account settings", + "value": "settings" + }) + out.push({ + "label": account.enabled ? "Hide from home" : "Show on home", + "value": "toggle" + }) + out.push({ + "label": "Remove", + "value": "remove" + }) + return out + } + + function choose(index) { + const account = menuAccount + const action = menuActions[index] ? menuActions[index].value : "" + menuLoader.active = false + InputKeys.focus(root) + if (action === "settings") + shell.openProviderScreen(Providers.openSettings(account.id)) + else if (action === "toggle") + Providers.setAccountEnabled(account.id, !account.enabled) + else if (action === "remove") + Providers.removeAccount(account.id) + } + + Loader { + id: menuLoader + anchors.fill: parent + active: false + sourceComponent: OptionPickerDialog { + visible: true + anchorItem: grid.currentItem + title: root.menuAccount ? root.menuAccount.label : "" + options: root.menuActions.map(action => action.label) + currentIndex: 0 + onSelected: index => root.choose(index) + onDismissed: { + menuLoader.active = false + InputKeys.focus(root) + } + } + } +} diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml new file mode 100644 index 00000000..849030ee --- /dev/null +++ b/qml/pages/AddProviderPage.qml @@ -0,0 +1,490 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Choosing where media comes from: official providers first, then the +// community's, then any provider by link. Installing and signing in are one +// step from here, and the same page updates or removes what is installed. +FocusScope { + id: root + + property var shell + property string pendingSetup: "" + property bool linkEditing: false + property var menuEntry: null + + readonly property var rows: { + const out = [ + { + "kind": "header", + "title": "Official" + } + ] + out.push(...Store.official) + out.push({ + "kind": "header", + "title": "Community", + "loading": Store.loading + }) + out.push(...Store.community) + out.push({ + "kind": "link", + "id": "link" + }) + return out + } + + function step(from, delta) { + let index = from + delta + while (index >= 0 && index < rows.length && rows[index].kind === "header") + index += delta + return index >= 0 && index < rows.length ? index : from + } + + focus: true + Component.onCompleted: { + Store.refresh(true) + list.currentIndex = 1 + InputKeys.focus(list) + } + + function stateOf(entry) { + if (entry.kind === "link") + return Store.busy.url ? "busy" : "link" + if (entry.busy) + return "busy" + if (!entry.compatible) + return "incompatible" + if (entry.updateAvailable) + return "update" + return entry.installed ? "installed" : "get" + } + + function beginSetup(id) { + pendingSetup = "" + const context = Providers.beginSetup(id) + if (context) + shell.openProviderScreen(context) + } + + function activateRow(index) { + const entry = rows[index] + if (!entry) + return + switch (stateOf(entry)) { + case "link": + linkEditing = true + Qt.callLater(() => { + const item = list.itemAtIndex(index) + if (item) + item.focusField() + }) + break + case "update": + Store.update(entry.id) + break + case "installed": + beginSetup(entry.id) + break + case "get": + pendingSetup = entry.id + Store.install(entry.id) + break + } + } + + function submitLink(text) { + const url = String(text || "").trim() + if (url.length === 0) + return + pendingSetup = "*" + Store.addFromUrl(url) + linkEditing = false + InputKeys.focus(list) + } + + function openMenu(index) { + const entry = rows[index] + if (!entry || !entry.installed) + return false + menuEntry = entry + menuLoader.active = true + return true + } + + readonly property var menuActions: { + const entry = menuEntry + if (!entry) + return [] + const out = [ + { + "label": "Add an account", + "value": "setup" + } + ] + if (entry.updateAvailable) + out.push({ + "label": "Update to " + entry.version, + "value": "update" + }) + if (entry.removable !== false && !entry.bundled) + out.push({ + "label": "Remove", + "value": "remove" + }) + return out + } + + function choose(index) { + const entry = menuEntry + const action = menuActions[index] ? menuActions[index].value : "" + closeMenu() + if (action === "setup") + beginSetup(entry.id) + else if (action === "update") + Store.update(entry.id) + else if (action === "remove") + Store.uninstall(entry.id) + } + + function closeMenu() { + menuLoader.active = false + InputKeys.focus(list) + } + + function activate() { + if (menuLoader.item) + return menuLoader.item.activate() + activateRow(list.currentIndex) + } + + function longPress() { + return openMenu(list.currentIndex) + } + + function back() { + if (menuLoader.item) { + closeMenu() + return true + } + if (linkEditing) { + linkEditing = false + InputKeys.focus(list) + return true + } + return false + } + + function routeKey(key, phase, repeat) { + if (menuLoader.item) + return menuLoader.item.routeKey(key, phase, repeat) + if (linkEditing) + return false + if (phase !== "press") + return InputKeys.isDirection(key) + if (key === Qt.Key_Down) + list.currentIndex = step(list.currentIndex, 1) + else if (key === Qt.Key_Up) + list.currentIndex = step(list.currentIndex, -1) + else if (key === Qt.Key_Menu || key === Qt.Key_M) + return openMenu(list.currentIndex) + else + return InputKeys.isDirection(key) + return true + } + + Connections { + target: Store + function onInstalled(id) { + if (root.pendingSetup === id || root.pendingSetup === "*") + root.beginSetup(id) + } + function onProblem() { + root.pendingSetup = "" + } + } + + Rectangle { + anchors.fill: parent + color: Theme.bg + } + + Item { + id: column + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(760)) + + Row { + id: header + y: Metrics.pageMarginPx + height: Metrics.touchTargetPx + spacing: Metrics.scaled(12) + + IconButton { + anchors.verticalCenter: parent.verticalCenter + visible: Providers.hasAccounts + iconName: "arrow_back" + accessibleName: "Back" + onClicked: Router.pop("accounts") + } + + AppText { + anchors.verticalCenter: parent.verticalCenter + text: Providers.hasAccounts ? "Add a provider" : "Welcome to Spool" + font.pixelSize: Metrics.titleSizePx + font.weight: Font.DemiBold + } + } + + ListView { + id: list + anchors.top: header.bottom + anchors.topMargin: Metrics.scaled(16) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + model: root.rows + currentIndex: 0 + focus: true + keyNavigationEnabled: false + boundsBehavior: Flickable.StopAtBounds + highlightMoveDuration: 0 + spacing: Metrics.scaled(6) + footer: Item { + width: list.width + height: Metrics.pageMarginPx + } + + delegate: Item { + id: slot + required property int index + required property var modelData + readonly property bool isHeader: modelData.kind === "header" + + function focusField() { + linkField.focusRow() + } + + width: list.width + height: isHeader ? Metrics.scaled(index === 0 ? 28 : 48) : row.height + + AppText { + visible: slot.isHeader + anchors.left: parent.left + anchors.leftMargin: Metrics.scaled(4) + anchors.bottom: parent.bottom + anchors.bottomMargin: Metrics.scaled(4) + text: String(slot.modelData.title || "").toUpperCase() + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + font.weight: Font.DemiBold + font.letterSpacing: Metrics.scaled(1) + } + + BusySpinner { + anchors.right: parent.right + anchors.bottom: parent.bottom + width: Metrics.scaled(18) + height: width + running: slot.isHeader && Boolean(slot.modelData.loading) + visible: running + } + + Rectangle { + id: row + visible: !slot.isHeader + readonly property int index: slot.index + readonly property var modelData: slot.modelData + readonly property string state: root.stateOf(modelData) + readonly property bool current: slot.ListView.isCurrentItem && Metrics.keyboardFocusActive + && list.activeFocus + readonly property bool isLink: modelData.kind === "link" + + width: list.width + height: isLink && root.linkEditing ? linkForm.implicitHeight + Metrics.scaled(24) : Math.max( + Metrics.touchTargetPx, Metrics.scaled(76)) + radius: Theme.radiusLarge + color: current ? Theme.focusedFill : hover.hovered ? Theme.bgHover : Theme.bgRaised + border.width: current ? Theme.focusBorderWidth : Theme.hoverBorderWidth + border.color: current ? Theme.accent : Theme.border + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.scaled(16) + anchors.rightMargin: Metrics.scaled(16) + spacing: Metrics.scaled(16) + visible: !(row.isLink && root.linkEditing) + + Loader { + Layout.preferredWidth: Metrics.scaled(44) + Layout.preferredHeight: Metrics.scaled(44) + sourceComponent: row.isLink ? linkIcon : providerIcon + Component { + id: providerIcon + ProviderIcon { + source: row.modelData.iconUrl || "" + name: row.modelData.name || "" + seed: row.modelData.id || "" + } + } + Component { + id: linkIcon + Rectangle { + radius: Math.round(width * 0.22) + color: Theme.bgPanel + MaterialIcon { + anchors.centerIn: parent + name: "link" + iconSize: Metrics.scaled(24) + iconColor: Theme.textSecondary + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.scaled(2) + + AppText { + Layout.fillWidth: true + text: row.isLink ? "Add from a link" : row.modelData.name + font.pixelSize: Metrics.bodySizePx + Metrics.scaled(1) + font.weight: Font.DemiBold + elide: Text.ElideRight + maximumLineCount: 1 + } + + SecondaryText { + Layout.fillWidth: true + text: row.isLink ? "GitHub, GitLab or a spool-provider.json" : row.state + === "incompatible" ? "Needs a newer Spool" : (row.modelData.summary + || "") + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + // One action per row, named for what pressing the row does. + Rectangle { + Layout.preferredHeight: Metrics.scaled(32) + Layout.preferredWidth: Math.max(Metrics.scaled(72), chipText.implicitWidth + Metrics.scaled( + 28)) + visible: row.state !== "incompatible" && row.state !== "busy" + radius: height / 2 + color: row.state === "get" || row.state === "update" ? Theme.accentDim : Theme.bgPanel + border.width: Theme.hoverBorderWidth + border.color: row.state === "get" || row.state === "update" ? Theme.accentDim : Theme.border + + AppText { + id: chipText + anchors.centerIn: parent + text: ({ + "get": "Get", + "update": "Update", + "installed": "Add", + "link": "Paste" + })[row.state] || "" + font.pixelSize: Metrics.metaSizePx + font.weight: Font.DemiBold + } + } + + BusySpinner { + Layout.preferredWidth: Metrics.scaled(24) + Layout.preferredHeight: Metrics.scaled(24) + running: row.state === "busy" + visible: running + } + } + + ColumnLayout { + id: linkForm + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Metrics.scaled(12) + visible: row.isLink && root.linkEditing + spacing: Metrics.scaled(10) + + TextFieldRow { + id: linkField + Layout.fillWidth: true + label: "Link" + placeholderText: "https://github.com/you/spool-provider" + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + onAccepted: root.submitLink(text) + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Metrics.scaled(10) + ActionButton { + text: "Cancel" + kind: "flat" + onClicked: root.back() + } + ActionButton { + text: "Add" + kind: "primary" + enabled: linkField.text.trim().length > 0 + onClicked: root.submitLink(linkField.text) + } + } + } + + HoverHandler { + id: hover + enabled: !(row.isLink && root.linkEditing) + } + + TapHandler { + enabled: !(row.isLink && root.linkEditing) + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: (point, button) => { + list.currentIndex = row.index + if (button === Qt.RightButton) + root.openMenu(row.index) + else + root.activateRow(row.index) + } + onLongPressed: root.openMenu(row.index) + } + } + } + } + + AppText { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Metrics.pageMarginPx + visible: Store.error.length > 0 + text: Store.error + color: Theme.textMuted + font.pixelSize: Metrics.metaSizePx + } + } + + Loader { + id: menuLoader + anchors.fill: parent + active: false + sourceComponent: OptionPickerDialog { + visible: true + anchorItem: list.currentItem + title: root.menuEntry ? root.menuEntry.name : "" + options: root.menuActions.map(action => action.label) + onSelected: index => root.choose(index) + onDismissed: root.closeMenu() + } + } +} diff --git a/qml/pages/PlayerAudioSyncPanel.qml b/qml/pages/PlayerAudioSyncPanel.qml index e6a3a302..6f048c9a 100644 --- a/qml/pages/PlayerAudioSyncPanel.qml +++ b/qml/pages/PlayerAudioSyncPanel.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" diff --git a/qml/pages/PlayerBrowsePanel.qml b/qml/pages/PlayerBrowsePanel.qml index 6b6c500a..42b490b0 100644 --- a/qml/pages/PlayerBrowsePanel.qml +++ b/qml/pages/PlayerBrowsePanel.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" import "../browse" import "../theme" diff --git a/qml/pages/PlayerMusicStage.qml b/qml/pages/PlayerMusicStage.qml index 9ff26909..61eb4734 100644 --- a/qml/pages/PlayerMusicStage.qml +++ b/qml/pages/PlayerMusicStage.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -18,7 +18,7 @@ Item { readonly property string trackTitle: { const queued = String(root.item.title || "").trim() if (queued.length > 0) - return queued + return queued return overlay.hasPlayer ? overlay.player.title : "" } readonly property string artistText: String(root.item.albumArtist || "").trim() @@ -27,7 +27,7 @@ Item { readonly property string queuePositionText: { const queue = overlay.playQueue if (!queue || queue.count <= 1) - return "" + return "" return "Track " + (queue.currentIndex + 1) + " of " + queue.count } readonly property real viewportScale: Math.max(1, Math.min(1.8, Math.min(root.width / root.dp(1600), root.height diff --git a/qml/pages/PlayerOverlayChrome.qml b/qml/pages/PlayerOverlayChrome.qml index ea66653e..190eeb84 100644 --- a/qml/pages/PlayerOverlayChrome.qml +++ b/qml/pages/PlayerOverlayChrome.qml @@ -2,11 +2,10 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" import "../shell" as Shell -import "../providers/jellyfin" as Provider Item { id: root @@ -432,7 +431,7 @@ Item { width: root.dp(420) z: 55 active: root.overlay.syncPlay !== null - sourceComponent: Provider.SyncPlayMenu { + sourceComponent: Shell.GroupMenu { onRequestClose: root.overlay.closeMenu() } } diff --git a/qml/pages/PlayerOverlayPage.qml b/qml/pages/PlayerOverlayPage.qml index 85a56d19..e19ebf7f 100644 --- a/qml/pages/PlayerOverlayPage.qml +++ b/qml/pages/PlayerOverlayPage.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -66,7 +66,7 @@ FocusScope { // SyncPlay is only read behind its capability, so a source without group // watch never has the singleton evaluated; the chrome and the transport // bar take it from here rather than reaching for it themselves. - readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null + readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false readonly property bool syncPlayWaiting: syncPlayActive && syncPlay.waitingForPlayback readonly property bool syncPlayMenuOpen: chrome.syncPlayMenuOpen diff --git a/qml/pages/PlayerQueuePanel.qml b/qml/pages/PlayerQueuePanel.qml index cac83b1d..6fb23a7d 100644 --- a/qml/pages/PlayerQueuePanel.qml +++ b/qml/pages/PlayerQueuePanel.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" import "../theme" diff --git a/qml/pages/PlayerQueueRow.qml b/qml/pages/PlayerQueueRow.qml index 9eb018a5..8a0d2e9c 100644 --- a/qml/pages/PlayerQueueRow.qml +++ b/qml/pages/PlayerQueueRow.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../primitives" import "../theme" diff --git a/qml/pages/PlayerStatsPanel.qml b/qml/pages/PlayerStatsPanel.qml index 546091ea..8080bd59 100644 --- a/qml/pages/PlayerStatsPanel.qml +++ b/qml/pages/PlayerStatsPanel.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" diff --git a/qml/pages/ProviderScreenPage.qml b/qml/pages/ProviderScreenPage.qml new file mode 100644 index 00000000..c6aa6b3a --- /dev/null +++ b/qml/pages/ProviderScreenPage.qml @@ -0,0 +1,17 @@ +import QtQuick +import "../shell" + +// The route for a provider's sign-in or settings screen. +ProviderSurface { + id: page + + property var shell + + context: shell ? shell.routeArgs.context : null + onFinished: { + // Signing in lands on home through Providers.accountAdded; anything + // else goes back to where it was opened from. + if (Router.route === "providerScreen" && Router.canPop) + Router.pop("accounts") + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index d3809e6e..d87e330c 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -264,8 +264,12 @@ FocusScope { } function rowDescription(row) { - if (row.key === "session/account") - return ProviderCapabilities.auth ? Session.serverUrl : "" + if (row.key === "action/accounts") { + const count = Providers.accounts.length + return count === 1 ? "1 account" : count + " accounts" + } + if (row.key === "action/providers" && Store.updates.length > 0) + return Store.updates.length === 1 ? "1 update" : Store.updates.length + " updates" if (row.key === "subtitles/mode" || row.key === "audio/trackMode") { const index = rowCurrentIndex(row) const labels = rowOptions(row) @@ -293,18 +297,13 @@ FocusScope { } function rowValueText(row) { - if (row.key === "action/switchUser") - return "Choose" - if (row.key === "action/logout") - return "Sign out" + if (row.key === "action/accounts" || row.key === "action/providers") + return "Manage" if (row.key === "action/openSourceNotices" || row.key === "action/exportDiagnostics" || row.key === "action/subtitleSettings" || row.key === "action/manageCertificates") return "Open" if (row.key === "action/clearLatencyStatistics" || row.key === "action/clearLogs") return "Clear" - if (row.key === "session/account") - return ProviderCapabilities.auth && Session.activeProfileLabel.length > 0 ? Session.activeProfileLabel : - "Offline" if (row.key === "about/version") return "v" + Qt.application.version @@ -438,10 +437,10 @@ FocusScope { return } if (row.type === "action") { - if (row.key === "action/switchUser" && shell) - shell.switchUser() - else if (row.key === "action/logout" && ProviderCapabilities.auth) - Session.logout() + if (row.key === "action/accounts" && shell) + shell.pushRoute("accounts") + else if (row.key === "action/providers" && shell) + shell.pushRoute("addProvider") else if (row.key === "action/manageCertificates") certificateManagerVisible = true else if (row.key === "action/clearLatencyStatistics") diff --git a/qml/primitives/ProviderIcon.qml b/qml/primitives/ProviderIcon.qml new file mode 100644 index 00000000..e4e4a3c4 --- /dev/null +++ b/qml/primitives/ProviderIcon.qml @@ -0,0 +1,37 @@ +import QtQuick +import "../theme" + +// A provider's icon from its package, or its initial on a tint derived from +// its id while the image loads or when there is none. +Rectangle { + id: root + + property url source + property string name: "" + property string seed: name + + radius: Math.round(width * 0.22) + color: image.status === Image.Ready ? Theme.bgPanel : Theme.libraryTint(seed) + antialiasing: true + + AppText { + anchors.centerIn: parent + visible: image.status !== Image.Ready + text: root.name.length > 0 ? root.name.charAt(0).toUpperCase() : "?" + color: "white" + font.pixelSize: Math.round(root.height * 0.46) + font.weight: Font.DemiBold + } + + Image { + id: image + anchors.fill: parent + anchors.margins: Math.round(root.width * 0.14) + source: root.source + sourceSize.width: width * 2 + sourceSize.height: height * 2 + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + } +} diff --git a/qml/providers/jellyfin/LoginAccountStep.qml b/qml/providers/jellyfin/LoginAccountStep.qml deleted file mode 100644 index a7eaf763..00000000 --- a/qml/providers/jellyfin/LoginAccountStep.qml +++ /dev/null @@ -1,211 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -// Signing in to the server the previous step settled on. -// -// The server it is about stays on screen as the same row that chose it, and -// stays reachable, because getting it wrong is the failure this form actually -// has: a password rejected by the wrong machine reads exactly like a password -// typed wrong. -FocusScope { - id: root - - property bool dense: false - property string serverName: "" - property string serverAddress: "" - - signal changeServerRequested - signal signInRequested - - function controls() { - return [serverRow, usernameRow, passwordRow, signInButton, quickConnectButton] - } - - function focusControl(item) { - if (item === usernameRow || item === passwordRow) - item.focusRow() - else - InputKeys.focus(item) - } - - function focusDefault() { - usernameRow.focusField() - } - - function moveInside(control, key) { - return false - } - - function activateControl(control) { - if (control === serverRow) - root.changeServerRequested() - else if (control === usernameRow || control === passwordRow) - control.focusField() - else if (control === quickConnectButton) - quickConnectButton.clicked() - else - root.signInRequested() - } - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - spacing: Metrics.scaled(10) - - AppText { - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(8) - visible: !root.dense - text: "Sign in" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - elide: Text.ElideRight - } - - ServerCard { - id: serverRow - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(6) - title: root.serverName - serverAddress: root.serverAddress - status: "Change" - onAccepted: { - InputKeys.focus(serverRow) - root.changeServerRequested() - } - } - - AppText { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(4) - text: "Username" - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - - TextFieldRow { - id: usernameRow - Layout.fillWidth: true - accessibleName: "Username" - text: Session.username - inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - enterKeyType: Qt.EnterKeyNext - onTextEdited: text => Session.username = text - onAccepted: passwordRow.focusField() - } - - AppText { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(4) - text: "Password" - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - - TextFieldRow { - id: passwordRow - Layout.fillWidth: true - Layout.bottomMargin: Metrics.scaled(10) - accessibleName: "Password" - text: Session.password - echoMode: TextInput.Password - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - enterKeyType: Qt.EnterKeyGo - onTextEdited: text => Session.password = text - onAccepted: root.signInRequested() - } - - ActionButton { - id: signInButton - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Metrics.controlHeightPx, Metrics.scaled(56)) - text: "Sign in" - iconName: "login" - kind: "primary" - onClicked: root.signInRequested() - } - - // Quick Connect is the other way in, not a second half of this one, so - // it sits below the rule rather than beside the primary button. - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(6) - Layout.bottomMargin: Metrics.scaled(6) - visible: !root.dense - spacing: Metrics.scaled(12) - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - - SecondaryText { - text: "or" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - } - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - } - - ActionButton { - id: quickConnectButton - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Metrics.controlHeightPx, Metrics.scaled(52)) - text: QuickConnect.active ? "Cancel Quick Connect" : "Use Quick Connect" - iconName: QuickConnect.active ? "close" : "bolt" - onClicked: { - if (QuickConnect.active) { - QuickConnect.cancel() - } else { - App.clearError() - QuickConnect.start(Session.serverUrl) - } - } - } - - Surface { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(6) - Layout.preferredHeight: QuickConnect.active ? quickConnectPanel.implicitHeight + Metrics.scaled(34) : 0 - visible: QuickConnect.active - clip: true - baseColor: Theme.accentPanel - - ColumnLayout { - id: quickConnectPanel - anchors.centerIn: parent - width: Math.min(parent.width - Metrics.scaled(40), Metrics.scaled(420)) - spacing: Metrics.scaled(8) - - AppText { - Layout.alignment: Qt.AlignHCenter - text: QuickConnect.code - font.pixelSize: Metrics.scaled(32) - font.weight: Font.Bold - font.letterSpacing: Metrics.scaled(7) - } - - SecondaryText { - Layout.fillWidth: true - text: QuickConnect.phase === "server" ? "Approved. Signing you in…" : - "Enter this code on a device that is already signed in to this server." - color: Theme.textSecondary - font.pixelSize: Metrics.metaSizePx + Metrics.scaled(1) - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - } - } -} diff --git a/qml/providers/jellyfin/LoginBrandPanel.qml b/qml/providers/jellyfin/LoginBrandPanel.qml deleted file mode 100644 index 1e536a86..00000000 --- a/qml/providers/jellyfin/LoginBrandPanel.qml +++ /dev/null @@ -1,79 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -// The header of the sign-in screen: where you are, and the way back out. -// Nothing else in here takes focus, so a remote never walks through it on the -// way to the form. -Item { - id: root - - // Room is short — a landscape phone, or an on-screen keyboard eating half - // the panel — so the version and some of the weight give up their space. - property bool dense: false - property bool backVisible: false - - readonly property alias backControl: backButton - - signal backRequested - - implicitHeight: headerRow.implicitHeight - - // Back sits at the head of the identity rather than floating over the - // page, so the form below never has to leave a hole for it. The slot keeps - // its width whether or not there is anywhere to go back to. - RowLayout { - id: headerRow - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Metrics.scaled(12) - - Item { - Layout.preferredWidth: backButton.width - Layout.preferredHeight: backButton.height - - IconButton { - id: backButton - anchors.centerIn: parent - iconName: "arrow_back" - visible: root.backVisible - enabled: visible - onClicked: root.backRequested() - } - } - - Image { - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: Metrics.scaled(root.dense ? 24 : 30) - Layout.preferredHeight: Layout.preferredWidth - source: "qrc:/icons/spool.svg" - sourceSize.width: Layout.preferredWidth - sourceSize.height: Layout.preferredHeight - fillMode: Image.PreserveAspectFit - smooth: true - asynchronous: true - } - - AppText { - Layout.alignment: Qt.AlignVCenter - text: "Spool" - font.pixelSize: Metrics.scaled(root.dense ? 19 : 23) - font.weight: Font.DemiBold - } - - AppText { - Layout.alignment: Qt.AlignBaseline - visible: !root.dense - text: Qt.application.version - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Medium - } - - Item { - Layout.fillWidth: true - } - } -} diff --git a/qml/providers/jellyfin/LoginPage.qml b/qml/providers/jellyfin/LoginPage.qml deleted file mode 100644 index 1ee67419..00000000 --- a/qml/providers/jellyfin/LoginPage.qml +++ /dev/null @@ -1,364 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import "../../theme" -import "../../primitives" - -// The way into the app: pick an account, or add one by finding a server and -// signing in to it. -// -// This file is the frame — which of the three steps is showing, and where a -// key press goes. Each step owns its own state and answers four questions: -// which controls it has, how to focus one, how to move inside one, and what -// Select does to one. That contract is what lets the same single column read -// at arm's length on a laptop, across a room on a television, and pinned above -// an on-screen keyboard on a phone, without any of the steps knowing which it -// is looking at. -FocusScope { - id: root - - property var shell - property bool addMode: Session.accountProfiles.length === 0 || Session.profileSignInRequired - property int addStep: Session.profileSignInRequired ? 2 : 1 - property string selectedServerName: Session.serverName - property string selectedServerAddress: Session.serverUrl - property Item backReturnControl: null - - readonly property bool hasSavedAccounts: Session.accountProfiles.length > 0 - // A real on-screen keyboard, not merely a focused field: on desktop the - // form must not rearrange itself the moment you click into it. - readonly property bool keyboardVisible: Qt.inputMethod.visible - // Room, not hardware. A keyboard covering half the panel and a phone held - // sideways are the same problem and take the same answer: drop everything - // that is not the form and pin what is left to the top. - // A step is laid out at its natural size and then shrunk to fit if that - // does not leave room, rather than being clipped at the bottom edge. The - // interface scale is a user setting and defaults to 150% on a television, - // so a form that fits at 100% can easily not fit here -- and the part that - // falls off is the bottom, which is where Quick Connect's code appears. - // Scaling keeps every control reachable at any zoom instead of trusting - // that the content happened to be short enough. - readonly property real stepFitMargin: Metrics.scaled(12) - - function stepFitScale(needed, available) { - if (!(needed > 0) || !(available > 0)) - return 1 - const room = available - root.stepFitMargin - // Below this the text stops being legible across a room, and something - // has gone wrong that shrinking further will not rescue. - return Math.max(0.65, Math.min(1, room / needed)) - } - - readonly property bool dense: keyboardVisible || Metrics.units(height) < 620 - - readonly property string fallbackServerName: "Jellyfin Server" - readonly property string chosenServerName: selectedServerName.length > 0 ? selectedServerName : fallbackServerName - readonly property string chosenServerAddress: selectedServerAddress.length > 0 ? selectedServerAddress : - Session.serverUrl - readonly property bool canNavigateBack: true - - focus: true - - function currentStep() { - if (!addMode) - return profilePicker - return addStep === 2 ? accountStep : serverStep - } - - function enterProfile(profileId) { - Session.activateProfile(profileId) - } - - function openAddAccount() { - addMode = true - addStep = 1 - Qt.callLater(function () { - serverStep.focusDefault() - }) - } - - function showProfiles() { - if (!hasSavedAccounts) - return - addMode = false - Qt.callLater(function () { - profilePicker.focusDefault() - }) - } - - function useServer(name, address) { - selectedServerName = name && name.length > 0 ? name : fallbackServerName - selectedServerAddress = address - addStep = 2 - Qt.callLater(function () { - accountStep.focusDefault() - }) - } - - function changeServer() { - addStep = 1 - Qt.callLater(function () { - serverStep.focusDefault() - }) - } - - function signIn() { - App.clearError() - Session.login() - } - - function controls() { - return currentStep().controls() - } - - function focusedControl() { - const items = controls() - for (let i = 0; i < items.length; ++i) - if (items[i].activeFocus) - return items[i] - return items.length > 0 ? items[0] : null - } - - function focusControl(item) { - if (item) - currentStep().focusControl(item) - } - - function focusBackButton(returnControl) { - if (!brand.backControl.visible) - return false - backReturnControl = returnControl - InputKeys.focus(brand.backControl) - return true - } - - function restoreBackButtonFocus() { - const items = controls() - const target = items.indexOf(backReturnControl) >= 0 ? backReturnControl : items.length > 0 ? items[0] : null - backReturnControl = null - focusControl(target) - } - - function moveControl(delta) { - const items = controls() - const currentIndex = items.indexOf(focusedControl()) - const nextIndex = Math.max(0, Math.min(items.length - 1, currentIndex + delta)) - if (nextIndex === currentIndex || nextIndex < 0) - return false - focusControl(items[nextIndex]) - return true - } - - function back() { - if (profileDialogs.open) - return profileDialogs.back() - if (addMode && addStep === 2) { - changeServer() - return true - } - if (addMode && hasSavedAccounts) { - showProfiles() - return true - } - if (!addMode && shell && shell.canCancelSwitchUser) - return shell.cancelSwitchUser() - if (Router.canGoBack) { - Router.back() - return true - } - Router.replace("providerPicker") - return true - } - - function routeKey(key, phase, repeat) { - if (profileDialogs.open) - return profileDialogs.routeKey(key, phase, repeat) - if (InputKeys.isMedia(key) && phase === "press") { - activate() - return true - } - if (!InputKeys.isDirection(key)) - return false - if (brand.backControl.activeFocus) { - if (key === Qt.Key_Right || key === Qt.Key_Down) - restoreBackButtonFocus() - return true - } - - const current = focusedControl() - // A step gets first refusal, because a grid or a list moves inside - // itself before the page moves between the things it holds. - if (currentStep().moveInside(current, key)) - return true - if (InputKeys.isVertical(key)) { - const moved = moveControl(key === Qt.Key_Down ? 1 : -1) - if (!moved && key === Qt.Key_Up) - focusBackButton(current) - return true - } - // The steps are single columns, so sideways has nowhere to go but out - // to the one control that is not part of one. - if (key === Qt.Key_Left) - focusBackButton(current) - return true - } - - function activate() { - if (profileDialogs.open) { - profileDialogs.activate() - return - } - if (brand.backControl.activeFocus) { - brand.backControl.clicked() - return - } - currentStep().activateControl(focusedControl()) - } - - // A remote has no right mouse button, so the account menu hangs off a long - // press of Select the way it does everywhere else in the app. - function longPress() { - if (profileDialogs.open || addMode) - return false - return profilePicker.openContextMenu() - } - - Connections { - target: Session - - function onProfileSignInRequiredChanged() { - if (!Session.profileSignInRequired) - return - root.selectedServerName = Session.serverName - root.selectedServerAddress = Session.serverUrl - root.addMode = true - root.addStep = 2 - Qt.callLater(function () { - accountStep.focusDefault() - }) - } - } - - Component.onCompleted: Qt.callLater(function () { - currentStep().focusDefault() - }) - - Rectangle { - anchors.fill: parent - color: Theme.bg - } - - LoginBrandPanel { - id: brand - x: Metrics.pageMarginPx - y: root.dense ? Metrics.scaled(8) : Metrics.pageMarginPx - width: Math.max(0, root.width - Metrics.pageMarginPx * 2) - height: implicitHeight - dense: root.dense - backVisible: root.canNavigateBack - onBackRequested: root.back() - - Behavior on y { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - } - - Item { - id: stepHost - - // One column at every size. It is capped where a line of text stops - // being comfortable to scan and centred in whatever is left, so a - // television and a phone are the same screen at two magnifications - // rather than two designs. - readonly property int columnWidth: { - const preferred = Metrics.scaled(root.addMode ? 620 : 940) - const available = root.width - Metrics.pageMarginPx * 2 - return Math.max(Metrics.scaled(200), Math.min(preferred, available)) - } - - x: Math.round((root.width - columnWidth) / 2) - y: brand.y + brand.height + Metrics.scaled(root.dense ? 14 : 34) - width: columnWidth - height: Math.max(0, root.height - y - Metrics.pageMarginPx) - - Behavior on y { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - - LoginProfilePicker { - id: profilePicker - anchors.fill: parent - visible: !root.addMode - enabled: visible - dense: root.dense - onProfileChosen: profileId => root.enterProfile(profileId) - onAddRequested: root.openAddAccount() - onChangeProviderRequested: Router.push("providerPicker") - onContextRequested: (profileId, anchor, serverName, serverUrl) => profileDialogs.show(profileId, anchor, serverName, - serverUrl) - } - - // Both forms are short enough to sit off the top edge on a tall screen - // rather than climbing it, and pinned when there is no room to spare. - LoginServerStep { - id: serverStep - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: root.dense ? 0 : Math.max(0, Math.round((parent.height - height) * 0.16)) - readonly property real fitScale: root.stepFitScale(implicitHeight, parent.height) - height: Math.min(parent.height, implicitHeight * fitScale) - implicitHeight: childrenRect.height - scale: fitScale - transformOrigin: Item.Top - visible: root.addMode && root.addStep === 1 - enabled: visible - dense: root.dense - onServerChosen: (name, address) => root.useServer(name, address) - } - - LoginAccountStep { - id: accountStep - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: root.dense ? 0 : Math.max(0, Math.round((parent.height - height) * 0.16)) - readonly property real fitScale: root.stepFitScale(implicitHeight, parent.height) - height: Math.min(parent.height, implicitHeight * fitScale) - implicitHeight: childrenRect.height - scale: fitScale - transformOrigin: Item.Top - visible: root.addMode && root.addStep === 2 - enabled: visible - dense: root.dense - serverName: root.chosenServerName - serverAddress: root.chosenServerAddress - onChangeServerRequested: root.changeServer() - onSignInRequested: root.signIn() - } - } - - LoginProfileDialogs { - id: profileDialogs - anchors.fill: parent - z: 200 - onSignInAgainRequested: { - root.addMode = true - root.addStep = 2 - } - onClosed: Qt.callLater(function () { - if (!root.addMode) - profilePicker.focusDefault() - if (Session.accountProfiles.length === 0) - root.openAddAccount() - }) - } -} diff --git a/qml/providers/jellyfin/LoginProfileDialogs.qml b/qml/providers/jellyfin/LoginProfileDialogs.qml deleted file mode 100644 index 1cf6e4fe..00000000 --- a/qml/providers/jellyfin/LoginProfileDialogs.qml +++ /dev/null @@ -1,225 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -// The three things you can do to a saved account: sign in again, correct the -// server it points at, or forget it. They share one anchor and one key route, -// so the picker only has to open and close this. -Item { - id: root - - property string profileId: "" - property string serverName: "" - property string serverUrl: "" - property Item anchorItem: null - - // "" | "menu" | "edit" | "remove" - property string mode: "" - readonly property bool open: mode.length > 0 - - signal signInAgainRequested - signal closed - - function show(id, anchor, name, url) { - profileId = id - anchorItem = anchor - serverName = name - serverUrl = url - mode = "menu" - } - - function close() { - mode = "" - profileId = "" - anchorItem = null - root.closed() - } - - function routeKey(key, phase, repeat) { - return loader.item ? loader.item.routeKey(key, phase, repeat) : false - } - - function activate() { - if (loader.item) - loader.item.activate() - } - - function back() { - if (!open) - return false - close() - return true - } - - Loader { - id: loader - anchors.fill: parent - active: root.open - sourceComponent: root.mode === "menu" ? actionMenu : root.mode === "remove" ? removeDialog : editDialog - } - - Component { - id: actionMenu - - OptionPickerDialog { - title: "Account actions" - options: ["Sign in again", "Edit server", "Remove from this device"] - currentIndex: 0 - anchorItem: root.anchorItem - onSelected: index => { - if (index === 0) { - Session.prepareProfileSignIn(root.profileId) - root.close() - root.signInAgainRequested() - return - } - root.mode = index === 1 ? "edit" : "remove" - } - onDismissed: root.close() - } - } - - Component { - id: removeDialog - - ConfirmationDialog { - title: "Remove " + root.serverName + "?" - message: "This removes the saved account and token for " + root.serverName - + " on this device. Other accounts are unchanged." - confirmText: "Remove" - destructive: true - onAccepted: { - Session.removeProfile(root.profileId) - root.close() - } - onDismissed: root.close() - } - } - - Component { - id: editDialog - - FocusScope { - id: editScope - - property string nameDraft: root.serverName - property string urlDraft: root.serverUrl - readonly property var controls: [nameField, addressField, cancelButton, saveButton] - - anchors.fill: parent - focus: true - - function focusControl(item) { - if (item === nameField || item === addressField) - item.focusRow() - else - InputKeys.focus(item) - } - - function routeKey(key, phase, repeat) { - if (InputKeys.isBack(key, false, false)) { - if (phase === "release") - root.close() - return true - } - if (!InputKeys.isDirection(key)) - return InputKeys.isAccept(key) - if (phase !== "press") - return true - - let index = controls.findIndex(item => item.activeFocus) - if (index < 0) - index = 0 - if (key === Qt.Key_Down || key === Qt.Key_Right) - index = Math.min(controls.length - 1, index + 1) - else - index = Math.max(0, index - 1) - focusControl(controls[index]) - return true - } - - function activate() { - if (saveButton.activeFocus) - saveButton.clicked() - else if (cancelButton.activeFocus) - root.close() - } - - Component.onCompleted: Qt.callLater(function () { - nameField.focusRow() - }) - - Rectangle { - anchors.fill: parent - color: Theme.overlayScrimStrong - } - - Surface { - anchors.centerIn: parent - width: Math.min(parent.width - Metrics.scaled(96), Metrics.scaled(620)) - height: editContent.implicitHeight + Metrics.scaled(48) - elevated: true - baseColor: Theme.floatingPanel - - ColumnLayout { - id: editContent - anchors.fill: parent - anchors.margins: Metrics.scaled(24) - spacing: Metrics.scaled(14) - - AppText { - Layout.fillWidth: true - text: "Edit server" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - TextFieldRow { - id: nameField - Layout.fillWidth: true - label: "Server label" - text: editScope.nameDraft - onTextEdited: text => editScope.nameDraft = text - } - - TextFieldRow { - id: addressField - Layout.fillWidth: true - label: "Server address" - text: editScope.urlDraft - inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - onTextEdited: text => editScope.urlDraft = text - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(12) - - Item { - Layout.fillWidth: true - } - - ActionButton { - id: cancelButton - text: "Cancel" - onClicked: root.close() - } - - ActionButton { - id: saveButton - text: "Save" - kind: "primary" - onClicked: { - Session.updateProfileServer(root.profileId, editScope.nameDraft, editScope.urlDraft) - root.close() - } - } - } - } - } - } - } -} diff --git a/qml/providers/jellyfin/LoginProfilePicker.qml b/qml/providers/jellyfin/LoginProfilePicker.qml deleted file mode 100644 index faee5b63..00000000 --- a/qml/providers/jellyfin/LoginProfilePicker.qml +++ /dev/null @@ -1,206 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import "../../theme" -import "../../primitives" -import "ProfileNavigation.js" as ProfileNavigation - -// The accounts already saved on this device, plus the way to add another. -// -// One grid holds both: the add tile is the cell after the last account, so it -// wraps with everything else and a phone showing two to a row navigates by the -// same rules as a television showing six. -FocusScope { - id: root - - property bool dense: false - - readonly property var profiles: Session.accountProfiles - readonly property int profileCount: profiles.length - readonly property int totalItemCount: root.profileCount + 2 - // Tiles take the room a lane offers, but never more room than there is: - // a pane narrower than one tile shrinks the tile rather than clipping it. - readonly property int tileSize: { - const preferred = Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 168 : Metrics.laneAtLeast(width, - "regular") - ? 148 : 124) - return Math.max(Metrics.scaled(72), Math.min(preferred, width - cellPadding * 2)) - } - readonly property int cellPadding: Metrics.scaled(14) - - signal profileChosen(string profileId) - signal addRequested - signal changeProviderRequested - signal contextRequested(string profileId, Item anchor, string serverName, string serverUrl) - - function controls() { - return [grid] - } - - function focusControl(item) { - InputKeys.focus(item) - } - - function focusDefault() { - if (grid.currentIndex < 0 && grid.count > 0) - grid.currentIndex = 0 - InputKeys.focus(grid) - } - - function moveInside(control, key) { - if (control !== grid || grid.count <= 0) - return false - const direction = key === Qt.Key_Left ? "left" : key === Qt.Key_Right ? "right" : key === Qt.Key_Up ? "up" : - "down" - const next = ProfileNavigation.move(grid.currentIndex, root.totalItemCount - 1, grid.columnCount(), direction) - if (next < 0 || next === grid.currentIndex) - return false - grid.currentIndex = next - grid.positionViewAtIndex(next, GridView.Contain) - return true - } - - function activateControl(control) { - if (control === grid) - grid.activateCurrent() - } - - function openContextMenu() { - return grid.requestContextMenu() - } - - // Heading and tiles are one block, centred in the pane rather than pinned - // to its top corner: with a couple of accounts the page is mostly empty, - // and a chooser adrift in the top left of it reads as a layout that ran - // out of content rather than one that is waiting for an answer. It only - // fills the pane, and lets the grid scroll, when the accounts outgrow it. - Item { - id: content - - readonly property int headingSpan: heading.visible ? heading.height + Metrics.scaled(26) : 0 - readonly property int naturalHeight: headingSpan + grid.contentHeight - - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - width: parent.width - height: Math.min(parent.height, naturalHeight) - - AppText { - id: heading - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - visible: !root.dense - text: "Choose an account" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - } - - GridView { - id: grid - - readonly property int cellSpan: root.tileSize + root.cellPadding * 2 - // Whole cells, and never more of them than there are tiles to put in - // them: sizing to the columns the pane could hold left a row of two - // accounts hugging the left edge of four columns of empty space. - readonly property int columnsInUse: Math.max(1, Math.min(root.totalItemCount, Math.floor(parent.width - / cellSpan))) - - anchors.top: parent.top - anchors.topMargin: content.headingSpan - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - width: cellSpan * columnsInUse - cellWidth: cellSpan - cellHeight: root.tileSize + Metrics.scaled(root.dense ? 52 : 84) + root.cellPadding - clip: true - focus: false - keyNavigationEnabled: false - boundsBehavior: Flickable.StopAtBounds - model: root.totalItemCount - currentIndex: 0 - onCountChanged: if (currentIndex >= count) - currentIndex = Math.max(0, count - 1) - - function columnCount() { - return Math.max(1, Math.floor(width / Math.max(1, cellWidth))) - } - - function activateCurrent() { - if (currentItem) - currentItem.accepted() - } - - function requestContextMenu() { - if (!currentItem || currentIndex >= root.profileCount) - return false - currentItem.contextRequested() - return true - } - - FastWheelHandler { - flickable: grid - } - - delegate: Item { - id: cell - - required property int index - - readonly property var profile: cell.index < root.profileCount ? root.profiles[cell.index] : null - readonly property bool isAddTile: cell.index === root.profileCount - readonly property bool isProviderTile: cell.index === root.profileCount + 1 - - width: grid.cellWidth - height: grid.cellHeight - - function accepted() { - tile.accepted() - } - - function contextRequested() { - tile.contextRequested() - } - - ProfileTile { - id: tile - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: root.cellPadding - tileSize: root.tileSize - addTile: cell.isAddTile - actionTile: cell.isProviderTile - actionIcon: "swap_horiz" - providerName: cell.profile ? "Jellyfin" : "" - username: cell.profile ? String(cell.profile.userName || "Saved account") : (cell.isAddTile ? "Add account" : - "Change provider") - serverName: cell.profile ? String(cell.profile.serverName || "Jellyfin Server") : ( - cell.isProviderTile ? "Switch media source" : "") - serverAddress: cell.profile ? String(cell.profile.serverHost || cell.profile.serverUrl || "") : "" - needsSignIn: cell.profile ? Boolean(cell.profile.needsAuthentication) : false - focused: cell.GridView.isCurrentItem && grid.activeFocus - onAccepted: { - grid.currentIndex = cell.index - InputKeys.focus(grid) - if (cell.profile) - root.profileChosen(String(cell.profile.profileId || "")) - else if (cell.isAddTile) - root.addRequested() - else if (cell.isProviderTile) - root.changeProviderRequested() - } - onContextRequested: { - if (!cell.profile) - return - grid.currentIndex = cell.index - InputKeys.focus(grid) - root.contextRequested(String(cell.profile.profileId || ""), tile, tile.serverName, String( - cell.profile.serverUrl || "")) - } - } - } - } - } -} diff --git a/qml/providers/jellyfin/LoginServerStep.qml b/qml/providers/jellyfin/LoginServerStep.qml deleted file mode 100644 index f2609515..00000000 --- a/qml/providers/jellyfin/LoginServerStep.qml +++ /dev/null @@ -1,419 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -// Choosing a server: type an address, or take one off the network. -// -// The two are one list. What you have typed resolves into the same row shape -// as anything discovered, directly under the field, so there is never a card -// on the page saying one thing and a list underneath saying another. -FocusScope { - id: root - - // Room is short: the heading and the hint give up their space first. - property bool dense: false - readonly property bool scanning: Discovery.active - readonly property string fallbackServerName: "Jellyfin Server" - - // Live probe of whatever is in the address field. `probeInput` is the text - // handed to Discovery, so comparing it with the current draft tells us - // whether the result on screen still describes what the user has typed. - property string manualDraft: "" - property string probeInput: "" - // idle | checking | online | offline - property string probeState: "idle" - property string probeAddress: "" - property string probeServerName: "" - property string probeStatus: "" - property string probeDetail: "" - // Set when the user commits to an address that is still being checked, so - // the wait costs them nothing: the moment it answers, we move on. - property bool advanceWhenOnline: false - - readonly property string draftAddress: String(manualDraft || "").trim() - readonly property bool probeRowVisible: probeInput.length > 0 && probeInput === draftAddress - readonly property bool probeOnline: probeRowVisible && probeState === "online" - - signal serverChosen(string name, string address) - - // Reaching a server is the slow part of adding an account, so the field - // starts it as soon as the text names somewhere reachable. Enter still - // forces an attempt for addresses too unusual to recognise. - function probeDraft(force) { - const address = draftAddress - if (address.length === 0) { - clearProbe() - return - } - if (!force) { - if (address === probeInput) - return - if (!Discovery.looksLikeServerAddress(address)) { - clearProbe() - return - } - } - probeInput = address - probeAddress = address - advanceWhenOnline = false - probeServerName = "" - probeStatus = "" - probeDetail = "" - probeState = "checking" - Discovery.probeServer(address) - } - - function clearProbe() { - Discovery.cancelServerProbe() - probeInput = "" - advanceWhenOnline = false - probeState = "idle" - probeAddress = "" - probeServerName = "" - probeStatus = "" - probeDetail = "" - } - - // Enter or a click means "use this one", whatever the probe is doing. If - // the answer has not landed yet the intent is queued rather than refused. - function commitProbedServer() { - if (probeOnline) { - advanceWhenOnline = false - Session.rememberServer(probeServerName, probeAddress) - root.serverChosen(probeServerName, probeAddress) - return - } - if (probeState !== "checking") - probeDraft(true) - advanceWhenOnline = probeState === "checking" - } - - // Picking a server that is already listed goes straight to sign-in. A - // saved entry has been reached before, and re-checking it first would sit - // the user in front of a spinner to be told what the row already said. - function chooseDiscoveredServer(index, name, address) { - if (index < 0) - return - Session.chooseDiscoveredServer(index) - root.serverChosen(name, address) - } - - function controls() { - const items = [addressRow] - if (probeRowVisible) - items.push(probeRow) - if (discoveredList.count > 0) - items.push(discoveredList) - return items - } - - function focusControl(item) { - if (item === addressRow) - addressRow.focusRow() - else - InputKeys.focus(item) - } - - function focusDefault() { - if (probeRowVisible) - InputKeys.focus(probeRow) - else if (discoveredList.count > 0) { - if (discoveredList.currentIndex < 0) - discoveredList.currentIndex = 0 - InputKeys.focus(discoveredList) - } else { - // The row decides whether that means the caret or the D-pad stop. - addressRow.focusRow() - } - } - - function moveInside(control, key) { - if (control !== discoveredList || !InputKeys.isVertical(key)) - return false - const next = discoveredList.currentIndex + (key === Qt.Key_Down ? 1 : -1) - if (next < 0 || next >= discoveredList.count) - return false - discoveredList.currentIndex = next - return true - } - - function activateControl(control) { - if (control === addressRow) - addressRow.focusField() - else if (control === probeRow) - commitProbedServer() - else if (control === discoveredList && discoveredList.currentItem) - discoveredList.currentItem.accepted() - } - - Connections { - target: Discovery - - function onServerProbeSucceeded(input, server, version, plainHttp) { - if (input !== root.probeInput) - return - root.probeState = "online" - root.probeAddress = server.address - root.probeServerName = server.name - root.probeStatus = plainHttp ? "Online · HTTP" : "Online" - Session.serverUrl = server.address - if (root.advanceWhenOnline) - root.commitProbedServer() - } - - function onServerProbeFailed(input, message) { - if (input !== root.probeInput) - return - root.probeState = "offline" - root.advanceWhenOnline = false - root.probeStatus = TlsTrust.pending ? "Certificate not trusted" : "Couldn't connect" - root.probeDetail = TlsTrust.pending ? TlsTrust.pendingFingerprint : message - } - } - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - spacing: 0 - - // Typing an address and the result of typing it are one thing, so - // they share one frame. What you get back appears inside the box you - // typed into rather than as a second card underneath it. - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: connectGroup.implicitHeight + Metrics.scaled(40) - radius: Theme.radiusMedium - color: "transparent" - border.width: Math.max(1, Metrics.scaled(1)) - border.color: Theme.border - - ColumnLayout { - id: connectGroup - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Metrics.scaled(20) - anchors.rightMargin: Metrics.scaled(20) - spacing: Metrics.scaled(14) - - AppText { - Layout.fillWidth: true - visible: !root.dense - text: "Sign in to your server" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - elide: Text.ElideRight - } - - TextFieldRow { - id: addressRow - Layout.fillWidth: true - placeholderText: "Server address" - text: root.manualDraft - inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - enterKeyType: Qt.EnterKeyGo - onTextEdited: text => { - root.manualDraft = text - probeDebounce.restart() - } - // Enter before the debounce has even fired still means - // "go": it starts the check and rides it in when it - // answers. - onAccepted: { - probeDebounce.stop() - root.commitProbedServer() - } - } - - // Grows out of the field rather than appearing beneath it, so - // the frame stretches once instead of the page jumping. - ServerCard { - id: probeRow - Layout.fillWidth: true - Layout.preferredHeight: root.probeRowVisible ? implicitHeight : 0 - opacity: root.probeRowVisible ? 1 : 0 - clip: true - focus: false - // Clickable while it is still checking too: committing - // early queues the move rather than turning the click away. - selectable: root.probeOnline || root.probeState === "checking" - tone: root.probeState === "checking" ? "pending" : root.probeState === "online" ? "positive" : - "negative" - - title: root.probeState === "online" && root.probeServerName.length > 0 ? root.probeServerName : - root.probeAddress - serverAddress: root.probeState === "online" ? root.probeAddress : "" - status: root.probeState === "checking" ? "Connecting…" : root.probeStatus - detail: root.probeState === "offline" ? root.probeDetail : "" - onAccepted: { - InputKeys.focus(probeRow) - root.commitProbedServer() - } - - Behavior on Layout.preferredHeight { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - easing.type: Easing.OutCubic - } - } - - Behavior on opacity { - enabled: !Theme.reducedMotion - NumberAnimation { - duration: 150 - } - } - } - } - } - - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.scaled(34) - Layout.bottomMargin: Metrics.scaled(10) - spacing: Metrics.scaled(10) - - AppText { - text: "On your network" - color: Theme.textSecondary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - font.capitalization: Font.AllUppercase - font.letterSpacing: Math.max(1, Metrics.scaled(1)) - } - - Rectangle { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Math.max(1, Metrics.scaled(1)) - color: Theme.border - } - - BusySpinner { - Layout.alignment: Qt.AlignVCenter - implicitWidth: Metrics.scaled(13) - implicitHeight: Metrics.scaled(13) - visible: root.scanning - color: Theme.textPrimary - } - - AppText { - Layout.alignment: Qt.AlignVCenter - text: root.scanning ? "Searching…" : discoveredList.count > 0 ? discoveredList.count + " found" : "" - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Medium - } - } - - // The well keeps its frame whether or not anything has been found yet, - // so a server arriving mid-scan slides into a slot that was already - // there instead of shoving the page around. Two rows is the size of - // nearly every home network; past that it scrolls rather than growing - // until the form leaves the screen. - Rectangle { - readonly property int rowSpan: Metrics.scaled(64) - - Layout.fillWidth: true - Layout.minimumHeight: Metrics.scaled(88) - Layout.preferredHeight: Math.max(Metrics.scaled(88), Math.min(discoveredList.contentHeight + Metrics.scaled(8), - Math.round(rowSpan * 2.15))) - Layout.maximumHeight: Math.round(rowSpan * 2.15) - radius: Theme.radiusMedium - color: "transparent" - border.width: Math.max(1, Metrics.scaled(1)) - border.color: Theme.border - - ListView { - id: discoveredList - anchors.fill: parent - anchors.margins: Metrics.scaled(4) - visible: count > 0 - clip: true - spacing: 0 - focus: false - keyNavigationEnabled: false - boundsBehavior: Flickable.StopAtBounds - model: DiscoveredServers - currentIndex: count > 0 ? 0 : -1 - onCountChanged: { - if (count > 0 && !addressRow.editing && !probeRow.activeFocus) - Qt.callLater(root.focusDefault) - } - onCurrentIndexChanged: if (currentIndex >= 0) - positionViewAtIndex(currentIndex, ListView.Contain) - - FastWheelHandler { - flickable: discoveredList - } - - delegate: ServerCard { - id: discoveredRow - - required property int index - required property string name - required property string address - required property bool online - - width: discoveredList.width - inset: true - title: name.length > 0 ? name : root.fallbackServerName - serverAddress: address - status: online ? "Online" : "Saved" - tone: online ? "positive" : "neutral" - focused: ListView.isCurrentItem && discoveredList.activeFocus - onAccepted: { - discoveredList.currentIndex = index - InputKeys.focus(discoveredList) - root.chooseDiscoveredServer(index, title, address) - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: Metrics.scaled(16) - anchors.rightMargin: Metrics.scaled(16) - height: Math.max(1, Metrics.scaled(1)) - visible: discoveredRow.index < discoveredList.count - 1 && !discoveredRow.focused - color: Theme.border - } - } - } - - Column { - anchors.centerIn: parent - spacing: Metrics.scaled(7) - visible: discoveredList.count === 0 - - MaterialIcon { - anchors.horizontalCenter: parent.horizontalCenter - name: root.scanning ? "wifi_tethering" : "wifi_off" - iconSize: Metrics.scaled(24) - iconColor: Theme.textDisabled - } - - SecondaryText { - anchors.horizontalCenter: parent.horizontalCenter - width: Math.min(implicitWidth, root.width - Metrics.scaled(48)) - text: root.scanning ? "Searching your network…" : "No servers found" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - } - } - - Timer { - id: probeDebounce - interval: 450 - onTriggered: root.probeDraft(false) - } -} diff --git a/qml/providers/jellyfin/ManagementDialog.qml b/qml/providers/jellyfin/ManagementDialog.qml deleted file mode 100644 index 9f9d2bd7..00000000 --- a/qml/providers/jellyfin/ManagementDialog.qml +++ /dev/null @@ -1,199 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" -import "../../shell" - -OverlayDialog { - id: root - - property string mode: "" - property var item: ({}) - property string nameDraft: "" - property int targetIndex: 0 - readonly property var targets: mode === "collection" ? Management.collectionTargets : Management.playlistTargets - readonly property string heading: mode === "playlist" ? "Add to playlist" : mode === "collection" - ? "Add to collection" : mode === "rename" ? "Rename" : mode - === "delete" - ? "Delete item" : - Browse.viewKind - === "playlist" - ? "Remove from playlist" : - "Remove from collection" - - function itemTitle(value) { - return String(value && (value.displayTitle || value.title || value.seriesName || value.name) || "Selected item") - } - - function focusNameField() { - InputKeys.focus(managementNameField) - managementNameField.focusField() - } - - function prepare() { - targetIndex = 0 - nameDraft = mode === "rename" ? itemTitle(item) : "" - if (mode === "playlist" || mode === "collection") - Management.refreshTargets(mode) - if (mode === "rename") - Qt.callLater(focusNameField) - else - Qt.callLater(function () { - InputKeys.focus(root) - }) - } - - function submitName(name) { - const value = String(name || "").trim() - if (value.length === 0) - return - if (mode === "playlist") - Management.createPlaylistForItem(value, item) - else if (mode === "collection") - Management.createCollectionForItem(value, item) - else - Management.renameItem(item, value) - dismissed() - } - - function selectTarget(index) { - const target = targets[index] || ({}) - const targetId = String(target.movieId || target.id || "") - if (targetId.length === 0) - return - if (mode === "playlist") - Management.addItemToPlaylist(targetId, item) - else - Management.addItemToCollection(targetId, item) - dismissed() - } - - function confirm() { - if (mode === "delete") - Management.deleteItem(item) - else - Management.removeItemFromCurrentParent(item) - dismissed() - } - - function routeKey(key, phase, repeat) { - if (mode !== "playlist" && mode !== "collection") - return false - if (key === Qt.Key_Up && targetIndex === 0) { - focusNameField() - return true - } - targetIndex = Math.max(0, Math.min(targets.length - 1, targetIndex + (key === Qt.Key_Down ? 1 : key - === Qt.Key_Up ? -1 : - 0))) - return InputKeys.isHorizontal(key) || key === Qt.Key_Up || key === Qt.Key_Down - } - - function activate() { - if (mode === "playlist" || mode === "collection") - selectTarget(targetIndex) - else if (mode === "rename") - submitName(nameDraft) - else - confirm() - } - - function back() { - dismissed() - return true - } - - AppText { - Layout.fillWidth: true - text: root.heading - color: Theme.textPrimary - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - AppText { - Layout.fillWidth: true - text: root.itemTitle(root.item) - color: Theme.textMuted - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - visible: root.mode.length > 0 - } - - TextFieldRow { - id: managementNameField - Layout.fillWidth: true - visible: root.mode === "playlist" || root.mode === "collection" || root.mode === "rename" - label: root.mode === "rename" ? "Name" : (root.mode === "collection" ? "New collection name" : - "New playlist name") - text: root.nameDraft - onTextEdited: value => root.nameDraft = value - onAccepted: root.submitName(root.nameDraft) - } - - AppText { - Layout.fillWidth: true - visible: root.mode === "delete" || root.mode === "remove" - text: root.mode === "delete" ? "This permanently deletes the item from the server." : - "This removes the item from the current playlist or collection." - color: Theme.textMuted - font.pixelSize: Metrics.bodySizePx - wrapMode: Text.Wrap - } - - Repeater { - model: root.mode === "playlist" || root.mode === "collection" ? root.targets.length : 0 - delegate: Rectangle { - required property int index - Layout.fillWidth: true - height: Metrics.controlHeightPx - radius: Theme.radiusMedium - color: root.targetIndex === index ? Theme.focusedFill : "transparent" - border.width: root.targetIndex === index ? Theme.focusBorderWidth : Theme.hoverBorderWidth - border.color: root.targetIndex === index ? Theme.accent : Theme.border - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Metrics.scaled(14) - anchors.rightMargin: Metrics.scaled(14) - spacing: Metrics.scaled(12) - MaterialIcon { - name: root.mode === "collection" ? "collections_bookmark" : "playlist_play" - iconColor: Theme.textPrimary - iconSize: Metrics.iconSizePx - } - AppText { - Layout.fillWidth: true - text: root.itemTitle(root.targets[index] || ({})) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - } - } - - MouseArea { - anchors.fill: parent - onClicked: root.selectTarget(index) - } - } - } - - RowLayout { - Layout.fillWidth: true - visible: root.mode === "delete" || root.mode === "remove" - Item { - Layout.fillWidth: true - } - ActionButton { - text: "Cancel" - onClicked: root.dismissed() - } - ActionButton { - text: root.mode === "delete" ? "Delete" : "Remove" - kind: "primary" - onClicked: root.confirm() - } - } -} diff --git a/qml/providers/jellyfin/ProfileNavigation.js b/qml/providers/jellyfin/ProfileNavigation.js deleted file mode 100644 index f340a52a..00000000 --- a/qml/providers/jellyfin/ProfileNavigation.js +++ /dev/null @@ -1,28 +0,0 @@ -.pragma library - -// Where a directional press lands in the account grid. -// -// The add-account tile is simply the cell after the last profile, so the grid -// is one uniform run of `profileCount + 1` cells and nothing has to carry a -// special case for it. Left and right walk that run; up and down step by a -// row. Returns -1 when the press has walked off the top or the left of the -// grid and belongs to whatever the page keeps above it. -function move(index, profileCount, columns, direction) { - const total = Math.max(1, Math.round(profileCount) + 1) - const columnCount = Math.max(1, Math.round(columns)) - const current = Math.max(0, Math.min(total - 1, Math.round(index))) - - switch (direction) { - case "left": - return current > 0 ? current - 1 : -1 - case "right": - return Math.min(total - 1, current + 1) - case "up": - return current >= columnCount ? current - columnCount : -1 - case "down": - // The last row is ragged, so stepping down from above it lands on the - // add tile rather than refusing to move because that column is short. - return current + columnCount < total ? current + columnCount : total - 1 - } - return current -} diff --git a/qml/providers/jellyfin/RemoteControlMenu.qml b/qml/providers/jellyfin/RemoteControlMenu.qml deleted file mode 100644 index 45057334..00000000 --- a/qml/providers/jellyfin/RemoteControlMenu.qml +++ /dev/null @@ -1,196 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -FocusScope { - id: menu - - property bool menuOpen: false - property var entries: [] - property int currentIndex: 0 - signal requestClose - - visible: menuOpen - implicitHeight: panel.implicitHeight - height: implicitHeight - - function actionable(entry) { - return entry && entry.kind !== "empty" && entry.kind !== "status" - } - - function buildEntries() { - const out = [] - if (RemoteControl.targetSelected) { - out.push({ - kind: "status", - label: "Controlling " + (RemoteControl.selectedTargetName || "selected device"), - detail: RemoteControl.selectedTargetDetail || "Connected", - icon: "cast_connected" - }) - out.push({ - kind: "disconnect", - label: "Stop controlling", - detail: "Playback on the target continues", - icon: "cast" - }) - } - const targets = RemoteControl.targets || [] - for (let index = 0; index < targets.length; ++index) { - const target = targets[index] || ({}) - if (target.sessionId === RemoteControl.selectedSessionId) - continue - const detail = [] - if (target.client) - detail.push(target.client) - if (target.userName) - detail.push(target.userName) - if (target.nowPlayingTitle) - detail.push("Playing " + target.nowPlayingTitle) - out.push({ - kind: "target", - sessionId: target.sessionId || "", - label: target.deviceName || target.client || "Jellyfin client", - detail: detail.join(" · "), - icon: target.deviceType === "TV" ? "tv" : "devices" - }) - } - if (out.length === 0) - out.push({ - kind: "empty", - label: RemoteControl.busy ? "Looking for clients…" : "No remote clients available", - detail: "Enable remote control on the target device", - icon: RemoteControl.busy ? "sync" : "cast" - }) - out.push({ - kind: "refresh", - label: "Refresh clients", - detail: "", - icon: "refresh" - }) - return out - } - - function firstActionable() { - for (let index = 0; index < entries.length; ++index) - if (actionable(entries[index])) - return index - return 0 - } - - function openMenu() { - RemoteControl.refreshTargets() - entries = buildEntries() - currentIndex = firstActionable() - menuOpen = true - Qt.callLater(function () { - list.currentIndex = currentIndex - InputKeys.focus(list) - }) - } - - function closeMenu() { - menuOpen = false - } - - function activateEntry(index) { - const entry = entries[index] - if (!actionable(entry)) - return - if (entry.kind === "target") { - RemoteControl.selectTarget(entry.sessionId) - } else if (entry.kind === "disconnect") { - RemoteControl.clearTarget() - } else if (entry.kind === "refresh") { - RemoteControl.refreshTargets() - return - } - requestClose() - } - - function routeKey(key, phase, repeat) { - return list.routeKey(key, phase, repeat) - } - - function activate() { - list.activate() - } - - function back() { - requestClose() - return true - } - - Connections { - target: RemoteControl - enabled: menu.menuOpen - function onTargetsChanged() { - menu.entries = menu.buildEntries() - menu.currentIndex = Math.min(menu.currentIndex, Math.max(0, menu.entries.length - 1)) - } - function onTargetChanged() { - menu.entries = menu.buildEntries() - menu.currentIndex = menu.firstActionable() - } - function onBusyChanged() { - menu.entries = menu.buildEntries() - } - } - - Surface { - id: panel - anchors.fill: parent - implicitHeight: Math.min(Metrics.scaled(520), contentColumn.implicitHeight + Metrics.scaled(20)) - elevated: true - baseColor: Theme.bgRaised - clip: true - - PopupShield {} - - ColumnLayout { - id: contentColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(10) - spacing: Metrics.scaled(6) - - AppText { - Layout.fillWidth: true - Layout.leftMargin: Metrics.scaled(4) - text: "Play On" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - } - - MenuListView { - id: list - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, Metrics.scaled(470)) - model: menu.entries - currentIndex: menu.currentIndex - rowEnabled: function (entry, index) { - return menu.actionable(menu.entries[index]) - } - onCurrentIndexChanged: menu.currentIndex = currentIndex - onDismissed: menu.requestClose() - onAccepted: index => menu.activateEntry(index) - - delegate: MenuRow { - required property int index - required property var modelData - width: list.width - label: modelData.label || "" - detail: modelData.detail || "" - iconName: modelData.icon || "" - actionable: menu.actionable(modelData) - highlighted: ListView.isCurrentItem && list.activeFocus - checkIconName: "" - onHovered: list.currentIndex = index - onActivated: menu.activateEntry(index) - } - } - } - } -} diff --git a/qml/providers/jellyfin/RemoteControlPage.qml b/qml/providers/jellyfin/RemoteControlPage.qml deleted file mode 100644 index c8e8a86e..00000000 --- a/qml/providers/jellyfin/RemoteControlPage.qml +++ /dev/null @@ -1,681 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -FocusScope { - id: root - - property var shell - property real shownPositionTicks: RemoteControl.positionTicks - readonly property bool contentReady: true - readonly property var item: RemoteControl.nowPlayingItem || ({}) - readonly property bool hasMedia: Boolean(item.movieId || item.title) - readonly property real remoteButtonSize: Math.max(Metrics.touchTargetPx, Math.min(Metrics.scaled(96), (width - 2 - * Metrics.pageMarginPx - - Metrics.scaled( - 52)) / 3.17)) - readonly property real primaryRemoteButtonSize: Math.min(Metrics.scaled(112), remoteButtonSize * 1.17) - readonly property var seekPreviewData: positionSlider.dragging && RemoteControl.trickplayAvailable - ? RemoteControl.trickplayForTicks(shownPositionTicks) : ({}) - readonly property bool seekPreviewReady: seekPreviewData && seekPreviewData.available === true - - focus: true - - function formatTicks(ticks) { - const total = Math.max(0, Math.floor(Number(ticks || 0) / 10000000)) - const hours = Math.floor(total / 3600) - const minutes = Math.floor((total % 3600) / 60) - const seconds = total % 60 - const minuteText = hours > 0 && minutes < 10 ? "0" + minutes : String(minutes) - const secondText = seconds < 10 ? "0" + seconds : String(seconds) - return hours > 0 ? hours + ":" + minuteText + ":" + secondText : minuteText + ":" + secondText - } - - function selectedTrackLabel(tracks, fallback) { - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - return tracks[index].label - return fallback - } - - function cycleTrack(tracks, select) { - if (!tracks || tracks.length === 0) - return - let selected = -1 - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - selected = index - const next = tracks[(selected + 1) % tracks.length] - select(Number(next.index)) - } - function openTargetKeyboard() { - remoteKeyboard.text = "" - InputKeys.focus(remoteKeyboard) - Qt.inputMethod.show() - } - - function commitTargetKeyboard() { - const text = remoteKeyboard.text - remoteKeyboard.text = "" - if (text.length > 0) - RemoteControl.sendGeneralCommand("SendString", { - "String": text - }) - Qt.inputMethod.hide() - Qt.callLater(function () { - InputKeys.focus(keyboardButton) - }) - } - - function routeKey(key, phase, repeat) { - if (phase !== "press" || repeat || !InputKeys.isDirection(key)) - return InputKeys.isDirection(key) - const window = root.Window.window - const current = window ? window.activeFocusItem : null - const backwards = key === Qt.Key_Left || key === Qt.Key_Up - const next = current && current.nextItemInFocusChain ? current.nextItemInFocusChain(backwards) : - disconnectButton - InputKeys.focus(next) - if (next && next.mapToItem) { - const point = next.mapToItem(content, 0, 0) - flick.contentY = Math.max(0, Math.min(flick.contentHeight - flick.height, point.y - Metrics.scaled(80))) - } - return true - } - - function activate() { - const window = root.Window.window - const current = window ? window.activeFocusItem : null - if (current && current.clicked) - current.clicked() - } - - onActiveFocusChanged: if (activeFocus) - InputKeys.focus(RemoteControl.targetSelected ? disconnectButton : refreshButton) - - Connections { - target: RemoteControl - function onStateChanged() { - if (!positionSlider.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - function onTargetChanged() { - if (RemoteControl.targetSelected) - RemoteControl.refreshTargets() - } - } - Connections { - target: Qt.inputMethod - function onVisibleChanged() { - if (!Qt.inputMethod.visible && remoteKeyboard.activeFocus) - root.commitTargetKeyboard() - } - } - - Timer { - interval: 1000 - repeat: true - running: root.visible && RemoteControl.targetSelected && !RemoteControl.paused - onTriggered: if (!positionSlider.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - - Flickable { - id: flick - anchors.fill: parent - anchors.leftMargin: Metrics.pageMarginPx - anchors.rightMargin: Metrics.pageMarginPx - contentWidth: width - contentHeight: content.implicitHeight + Metrics.pageMarginPx * 2 - boundsBehavior: Flickable.StopAtBounds - flickDeceleration: Metrics.flickDecelerationPx - maximumFlickVelocity: Metrics.maximumFlickVelocityPx - clip: true - - ColumnLayout { - id: content - width: flick.width - spacing: Metrics.scaled(18) - - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Metrics.pageMarginPx - spacing: Metrics.scaled(12) - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(2) - - AppText { - Layout.fillWidth: true - text: RemoteControl.targetSelected ? RemoteControl.selectedTargetName : "Remote Control" - color: Theme.textPrimary - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.targetSelected ? RemoteControl.selectedTargetDetail : - "Choose a Jellyfin client" - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - } - } - - ActionButton { - id: refreshButton - text: "Refresh" - iconName: "refresh" - onClicked: RemoteControl.refreshTargets() - } - - ActionButton { - id: disconnectButton - visible: RemoteControl.targetSelected - text: "Disconnect" - iconName: "cast" - onClicked: RemoteControl.clearTarget() - } - } - - ColumnLayout { - Layout.fillWidth: true - visible: !RemoteControl.targetSelected - EmptyPlaceholder { - Layout.fillWidth: true - Layout.preferredHeight: Metrics.scaled(120) - title: RemoteControl.busy ? "Looking for clients…" : "No client selected" - detail: RemoteControl.busy ? "Checking active Jellyfin sessions" : - "Use the Cast button or choose below" - } - - Repeater { - model: RemoteControl.targets - - delegate: ActionButton { - required property var modelData - Layout.fillWidth: true - text: (modelData.deviceName || modelData.client || "Jellyfin client") + (modelData.userName - ? " — " + modelData.userName : - "") - iconName: modelData.deviceType === "TV" ? "tv" : "devices" - onClicked: RemoteControl.selectTarget(modelData.sessionId) - } - } - } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: nowPlayingLayout.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected - elevated: true - baseColor: Theme.bgRaised - - RowLayout { - id: nowPlayingLayout - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(16) - - ImageCard { - Layout.preferredWidth: Metrics.scaled(root.hasMedia ? 128 : 84) - Layout.preferredHeight: width * 9 / 16 - imageUrl: root.hasMedia ? Art.url(root.item, "landscape") : "" - fallbackText: root.hasMedia ? String(root.item.itemType || "Media") : "Idle" - fallbackIcon: root.hasMedia ? "movie" : "cast_connected" - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(4) - - AppText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Starting " + (RemoteControl.pendingTitle - || "playback") : root.hasMedia ? String( - root.item.title - || "Playing") : - "Nothing playing" - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx + Metrics.scaled(5) - font.weight: Font.DemiBold - maximumLineCount: 2 - elide: Text.ElideRight - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Waiting for " + RemoteControl.selectedTargetName : - String(root.item.seriesName || root.item.albumArtist - || root.item.album || "") - font.pixelSize: Metrics.bodySizePx - visible: text.length > 0 - } - } - } - } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: playbackColumn.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected && root.hasMedia - elevated: true - baseColor: Theme.bgRaised - - ColumnLayout { - id: playbackColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(12) - - SectionHeader { - Layout.fillWidth: true - title: "Playback" - } - - Item { - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: Math.min(content.width - Metrics.scaled(16), Metrics.scaled(320)) - Layout.preferredHeight: 0 - visible: root.seekPreviewReady - z: 10 - - Column { - anchors.bottom: parent.top - anchors.bottomMargin: Metrics.scaled(8) - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - spacing: Metrics.scaled(4) - - Rectangle { - id: previewFrame - readonly property real imageScale: root.seekPreviewReady && root.seekPreviewData.width - > 0 ? width / root.seekPreviewData.width : 1 - width: parent.width - height: root.seekPreviewReady && root.seekPreviewData.width > 0 ? width - * root.seekPreviewData.height - / root.seekPreviewData.width : - 0 - radius: Theme.radiusLarge - color: "black" - border.width: 1 - border.color: Theme.borderStrong - clip: true - - Image { - source: root.seekPreviewReady ? "image://artwork/" + encodeURIComponent( - root.seekPreviewData.url) : "" - x: root.seekPreviewReady ? root.seekPreviewData.offsetX * previewFrame.imageScale : - 0 - y: root.seekPreviewReady ? root.seekPreviewData.offsetY * previewFrame.imageScale : - 0 - width: root.seekPreviewReady ? root.seekPreviewData.sheetWidth - * previewFrame.imageScale : 0 - height: root.seekPreviewReady ? root.seekPreviewData.sheetHeight - * previewFrame.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - AppText { - id: previewClock - anchors.horizontalCenter: parent.horizontalCenter - text: root.formatTicks(root.shownPositionTicks) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - font.weight: Font.Medium - } - } - } - - InlineSlider { - id: positionSlider - Layout.leftMargin: Metrics.scaled(8) - Layout.rightMargin: Metrics.scaled(8) - from: 0 - to: Math.max(1, RemoteControl.runtimeTicks) - value: root.shownPositionTicks - interactionMargin: Metrics.scaled(16) - barHeight: Metrics.scaled(8) - handleSize: Metrics.scaled(22) - accented: true - onMoved: newValue => { - root.shownPositionTicks = newValue - if (dragging) - RemoteControl.previewSeek(Math.round(newValue), true) - } - onCommitted: newValue => { - RemoteControl.cancelSeekPreview() - RemoteControl.seek(Math.round(newValue)) - } - } - - RowLayout { - - SecondaryText { - text: root.formatTicks(root.shownPositionTicks) - } - Item {} - SecondaryText { - text: root.formatTicks(RemoteControl.runtimeTicks) - } - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Metrics.scaled(8) - - IconButton { - iconName: "skip_previous" - accessibleName: "Previous" - onClicked: RemoteControl.previousTrack() - } - IconButton { - iconName: "replay_10" - accessibleName: "Back 10 seconds" - onClicked: RemoteControl.seekRelative(-100000000) - } - IconButton { - width: Math.max(Metrics.touchTargetPx, Metrics.scaled(62)) - height: width - iconName: RemoteControl.paused ? "play_arrow" : "pause" - accessibleName: RemoteControl.paused ? "Play" : "Pause" - selected: true - onClicked: RemoteControl.togglePause() - } - IconButton { - iconName: "forward_10" - accessibleName: "Forward 10 seconds" - onClicked: RemoteControl.seekRelative(100000000) - } - IconButton { - iconName: "skip_next" - accessibleName: "Next" - onClicked: RemoteControl.nextTrack() - } - IconButton { - iconName: "stop" - accessibleName: "Stop" - onClicked: RemoteControl.stopPlayback() - } - } - - RowLayout { - spacing: Metrics.scaled(12) - - IconButton { - iconName: RemoteControl.muted ? "volume_off" : "volume_up" - accessibleName: RemoteControl.muted ? "Unmute" : "Mute" - onClicked: RemoteControl.toggleMute() - } - - InlineSlider { - id: volumeSlider - from: 0 - to: 100 - value: RemoteControl.volume - stepSize: 1 - interactionMargin: Metrics.scaled(16) - onCommitted: newValue => RemoteControl.setVolume(Math.round(newValue)) - } - - SecondaryText { - text: RemoteControl.volume + "%" - color: Theme.textSecondary - } - } - - RowLayout { - spacing: Metrics.scaled(8) - - ActionButton { - visible: RemoteControl.audioTracks.length > 1 - text: "Audio: " + root.selectedTrackLabel(RemoteControl.audioTracks, "Default") - iconName: "audiotrack" - onClicked: root.cycleTrack(RemoteControl.audioTracks, function (index) { - RemoteControl.selectAudioTrack(index) - }) - } - ActionButton { - visible: RemoteControl.subtitleTracks.length > 0 - text: "Subtitles: " + root.selectedTrackLabel(RemoteControl.subtitleTracks, "Off") - iconName: "subtitles" - onClicked: root.cycleTrack(RemoteControl.subtitleTracks, function (index) { - RemoteControl.selectSubtitleTrack(index) - }) - } - ActionButton { - text: RemoteControl.repeatMode === "RepeatNone" ? "Repeat off" : RemoteControl.repeatMode - iconName: "repeat" - onClicked: RemoteControl.setRepeatMode(RemoteControl.repeatMode === "RepeatNone" - ? "RepeatAll" : RemoteControl.repeatMode - === "RepeatAll" ? "RepeatOne" : "RepeatNone") - } - ActionButton { - text: RemoteControl.shuffled ? "Shuffled" : "Shuffle" - iconName: "shuffle" - onClicked: RemoteControl.setShuffled(!RemoteControl.shuffled) - } - } - } - } - - Surface { - Layout.fillWidth: true - Layout.preferredHeight: navigationColumn.implicitHeight + Metrics.scaled(32) - visible: RemoteControl.targetSelected - elevated: true - baseColor: Theme.bgRaised - - ColumnLayout { - id: navigationColumn - anchors.fill: parent - anchors.margins: Metrics.scaled(16) - spacing: Metrics.scaled(14) - - SectionHeader { - Layout.fillWidth: true - title: "Navigation" - } - - SecondaryText { - Layout.fillWidth: true - text: "Control the screen on " + RemoteControl.selectedTargetName - } - - GridLayout { - Layout.alignment: Qt.AlignHCenter - columns: 3 - rowSpacing: Metrics.scaled(10) - columnSpacing: Metrics.scaled(10) - - Item { - Layout.preferredWidth: root.remoteButtonSize - Layout.preferredHeight: root.remoteButtonSize - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_up" - accessibleName: "Up" - onClicked: RemoteControl.sendGeneralCommand("MoveUp") - } - Item { - Layout.preferredWidth: root.remoteButtonSize - Layout.preferredHeight: root.remoteButtonSize - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_left" - accessibleName: "Left" - onClicked: RemoteControl.sendGeneralCommand("MoveLeft") - } - IconButton { - width: root.primaryRemoteButtonSize - height: width - iconName: "radio_button_checked" - accessibleName: "Select" - selected: true - onClicked: RemoteControl.sendGeneralCommand("Select") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_right" - accessibleName: "Right" - onClicked: RemoteControl.sendGeneralCommand("MoveRight") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "arrow_back" - accessibleName: "Back" - onClicked: RemoteControl.sendGeneralCommand("Back") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "keyboard_arrow_down" - accessibleName: "Down" - onClicked: RemoteControl.sendGeneralCommand("MoveDown") - } - IconButton { - width: root.remoteButtonSize - height: width - iconName: "menu" - accessibleName: "Menu" - onClicked: RemoteControl.sendGeneralCommand("ToggleContextMenu") - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(8) - - ActionButton { - Layout.fillWidth: true - text: "Home" - iconName: "home" - onClicked: RemoteControl.sendGeneralCommand("GoHome") - } - ActionButton { - Layout.fillWidth: true - text: "Search" - iconName: "search" - onClicked: RemoteControl.sendGeneralCommand("GoToSearch") - } - ActionButton { - id: keyboardButton - Layout.fillWidth: true - visible: !Platform.isTV - text: "Keyboard" - iconName: "keyboard" - onClicked: root.openTargetKeyboard() - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(8) - - ActionButton { - Layout.fillWidth: true - text: "Settings" - iconName: "settings" - onClicked: RemoteControl.sendGeneralCommand("GoToSettings") - } - ActionButton { - Layout.fillWidth: true - text: "Player overlay" - iconName: "picture_in_picture_alt" - onClicked: RemoteControl.sendGeneralCommand("ToggleOsd") - } - } - - TextInput { - id: remoteKeyboard - Layout.preferredWidth: 1 - Layout.preferredHeight: 1 - opacity: 0 - inputMethodHints: Qt.ImhNoPredictiveText - onAccepted: root.commitTargetKeyboard() - } - } - } - - ColumnLayout { - Layout.fillWidth: true - visible: RemoteControl.targetSelected && RemoteControl.queue.length > 0 - spacing: Metrics.scaled(6) - - SectionHeader { - Layout.fillWidth: true - title: "Playback queue" - } - - Repeater { - model: RemoteControl.queue - - delegate: Surface { - required property int index - required property var modelData - Layout.fillWidth: true - Layout.preferredHeight: Metrics.controlHeightPx + Metrics.scaled(10) - baseColor: modelData.current ? Theme.accentPanel : Theme.bgPanel - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Metrics.scaled(14) - anchors.rightMargin: Metrics.scaled(8) - spacing: Metrics.scaled(6) - - AppText { - Layout.fillWidth: true - text: (modelData.current ? "Now playing · " : "") + (modelData.title - || modelData.seriesName - || "Queue item " + (index + 1)) - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - elide: Text.ElideRight - } - IconButton { - iconName: "play_arrow" - accessibleName: "Play queue item" - onClicked: RemoteControl.playQueueItem(index) - } - IconButton { - enabled: index > 0 - iconName: "keyboard_arrow_up" - accessibleName: "Move up" - onClicked: RemoteControl.moveQueueItem(index, index - 1) - } - IconButton { - enabled: index + 1 < RemoteControl.queue.length - iconName: "keyboard_arrow_down" - accessibleName: "Move down" - onClicked: RemoteControl.moveQueueItem(index, index + 1) - } - IconButton { - iconName: "delete" - accessibleName: "Remove" - onClicked: RemoteControl.removeQueueItem(index) - } - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.preferredHeight: Metrics.pageMarginPx - } - } - } -} diff --git a/qml/providers/jellyfin/RemoteNowPlayingBar.qml b/qml/providers/jellyfin/RemoteNowPlayingBar.qml deleted file mode 100644 index b8424484..00000000 --- a/qml/providers/jellyfin/RemoteNowPlayingBar.qml +++ /dev/null @@ -1,472 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../theme" -import "../../primitives" - -// A compact transport for the client this device is driving, kept in reach -// while the user browses everything else. Dragging its bar previews on the -// other screen exactly as the remote control page does, so the two are the -// same control in two places rather than two behaviours. -Item { - id: root - - signal openRequested - - readonly property var item: RemoteControl.nowPlayingItem || ({}) - readonly property bool hasMedia: Boolean(item.movieId || item.title) - readonly property bool shown: RemoteControl.targetSelected && (hasMedia || RemoteControl.playbackPending) - property real shownPositionTicks: RemoteControl.positionTicks - property string openMenu: "" - - // The frame at the position being shown, which during a drag is the one - // under the finger. Falls back to the item's own artwork when the server - // has no trickplay for it. - readonly property var previewData: RemoteControl.trickplayAvailable ? RemoteControl.trickplayForTicks( - shownPositionTicks) : ({}) - readonly property bool previewReady: previewData && previewData.available === true - readonly property var audioTracks: RemoteControl.audioTracks || [] - readonly property var subtitleTracks: RemoteControl.subtitleTracks || [] - - function formatTicks(ticks) { - const total = Math.max(0, Math.floor(Number(ticks || 0) / 10000000)) - const hours = Math.floor(total / 3600) - const minutes = Math.floor((total % 3600) / 60) - const seconds = total % 60 - const minuteText = hours > 0 && minutes < 10 ? "0" + minutes : String(minutes) - const secondText = seconds < 10 ? "0" + seconds : String(seconds) - return hours > 0 ? hours + ":" + minuteText + ":" + secondText : minuteText + ":" + secondText - } - - function selectedTrackLabel(tracks, fallback) { - for (let index = 0; index < tracks.length; ++index) - if (tracks[index].selected) - return tracks[index].label - return fallback - } - - function toggleMenu(name) { - openMenu = openMenu === name ? "" : name - } - - implicitHeight: Metrics.scaled(87) - visible: shown - enabled: shown - - onShownChanged: if (!shown) - openMenu = "" - - Connections { - target: RemoteControl - function onStateChanged() { - if (!seekBar.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - } - - Timer { - interval: 1000 - repeat: true - running: root.shown && root.visible && !RemoteControl.paused - onTriggered: if (!seekBar.dragging) - root.shownPositionTicks = RemoteControl.predictedPositionTicks() - } - - // An enlarged frame while scrubbing, clear of the thumb doing the - // scrubbing. The inline thumbnail tracks the same position, but at this - // size the picture is actually readable. - Item { - width: Math.min(root.width - Metrics.scaled(24), Metrics.scaled(300)) - height: 0 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - visible: root.previewReady && seekBar.dragging - z: 20 - - Column { - anchors.bottom: parent.top - anchors.bottomMargin: Metrics.scaled(10) - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - spacing: Metrics.scaled(4) - - Rectangle { - id: previewFrame - readonly property real imageScale: root.previewReady && root.previewData.width > 0 ? width - / root.previewData.width : - 1 - width: parent.width - height: root.previewReady && root.previewData.width > 0 ? width * root.previewData.height - / root.previewData.width : 0 - radius: Theme.radiusLarge - color: "black" - border.width: 1 - border.color: Theme.borderStrong - clip: true - - Image { - source: root.previewReady ? "image://artwork/" + encodeURIComponent(root.previewData.url) : "" - x: root.previewReady ? root.previewData.offsetX * previewFrame.imageScale : 0 - y: root.previewReady ? root.previewData.offsetY * previewFrame.imageScale : 0 - width: root.previewReady ? root.previewData.sheetWidth * previewFrame.imageScale : 0 - height: root.previewReady ? root.previewData.sheetHeight * previewFrame.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - width: previewClock.width + Metrics.scaled(14) - height: previewClock.height + Metrics.scaled(6) - radius: height / 2 - color: Theme.bgRaised - - AppText { - id: previewClock - anchors.centerIn: parent - text: root.formatTicks(root.shownPositionTicks) - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - font.weight: Font.DemiBold - } - } - } - } - - Rectangle { - anchors.fill: parent - color: Theme.bgRaised - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: 1 - color: Theme.borderStrong - } - - // Opens the full remote. Sits behind the controls so their taps win. - MouseArea { - anchors.fill: parent - onClicked: { - if (root.openMenu.length > 0) - root.openMenu = "" - else - root.openRequested() - } - } - - // Left: what is playing, and where. - RowLayout { - id: nowPlaying - anchors.left: parent.left - anchors.leftMargin: Metrics.scaled(12) - anchors.right: transport.left - anchors.rightMargin: Metrics.scaled(12) - anchors.top: parent.top - anchors.topMargin: Metrics.scaled(11) - anchors.bottom: parent.bottom - anchors.bottomMargin: Metrics.scaled(6) - spacing: Metrics.scaled(11) - // On a narrow screen the six transport buttons leave no room to - // say what is playing. Losing the caption beats crushing it. - visible: width > Metrics.scaled(96) - - Rectangle { - id: thumb - readonly property real imageScale: root.previewReady && root.previewData.width > 0 ? width - / root.previewData.width : - 1 - Layout.preferredWidth: Metrics.scaled(78) - Layout.preferredHeight: Metrics.scaled(44) - Layout.alignment: Qt.AlignVCenter - radius: Theme.radiusSmall - color: "black" - clip: true - - ImageCard { - anchors.fill: parent - visible: !root.previewReady - imageUrl: root.hasMedia ? Art.url(root.item, "landscape") : "" - fallbackText: "" - fallbackIcon: "cast_connected" - } - - Image { - visible: root.previewReady - source: root.previewReady ? "image://artwork/" + encodeURIComponent(root.previewData.url) : "" - x: root.previewReady ? root.previewData.offsetX * thumb.imageScale : 0 - y: root.previewReady ? root.previewData.offsetY * thumb.imageScale : 0 - width: root.previewReady ? root.previewData.sheetWidth * thumb.imageScale : 0 - height: root.previewReady ? root.previewData.sheetHeight * thumb.imageScale : 0 - fillMode: Image.Stretch - cache: true - asynchronous: true - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(2) - - AppText { - Layout.fillWidth: true - text: RemoteControl.playbackPending ? "Starting " + (RemoteControl.pendingTitle || "playback") : - String(root.item.title || "Nothing playing") - color: Theme.textPrimary - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold - maximumLineCount: 1 - elide: Text.ElideRight - } - - // The cast mark stands in for the word "on", so the row reads - // as one phrase naming where this is playing. - RowLayout { - Layout.fillWidth: true - spacing: Metrics.scaled(5) - - MaterialIcon { - name: "cast_connected" - iconSize: Metrics.scaled(17) - iconColor: Theme.accent - Layout.preferredWidth: Metrics.scaled(17) - Layout.preferredHeight: Metrics.scaled(17) - } - - SecondaryText { - Layout.fillWidth: true - text: RemoteControl.selectedTargetName - color: Theme.textMuted - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - elide: Text.ElideRight - } - - // Mirrors the group state so the controller and the client - // it drives both show they are in a SyncPlay group. - MaterialIcon { - visible: SyncPlay && SyncPlay.enabled - name: "groups" - iconSize: Metrics.scaled(17) - iconColor: Theme.accent - Layout.preferredWidth: Metrics.scaled(17) - Layout.preferredHeight: Metrics.scaled(17) - } - } - } - } - - // Centre: the transport the player overlay offers, in the same order, - // so the two read as one control in two places. - RowLayout { - id: transport - readonly property real secondarySize: Math.max(Metrics.touchTargetPx * 0.82, Metrics.scaled(40)) - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: Metrics.scaled(3) - spacing: Metrics.scaled(2) - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "skip_previous" - accessibleName: "Previous" - onClicked: RemoteControl.previousTrack() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "replay_10" - accessibleName: "Back 10 seconds" - onClicked: RemoteControl.seekRelative(-100000000) - } - - IconButton { - width: Math.max(Metrics.touchTargetPx, Metrics.scaled(52)) - height: width - iconRatio: 0.7 - iconName: RemoteControl.paused ? "play_arrow" : "pause" - accessibleName: RemoteControl.paused ? "Play" : "Pause" - selected: true - onClicked: RemoteControl.togglePause() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "forward_10" - accessibleName: "Forward 10 seconds" - onClicked: RemoteControl.seekRelative(100000000) - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "skip_next" - accessibleName: "Next" - onClicked: RemoteControl.nextTrack() - } - - IconButton { - width: transport.secondarySize - height: width - iconRatio: 0.68 - iconName: "stop" - accessibleName: "Stop" - onClicked: RemoteControl.stopPlayback() - } - } - - // Right: track selection, only for what this item actually offers. - RowLayout { - id: trackButtons - anchors.right: parent.right - anchors.rightMargin: Metrics.scaled(8) - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: Metrics.scaled(3) - spacing: Metrics.scaled(2) - - IconButton { - id: audioButton - visible: root.audioTracks.length > 1 - iconRatio: 0.62 - iconName: "audiotrack" - accessibleName: "Audio track" - selected: root.openMenu === "audio" - onClicked: root.toggleMenu("audio") - } - - IconButton { - id: subtitleButton - visible: root.subtitleTracks.length > 0 - iconRatio: 0.62 - iconName: "subtitles" - accessibleName: "Subtitles" - selected: root.openMenu === "subtitles" - onClicked: root.toggleMenu("subtitles") - } - } - } - - // Both pickers open upward from their own button, so the button stays - // under the thumb that opened it. - Component { - id: trackMenu - - Surface { - id: panel - property var tracks: [] - property string heading: "" - property var chooser: null - property Item anchorItem: null - - readonly property real panelWidth: Math.min(root.width - Metrics.scaled(16), Metrics.scaled(300)) - - width: panelWidth - height: Math.min(Metrics.scaled(300), column.implicitHeight + Metrics.scaled(12)) - x: anchorItem ? Math.max(Metrics.scaled(8), Math.min(root.width - width - Metrics.scaled(8), - anchorItem.mapToItem(root, 0, 0).x + anchorItem.width - / 2 - width / 2)) : 0 - y: -height - Metrics.scaled(8) - z: 30 - elevated: true - baseColor: Theme.bgRaised - - Flickable { - anchors.fill: parent - anchors.margins: Metrics.scaled(6) - contentWidth: width - contentHeight: column.implicitHeight - boundsBehavior: Flickable.StopAtBounds - clip: true - - Column { - id: column - width: parent.width - - Repeater { - model: panel.tracks - - delegate: MenuRow { - required property var modelData - width: column.width - compact: true - label: String(modelData.label || "Track") - checked: modelData.selected === true - onActivated: { - if (panel.chooser) - panel.chooser(Number(modelData.index)) - root.openMenu = "" - } - } - } - } - } - } - } - - Loader { - active: root.openMenu === "audio" - sourceComponent: trackMenu - onLoaded: { - item.tracks = Qt.binding(() => root.audioTracks) - item.anchorItem = audioButton - item.chooser = index => RemoteControl.selectAudioTrack(index) - } - } - - Loader { - active: root.openMenu === "subtitles" - sourceComponent: trackMenu - onLoaded: { - item.tracks = Qt.binding(() => root.subtitleTracks) - item.anchorItem = subtitleButton - item.chooser = index => RemoteControl.selectSubtitleTrack(index) - } - } - - // The seek bar rides the top edge, where it reads as the progress of the - // bar itself rather than one more control competing with the row. - InlineSlider { - id: seekBar - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: -implicitHeight / 2 + Metrics.scaled(2) - from: 0 - to: Math.max(1, RemoteControl.runtimeTicks) - value: root.shownPositionTicks - barHeight: Metrics.scaled(4) - handleSize: dragging ? Metrics.scaled(18) : Metrics.scaled(11) - interactionMargin: Metrics.scaled(18) - accented: true - enabled: root.hasMedia && RemoteControl.runtimeTicks > 0 - opacity: enabled ? 1 : 0 - z: 25 - - onMoved: newValue => { - root.shownPositionTicks = newValue - if (dragging) - RemoteControl.previewSeek(Math.round(newValue), true) - } - onCommitted: newValue => { - RemoteControl.cancelSeekPreview() - RemoteControl.seek(Math.round(newValue)) - } - - Behavior on handleSize { - NumberAnimation { - duration: 90 - } - } - } -} diff --git a/qml/shell/AppShell.qml b/qml/shell/AppShell.qml index 97f2799c..c7a31072 100644 --- a/qml/shell/AppShell.qml +++ b/qml/shell/AppShell.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" -import "../providers/jellyfin" import "RoutePolicy.js" as RoutePolicy KeyRouter { @@ -128,24 +127,10 @@ KeyRouter { readonly property string route: Router.route readonly property var routeArgs: Router.args || ({}) - // Provider-shaped singletons are only read behind their capability, so a - // source without sign-in or remote control never has them evaluated. A - // source with no sign-in is simply always signed in. - readonly property var session: ProviderCapabilities.auth ? Session : null - readonly property bool signedIn: session ? session.authenticated : true - readonly property var remoteControl: ProviderCapabilities.remoteControl ? RemoteControl : null - readonly property bool remoteTargetSelected: remoteControl ? remoteControl.targetSelected : false - property bool remoteConnectionKnown: false - property string lastRemoteTargetName: "" - onRouteChanged: { - root.exitArmedAt = 0 - if (route === "remoteControl" && !root.remoteTargetSelected) { - Qt.callLater(function () { - if (root.route === "remoteControl" && !root.remoteTargetSelected) - root.goHome() - }) - } - } + // Routes that stand outside browsing: no rail, no now-playing chrome. + readonly property bool setupRoute: chromeRoute === "addProvider" || chromeRoute === "providerScreen" + readonly property bool signedIn: Providers.hasAccounts + onRouteChanged: root.exitArmedAt = 0 // Back is how an Android app is left, and at the top of the stack there is // nowhere further to go. Nothing here ever exited, so the press did @@ -159,11 +144,6 @@ KeyRouter { property int exitConfirmWindowMs: 3000 property double exitArmedAt: 0 property bool diagnosticsVisible: false - property string switchUserReturnProfileId: "" - property string switchUserReturnRoute: "" - property var switchUserReturnArgs: ({}) - property bool switchUserReturnPending: false - readonly property bool canCancelSwitchUser: switchUserReturnProfileId.length > 0 property bool mediaInfoVisible: false property bool itemMenuLoaded: false property int uiScaleShortcutKey: 0 @@ -172,16 +152,15 @@ KeyRouter { readonly property bool itemMenuOpen: itemContextMenuLoader.item ? itemContextMenuLoader.item.opened : false readonly property bool tlsTrustPending: TlsTrust.pending property var mediaInfoItem: ({}) - property bool managementOverlayVisible: false - property string managementMode: "" - property var managementItem: ({}) + // A provider screen asked for while something plays (a release picker). + property var providerOverlay: null property var personItem: ({}) property var pendingPlaybackBackItem: ({}) textInputActive: Qt.inputMethod.visible || InputKeys.isTextInputItem(root.Window.window ? root.Window.window.activeFocusItem : null) property var navigationTarget: routeStack - activeTarget: updateDialog.open ? updateDialog : tlsTrustPending ? tlsTrustDialog : managementOverlayVisible - ? managementOverlayLoader.item : itemMenuOpen + activeTarget: updateDialog.open ? updateDialog : tlsTrustPending ? tlsTrustDialog : providerOverlay + ? providerOverlayLoader.item : itemMenuOpen ? itemContextMenuLoader.item : mediaInfoVisible ? mediaInfoOverlayLoader.item : hasPlayer @@ -332,30 +311,19 @@ KeyRouter { "returnRoute": root.route }) } - function onRemoteSeekPreviewRequested(positionTicks, active) { - videoSurface.showRemoteSeekPreview(Number(positionTicks) / 10000000, active) - } } Connections { - target: root.remoteControl - function onTargetChanged() { - if (root.remoteTargetSelected) { - const name = root.remoteControl.selectedTargetName || "remote device" - if (!root.remoteConnectionKnown || root.lastRemoteTargetName !== name) - toast.show("Connected to " + name, toast.briefDurationMs) - root.remoteConnectionKnown = true - root.lastRemoteTargetName = name - return - } - if (!root.remoteConnectionKnown) - return - const targetName = root.lastRemoteTargetName || "remote device" - root.remoteConnectionKnown = false - root.lastRemoteTargetName = "" - if (root.route === "remoteControl") - root.goHome() - toast.show("Disconnected from " + targetName, toast.briefDurationMs) + target: Providers + function onRestoredChanged() { + if (App.initialized) + root.applyInitializedRoute() + } + function onAccountAdded() { + root.goHome() + } + function onComponentRequested(context) { + root.providerOverlay = context } } function showToastAction(message, actionText, callback) { @@ -363,9 +331,7 @@ KeyRouter { } function defaultRoute() { - if (!Sources.hasInstalledProviders) - return "providerPicker" - return root.signedIn ? "home" : "login" + return root.signedIn ? "home" : "addProvider" } function restoreRecoveredRoute() { @@ -414,6 +380,8 @@ KeyRouter { } function applyInitializedRoute() { + if (!Providers.restored) + return if (Router.recoveryPending) { if (root.restoreRecoveredRoute()) return @@ -423,24 +391,6 @@ KeyRouter { Router.reset(root.defaultRoute()) } - Connections { - target: root.session - function onAuthenticatedStateChanged() { - if (root.signedIn && root.switchUserReturnPending) { - root.completeSwitchUserReturn() - return - } - if (root.signedIn) - root.clearSwitchUserReturn() - if (root.signedIn && root.restoreRecoveredRoute()) - return - if (root.signedIn) - Router.replace(root.defaultRoute()) - else - Router.reset(root.defaultRoute()) - } - } - // Every platform states its own text rendering rather than inheriting a // default, so that tuning one cannot quietly move another. // @@ -480,8 +430,6 @@ KeyRouter { // is what raises the on-screen keyboard, so the row stays the D-pad // target until Select is pressed. Theme.textEntryFollowsFocus = !Platform.isTV - root.remoteConnectionKnown = root.remoteTargetSelected - root.lastRemoteTargetName = root.remoteTargetSelected ? root.remoteControl.selectedTargetName : "" if (App.initialized) root.applyInitializedRoute() } @@ -651,58 +599,11 @@ KeyRouter { } } - function switchUser() { - if (!root.session) - return - switchUserReturnProfileId = root.session.activeProfileId - switchUserReturnRoute = route - switchUserReturnArgs = Object.assign({}, routeArgs) - switchUserReturnPending = false - Router.reset("login") - root.session.switchUser() - navigationTarget = routeStack - InputKeys.focus(routeStack) - } - - // The way out of a start that is not arriving: back to the server list, - // with discovery running again, without waiting for whatever is not - // answering. - function chooseServer() { - switchUser() - Qt.callLater(function () { - const page = routeStack.activeItem - if (page && page.openAddAccount) - page.openAddAccount() - }) - } - - function cancelSwitchUser() { - if (!canCancelSwitchUser || !root.session) - return false - if (switchUserReturnPending) - return true - switchUserReturnPending = true - root.session.activateProfile(switchUserReturnProfileId) - return true - } - - function completeSwitchUserReturn() { - if (!switchUserReturnPending || !root.signedIn) - return false - const returnRoute = switchUserReturnRoute - const returnArgs = switchUserReturnArgs - clearSwitchUserReturn() - Router.reset(returnRoute, returnArgs) - navigationTarget = routeStack - InputKeys.focus(routeStack) - return true - } - - function clearSwitchUserReturn() { - switchUserReturnProfileId = "" - switchUserReturnRoute = "" - switchUserReturnArgs = ({}) - switchUserReturnPending = false + function openProviderScreen(context) { + if (context) + pushRoute("providerScreen", { + "context": context + }) } function releaseTextInput() { @@ -725,12 +626,8 @@ KeyRouter { releaseTextInput() return true } - if (navBar.visible && navBar.remoteControlMenuOpen) { - navBar.closeRemoteMenu() - return true - } - if (navBar.visible && navBar.syncPlayMenuOpen) { - navBar.closeSyncPlayMenu() + if (navBar.visible && navBar.groupMenuOpen) { + navBar.closeGroupMenu() return true } if (diagnosticsVisible) { @@ -741,8 +638,8 @@ KeyRouter { itemContextMenuLoader.item.closeMenu() return true } - if (managementOverlayVisible) { - closeManagementOverlay() + if (providerOverlay) { + providerOverlay.close() return true } if (mediaInfoVisible) { @@ -768,7 +665,7 @@ KeyRouter { goHome() return true } - if (route === "home" || route === "login") + if (route === "home" || (route === "addProvider" && !root.signedIn)) return backAtRoot() if (Router.canPop) { Router.pop(route === "personDetails" ? "itemDetails" : "home") @@ -792,10 +689,8 @@ KeyRouter { } function forward() { - if (tlsTrustPending || textInputActive || (navBar.visible && (navBar.syncPlayMenuOpen - || navBar.remoteControlMenuOpen)) - || diagnosticsVisible || itemMenuOpen || managementOverlayVisible || mediaInfoVisible - || playerSessionActive) + if (tlsTrustPending || textInputActive || (navBar.visible && navBar.groupMenuOpen) || diagnosticsVisible + || itemMenuOpen || providerOverlay || mediaInfoVisible || playerSessionActive) return true if (!Router.canForward) return false @@ -813,8 +708,6 @@ KeyRouter { } function openItemMenu(item, anchorItem, context) { - if (ProviderCapabilities.libraryManagement) - Management.loadCurrentUserPolicy() itemMenuLoaded = true return itemContextMenuLoader.item ? itemContextMenuLoader.item.openForItem(item || ({}), anchorItem || null, context || ({})) : false @@ -826,7 +719,7 @@ KeyRouter { } function restoreFocusAfterItemMenu() { - if (managementOverlayVisible || mediaInfoVisible || diagnosticsVisible || player.visible) + if (providerOverlay || mediaInfoVisible || diagnosticsVisible || player.visible) return navigationTarget = routeStack InputKeys.focus(routeStack) @@ -853,22 +746,6 @@ KeyRouter { InputKeys.focus(routeStack) } - function openManagement(mode, item) { - if (!ProviderCapabilities.libraryManagement) - return - managementMode = mode - managementItem = item || ({}) - managementOverlayVisible = true - } - - function closeManagementOverlay() { - managementOverlayVisible = false - managementMode = "" - managementItem = ({}) - Qt.inputMethod.hide() - InputKeys.focus(routeStack) - } - function openPerson(person) { personItem = person || ({}) const personId = String(personItem.id || "") @@ -896,7 +773,7 @@ KeyRouter { } function focusNavBar() { - if (route === "login") + if (setupRoute) return const page = routeStack.activeItem if (page && page.revealHeader) @@ -1009,13 +886,9 @@ KeyRouter { PointHandler { acceptedButtons: Qt.LeftButton onActiveChanged: if (active) { - if (navBar.remoteControlMenuOpen && !navBar.containsRemoteControlPoint(root, - point.position.x, - point.position.y)) - navBar.closeRemoteMenu(false) - if (navBar.syncPlayMenuOpen && !navBar.containsSyncPlayPoint(root, point.position.x, - point.position.y)) - navBar.closeSyncPlayMenu(false) + if (navBar.groupMenuOpen && !navBar.containsGroupPoint(root, point.position.x, + point.position.y)) + navBar.closeGroupMenu(false) } } } @@ -1054,9 +927,9 @@ KeyRouter { // window lane; top and bottom can briefly coexist and leave the // bar stretched across the viewport. y: root.navBarAtBottom ? Math.max(0, parent.height - height) : 0 - height: (root.chromeRoute === "login" || root.chromeRoute === "providerPicker") ? 0 : Metrics.topBarHeightPx + height: root.setupRoute ? 0 : Metrics.topBarHeightPx edge: root.navBarAtBottom ? "bottom" : "top" - visible: root.chromeRoute !== "login" && root.chromeRoute !== "providerPicker" + visible: !root.setupRoute z: 1 // Same reason as the height above: the rail marks where you are, // not where you are going, so it does not blink its selection off @@ -1067,8 +940,6 @@ KeyRouter { onNavigate: r => { if (r === "home") root.goHome() - else if (r === "switchUser") - root.switchUser() else if (r === "settings") root.pushRoute("settings") else @@ -1077,17 +948,13 @@ KeyRouter { onContentRequested: root.focusContent() } - // Space the now-playing bar takes out of the page, so content ends - // above it rather than under it. - readonly property real nowPlayingReserve: nowPlayingBar.visible ? nowPlayingBar.height : 0 - RouteStack { id: routeStack objectName: "shellRouteStack" anchors.left: parent.left anchors.right: parent.right y: root.navBarAtBottom ? 0 : navBar.height - height: Math.max(0, parent.height - navBar.height - contentLayer.nowPlayingReserve) + height: Math.max(0, parent.height - navBar.height) route: root.route shell: root startupReady: App.initialized @@ -1095,30 +962,6 @@ KeyRouter { onActiveFocusChanged: if (activeFocus) root.navigationTarget = routeStack } - - // Only a source that can drive another device has a bar for it; the - // loader keeps the bar's bindings from ever running otherwise. - Loader { - id: nowPlayingBar - objectName: "shellRemoteNowPlayingBar" - anchors.left: parent.left - anchors.right: parent.right - // Above the navigation when it sits at the bottom, against the - // viewport edge when it does not. - y: root.navBarAtBottom ? Math.max(0, navBar.y - height) : Math.max(0, parent.height - height) - z: 2 - active: ProviderCapabilities.remoteControl - height: item ? item.height : 0 - visible: item ? item.visible : false - sourceComponent: RemoteNowPlayingBar { - // The remote control page is this bar in full, so it would only - // duplicate itself there. - visible: shown && root.chromeRoute !== "remoteControl" && root.chromeRoute !== "login" - && root.chromeRoute !== "providerPicker" - enabled: visible - onOpenRequested: root.pushRoute("remoteControl") - } - } } } @@ -1224,9 +1067,12 @@ KeyRouter { ActionButton { id: switchServerButton Layout.alignment: Qt.AlignHCenter - text: "Switch server" + text: "Accounts" kind: "secondary" - onClicked: root.chooseServer() + onClicked: { + startupSplash.dismissed = true + Router.reset("accounts") + } } } } @@ -1250,18 +1096,30 @@ KeyRouter { } } + // A provider's own screen over whatever is showing, such as the release + // picker a provider raises while resolving playback. Loader { - id: managementOverlayLoader + id: providerOverlayLoader anchors.fill: parent z: 57 - active: ProviderCapabilities.libraryManagement && root.managementOverlayVisible - asynchronous: true - sourceComponent: ManagementDialog { - mode: root.managementMode - item: root.managementItem - onDismissed: root.closeManagementOverlay() + active: root.providerOverlay !== null + sourceComponent: ProviderSurface { + context: root.providerOverlay + overlay: true + onFinished: { + root.providerOverlay = null + InputKeys.focus(routeStack) + } } - onLoaded: item.prepare() + } + + ProviderUpdatePrompt { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: root.safeBottomPx + Metrics.scaled(24) + shown: root.chromeRoute === "home" && !root.playerHoldsScreen && App.initialized + z: 65 } Loader { diff --git a/qml/shell/DiagnosticsOverlay.qml b/qml/shell/DiagnosticsOverlay.qml index 5716ea5c..dc1e568c 100644 --- a/qml/shell/DiagnosticsOverlay.qml +++ b/qml/shell/DiagnosticsOverlay.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import JellyfinWebOS +import Spool import "../theme" import "../primitives" @@ -10,7 +10,7 @@ Item { property string focusedItemId: "" // Only read behind the capability: a source without SyncPlay has no such // singleton to report on. - readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null + readonly property var syncPlay: ProviderCapabilities.groupPlayback ? Group : null readonly property bool syncPlayActive: syncPlay ? syncPlay.enabled : false function formatBytes(bytes) { @@ -117,7 +117,7 @@ Item { SecondaryText { visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: !root.syncPlayActive ? "" : "SyncPlay " + (root.syncPlay.groupState || "Unknown") + ( + text: !root.syncPlayActive ? "" : "Group " + (root.syncPlay.groupState || "Unknown") + ( root.syncPlay.groupStateReason ? " / " + root.syncPlay.groupStateReason : "") + (root.syncPlay.waitingForPlayback @@ -127,9 +127,10 @@ Item { SecondaryText { visible: root.syncPlayActive Layout.maximumWidth: Metrics.scaled(396) - text: !root.syncPlayActive ? "" : "Time sync " + root.syncPlay.timeSyncDevice + " offset " - + root.signedMs(root.syncPlay.clockOffsetMs) + " ping " + Number( - root.syncPlay.pingMs || 0).toFixed(2) + " ms" + text: !root.syncPlayActive ? "" : "Time sync " + "server offset " + root.signedMs( + root.syncPlay.clockOffsetMs) + " ping " + Number(root.syncPlay.pingMs + || 0).toFixed(2) + + " ms" elide: Text.ElideRight } SecondaryText { diff --git a/qml/providers/jellyfin/SyncPlayMenu.qml b/qml/shell/GroupMenu.qml similarity index 60% rename from qml/providers/jellyfin/SyncPlayMenu.qml rename to qml/shell/GroupMenu.qml index 03349c0b..a9cc2239 100644 --- a/qml/providers/jellyfin/SyncPlayMenu.qml +++ b/qml/shell/GroupMenu.qml @@ -2,17 +2,15 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import "../../theme" -import "../../primitives" +import "../theme" +import "../primitives" -// Dropdown menu for the top-bar SyncPlay button. Mirrors jellyfin-web's -// SyncPlayMenu: when in a group it shows the active group and a Leave action; -// otherwise it lists joinable groups and an option to create a new one. +// The top bar's watch-together menu. In a group it shows who is in it and a +// way out; otherwise the groups every account can see, and a way to start one. FocusScope { id: menu property bool menuOpen: false - readonly property var syncPlay: SyncPlay property var entries: [] property int currentIndex: 0 signal requestClose @@ -21,11 +19,6 @@ FocusScope { implicitHeight: panel.implicitHeight height: implicitHeight - function defaultGroupName() { - const name = Session.username ? String(Session.username) : "" - return (name.length > 0 ? name : "My") + "'s group" - } - function isActionable(entry) { return entry && (entry.kind === "join" || entry.kind === "create" || entry.kind === "leave") } @@ -37,54 +30,67 @@ FocusScope { return 0 } + function people(count) { + return count + (count === 1 ? " person" : " people") + } + function buildEntries() { const out = [] - if (!syncPlay) - return out - if (syncPlay.enabled) { - // The server reports distinct usernames, not sessions, so several - // devices signed in as one person count once. - const memberCount = syncPlay.participantCount || 0 - const statusParts = [memberCount + " user" + (memberCount === 1 ? "" : "s")] - if ((syncPlay.groupState || "").length > 0) - statusParts.push(syncPlay.groupState) - statusParts.push(syncPlay.socketConnected ? Math.round(syncPlay.pingMs) + " ms" : "Reconnecting") + if (Group.enabled) { + const parts = [people(Group.participantCount)] + if (Group.groupState.length > 0) + parts.push(Group.groupState) + if (Group.pingMs > 0) + parts.push(Math.round(Group.pingMs) + " ms") out.push({ kind: "current", - label: syncPlay.currentGroupName || "SyncPlay group", - sub: statusParts.join(" / "), + label: Group.currentGroupName, + sub: parts.join(" · "), icon: "groups" }) + const names = Group.participants.join(", ") + if (names.length > 0) + out.push({ + kind: "info", + label: names, + sub: "", + icon: "person" + }) out.push({ kind: "leave", - label: "Leave group", + label: "Leave", sub: "", icon: "logout" }) - } else { - const groups = syncPlay.groups || [] - for (let i = 0; i < groups.length; ++i) { - const group = groups[i] || ({}) - const count = group.Participants ? group.Participants.length : 0 - out.push({ - kind: "join", - groupId: group.GroupId || "", - label: group.GroupName || "Group", - sub: count + " user" + (count === 1 ? "" : "s"), - icon: "login" - }) - } - if (groups.length === 0) - out.push({ - kind: "empty", - label: "No groups available", - sub: "", - icon: "block" - }) + return out + } + const groups = Group.groups || [] + const accounts = {} + for (const group of groups) { + accounts[group.accountId] = group.accountLabel out.push({ - kind: "create", - label: "Create a new group", + kind: "join", + groupId: group.id, + label: group.name || "Group", + sub: people((group.participants || []).length) + " · " + group.accountLabel, + icon: "login" + }) + } + if (groups.length === 0) + out.push({ + kind: "info", + label: Group.loadingGroups ? "Looking for groups…" : "No groups yet", sub: "", + icon: Group.loadingGroups ? "hourglass_empty" : "block" + }) + for (const source of Providers.accounts) { + if (!source.running || !source.enabled) + continue + out.push({ + kind: "create", + accountId: source.id, + label: "New group", + sub: source.label + " · " + source.providerName, icon: "add" }) } @@ -92,6 +98,7 @@ FocusScope { } function openMenu() { + Group.refreshGroups() entries = buildEntries() currentIndex = firstActionable(entries) menuOpen = true @@ -100,7 +107,6 @@ FocusScope { return list.currentIndex = currentIndex InputKeys.focus(list) - list.forceActiveFocus(Qt.PopupFocusReason) }) } @@ -110,19 +116,14 @@ FocusScope { function activateEntry(index) { const entry = entries[index] - if (!entry || !syncPlay) + if (!entry) return - if (entry.kind === "join") { - syncPlay.joinGroup(entry.groupId) - // The device that is playing is the one the group has to contain, - // so the target joins on its own behalf rather than being driven - // from here. - if (RemoteControl.targetSelected) - RemoteControl.requestSyncPlayJoin(entry.groupId) - } else if (entry.kind === "create") - syncPlay.createGroup(defaultGroupName()) + if (entry.kind === "join") + Group.joinGroup(entry.groupId) + else if (entry.kind === "create") + Group.createGroup(entry.accountId, "Watch together") else if (entry.kind === "leave") - syncPlay.leaveGroup() + Group.leaveGroup() else return requestClose() @@ -142,7 +143,7 @@ FocusScope { } Connections { - target: menu.syncPlay + target: Group enabled: menu.menuOpen function onGroupsChanged() { menu.entries = menu.buildEntries() @@ -152,9 +153,6 @@ FocusScope { menu.entries = menu.buildEntries() menu.currentIndex = menu.firstActionable(menu.entries) } - function onConnectionChanged() { - menu.entries = menu.buildEntries() - } } Surface { @@ -181,7 +179,7 @@ FocusScope { AppText { Layout.fillWidth: true Layout.leftMargin: 4 - text: "SyncPlay" + text: "Watch together" color: Theme.textMuted font.pixelSize: 12 font.weight: Font.DemiBold @@ -204,12 +202,11 @@ FocusScope { delegate: MenuRow { required property int index required property var modelData - readonly property bool rowActionable: menu.isActionable(modelData) width: list.width label: modelData.label || "" detail: modelData.sub || "" iconName: modelData.icon || "" - actionable: rowActionable + actionable: menu.isActionable(modelData) highlighted: ListView.isCurrentItem && list.activeFocus checkIconName: "" onHovered: list.currentIndex = index diff --git a/qml/shell/ItemContextMenu.qml b/qml/shell/ItemContextMenu.qml index 6944b3ed..61385404 100644 --- a/qml/shell/ItemContextMenu.qml +++ b/qml/shell/ItemContextMenu.qml @@ -42,13 +42,6 @@ FocusScope { readonly property bool inCollection: currentViewKind === "boxset" || currentViewKind === "collection" readonly property bool collectionEligible: actionable && (itemType === "Movie" || itemType === "Series" || itemType === "Episode") - // Management only exists on a source that has playlists and collections, - // so it is only consulted behind the capability. - readonly property var management: ProviderCapabilities.libraryManagement ? Management : null - readonly property bool canManagePlaylists: management ? management.currentUserCanManagePlaylists : false - readonly property bool canManageCollections: management ? management.currentUserCanManageCollections : false - readonly property bool canRenameItem: management ? management.currentUserCanRenameItems : false - readonly property bool canDeleteItem: management ? management.currentUserCanDeleteItems : false visible: opened focus: opened @@ -146,59 +139,13 @@ FocusScope { label: favoriteState ? "Remove favourite" : "Add favourite", checked: favoriteState }) - if (canManagePlaylists && queueable) + // Whatever else the item's provider does with it: playlists, + // collections, renaming. Declared up front, so this costs nothing. + for (const action of Sources.itemActions(itemId, itemType)) options.push({ - action: "playlist", - icon: "playlist_add", - label: "Add to playlist", - checked: false - }) - if (canManageCollections && collectionEligible) - options.push({ - action: "collection", - icon: "library_add", - label: "Add to collection", - checked: false - }) - if (inPlaylist && item.playlistItemId && management) { - options.push({ - action: "moveUp", - icon: "keyboard_arrow_up", - label: "Move up", - checked: false - }) - options.push({ - action: "moveDown", - icon: "keyboard_arrow_down", - label: "Move down", - checked: false - }) - options.push({ - action: "removeParent", - icon: "remove_circle", - label: "Remove from playlist", - checked: false - }) - } else if (inCollection && canManageCollections) { - options.push({ - action: "removeParent", - icon: "remove_circle", - label: "Remove from collection", - checked: false - }) - } - if ((itemType === "Playlist" && canManagePlaylists) || canRenameItem) - options.push({ - action: "rename", - icon: "drive_file_rename_outline", - label: "Rename", - checked: false - }) - if (canDeleteItem) - options.push({ - action: "delete", - icon: "delete", - label: "Delete", + action: "provider:" + action.id, + icon: action.icon || "more_horiz", + label: action.label, checked: false }) } @@ -303,14 +250,8 @@ FocusScope { } else if (action === "favorite") { favoriteState = !favoriteState ItemState.setFavorite(itemId, favoriteState) - } else if (action === "playlist" || action === "collection") { - shell.openManagement(action, item) - } else if (action === "removeParent") { - shell.openManagement("remove", item) - } else if (action === "moveUp" || action === "moveDown") { - management.movePlaylistItemInCurrent(item, action === "moveUp" ? -1 : 1) - } else if (action === "rename" || action === "delete") { - shell.openManagement(action, item) + } else if (action.startsWith("provider:")) { + Sources.runItemAction(action.slice(9), itemId, itemType) } else if (action === "info") { shell.openMediaInfo(item) } diff --git a/qml/shell/ProviderSurface.qml b/qml/shell/ProviderSurface.qml new file mode 100644 index 00000000..54e4e4c2 --- /dev/null +++ b/qml/shell/ProviderSurface.qml @@ -0,0 +1,146 @@ +import QtQuick +import "../theme" +import "../primitives" + +// Hosts one of a provider's own screens: sign-in, account settings or a +// picker. The component gets the context as `provider` and nothing else, and +// the surface closes when the context settles either way. +FocusScope { + id: root + + property var context: null + // Over the current page with a scrim, rather than as a page of its own. + property bool overlay: false + readonly property var provider: context ? Providers.modules.find(m => m.id === context.moduleId) : null + readonly property Item screen: loader.item + signal finished + + focus: true + + // Provider screens are ordinary QML forms. Where they route keys + // themselves they get first say; otherwise Up and Down walk the focus + // chain, which is enough for a D-pad to reach every field and button. + function routeKey(key, phase, repeat) { + if (screen && screen.routeKey && screen.routeKey(key, phase, repeat)) + return true + if (phase !== "press") + return false + const current = Window.activeFocusItem + if (!current || InputKeys.isTextInputItem(current)) + return false + if (key === Qt.Key_Down || key === Qt.Key_Up) { + const next = current.nextItemInFocusChain(key === Qt.Key_Down) + if (next && root.contains(root.mapFromItem(next, 0, 0))) + InputKeys.focus(next) + return true + } + return false + } + + function activate() { + if (screen && screen.activate) + return screen.activate() + const item = Window.activeFocusItem + if (item && typeof item.activate === "function") + item.activate() + else if (item && typeof item.clicked === "function") + item.clicked() + } + + function back() { + if (screen && screen.back && screen.back()) + return true + if (context) + context.close() + return true + } + + Connections { + target: root.context + function onFinished() { + root.finished() + } + } + + Rectangle { + anchors.fill: parent + color: root.overlay ? Theme.overlayScrimStrong : Theme.bg + } + + Item { + id: frame + anchors.centerIn: parent + width: root.overlay ? Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(760)) : parent.width + height: root.overlay ? Math.min(parent.height - Metrics.pageMarginPx * 2, Metrics.scaled(820)) : parent.height + + Rectangle { + anchors.fill: parent + visible: root.overlay + radius: Theme.radiusPanel + color: Theme.bgRaised + border.width: Theme.hoverBorderWidth + border.color: Theme.border + } + + Row { + id: header + x: Metrics.pageMarginPx + y: Metrics.pageMarginPx + spacing: Metrics.scaled(14) + height: Metrics.touchTargetPx + + IconButton { + anchors.verticalCenter: parent.verticalCenter + iconName: root.overlay ? "close" : "arrow_back" + accessibleName: root.overlay ? "Close" : "Back" + onClicked: root.back() + } + + ProviderIcon { + anchors.verticalCenter: parent.verticalCenter + width: Metrics.scaled(34) + height: width + source: root.provider ? root.provider.iconUrl : "" + name: root.provider ? root.provider.name : "" + seed: root.context ? root.context.moduleId : "" + } + + AppText { + anchors.verticalCenter: parent.verticalCenter + text: root.provider ? root.provider.name : "" + font.pixelSize: Metrics.bodySizePx + Metrics.scaled(2) + font.weight: Font.DemiBold + } + } + + Loader { + id: loader + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Metrics.scaled(12) + focus: true + asynchronous: true + Component.onCompleted: { + if (root.context) + setSource(root.context.component, { + "provider": root.context + }) + } + onLoaded: InputKeys.focus(item) + onStatusChanged: if (status === Loader.Error && root.context) { + App.toastMessage("This provider's screen could not be opened") + root.context.close() + } + } + + BusySpinner { + anchors.centerIn: loader + width: Metrics.scaled(34) + height: width + running: loader.status === Loader.Loading + visible: running + } + } +} diff --git a/qml/shell/ProviderUpdatePrompt.qml b/qml/shell/ProviderUpdatePrompt.qml new file mode 100644 index 00000000..35cc528b --- /dev/null +++ b/qml/shell/ProviderUpdatePrompt.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Layouts +import "../theme" +import "../primitives" + +// Offered on home when providers have updates and the preference is "Ask +// first". "Always" switches the preference to automatic and updates now. +Item { + id: root + + property bool shown: false + property bool dismissed: false + readonly property var updates: Store.updates + readonly property bool visibleNow: shown && !dismissed && updates.length > 0 && Settings.value("providers/updates") + === "ask" + + height: visibleNow ? card.height : 0 + visible: visibleNow + + function names() { + const first = updates[0] ? updates[0].name : "" + return updates.length > 1 ? first + " and " + (updates.length - 1) + " more" : first + } + + Surface { + id: card + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - Metrics.pageMarginPx * 2, Metrics.scaled(680)) + height: content.implicitHeight + Metrics.scaled(28) + baseColor: Theme.floatingPanel + elevated: true + + RowLayout { + id: content + anchors.fill: parent + anchors.leftMargin: Metrics.scaled(18) + anchors.rightMargin: Metrics.scaled(14) + spacing: Metrics.scaled(12) + + MaterialIcon { + name: "system_update" + iconSize: Metrics.scaled(24) + iconColor: Theme.accent + } + + AppText { + Layout.fillWidth: true + text: "Updates for " + root.names() + elide: Text.ElideRight + maximumLineCount: 1 + } + + ActionButton { + text: "Not now" + kind: "flat" + onClicked: root.dismissed = true + } + ActionButton { + text: "Always" + kind: "secondary" + onClicked: { + Settings.setValue("providers/updates", "auto") + Store.updateAll() + } + } + ActionButton { + text: "Update" + kind: "primary" + onClicked: { + Store.updateAll() + root.dismissed = true + } + } + } + } +} diff --git a/qml/shell/RouteStack.qml b/qml/shell/RouteStack.qml index d1b6a8e0..454608fb 100644 --- a/qml/shell/RouteStack.qml +++ b/qml/shell/RouteStack.qml @@ -5,7 +5,7 @@ import "PageReadiness.js" as PageReadiness FocusScope { id: root - property string route: "login" + property string route: "home" property var shell property bool startupReady: true focus: true @@ -13,7 +13,7 @@ FocusScope { property var uiTransitionToken: 0 // Resident-page host: pages are created once and route changes switch - // visibility + focus only. Login is destroyed on leave; + // visibility + focus only. Setup pages are destroyed on leave; // itemDetails/personDetails/search are evicted under memory pressure. property var pages: ({}) property var activeLoader: null @@ -28,20 +28,17 @@ FocusScope { function pageKey(nextRoute) { switch (nextRoute) { - case "login": - return "login" - case "providerPicker": - return "providerPicker" + case "accounts": + case "addProvider": + case "providerScreen": case "libraryGrid": - return "libraryGrid" + return nextRoute case "itemDetails": return "itemDetails" case "personDetails": return "personDetails" case "search": return "search" - case "remoteControl": - return "remoteControl" case "openSourceNotices": return "openSourceNotices" case "settings": @@ -55,10 +52,12 @@ FocusScope { function pageSource(key) { switch (key) { - case "login": - return Qt.resolvedUrl("../providers/jellyfin/LoginPage.qml") - case "providerPicker": - return Qt.resolvedUrl("../pages/ProviderPickerPage.qml") + case "accounts": + return Qt.resolvedUrl("../pages/AccountsPage.qml") + case "addProvider": + return Qt.resolvedUrl("../pages/AddProviderPage.qml") + case "providerScreen": + return Qt.resolvedUrl("../pages/ProviderScreenPage.qml") case "libraryGrid": return Qt.resolvedUrl("../pages/LibraryGridPage.qml") case "itemDetails": @@ -67,8 +66,6 @@ FocusScope { return Qt.resolvedUrl("../pages/PersonDetailsPage.qml") case "search": return Qt.resolvedUrl("../pages/SearchPage.qml") - case "remoteControl": - return Qt.resolvedUrl("../providers/jellyfin/RemoteControlPage.qml") case "openSourceNotices": return Qt.resolvedUrl("../pages/OpenSourceNoticesPage.qml") case "settings": @@ -176,7 +173,7 @@ FocusScope { // while it builds. What made a television slow was never the // prewarming; it was dropping every page the moment it left the // screen and paying to build it again on the way back. - if ((!ProviderCapabilities.auth || Session.authenticated) && !prewarmScheduled) { + if (Providers.hasAccounts && !prewarmScheduled) { prewarmScheduled = true // Most-wanted first. The budget stops this queue partway through on // a small television, so whatever stands at the front is what @@ -190,7 +187,7 @@ FocusScope { } function dropTransientPages() { - for (const key of ["login", "providerPicker"]) { + for (const key of ["accounts", "addProvider", "providerScreen"]) { const loader = pages[key] if (loader && loader !== activeLoader) { delete pages[key] diff --git a/qml/shell/TopBar.qml b/qml/shell/TopBar.qml index cd2ea31f..687c7852 100644 --- a/qml/shell/TopBar.qml +++ b/qml/shell/TopBar.qml @@ -4,11 +4,9 @@ import QtQuick import QtQuick.Layouts import "../theme" import "../primitives" -import "../providers/jellyfin" -import "TopBarNavigation.js" as TopBarNavigation -// Horizontal top navigation bar. Hosts primary routes on the left and playback -// destination, remote-control, and SyncPlay actions on the right. D-pad: +// Horizontal navigation bar: primary routes on the left, watching together on +// the right when an account supports it. D-pad: // Left/Right move between bar items, Down enters the content area, and pages // return focus here by pressing Up at their top edge. FocusScope { @@ -21,46 +19,18 @@ FocusScope { signal navigate(string route) signal contentRequested - readonly property bool syncPlayMenuOpen: syncMenuLoader.item ? syncMenuLoader.item.menuOpen : false - property bool syncPlayMenuLoaded: false - readonly property bool remoteControlMenuOpen: remoteMenuLoader.item ? remoteMenuLoader.item.menuOpen : false - property bool remoteControlMenuLoaded: false - // Provider-shaped singletons are only touched behind their capability, so - // a source without them never has the bindings evaluated. - readonly property var session: ProviderCapabilities.auth ? Session : null - readonly property var remoteControl: ProviderCapabilities.remoteControl ? RemoteControl : null - readonly property bool remoteTargetSelected: remoteControl ? remoteControl.targetSelected : false - readonly property string remoteTargetName: remoteControl ? remoteControl.selectedTargetName : "" - readonly property bool castVisible: remoteControl !== null && Settings.castButtonEnabled - readonly property bool remoteVisible: remoteTargetSelected - readonly property var syncPlay: ProviderCapabilities.syncPlay ? SyncPlay : null - readonly property bool syncVisible: syncPlay !== null - readonly property bool syncActive: syncPlay ? syncPlay.enabled : false - readonly property var syncGroups: syncPlay ? syncPlay.groups : [] - readonly property bool syncAvailable: syncGroups && syncGroups.length > 0 + readonly property bool groupMenuOpen: groupMenuLoader.item ? groupMenuLoader.item.menuOpen : false + property bool groupMenuLoaded: false + readonly property bool groupVisible: ProviderCapabilities.groupPlayback readonly property string selectedRoute: currentRoute === "libraryGrid" ? "home" : currentRoute - // A rail cell is never narrower than the button inside it. It used to be - // a flat scaled(50) while the button took the touch-target floor, so on a - // phone a 72px button sat in a 41px cell: the buttons overlapped by more - // than half their width and a tap near a boundary went to whichever - // happened to be stacked last. With a pointer the reverse held, and the - // few pixels either side of each button were dead. + // A rail cell is never narrower than the button inside it, so hit areas + // tile with nothing dead between them on any pointer. readonly property int railCellWidth: Math.max(Metrics.scaled(50), Metrics.touchTargetPx) - // Index space: navigation buttons, then the optional Remote, Cast and - // SyncPlay buttons. TopBarNavigation.js owns the arithmetic so a gated-out - // button drops out of the sequence rather than leaving a dead index. + // Index space: the rail's buttons, then the group button when shown. function lastIndex() { - return TopBarNavigation.lastIndex(railRepeater.count, remoteVisible, castVisible, syncVisible) - } - - function slotIndex(kind) { - return TopBarNavigation.indexOf(kind, railRepeater.count, remoteVisible, castVisible, syncVisible) - } - - function slotAt(index) { - return TopBarNavigation.slotAt(index, railRepeater.count, remoteVisible, castVisible, syncVisible) + return railRepeater.count - (groupVisible ? 0 : 1) } function focusedIndex() { @@ -69,30 +39,18 @@ FocusScope { if (item && item.hasButtonFocus()) return i } - if (remoteVisible && remoteButton.activeFocus) - return slotIndex("remote") - if (castVisible && castButton.activeFocus) - return slotIndex("cast") - if (syncVisible && syncButton.activeFocus) - return slotIndex("sync") - return 0 + return groupButton.activeFocus ? railRepeater.count : 0 } function focusIndex(index) { - const slot = slotAt(index) - if (!slot) + const clamped = Math.max(0, Math.min(lastIndex(), index)) + if (clamped === railRepeater.count) { + InputKeys.focus(groupButton) return - if (slot.kind === "rail") { - const item = railRepeater.itemAt(slot.railIndex) - if (item) - item.forceButtonFocus() - } else if (slot.kind === "remote") { - InputKeys.focus(remoteButton) - } else if (slot.kind === "cast") { - InputKeys.focus(castButton) - } else { - InputKeys.focus(syncButton) } + const item = railRepeater.itemAt(clamped) + if (item) + item.forceButtonFocus() } function focusCurrent() { @@ -103,140 +61,61 @@ FocusScope { return } } - const first = railRepeater.itemAt(0) - if (first) - first.forceButtonFocus() - } - - function remoteMenu() { - remoteControlMenuLoaded = true - return remoteMenuLoader.item - } - - function openRemoteMenu() { - if (!castVisible) - return - closeSyncPlayMenu(false) - const menu = remoteMenu() - if (menu) - menu.openMenu() - } - - function closeRemoteMenu(restoreFocus) { - const menu = remoteMenuLoader.item - if (menu) - menu.closeMenu() - if (restoreFocus !== false && castVisible) - InputKeys.focus(castButton) + focusIndex(0) } - function syncMenu() { - syncPlayMenuLoaded = true - return syncMenuLoader.item - } - - function openSyncMenu() { - if (!syncVisible) + function openGroupMenu() { + if (!groupVisible) return - closeRemoteMenu(false) - syncPlay.refreshGroups() - const menu = syncMenu() - if (menu) - menu.openMenu() - } - - function closeSyncPlayMenu(restoreFocus) { - const menu = syncMenuLoader.item - if (menu) - menu.closeMenu() - if (restoreFocus !== false && syncVisible) - InputKeys.focus(syncButton) - } - - function closeMenus(restoreFocus) { - if (remoteControlMenuOpen) - closeRemoteMenu(restoreFocus) - if (syncPlayMenuOpen) - closeSyncPlayMenu(restoreFocus) + groupMenuLoaded = true + if (groupMenuLoader.item) + groupMenuLoader.item.openMenu() } - function containsSyncPlayPoint(item, x, y) { - const buttonPoint = syncButton.mapFromItem(item, x, y) - if (syncButton.contains(buttonPoint)) - return true - const menu = syncMenuLoader.item - if (!menu || !menu.menuOpen) - return false - const menuPoint = menu.mapFromItem(item, x, y) - return menu.contains(menuPoint) + function closeGroupMenu(restoreFocus) { + if (groupMenuLoader.item) + groupMenuLoader.item.closeMenu() + if (restoreFocus !== false && groupVisible) + InputKeys.focus(groupButton) } - function containsRemoteControlPoint(item, x, y) { - const buttonPoint = castButton.mapFromItem(item, x, y) - if (castButton.contains(buttonPoint)) + function containsGroupPoint(item, x, y) { + if (groupButton.contains(groupButton.mapFromItem(item, x, y))) return true - if (remoteVisible) { - const remoteButtonPoint = remoteButton.mapFromItem(item, x, y) - if (remoteButton.contains(remoteButtonPoint)) - return true - } - const menu = remoteMenuLoader.item - if (!menu || !menu.menuOpen) - return false - const menuPoint = menu.mapFromItem(item, x, y) - return menu.contains(menuPoint) + const menu = groupMenuLoader.item + return Boolean(menu && menu.menuOpen && menu.contains(menu.mapFromItem(item, x, y))) } function activate() { - const remote = remoteMenuLoader.item - if (remote && remote.menuOpen) { - remote.activate() - return - } - const sync = syncMenuLoader.item - if (sync && sync.menuOpen) { - sync.activate() + const menu = groupMenuLoader.item + if (menu && menu.menuOpen) { + menu.activate() return } - const slot = slotAt(focusedIndex()) - if (!slot) + const index = focusedIndex() + if (index === railRepeater.count) { + openGroupMenu() return - if (slot.kind === "remote") { - closeRemoteMenu(false) - navigate("remoteControl") - } else if (slot.kind === "cast") { - openRemoteMenu() - } else if (slot.kind === "sync") { - openSyncMenu() - } else { - const item = railRepeater.itemAt(slot.railIndex) - if (item) - navigate(item.route) } + const item = railRepeater.itemAt(index) + if (item) + navigate(item.route) } function back() { - if (remoteControlMenuOpen) { - closeRemoteMenu() - return true - } - if (syncPlayMenuOpen) { - closeSyncPlayMenu() - return true - } - return false + if (!groupMenuOpen) + return false + closeGroupMenu() + return true } onActiveFocusChanged: if (!activeFocus) - closeMenus(false) + closeGroupMenu(false) function routeKey(key, phase, repeat) { - const remote = remoteMenuLoader.item - if (remote && remote.menuOpen) - return remote.routeKey(key, phase, repeat) - const sync = syncMenuLoader.item - if (sync && sync.menuOpen) - return sync.routeKey(key, phase, repeat) + const menu = groupMenuLoader.item + if (menu && menu.menuOpen) + return menu.routeKey(key, phase, repeat) // The rail leaves towards wherever the content is, which is below it // on a page and above it once it has moved down within thumb reach. const towardsContent = root.edge === "bottom" ? Qt.Key_Up : Qt.Key_Down @@ -258,12 +137,8 @@ FocusScope { TapHandler { acceptedButtons: Qt.LeftButton onTapped: eventPoint => { - const remotePoint = castButton.mapFromItem(root, eventPoint.position.x, eventPoint.position.y) - if (!castButton.contains(remotePoint)) - root.closeRemoteMenu(false) - const syncPoint = syncButton.mapFromItem(root, eventPoint.position.x, eventPoint.position.y) - if (!syncButton.contains(syncPoint)) - root.closeSyncPlayMenu(false) + if (!root.containsGroupPoint(root, eventPoint.position.x, eventPoint.position.y)) + root.closeGroupMenu(false) } } @@ -289,34 +164,28 @@ FocusScope { Repeater { id: railRepeater - // Switching user is the provider's sign-in flow, so a source - // without one has no such button. - model: { - const entries = [ - { - label: "My Media", - route: "home", - icon: "home" - }, - { - label: "Search", - route: "search", - icon: "search" - } - ] - if (ProviderCapabilities.auth) - entries.push({ - label: "Switch user", - route: "switchUser", - icon: "person" - }) - entries.push({ - label: "Settings", - route: "settings", - icon: "settings" - }) - return entries - } + model: [ + { + label: "My Media", + route: "home", + icon: "home" + }, + { + label: "Search", + route: "search", + icon: "search" + }, + { + label: "Accounts", + route: "accounts", + icon: "switch_account" + }, + { + label: "Settings", + route: "settings", + icon: "settings" + } + ] delegate: Item { id: railDelegate @@ -335,42 +204,16 @@ FocusScope { id: button anchors.centerIn: parent iconName: modelData.icon - accessibleName: modelData.route === "switchUser" && root.session - && root.session.activeProfileLabel.length > 0 ? "Switch user — " - + root.session.activeProfileLabel : - modelData.label + accessibleName: modelData.label railStyle: true // The rail changes pages; the selection goes with them. focusOnClick: false selected: root.selectedRoute === modelData.route onClicked: { - root.closeSyncPlayMenu(false) + root.closeGroupMenu(false) root.navigate(modelData.route) } } - - Rectangle { - anchors.top: button.bottom - anchors.topMargin: Metrics.scaled(5) - anchors.horizontalCenter: button.horizontalCenter - width: switchTooltip.implicitWidth + Metrics.scaled(16) - height: switchTooltip.implicitHeight + Metrics.scaled(10) - radius: Theme.radiusSmall - color: Theme.floatingPanel - border.width: Theme.hoverBorderWidth - border.color: Theme.borderStrong - visible: Platform.hasDesktopPointer && modelData.route === "switchUser" && button.pointerHovered - z: 100 - - AppText { - id: switchTooltip - anchors.centerIn: parent - text: "Switch user — " + (root.session ? root.session.activeProfileLabel : "") - color: Theme.textPrimary - font.pixelSize: Metrics.metaSizePx - maximumLineCount: 1 - } - } } } @@ -379,99 +222,22 @@ FocusScope { } Item { - visible: root.castVisible + visible: root.groupVisible Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: visible ? root.railCellWidth : 0 Layout.fillHeight: true IconButton { - id: castButton - anchors.centerIn: parent - iconName: root.remoteTargetSelected ? "cast_connected" : "cast" - accessibleName: root.remoteTargetSelected ? "Play on — " + root.remoteTargetName : "Play on" - railStyle: true - selected: root.remoteControlMenuOpen - onClicked: { - if (root.remoteControlMenuOpen) - root.closeRemoteMenu(false) - else - root.openRemoteMenu() - } - - Rectangle { - visible: root.remoteTargetSelected - width: Metrics.scaled(9) - height: width - radius: width / 2 - anchors.right: parent.right - anchors.top: parent.top - anchors.rightMargin: Metrics.scaled(7) - anchors.topMargin: Metrics.scaled(7) - color: Theme.success - border.width: Theme.focusBorderWidth - border.color: Theme.bgRaised - } - } - } - Item { - visible: root.remoteVisible - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: visible ? root.railCellWidth : 0 - Layout.fillHeight: true - - IconButton { - id: remoteButton - anchors.centerIn: parent - iconName: "settings_remote" - accessibleName: "Remote control — " + root.remoteTargetName - railStyle: true - focusOnClick: false - selected: root.currentRoute === "remoteControl" - onClicked: { - root.closeRemoteMenu(false) - root.navigate("remoteControl") - } - - Rectangle { - visible: root.remoteControl ? root.remoteControl.playbackPending : false - width: Metrics.scaled(9) - height: width - radius: width / 2 - anchors.right: parent.right - anchors.top: parent.top - anchors.rightMargin: Metrics.scaled(7) - anchors.topMargin: Metrics.scaled(7) - color: Theme.accent - border.width: Theme.focusBorderWidth - border.color: Theme.bgRaised - } - } - } - - Item { - visible: root.syncVisible - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: visible ? root.railCellWidth : 0 - Layout.fillHeight: true - - IconButton { - id: syncButton + id: groupButton anchors.centerIn: parent iconName: "groups" - accessibleName: "SyncPlay" + accessibleName: "Watch together" railStyle: true - selected: root.syncPlayMenuOpen - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: { - if (root.syncPlayMenuOpen) - root.closeSyncPlayMenu(false) - else - root.openSyncMenu() - } + selected: root.groupMenuOpen + onClicked: root.groupMenuOpen ? root.closeGroupMenu(false) : root.openGroupMenu() - // Status dot: accent when in a group, green when groups exist to join. Rectangle { - visible: root.syncActive || root.syncAvailable + visible: Group.enabled width: Metrics.scaled(9) height: width radius: width / 2 @@ -479,7 +245,7 @@ FocusScope { anchors.top: parent.top anchors.rightMargin: Metrics.scaled(7) anchors.topMargin: Metrics.scaled(7) - color: root.syncActive ? Theme.accent : Theme.success + color: Theme.accent border.width: Theme.focusBorderWidth border.color: Theme.bgRaised } @@ -488,30 +254,19 @@ FocusScope { } Loader { - id: remoteMenuLoader - width: Metrics.scaled(360) - anchors.top: parent.bottom - anchors.right: parent.right - anchors.topMargin: Metrics.scaled(6) - anchors.rightMargin: Metrics.scaled(64) - z: 50 - active: root.remoteControl !== null && root.remoteControlMenuLoaded - sourceComponent: RemoteControlMenu { - onRequestClose: root.closeRemoteMenu() - } - } - - Loader { - id: syncMenuLoader - width: Metrics.scaled(320) - anchors.top: parent.bottom + id: groupMenuLoader + width: Metrics.scaled(340) + anchors.top: root.edge === "bottom" ? undefined : parent.bottom + anchors.bottom: root.edge === "bottom" ? parent.top : undefined anchors.right: parent.right anchors.topMargin: Metrics.scaled(6) + anchors.bottomMargin: Metrics.scaled(6) anchors.rightMargin: Metrics.scaled(14) z: 50 - active: root.syncVisible && root.syncPlayMenuLoaded - sourceComponent: SyncPlayMenu { - onRequestClose: root.closeSyncPlayMenu() + active: root.groupVisible && root.groupMenuLoaded + sourceComponent: GroupMenu { + onRequestClose: root.closeGroupMenu() } + onLoaded: item.openMenu() } } diff --git a/qml/shell/TopBarNavigation.js b/qml/shell/TopBarNavigation.js deleted file mode 100644 index 446657a3..00000000 --- a/qml/shell/TopBarNavigation.js +++ /dev/null @@ -1,50 +0,0 @@ -.pragma library - -// The rail's index space: the navigation buttons first, then whichever of the -// optional Remote, Cast and SyncPlay buttons the provider and the settings put -// up, in that order. A button that is not there takes no index, so Left and -// Right never land on something invisible. -function slots(railCount, remoteVisible, castVisible, syncVisible) { - const result = [] - for (let index = 0; index < railCount; ++index) - result.push({ - "kind": "rail", - "railIndex": index - }) - if (remoteVisible) - result.push({ - "kind": "remote" - }) - if (castVisible) - result.push({ - "kind": "cast" - }) - if (syncVisible) - result.push({ - "kind": "sync" - }) - return result -} - -function lastIndex(railCount, remoteVisible, castVisible, syncVisible) { - return Math.max(0, slots(railCount, remoteVisible, castVisible, syncVisible).length - 1) -} - -// The slot an index resolves to, clamped into the rail; null only when the -// rail is empty. -function slotAt(index, railCount, remoteVisible, castVisible, syncVisible) { - const all = slots(railCount, remoteVisible, castVisible, syncVisible) - if (all.length === 0) - return null - return all[Math.max(0, Math.min(all.length - 1, index))] -} - -// Where a kind of button sits, or -1 when it is not on the rail. -function indexOf(kind, railCount, remoteVisible, castVisible, syncVisible) { - const all = slots(railCount, remoteVisible, castVisible, syncVisible) - for (let index = 0; index < all.length; ++index) { - if (all[index].kind === kind) - return index - } - return -1 -} diff --git a/qml/shell/VideoSurface.qml b/qml/shell/VideoSurface.qml index 828162c4..9a9aea90 100644 --- a/qml/shell/VideoSurface.qml +++ b/qml/shell/VideoSurface.qml @@ -1,5 +1,5 @@ import QtQuick -import JellyfinWebOS +import Spool import "../primitives" FocusScope { diff --git a/qml/theme/Theme.qml b/qml/theme/Theme.qml index 544fbce8..55be63d9 100644 --- a/qml/theme/Theme.qml +++ b/qml/theme/Theme.qml @@ -1,6 +1,6 @@ pragma Singleton import QtQuick -import JellyfinWebOS +import Spool QtObject { readonly property color bg: "#0E0E0E" diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index e43d4e21..f443a720 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -1,14 +1,251 @@ -/** Experimental compatibility revision 0.1; additive fixes keep this revision. */ -export interface HttpResponse { status: number; body: string } -export interface HttpOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; body?: string } -export interface Host { +/** + * Spool provider API 0.2. + * + * A provider is an ES module exporting `createSource(configuration, host)`. + * Spool calls it once per account; the returned object's methods are the + * operations below, each called as `operation(args, host)` and returning a + * plain object or a Promise of one. Every operation is optional except + * `describe`; implement what the service supports and declare it in + * manifest.json `capabilities`. + * + * Runs on a worker thread in Qt's JS engine: ES2020 modules and Promises, no + * Node or browser globals, no async/await. 500 ms of uninterrupted script + * disables the module, and each operation must settle within 15 seconds. + */ + +export type Value = null | boolean | number | string | Value[] | { [key: string]: Value }; + +export interface HttpOptions { + method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + body?: string; +} +/** Redirects are not followed: `status` is 3xx and `location` is set. */ +export interface HttpResponse { status: number; body: string; location?: string } + +export interface Socket { + onopen: (() => void) | null; + onmessage: ((text: string) => void) | null; + onclose: ((code: number) => void) | null; + send(text: string): void; + close(): void; +} + +export interface Device { + /** Stable per install and per running instance; present it to servers. */ + id: string; + name: string; + app: 'Spool'; + version: string; + platform: string; + /** BCP 47. */ + locale: string; +} + +/** + * Given to createSource and lives as long as the account: use it for + * connections that outlast an operation. Everything stops when the account + * is removed, disabled or the provider is updated. + */ +export interface SourceHost { + device: Device; + /** Only origins the account was set up with (or manifest `origins`). */ http(url: string, options?: HttpOptions): Promise; + /** 0–60000 ms. */ delay(milliseconds: number): Promise; + /** ws:// or wss:// on an allowed origin; at most four open. */ + socket(url: string, options?: { headers?: Record }): Socket; + /** Push to Spool: see Events. */ + emit(type: K, payload: Events[K]): void; +} + +/** Given to each operation; its requests are cancelled with it. */ +export interface OperationHost extends SourceHost { + /** 0–10000 ms. */ + delay(milliseconds: number): Promise; + /** UDP broadcast on the local network; replies within `timeout` ms (100–5000). */ + discover(options: { port: number; message: string; timeout?: number }): Promise<{ address: string; text: string }[]>; +} + +/** + * `configuration` is what the login screen completed with (empty while that + * screen is being shown). Keep account state in the closure, not in module + * globals: one module serves every account of this provider. + */ +export type CreateSource = (configuration: Record, host: SourceHost) => Source; + +/** Throw `new Error('code')` with a short snake_case code; Spool shows no provider text. `http_401` marks the account as needing sign-in. */ +export type Operation = (args: A, host: OperationHost) => R | Promise; + +export interface Page { items: Item[]; cursor: string | null; total?: number | null; exhausted: boolean } +export interface PageArgs { cursor?: string; limit: number } + +export interface Item { + id: string; + title: string; + type: 'Movie' | 'Series' | 'Season' | 'Episode' | 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist' | 'BoxSet' + | 'Folder' | 'Video' | 'MusicVideo' | 'Book' | 'Photo' | 'PhotoAlbum' | 'TvChannel' | string; + sortName?: string; overview?: string; year?: number; + /** 100 ns ticks; send as a decimal string when beyond 2^53. */ + runtimeTicks?: number | string; resumeTicks?: number | string; + favorite?: boolean; played?: boolean; playCount?: number; childCount?: number; virtual?: boolean; + datePlayed?: string; dateCreated?: string; dateUpdated?: string; premiereDate?: string; endDate?: string; status?: string; + seriesId?: string; seriesName?: string; seasonId?: string; season?: number; episode?: number; + album?: string; albumId?: string; albumArtist?: string; + /** Passed back as `{tag}` in the artwork template, or used as-is when it is an https URL and there is no template. */ + posterTag?: string; backdropTag?: string; logoTag?: string; bannerTag?: string; thumbTag?: string; + seriesPosterTag?: string; albumPosterTag?: string; + genres?: string[]; tags?: string[]; studios?: string[]; + officialRating?: string; communityRating?: number; criticRating?: number; + externalIds?: Record; + links?: { name: string; url: string }[]; + people?: { id: string; name: string; type?: string; role?: string; imageTag?: string }[]; + variants?: Variant[]; +} + +export interface Variant { + id: string; label?: string; container?: string; filename?: string; + sizeBytes?: number | string; bitrate?: number; runtimeTicks?: number | string; streams?: Stream[]; +} + +export interface Stream { + index: number; type: 'Video' | 'Audio' | 'Subtitle'; codec?: string; profile?: string; language?: string; + title?: string; width?: number; height?: number; frameRate?: number; bitrate?: number; bitDepth?: number; + channels?: number; sampleRate?: number; range?: string; rangeType?: string; + default?: boolean; forced?: boolean; external?: boolean; interlaced?: boolean; +} + +/** Merged into every resolve call by Spool. */ +export interface PlaybackContext { + /** The viewer's pick in the player; 0 is automatic. */ + maxBitrate: number; maxHeight: number; + /** The standing preference from settings. */ + preferredMaxBitrate: number; preferredMaxHeight: number; preferRemux: boolean; unlimitedLocalNetwork: boolean; + /** What this device decodes when `restrictVideoCodecs`; otherwise anything. */ + videoCodecs: string[]; restrictVideoCodecs: boolean; +} + +export interface Resolved { + url: string; headers?: Record; variantId: string; playSessionId?: string; + playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode'; container?: string; + streams?: Stream[]; segments?: Segment[]; + trickplay?: { width: number; height: number; columns: number; rows: number; count: number; intervalMs: number }; +} +/** Answer resolve with this to show the provider's `picker` screen first; Spool calls resolve again with what it completes with merged in. */ +export interface PickRequest { pick: Record } + +export interface Segment { type: 'Intro' | 'Outro' | 'Recap' | 'Preview' | 'Commercial'; startTicks: number | string; endTicks: number | string } + +export interface Source { + /** Required. Templates take {itemId} {type} {tag} {width} {height} {quality} {format}; trickplay {itemId} {width} {index} {variantId}. */ + describe(): { artwork?: string; trickplay?: string }; + + libraries?: Operation<{}, { items: { id: string; title: string; collectionType?: string; posterTag?: string }[] }>; + browse?: Operation }, Page>; + items?: Operation; + search?: Operation; + details?: Operation<{ itemId: string }, { item: Item }>; + seasons?: Operation; + episodes?: Operation; + resume?: Operation; + nextUp?: Operation; + latest?: Operation; + similar?: Operation; + personItems?: Operation; + filterOptions?: Operation<{ parentId: string; collectionType?: string }, + { genres?: string[]; years?: number[]; officialRatings?: string[]; tags?: string[] }>; + + resolve?: Operation; + segments?: Operation<{ itemId: string }, { segments: Segment[] }>; + report?: Operation<{ event: 'start' | 'progress' | 'stop'; itemId: string; variantId: string; playSessionId: string; + playMethod: string; positionTicks: string; paused?: boolean; rate: number; volume?: number; muted?: boolean; + failed?: boolean; audioStreamIndex: number; subtitleStreamIndex: number }, {}>; + + favorite?: Operation<{ itemId: string; value: boolean }, {}>; + played?: Operation<{ itemId: string; value: boolean }, {}>; + progress?: Operation<{ itemId: string; positionTicks: string }, {}>; + + /** Manifest `actions` are run here; `pick` shows the picker, then runs again with its result merged in. */ + runItemAction?: Operation<{ action: string; itemId: string; itemType: string; [choice: string]: Value }, + { changed?: boolean; itemId?: string; message?: string } | PickRequest>; + + /** Watching together (capability `groupPlayback`); state arrives as `group` events. */ + groups?: Operation<{}, { items: { id: string; name: string; participants: string[] }[] }>; + groupCreate?: Operation<{ name: string }, {}>; + groupJoin?: Operation<{ groupId: string }, {}>; + groupLeave?: Operation<{}, {}>; + groupSend?: Operation; + /** Server clock for sync: when it received and when it answered, in ms since the epoch. */ + clock?: Operation<{}, { received: number; sent: number }>; + + /** Called before an account is removed. */ + signOut?: Operation<{}, {}>; + + /** Anything else is callable from the provider's own QML through `provider.request()`. */ + [operation: string]: unknown; +} + +export type GroupAction = + | { action: 'pause' | 'unpause' } + | { action: 'seek'; positionTicks: string } + | { action: 'next' | 'previous' | 'play'; entryId: string } + | { action: 'setQueue'; itemIds: string[]; index: number; positionTicks: string } + | { action: 'queue'; itemIds: string[]; next: boolean } + | { action: 'move'; entryId: string; index: number } + | { action: 'remove'; entryIds: string[] } + | { action: 'buffering'; buffering: boolean; playing: boolean; positionTicks: string; entryId: string; at: number } + | { action: 'ping'; ms: number }; + +/** `host.emit(type, payload)`. Times are server ms since the epoch. */ +export interface Events { + /** Something on the server changed; `itemId` narrows it. */ + changed: { itemId?: string }; + /** Merged into the stored configuration, e.g. a refreshed token. */ + configuration: Record; + group: + | { type: 'connected' } + | { type: 'joined' | 'update'; groupId: string; name: string; state: string; reason?: string; participants: string[]; at?: number } + | { type: 'participants'; participants: string[] } + | { type: 'state'; state: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; reason?: string } + | { type: 'queue'; items: { itemId: string; entryId: string }[]; index: number; positionTicks: string; at?: number; reason?: string } + | { type: 'command'; command: 'pause' | 'unpause' | 'seek' | 'stop'; at: number; positionTicks: string; entryId?: string; emittedAt?: number } + | { type: 'left' } + | { type: 'error'; code: 'group_missing' | 'access_denied' | 'create_denied' | 'join_denied' | 'disabled' | string }; + remote: RemoteCommand; +} + +/** Another client asking this one to do something. */ +export type RemoteCommand = + | { command: 'play'; itemIds: string[]; index?: number; positionTicks?: string; mode?: 'now' | 'next' | 'last' | 'shuffle' } + | { command: 'pause' | 'unpause' | 'playPause' | 'stop' | 'next' | 'previous' | 'rewind' | 'fastForward' | 'toggleMute' | 'stats' } + | { command: 'seek'; positionTicks: string } + | { command: 'volume'; value: number } | { command: 'volumeStep'; delta: number } | { command: 'mute'; value: boolean } + | { command: 'audioTrack' | 'subtitleTrack'; index: number } + | { command: 'repeat'; mode: 'RepeatNone' | 'RepeatAll' | 'RepeatOne' } | { command: 'shuffle'; value: boolean } + | { command: 'quality'; bitrate: number; height?: number } + | { command: 'navigate'; to: 'home' | 'search' | 'settings' | 'toggle-osd' | 'context-menu' | 'fullscreen' } + | { command: 'show'; itemId: string; itemType?: string; title?: string } + | { command: 'message'; text: string } | { command: 'text'; value: string } + | { command: 'key'; name: 'up' | 'down' | 'left' | 'right' | 'pageUp' | 'pageDown' | 'select' | 'back' | 'home' | 'end' | 'space' }; + +/** + * The object a provider screen (manifest `ui`) receives as its `provider` + * property. Screens `import Spool` for Theme, Metrics, InputKeys and the + * app's primitives (ActionButton, TextFieldRow, MenuRow, BusySpinner, + * AppText, SecondaryText, MaterialIcon, IconButton, ProviderIcon, ...). + */ +export interface ScreenContext { + role: 'login' | 'settings' | 'picker'; + /** For a picker: the `pick` object resolve or runItemAction returned. */ + arguments: Record; + request(operation: string, args?: Record): Promise>; + /** Moves `items` into `rows` (a list model with `record` and `title` roles, up to 10,000 rows). */ + requestList(operation: string, args?: Record, append?: boolean): Promise>; + rows: unknown; + /** Login only: let the account reach a server the viewer typed or picked. */ + allowOrigin(url: string): Promise; + /** login: { account, label, detail?, group?, configuration }; settings: { configuration? }; picker: the choice. */ + complete(result: Record): void; + close(): void; } -export type Value = null | boolean | string | number | Value[] | { [key: string]: Value }; -export type Operation = (arguments_: Record, host: Host) => Record | Promise>; -export type Source = Record; -export type SourceFactory = (configuration: Record) => Source; -export interface ItemRef { sourceId: string; itemId: string } -export interface VariantRef extends ItemRef { variantId: string } -export interface Page { items: T[]; cursor: string | null; total: number | null; exhausted: boolean } diff --git a/sdk/spool-provider.py b/sdk/spool-provider.py new file mode 100755 index 00000000..aee76f7b --- /dev/null +++ b/sdk/spool-provider.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Build, check and describe Spool provider packages (.tar.zst, manifest format 2). + + spool-provider.py build DIR [--output FILE] deterministic package from a provider checkout + spool-provider.py validate FILE the same checks Spool runs before installing + spool-provider.py feed FILE --url URL the store/feed entry for a published package + +A package holds manifest.json, LICENSE/NOTICE, the icon, and the logic/, ui/ and +assets/ directories. Nothing in it is executed by these checks. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import pathlib +import re +import shutil +import subprocess +import sys +import tarfile +from typing import NoReturn + +API = "0.2" +MAX_ARCHIVE = 16 * 1024 * 1024 +MAX_EXPANDED = 32 * 1024 * 1024 +MAX_FILE = 8 * 1024 * 1024 +MAX_FILES = 512 +EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".md", ".map"} +QML_IMPORTS = {"QtQuick", "QtQuick.Layouts", "QtQuick.Controls", "QtQml", "QtQml.Models", "Spool.Ui"} +CAPABILITIES = {"search", "userState", "reporting", "segments", "streamQuality", "trickplay", "discovery", + "groupPlayback", "remoteControl"} +UI_ROLES = {"login", "settings", "picker"} +ROOTS = ("manifest.json", "LICENSE", "NOTICE", "logic", "ui", "assets") +NATIVE_MAGIC = (b"\x7fELF", b"MZ", b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe", b"\0asm") +ID = re.compile(r"[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+") +VERSION = re.compile(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?") + + +def fail(message: str) -> NoReturn: + raise ValueError(message) + + +def valid_path(name: str) -> bool: + parts = name.split("/") + suffix = pathlib.PurePosixPath(name).suffix.lower() + return (0 < len(name) <= 200 and "\\" not in name and ":" not in name + and all(part and not part.startswith(".") and not part.endswith(" ") for part in parts) + and (suffix in EXTENSIONS or parts[-1] in {"LICENSE", "NOTICE"})) + + +def validate(files: dict[str, bytes]) -> dict: + if not files or len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: + fail("package is empty or too large") + folded = set() + for name, data in files.items(): + if not valid_path(name): + fail(f"forbidden path: {name}") + if name.casefold() in folded: + fail(f"case-colliding path: {name}") + folded.add(name.casefold()) + if len(data) > MAX_FILE or data.startswith(NATIVE_MAGIC): + fail(f"oversized or native file: {name}") + try: + manifest = json.loads(files["manifest.json"]) + except (KeyError, ValueError): + fail("manifest.json is missing or not JSON") + if not isinstance(manifest, dict) or manifest.get("format") != 2 or manifest.get("api") != API: + fail(f"manifest must declare format 2 and api {API}") + for key in ("id", "name", "version", "entry"): + if not isinstance(manifest.get(key), str) or not manifest[key].strip(): + fail(f"manifest.{key} is required") + if not ID.fullmatch(manifest["id"]) or len(manifest["id"]) > 128: + fail("manifest.id must look like publisher.name") + if not VERSION.fullmatch(manifest["version"]): + fail("manifest.version must be semantic (1.2.3 or 1.2.3-beta.1)") + if len(manifest["name"]) > 64 or len(manifest.get("summary", "")) > 120: + fail("name is limited to 64 characters and summary to 120") + if not manifest["entry"].endswith(".mjs") or manifest["entry"] not in files: + fail("manifest.entry must name a packaged .mjs module") + if manifest.get("icon") and manifest["icon"] not in files: + fail("manifest.icon names a missing file") + ui = manifest.get("ui", {}) + if not isinstance(ui, dict) or not set(ui) <= UI_ROLES: + fail(f"manifest.ui roles are {sorted(UI_ROLES)}") + for role, path in ui.items(): + if path not in files or not path.endswith(".qml"): + fail(f"manifest.ui.{role} names a missing QML file") + unknown = set(manifest.get("capabilities", [])) - CAPABILITIES + if unknown: + fail(f"unknown capabilities: {sorted(unknown)}") + for origin in manifest.get("origins", []): + if origin != "*" and not re.fullmatch(r"https?://[^/\s]+", origin): + fail(f"origins are scheme://host[:port] or *: {origin}") + for name, data in files.items(): + if name.endswith(".qml"): + imports = set(re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", data.decode("utf-8"), re.MULTILINE)) + if not imports <= QML_IMPORTS: + fail(f"{name} imports modules Spool does not provide: {sorted(imports - QML_IMPORTS)}") + return manifest + + +def compress(data: bytes) -> bytes: + try: + from compression import zstd # Python 3.14+ + return zstd.compress(data, level=19) + except ImportError: + pass + if not shutil.which("zstd"): + fail("needs Python 3.14 or the zstd command") + return subprocess.run(["zstd", "-19", "-q", "-c", f"--stream-size={len(data)}"], input=data, + capture_output=True, check=True).stdout + + +def decompress(data: bytes) -> bytes: + try: + from compression import zstd + return zstd.decompress(data) + except ImportError: + pass + if not shutil.which("zstd"): + fail("needs Python 3.14 or the zstd command") + return subprocess.run(["zstd", "-d", "-q", "-c"], input=data, capture_output=True, check=True).stdout + + +def read(path: pathlib.Path) -> tuple[dict, dict[str, bytes]]: + data = path.read_bytes() + if len(data) > MAX_ARCHIVE: + fail("archive is larger than 16 MiB") + files = {} + with tarfile.open(fileobj=io.BytesIO(decompress(data)), mode="r:") as archive: + for member in archive.getmembers(): + if member.isdir(): + continue + if not member.isfile(): + fail(f"links and special files are not allowed: {member.name}") + files[member.name.removeprefix("./")] = archive.extractfile(member).read() + return validate(files), files + + +def collect(source: pathlib.Path) -> dict[str, bytes]: + files = {} + for root in ROOTS: + path = source / root + for entry in [path] if path.is_file() else sorted(path.rglob("*")) if path.is_dir() else []: + if entry.is_symlink(): + fail(f"symlinks are not allowed: {entry}") + if entry.is_file(): + files[entry.relative_to(source).as_posix()] = entry.read_bytes() + return files + + +def build(source: pathlib.Path, output: pathlib.Path | None) -> pathlib.Path: + files = collect(source) + manifest = validate(files) + tar = io.BytesIO() + with tarfile.open(fileobj=tar, mode="w:", format=tarfile.USTAR_FORMAT) as archive: + for name, data in sorted(files.items()): + info = tarfile.TarInfo(name) + info.size, info.mode, info.mtime = len(data), 0o644, 0 + archive.addfile(info, io.BytesIO(data)) + output = output or source / "dist" / f"{manifest['id']}-{manifest['version']}.tar.zst" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(compress(tar.getvalue())) + read(output) + return output + + +def feed(path: pathlib.Path, url: str) -> dict: + manifest, _ = read(path) + data = path.read_bytes() + entry = {key: manifest[key] for key in ("id", "name", "version", "api", "summary", "publisher", "homepage") + if manifest.get(key)} + entry.update(url=url, size=len(data), sha256=hashlib.sha256(data).hexdigest()) + return entry + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("command", choices=("build", "validate", "feed")) + parser.add_argument("path", type=pathlib.Path) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--url") + args = parser.parse_args() + if args.command == "build": + print(build(args.path, args.output)) + elif args.command == "validate": + manifest, _ = read(args.path) + print(f"{manifest['id']} {manifest['version']} ok") + else: + if not args.url: + parser.error("feed needs --url, the address the package is downloaded from") + print(json.dumps(feed(args.path, args.url), indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ValueError, OSError, tarfile.TarError, subprocess.CalledProcessError) as error: + print(f"spool-provider: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/api/HttpRequestPolicy.h b/src/api/HttpRequestPolicy.h deleted file mode 100644 index e8ba3edf..00000000 --- a/src/api/HttpRequestPolicy.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include "../common/NetworkAddress.h" -#include -#include - -#include - -namespace JellyfinNative { - -enum class HttpOperation { - Read, - Mutation, - PlaybackReport, -}; - -class HttpRequestPolicy final { -public: - static bool allowsCredentialTransport(const QUrl& url) - { - if (!url.isValid() || url.host().isEmpty() || !url.userInfo().isEmpty()) - return false; - if (url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0) - return true; - if (url.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0) - return false; - QHostAddress address; - return address.setAddress(url.host()) && isPrivateNetworkAddress(address); - } - - static bool sameOrigin(const QUrl& left, const QUrl& right) - { - const auto effectivePort = [](const QUrl& url) { - return url.port(url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0 ? 443 : 80); - }; - return left.scheme().compare(right.scheme(), Qt::CaseInsensitive) == 0 - && left.host().compare(right.host(), Qt::CaseInsensitive) == 0 - && effectivePort(left) == effectivePort(right); - } - - static constexpr int transferTimeoutMs() - { - return 30000; - } - - static constexpr int maximumAttempts(HttpOperation operation) - { - return operation == HttpOperation::Mutation ? 1 : 3; - } - - static bool shouldRetry( - HttpOperation operation, int completedAttempts, int statusCode, QNetworkReply::NetworkError networkError) - { - if (completedAttempts >= maximumAttempts(operation) || statusCode == 401) - return false; - - if (statusCode == 408 || statusCode == 425 || statusCode == 429 || statusCode >= 500) { - return true; - } - - if (networkError != QNetworkReply::NoError) { - switch (networkError) { - case QNetworkReply::ConnectionRefusedError: - case QNetworkReply::RemoteHostClosedError: - case QNetworkReply::HostNotFoundError: - case QNetworkReply::TimeoutError: - case QNetworkReply::TemporaryNetworkFailureError: - case QNetworkReply::NetworkSessionFailedError: - case QNetworkReply::ProxyConnectionRefusedError: - case QNetworkReply::ProxyConnectionClosedError: - case QNetworkReply::ProxyNotFoundError: - case QNetworkReply::ProxyTimeoutError: - case QNetworkReply::UnknownNetworkError: - case QNetworkReply::UnknownProxyError: - case QNetworkReply::UnknownServerError: - return true; - default: - return false; - } - } - - return false; - } - - static constexpr int retryDelayMs(int completedAttempts) - { - return std::min(2000, 250 << std::clamp(completedAttempts - 1, 0, 3)); - } -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.cpp b/src/api/JellyfinApiFacade.cpp deleted file mode 100644 index aa26a858..00000000 --- a/src/api/JellyfinApiFacade.cpp +++ /dev/null @@ -1,942 +0,0 @@ -#include "JellyfinApiFacade.h" - -#include "../common/AsyncTask.h" -#include "../common/TlsTrust.h" -#include "../diagnostics/Diagnostics.h" -#include "PlaybackNegotiation.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace JellyfinNative { - -namespace { - constexpr int kConnectionCacheExpirySeconds = 15 * 60; - - QString requireString(const QJsonObject& object, const QString& key) - { - const QString value = object.value(key).toString(); - if (value.isEmpty()) - throw std::runtime_error(QStringLiteral("Missing field: %1").arg(key).toStdString()); - return value; - } - - bool isQuickConnectPath(const QString& path) - { - return path.startsWith(QStringLiteral("/QuickConnect/")) - || path == QStringLiteral("/Users/AuthenticateWithQuickConnect"); - } -} // namespace - -JellyfinApiFacade::JellyfinApiFacade( - QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) - , m_networkAccessManager(networkAccessManager) - , m_rest(networkAccessManager, this) -{ - if (tlsTrust) - tlsTrust->attachNetworkAccessManager(m_networkAccessManager, QStringLiteral("Jellyfin API")); - m_requestFactory.setTransferTimeout(std::chrono::milliseconds(HttpRequestPolicy::transferTimeoutMs())); - m_requestFactory.setAttribute( - QNetworkRequest::ConnectionCacheExpiryTimeoutSecondsAttribute, kConnectionCacheExpirySeconds); - applyCommonHeaders(); -} - -JellyfinApiFacade::~JellyfinApiFacade() -{ - cancelRequests(); -} - -void JellyfinApiFacade::setServerUrl(const QString& serverUrl) -{ - QString normalized = serverUrl; - while (normalized.endsWith(QLatin1Char('/'))) - normalized.chop(1); - m_serverUrl = normalized; - m_requestFactory.setBaseUrl(QUrl(m_serverUrl)); - preconnectToServer(); -} - -QString JellyfinApiFacade::serverUrl() const -{ - return m_serverUrl; -} - -void JellyfinApiFacade::setAcceptLanguage(const QString& bcp47Tag) -{ - if (bcp47Tag.isEmpty()) - return; - m_acceptLanguage = bcp47Tag; - applyCommonHeaders(); -} - -void JellyfinApiFacade::setDeviceIdentity( - const QString& deviceId, const QString& deviceName, const QString& clientVersion) -{ - m_deviceId = deviceId; - m_deviceName = deviceName; - m_clientVersion = clientVersion; -} - -void JellyfinApiFacade::setDeviceId(const QString& deviceId) -{ - m_deviceId = deviceId; -} - -void JellyfinApiFacade::setDeviceName(const QString& deviceName) -{ - const QString trimmed = deviceName.trimmed(); - if (trimmed.isEmpty() || trimmed == m_deviceName) - return; - m_deviceName = trimmed; - applyCommonHeaders(); -} - -QString JellyfinApiFacade::deviceId() const -{ - return m_deviceId; -} - -void JellyfinApiFacade::setSession(const AuthSession& session) -{ - const bool changed = m_session != session; - m_session = session; - if (changed) { - m_authExpirationReported = false; - applyCommonHeaders(); - emit credentialsChanged(); - } -} - -AuthSession JellyfinApiFacade::session() const -{ - return m_session; -} - -void JellyfinApiFacade::setPlaybackPreferences( - qint64 /*manualMaxStreamingBitrate*/, bool /*unlimitedLocalNetwork*/, bool /*preferRemux*/, int maxStreamingHeight) -{ - const int normalizedHeight = maxStreamingHeight > 0 ? maxStreamingHeight : 0; - if (m_maxStreamingHeight != normalizedHeight) { - m_maxStreamingHeight = normalizedHeight; - emit deviceProfileChanged(); - } -} - -void JellyfinApiFacade::setRemoteControlTargetEnabled(bool enabled) -{ - if (m_remoteControlTargetEnabled == enabled) - return; - m_remoteControlTargetEnabled = enabled; - emit deviceProfileChanged(); -} - -void JellyfinApiFacade::cancelRequests() -{ - m_shuttingDown = true; - for (QNetworkReply *reply : m_activeReplies) { - if (reply && reply->isRunning()) - reply->abort(); - } - m_activeReplies.clear(); -} - -void JellyfinApiFacade::preconnectToServer() -{ - if (!m_networkAccessManager || m_serverUrl.isEmpty()) - return; - const QUrl url(m_serverUrl); - const QString authority = url.authority(); - if (authority.isEmpty() || authority == m_preconnectedAuthority) - return; - m_preconnectedAuthority = authority; - const int port = url.port(url.scheme() == QStringLiteral("https") ? 443 : 80); - m_networkAccessManager->connectToHost(url.host(), static_cast(port)); -} - -QCoro::Task JellyfinApiFacade::authenticateByName(QString username, QString password) -{ - const QJsonDocument response - = co_await requestJson(HttpMethod::Post, QStringLiteral("/Users/AuthenticateByName"), {}, - QJsonDocument(QJsonObject { - { QStringLiteral("Username"), username }, - { QStringLiteral("Pw"), password }, - })); - - const QJsonObject object = response.object(); - const QJsonObject user = object.value(QStringLiteral("User")).toObject(); - const AuthSession session { - requireString(user, QStringLiteral("Id")), - requireString(user, QStringLiteral("Name")), - requireString(object, QStringLiteral("AccessToken")), - object.value(QStringLiteral("ServerId")).toString(), - }; - setSession(session); - co_return session; -} - -QCoro::Task JellyfinApiFacade::quickConnectEnabled() -{ - const QByteArray response = co_await requestBytes(HttpMethod::Get, QStringLiteral("/QuickConnect/Enabled")); - co_return response.trimmed() == QByteArrayLiteral("true"); -} - -QCoro::Task JellyfinApiFacade::initiateQuickConnect() -{ - const QJsonDocument response = co_await requestJson(HttpMethod::Post, QStringLiteral("/QuickConnect/Initiate")); - co_return response.object(); -} - -QCoro::Task JellyfinApiFacade::pollQuickConnect(QString secret) -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("secret"), secret); - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/QuickConnect/Connect"), query); - co_return response.object(); -} - -QCoro::Task JellyfinApiFacade::authenticateWithQuickConnect(QString secret) -{ - const QJsonDocument response - = co_await requestJson(HttpMethod::Post, QStringLiteral("/Users/AuthenticateWithQuickConnect"), {}, - QJsonDocument(QJsonObject { - { QStringLiteral("Secret"), secret }, - })); - - const QJsonObject object = response.object(); - const QJsonObject user = object.value(QStringLiteral("User")).toObject(); - const AuthSession session { - requireString(user, QStringLiteral("Id")), - requireString(user, QStringLiteral("Name")), - requireString(object, QStringLiteral("AccessToken")), - object.value(QStringLiteral("ServerId")).toString(), - }; - setSession(session); - co_return session; -} - -QCoro::Task JellyfinApiFacade::fetchCurrentUserName() -{ - if (m_session.userId.isEmpty()) - co_return QString(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Name")).toString(); -} - -QCoro::Task JellyfinApiFacade::fetchUserConfiguration() -{ - if (m_session.userId.isEmpty()) - co_return QJsonObject(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Configuration")).toObject(); -} - -QCoro::Task JellyfinApiFacade::updateUserConfiguration(QJsonObject configuration) -{ - if (m_session.userId.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Users/%1/Configuration").arg(m_session.userId), - QJsonDocument(configuration)); -} - -QCoro::Task JellyfinApiFacade::fetchCurrentUserPolicy() -{ - if (m_session.userId.isEmpty()) - co_return QJsonObject(); - - const QJsonDocument response - = co_await requestJson(HttpMethod::Get, QStringLiteral("/Users/%1").arg(m_session.userId)); - co_return response.object().value(QStringLiteral("Policy")).toObject(); -} - -QCoro::Task JellyfinApiFacade::fetchCultures() -{ - const QJsonDocument response = co_await requestJson(HttpMethod::Get, QStringLiteral("/Localization/Options")); - co_return response.isArray() ? response.array() : response.object().value(QStringLiteral("Items")).toArray(); -} - -QCoro::Task> JellyfinApiFacade::fetchManagementTargets(QString itemType) -{ - if (m_serverUrl.isEmpty() || m_session.userId.isEmpty()) - co_return std::vector {}; - - itemType = itemType.trimmed(); - if (itemType != QStringLiteral("Playlist") && itemType != QStringLiteral("BoxSet")) - co_return std::vector {}; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("recursive"), QStringLiteral("true")); - query.addQueryItem(QStringLiteral("includeItemTypes"), itemType); - query.addQueryItem(QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,CanDelete")); - query.addQueryItem(QStringLiteral("sortBy"), QStringLiteral("SortName")); - query.addQueryItem(QStringLiteral("sortOrder"), QStringLiteral("Ascending")); - query.addQueryItem(QStringLiteral("enableImageTypes"), QStringLiteral("Primary,Backdrop,Banner,Thumb")); - query.addQueryItem(QStringLiteral("imageTypeLimit"), QStringLiteral("1")); - query.addQueryItem(QStringLiteral("limit"), QStringLiteral("200")); - - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - std::vector result; - result.reserve(items.size()); - for (const QJsonValue& val : items) { - const QJsonObject obj = val.toObject(); - if (obj.value(QStringLiteral("Type")).toString() == itemType) { - MovieItem item; - item.id = obj.value(QStringLiteral("Id")).toString(); - item.title = obj.value(QStringLiteral("Name")).toString(); - item.itemType = itemType; - result.push_back(std::move(item)); - } - } - co_return result; -} - -QCoro::Task JellyfinApiFacade::createPlaylist(QString name, QStringList itemIds) -{ - name = name.trimmed(); - if (name.isEmpty()) - throw std::runtime_error("Playlist name is required"); - - QJsonArray ids; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - ids.push_back(itemId); - } - QJsonObject body { - { QStringLiteral("Name"), name }, - { QStringLiteral("UserId"), m_session.userId }, - { QStringLiteral("Ids"), ids }, - { QStringLiteral("IsPublic"), false }, - }; - const QJsonObject response - = (co_await requestJson(HttpMethod::Post, QStringLiteral("/Playlists"), {}, QJsonDocument(body))).object(); - co_return response.value(QStringLiteral("Id")).toString(); -} - -QCoro::Task JellyfinApiFacade::addPlaylistItems(QString playlistId, QStringList itemIds, int position) -{ - if (playlistId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - if (position >= 0) - query.addQueryItem(QStringLiteral("position"), QString::number(position)); - co_await requestJson(HttpMethod::Post, QStringLiteral("/Playlists/%1/Items").arg(playlistId), query); -} - -QCoro::Task JellyfinApiFacade::removePlaylistItems(QString playlistId, QStringList entryIds) -{ - if (playlistId.isEmpty() || entryIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("entryIds"), entryIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Delete, QStringLiteral("/Playlists/%1/Items").arg(playlistId), query); -} - -QCoro::Task JellyfinApiFacade::movePlaylistItem(QString playlistId, QString playlistItemId, int newIndex) -{ - if (playlistId.isEmpty() || playlistItemId.isEmpty() || newIndex < 0) - co_return; - - co_await requestNoContent(HttpMethod::Post, - QStringLiteral("/Playlists/%1/Items/%2/Move/%3").arg(playlistId, playlistItemId, QString::number(newIndex)), - QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::updatePlaylistName(QString playlistId, QString name) -{ - name = name.trimmed(); - if (playlistId.isEmpty() || name.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Playlists/%1").arg(playlistId), - QJsonDocument(QJsonObject { { QStringLiteral("Name"), name } })); -} - -QCoro::Task JellyfinApiFacade::createCollection(QString name, QStringList itemIds) -{ - name = name.trimmed(); - if (name.isEmpty()) - throw std::runtime_error("Collection name is required"); - - QUrlQuery query; - query.addQueryItem(QStringLiteral("name"), name); - if (!itemIds.isEmpty()) - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - query.addQueryItem(QStringLiteral("isLocked"), QStringLiteral("false")); - const QJsonObject response - = (co_await requestJson(HttpMethod::Post, QStringLiteral("/Collections"), query)).object(); - co_return response.value(QStringLiteral("Id")).toString(); -} - -QCoro::Task JellyfinApiFacade::addCollectionItems(QString collectionId, QStringList itemIds) -{ - if (collectionId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Post, QStringLiteral("/Collections/%1/Items").arg(collectionId), query); -} - -QCoro::Task JellyfinApiFacade::removeCollectionItems(QString collectionId, QStringList itemIds) -{ - if (collectionId.isEmpty() || itemIds.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - co_await requestJson(HttpMethod::Delete, QStringLiteral("/Collections/%1/Items").arg(collectionId), query); -} - -QCoro::Task JellyfinApiFacade::renameItem(QString itemId, QString name) -{ - name = name.trimmed(); - if (itemId.isEmpty() || name.isEmpty()) - co_return; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - QJsonObject item = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items/%1").arg(itemId), query)).object(); - item.insert(QStringLiteral("Name"), name); - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Items/%1").arg(itemId), QJsonDocument(item)); -} - -QCoro::Task JellyfinApiFacade::deleteItem(QString itemId) -{ - if (itemId.isEmpty()) - co_return; - - co_await requestNoContent(HttpMethod::Delete, QStringLiteral("/Items/%1").arg(itemId), QJsonDocument()); -} - -QCoro::Task> JellyfinApiFacade::fetchItemsByIds(QStringList itemIds) -{ - itemIds.removeAll(QString()); - if (itemIds.isEmpty() || m_session.userId.isEmpty()) - co_return std::vector {}; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("ids"), itemIds.join(QLatin1Char(','))); - query.addQueryItem(QStringLiteral("userId"), m_session.userId); - query.addQueryItem(QStringLiteral("fields"), QStringLiteral("PrimaryImageAspectRatio,CanDelete,RunTimeTicks")); - const QJsonArray items = (co_await requestJson(HttpMethod::Get, QStringLiteral("/Items"), query)) - .object() - .value(QStringLiteral("Items")) - .toArray(); - std::vector result; - result.reserve(items.size()); - for (const QJsonValue& val : items) { - const QJsonObject obj = val.toObject(); - MovieItem item; - item.id = obj.value(QStringLiteral("Id")).toString(); - item.title = obj.value(QStringLiteral("Name")).toString(); - item.itemType = obj.value(QStringLiteral("Type")).toString(); - item.runtimeTicks = obj.value(QStringLiteral("RunTimeTicks")).toVariant().toLongLong(); - result.push_back(std::move(item)); - } - co_return result; -} - -QCoro::Task JellyfinApiFacade::fetchTrickplayInfo(QString itemId, QString mediaSourceId) -{ - if (itemId.isEmpty() || m_session.userId.isEmpty()) - co_return TrickplayInfo {}; - - QUrlQuery query; - query.addQueryItem(QStringLiteral("fields"), QStringLiteral("Trickplay")); - const QJsonObject item = (co_await requestJson(HttpMethod::Get, - QStringLiteral("/Users/%1/Items/%2").arg(m_session.userId, itemId), query)) - .object(); - co_return PlaybackNegotiation::selectTrickplay( - item.value(QStringLiteral("Trickplay")).toObject(), mediaSourceId, 320); -} - -QString JellyfinApiFacade::trickplayTileUrl(const QString& itemId, int width, int tileIndex) const -{ - if (m_serverUrl.isEmpty() || itemId.isEmpty() || width <= 0 || tileIndex < 0) - return {}; - - QUrl url = serverUrlWithPath(m_serverUrl, - { QStringLiteral("Videos"), itemId, QStringLiteral("Trickplay"), QString::number(width), - QStringLiteral("%1.jpg").arg(tileIndex) }); - - return url.toString(QUrl::FullyEncoded); -} - -QCoro::Task JellyfinApiFacade::fetchControllableSessions() -{ - QUrlQuery query; - query.addQueryItem(QStringLiteral("controllableByUserId"), m_session.userId); - const QJsonDocument document = co_await requestJson(HttpMethod::Get, QStringLiteral("/Sessions"), query); - co_return document.isArray() ? document.array() : document.object().value(QStringLiteral("Items")).toArray(); -} - -QCoro::Task JellyfinApiFacade::sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, - qint64 startPositionTicks, int startIndex, QString mediaSourceId, int audioStreamIndex, int subtitleStreamIndex) -{ - sessionId = sessionId.trimmed(); - itemIds.removeAll(QString()); - if (sessionId.isEmpty() || itemIds.isEmpty()) - throw std::runtime_error("Remote play needs a target and at least one item"); - - QUrlQuery query; - query.addQueryItem(QStringLiteral("playCommand"), playCommand.isEmpty() ? QStringLiteral("PlayNow") : playCommand); - query.addQueryItem(QStringLiteral("itemIds"), itemIds.join(QLatin1Char(','))); - if (startPositionTicks >= 0) - query.addQueryItem(QStringLiteral("startPositionTicks"), QString::number(startPositionTicks)); - if (startIndex >= 0) - query.addQueryItem(QStringLiteral("startIndex"), QString::number(startIndex)); - if (!mediaSourceId.isEmpty()) - query.addQueryItem(QStringLiteral("mediaSourceId"), mediaSourceId); - if (audioStreamIndex >= -1) - query.addQueryItem(QStringLiteral("audioStreamIndex"), QString::number(audioStreamIndex)); - if (subtitleStreamIndex >= -1) - query.addQueryItem(QStringLiteral("subtitleStreamIndex"), QString::number(subtitleStreamIndex)); - - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestBytes( - HttpMethod::Post, QStringLiteral("/Sessions/%1/Playing").arg(encodedSession), query, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks) -{ - sessionId = sessionId.trimmed(); - if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote playstate needs a target and a command"); - - QUrlQuery query; - if (seekPositionTicks >= 0) - query.addQueryItem(QStringLiteral("seekPositionTicks"), QString::number(seekPositionTicks)); - - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestBytes(HttpMethod::Post, QStringLiteral("/Sessions/%1/Playing/%2").arg(encodedSession, command), - query, QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments) -{ - sessionId = sessionId.trimmed(); - if (sessionId.isEmpty() || command.isEmpty()) - throw std::runtime_error("Remote command needs a target and a command name"); - - const QJsonObject body = { - { QStringLiteral("Name"), command }, - { QStringLiteral("Arguments"), arguments }, - }; - - const QString encodedSession = QString::fromLatin1(QUrl::toPercentEncoding(sessionId)); - co_await requestBytes( - HttpMethod::Post, QStringLiteral("/Sessions/%1/Command").arg(encodedSession), {}, QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::fetchSyncPlayGroups() -{ - const QJsonDocument document = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/List")); - co_return document.isArray() ? document.array() : QJsonArray {}; -} - -QCoro::Task JellyfinApiFacade::createSyncPlayGroup(QString name) -{ - const QJsonObject body = { { QStringLiteral("GroupName"), name } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/New"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::joinSyncPlayGroup(QString groupId) -{ - const QJsonObject body = { { QStringLiteral("GroupId"), groupId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Join"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::leaveSyncPlayGroup() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Leave"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::fetchUtcTime() -{ - const QJsonDocument response = co_await requestJson(HttpMethod::Get, QStringLiteral("/SyncPlay/Time")); - co_return response.object(); -} - -QCoro::Task JellyfinApiFacade::syncPlayReportPing(qint64 pingMs) -{ - const QJsonObject body = { { QStringLiteral("Ping"), pingMs } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Ping"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayReportBuffering( - bool buffering, qint64 positionTicks, bool playing, QString playlistItemId, QDateTime serverTime) -{ - if (playlistItemId.isEmpty()) - playlistItemId = QStringLiteral("00000000-0000-0000-0000-000000000000"); - - const QJsonObject body = { - { QStringLiteral("When"), serverTime.toUTC().toString(Qt::ISODateWithMs) }, - { QStringLiteral("PositionTicks"), positionTicks }, - { QStringLiteral("IsPlaying"), playing }, - { QStringLiteral("PlaylistItemId"), playlistItemId }, - }; - const QString path = buffering ? QStringLiteral("/SyncPlay/Buffering") : QStringLiteral("/SyncPlay/Ready"); - co_await requestNoContent(HttpMethod::Post, path, QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlaySetNewQueue( - QStringList itemIds, int playingItemPosition, qint64 startPositionTicks) -{ - QJsonArray playingQueue; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - playingQueue.append(itemId); - } - if (playingQueue.isEmpty() || playingItemPosition < 0 || playingItemPosition >= playingQueue.size()) - throw std::runtime_error("SyncPlay queue has no playable item"); - - const QJsonObject body = { - { QStringLiteral("PlayingQueue"), playingQueue }, - { QStringLiteral("PlayingItemPosition"), playingItemPosition }, - { QStringLiteral("StartPositionTicks"), startPositionTicks }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetNewQueue"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayUnpause() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Unpause"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::syncPlayPause() -{ - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Pause"), QJsonDocument()); -} - -QCoro::Task JellyfinApiFacade::syncPlaySeek(qint64 positionTicks) -{ - const QJsonObject body = { - { QStringLiteral("PositionTicks"), std::max(0, positionTicks) }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Seek"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayNextItem(QString playlistItemId) -{ - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/NextItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayPreviousItem(QString playlistItemId) -{ - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/PreviousItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayQueue(QStringList itemIds, bool queueNext) -{ - QJsonArray ids; - for (const QString& itemId : itemIds) { - if (!itemId.isEmpty()) - ids.append(itemId); - } - if (ids.isEmpty()) - throw std::runtime_error("SyncPlay queue request has no items"); - - const QJsonObject body = { - { QStringLiteral("ItemIds"), ids }, - { QStringLiteral("Mode"), queueNext ? QStringLiteral("QueueNext") : QStringLiteral("Queue") }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/Queue"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayMovePlaylistItem(QString playlistItemId, int newIndex) -{ - if (playlistItemId.isEmpty()) - throw std::runtime_error("SyncPlay move needs a playlist item id"); - - const QJsonObject body = { - { QStringLiteral("PlaylistItemId"), playlistItemId }, - { QStringLiteral("NewIndex"), std::max(0, newIndex) }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/MovePlaylistItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlayRemoveFromPlaylist(QStringList playlistItemIds) -{ - QJsonArray ids; - for (const QString& playlistItemId : playlistItemIds) { - if (!playlistItemId.isEmpty()) - ids.append(playlistItemId); - } - if (ids.isEmpty()) - throw std::runtime_error("SyncPlay removal has no playlist items"); - - const QJsonObject body = { - { QStringLiteral("PlaylistItemIds"), ids }, - { QStringLiteral("ClearPlaylist"), false }, - { QStringLiteral("ClearPlayingItem"), false }, - }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/RemoveFromPlaylist"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::syncPlaySetPlaylistItem(QString playlistItemId) -{ - if (playlistItemId.isEmpty()) - throw std::runtime_error("SyncPlay jump needs a playlist item id"); - - const QJsonObject body = { { QStringLiteral("PlaylistItemId"), playlistItemId } }; - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/SyncPlay/SetPlaylistItem"), QJsonDocument(body)); -} - -QCoro::Task JellyfinApiFacade::postCapabilities() -{ - QJsonArray supportedCommands; - if (m_remoteControlTargetEnabled) { - supportedCommands = { - QStringLiteral("MoveUp"), - QStringLiteral("MoveDown"), - QStringLiteral("MoveLeft"), - QStringLiteral("MoveRight"), - QStringLiteral("PageUp"), - QStringLiteral("PageDown"), - QStringLiteral("PreviousLetter"), - QStringLiteral("NextLetter"), - QStringLiteral("Select"), - QStringLiteral("Back"), - QStringLiteral("SendKey"), - QStringLiteral("SendString"), - QStringLiteral("VolumeUp"), - QStringLiteral("VolumeDown"), - QStringLiteral("Mute"), - QStringLiteral("Unmute"), - QStringLiteral("ToggleMute"), - QStringLiteral("SetVolume"), - QStringLiteral("SetAudioStreamIndex"), - QStringLiteral("SetSubtitleStreamIndex"), - QStringLiteral("ToggleOsd"), - QStringLiteral("ToggleOsdMenu"), - QStringLiteral("ToggleContextMenu"), - QStringLiteral("ToggleStats"), - QStringLiteral("ToggleFullscreen"), - QStringLiteral("GoHome"), - QStringLiteral("GoToSettings"), - QStringLiteral("GoToSearch"), - QStringLiteral("DisplayContent"), - QStringLiteral("DisplayMessage"), - QStringLiteral("SetRepeatMode"), - QStringLiteral("SetShuffleQueue"), - QStringLiteral("SetPlaybackOrder"), - QStringLiteral("SetMaxStreamingBitrate"), - QStringLiteral("Play"), - }; - } - const QJsonObject body = { - { QStringLiteral("PlayableMediaTypes"), - m_remoteControlTargetEnabled ? QJsonArray { QStringLiteral("Video"), QStringLiteral("Audio") } - : QJsonArray {} }, - { QStringLiteral("SupportedCommands"), supportedCommands }, - { QStringLiteral("SupportsMediaControl"), m_remoteControlTargetEnabled }, - { QStringLiteral("SupportsPersistentIdentifier"), true }, - { QStringLiteral("DeviceProfile"), buildDeviceProfile() }, - }; - - co_await requestNoContent(HttpMethod::Post, QStringLiteral("/Sessions/Capabilities/Full"), QJsonDocument(body)); -} - -QJsonObject JellyfinApiFacade::buildDeviceProfile() const -{ - return PlaybackNegotiation::buildDeviceProfile(20'000'000, m_maxStreamingHeight); -} - -QNetworkRequest JellyfinApiFacade::createRequest(const QString& path, const QUrlQuery& query) const -{ - QNetworkRequest request - = query.isEmpty() ? m_requestFactory.createRequest(path) : m_requestFactory.createRequest(path, query); - if (!HttpRequestPolicy::allowsCredentialTransport(request.url())) - throw std::runtime_error("Credentials require HTTPS or a numeric private/loopback HTTP address"); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - request.setRawHeader("Authorization", authorizationHeader().toUtf8()); - return request; -} - -QString JellyfinApiFacade::authorizationHeader(const QString& tokenOverride) const -{ - QStringList parts { - QStringLiteral("Client=\"Spool for Jellyfin\""), - QStringLiteral("Device=\"%1\"").arg(m_deviceName), - QStringLiteral("DeviceId=\"%1\"").arg(m_deviceId), - QStringLiteral("Version=\"%1\"").arg(m_clientVersion), - }; - - const QString token = tokenOverride.isEmpty() ? m_session.accessToken : tokenOverride; - if (!token.isEmpty()) - parts.push_back(QStringLiteral("Token=\"%1\"").arg(token)); - return QStringLiteral("MediaBrowser %1").arg(parts.join(QStringLiteral(", "))); -} - -void JellyfinApiFacade::applyCommonHeaders() -{ - QHttpHeaders headers; - headers.append(QHttpHeaders::WellKnownHeader::Accept, QStringLiteral("application/json")); - if (!m_acceptLanguage.isEmpty()) - headers.append(QHttpHeaders::WellKnownHeader::AcceptLanguage, m_acceptLanguage); - m_requestFactory.setCommonHeaders(headers); -} - -QCoro::Task JellyfinApiFacade::requestJson( - HttpMethod method, QString path, QUrlQuery query, QJsonDocument body) -{ - const QByteArray payload = co_await requestBytes(method, path, query, body); - if (payload.isEmpty()) - co_return QJsonDocument(); - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); - if (parseError.error != QJsonParseError::NoError) - throw std::runtime_error(parseError.errorString().toStdString()); - co_return document; -} - -QCoro::Task JellyfinApiFacade::requestNoContent(HttpMethod method, QString path, QJsonDocument body) -{ - co_await requestBytes(method, path, {}, body); -} - -QCoro::Task JellyfinApiFacade::requestBytes( - HttpMethod method, QString path, QUrlQuery query, QJsonDocument body) -{ - const QString methodName = method == HttpMethod::Get ? QStringLiteral("GET") - : method == HttpMethod::Post ? QStringLiteral("POST") - : QStringLiteral("DELETE"); - const HttpOperation operation = operationFor(method, path); - const int maximumAttempts = HttpRequestPolicy::maximumAttempts(operation); - - for (int attempt = 1; attempt <= maximumAttempts; ++attempt) { - if (m_shuttingDown) - throw std::runtime_error("Request canceled during shutdown"); - - QNetworkRequest request = createRequest(path, query); - if (method == HttpMethod::Post && body.isNull()) - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - Diagnostics::NetworkRequest diagnosticsRequest(methodName, request.url().toString(QUrl::FullyEncoded)); - QNetworkReply *reply = nullptr; - - if (isQuickConnectPath(path)) - qInfo() << "api: quick connect request" << methodName; - - switch (method) { - case HttpMethod::Get: - reply = m_rest.get(request); - break; - case HttpMethod::Post: - reply = body.isNull() ? m_rest.post(request, QByteArray {}) : m_rest.post(request, body); - break; - case HttpMethod::Delete: - reply = m_rest.deleteResource(request); - break; - } - - m_activeReplies.insert(reply); - reply = co_await reply; - m_activeReplies.remove(reply); - const QByteArray payload = reply && reply->isReadable() ? reply->readAll() : QByteArray {}; - const QString errorText = reply ? reply->errorString() : QStringLiteral("Network reply disappeared"); - const int statusCode = reply ? reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() : 500; - const auto networkError = reply ? reply->error() : QNetworkReply::UnknownNetworkError; - if (reply) - reply->deleteLater(); - diagnosticsRequest.finish(statusCode, networkError == QNetworkReply::NoError ? QString() : errorText); - - if (networkError == QNetworkReply::NoError && statusCode < 300) - co_return payload; - - QString details = payload.isEmpty() ? errorText : QString::fromUtf8(payload).trimmed(); - if (path == QStringLiteral("/Users/AuthenticateByName")) { - const QJsonDocument errorDocument = QJsonDocument::fromJson(payload); - if (errorDocument.isObject()) { - const QJsonObject object = errorDocument.object(); - details = object.value(QStringLiteral("detail")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("Message")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("message")).toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("ResponseStatus")) - .toObject() - .value(QStringLiteral("Message")) - .toString(); - if (details.isEmpty()) - details = object.value(QStringLiteral("title")).toString(); - } - const bool generic = details.isEmpty() || details == errorText - || details.startsWith(QStringLiteral("Error processing request"), Qt::CaseInsensitive) - || details.compare(QStringLiteral("Unauthorized"), Qt::CaseInsensitive) == 0 - || details.startsWith(QLatin1Char('<')); - if (generic) { - if (statusCode == 401) - details = QStringLiteral("Incorrect username or password."); - else if (statusCode == 403) - details - = QStringLiteral("Sign-in is not allowed for this account. Contact your server administrator."); - else - details = errorText; - } - } - if (statusCode == 401 && shouldExpireSession(path) && !m_authExpirationReported) { - m_authExpirationReported = true; - emit authenticationExpired(QStringLiteral("Your Jellyfin session has expired. Sign in again.")); - } - - if (!HttpRequestPolicy::shouldRetry(operation, attempt, statusCode, networkError)) { - if (isQuickConnectPath(path)) - qWarning() << "api: quick connect request failed" << statusCode; - throw std::runtime_error(QStringLiteral("%1 (%2)").arg(details).arg(statusCode).toStdString()); - } - - const int delayMs = HttpRequestPolicy::retryDelayMs(attempt); - qWarning() << "api: request attempt" << attempt << "failed with" << statusCode << "- retrying in" << delayMs - << "ms"; - co_await QCoro::sleepFor(std::chrono::milliseconds(delayMs)); - } - - throw std::runtime_error("HTTP retry policy exhausted"); -} - -HttpOperation JellyfinApiFacade::operationFor(HttpMethod method, const QString& path) const -{ - if (path.startsWith(QStringLiteral("/Sessions/Playing"))) - return HttpOperation::PlaybackReport; - return method == HttpMethod::Get ? HttpOperation::Read : HttpOperation::Mutation; -} - -bool JellyfinApiFacade::shouldExpireSession(const QString& path) const -{ - return !m_session.accessToken.isEmpty() && !isQuickConnectPath(path) - && !path.startsWith(QStringLiteral("/Users/Authenticate")); -} - -} // namespace JellyfinNative diff --git a/src/api/JellyfinApiFacade.h b/src/api/JellyfinApiFacade.h deleted file mode 100644 index 8bb072df..00000000 --- a/src/api/JellyfinApiFacade.h +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include "../media/MediaTypes.h" -#include "HttpRequestPolicy.h" -#include "JellyfinSession.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class TlsTrustController; - -class JellyfinApiFacade final : public QObject { - Q_OBJECT - -public: - explicit JellyfinApiFacade( - QNetworkAccessManager *networkAccessManager, TlsTrustController *tlsTrust, QObject *parent = nullptr); - ~JellyfinApiFacade() override; - - void setServerUrl(const QString& serverUrl); - QString serverUrl() const; - - void setDeviceIdentity(const QString& deviceId, const QString& deviceName, const QString& clientVersion); - void setDeviceId(const QString& deviceId); - void setDeviceName(const QString& deviceName); - QString deviceId() const; - - void setAcceptLanguage(const QString& bcp47Tag); - - void setSession(const AuthSession& session); - AuthSession session() const; - bool signedIn() const - { - return !m_session.accessToken.isEmpty(); - } - - void setPlaybackPreferences( - qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight = 0); - void setRemoteControlTargetEnabled(bool enabled); - bool remoteControlTargetEnabled() const - { - return m_remoteControlTargetEnabled; - } - - QString authorizationHeader(const QString& tokenOverride = {}) const; - void cancelRequests(); - - // Authentication & User configuration - QCoro::Task authenticateByName(QString username, QString password); - QCoro::Task quickConnectEnabled(); - QCoro::Task initiateQuickConnect(); - QCoro::Task pollQuickConnect(QString secret); - QCoro::Task authenticateWithQuickConnect(QString secret); - QCoro::Task fetchCurrentUserName(); - QCoro::Task fetchUserConfiguration(); - QCoro::Task updateUserConfiguration(QJsonObject configuration); - QCoro::Task fetchCurrentUserPolicy(); - QCoro::Task fetchCultures(); - - // Library management - QCoro::Task> fetchManagementTargets(QString itemType); - QCoro::Task createPlaylist(QString name, QStringList itemIds = {}); - QCoro::Task addPlaylistItems(QString playlistId, QStringList itemIds, int position = -1); - QCoro::Task removePlaylistItems(QString playlistId, QStringList entryIds); - QCoro::Task movePlaylistItem(QString playlistId, QString playlistItemId, int newIndex); - QCoro::Task updatePlaylistName(QString playlistId, QString name); - QCoro::Task createCollection(QString name, QStringList itemIds = {}); - QCoro::Task addCollectionItems(QString collectionId, QStringList itemIds); - QCoro::Task removeCollectionItems(QString collectionId, QStringList itemIds); - QCoro::Task renameItem(QString itemId, QString name); - QCoro::Task deleteItem(QString itemId); - QCoro::Task> fetchItemsByIds(QStringList itemIds); - - // Trickplay (used for remote control timeline) - QCoro::Task fetchTrickplayInfo(QString itemId, QString mediaSourceId = {}); - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; - - // Remote playback & target sessions - QCoro::Task fetchControllableSessions(); - QCoro::Task sendRemotePlay(QString sessionId, QStringList itemIds, QString playCommand, - qint64 startPositionTicks = -1, int startIndex = -1, QString mediaSourceId = {}, int audioStreamIndex = -2, - int subtitleStreamIndex = -2); - QCoro::Task sendRemotePlaystate(QString sessionId, QString command, qint64 seekPositionTicks = -1); - QCoro::Task sendRemoteGeneralCommand(QString sessionId, QString command, QJsonObject arguments = {}); - - // SyncPlay REST endpoints - QCoro::Task fetchSyncPlayGroups(); - QCoro::Task createSyncPlayGroup(QString name); - QCoro::Task joinSyncPlayGroup(QString groupId); - QCoro::Task leaveSyncPlayGroup(); - QCoro::Task fetchUtcTime(); - QCoro::Task syncPlayReportPing(qint64 pingMs); - QCoro::Task syncPlayReportBuffering( - bool buffering, qint64 positionTicks, bool playing, QString playlistItemId, QDateTime serverTime); - QCoro::Task syncPlaySetNewQueue(QStringList itemIds, int playingItemPosition, qint64 startPositionTicks); - QCoro::Task syncPlayPause(); - QCoro::Task syncPlayUnpause(); - QCoro::Task syncPlaySeek(qint64 positionTicks); - QCoro::Task syncPlayNextItem(QString playlistItemId); - QCoro::Task syncPlayPreviousItem(QString playlistItemId); - QCoro::Task syncPlayQueue(QStringList itemIds, bool queueNext); - QCoro::Task syncPlayMovePlaylistItem(QString playlistItemId, int newIndex); - QCoro::Task syncPlayRemoveFromPlaylist(QStringList playlistItemIds); - QCoro::Task syncPlaySetPlaylistItem(QString playlistItemId); - - // Capabilities - QCoro::Task postCapabilities(); - -signals: - void authenticationExpired(const QString& message); - void credentialsChanged(); - void deviceProfileChanged(); - -private: - enum class HttpMethod { - Get, - Post, - Delete, - }; - - QNetworkRequest createRequest(const QString& path, const QUrlQuery& query = {}) const; - QCoro::Task requestJson( - HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); - QCoro::Task requestNoContent(HttpMethod method, QString path, QJsonDocument body); - QCoro::Task requestBytes( - HttpMethod method, QString path, QUrlQuery query = {}, QJsonDocument body = {}); - - QJsonObject buildDeviceProfile() const; - HttpOperation operationFor(HttpMethod method, const QString& path) const; - bool shouldExpireSession(const QString& path) const; - void preconnectToServer(); - void applyCommonHeaders(); - - QNetworkAccessManager *m_networkAccessManager = nullptr; - QRestAccessManager m_rest; - QNetworkRequestFactory m_requestFactory; - QString m_serverUrl; - QString m_deviceId; - QString m_deviceName = QStringLiteral("LG webOS TV"); - QString m_clientVersion = QStringLiteral("0.1.0"); - AuthSession m_session; - QSet m_activeReplies; - QString m_preconnectedAuthority; - QString m_acceptLanguage; - int m_maxStreamingHeight = 0; - bool m_remoteControlTargetEnabled = true; - bool m_authExpirationReported = false; - bool m_shuttingDown = false; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinJsAdapter.cpp b/src/api/JellyfinJsAdapter.cpp deleted file mode 100644 index 26d7eec9..00000000 --- a/src/api/JellyfinJsAdapter.cpp +++ /dev/null @@ -1,699 +0,0 @@ -#include "JellyfinJsAdapter.h" - -#include "../common/MetaJson.h" -#include "../provider/ProviderMediaPage.h" -#include "../provider/ProviderRegistry.h" -#include "PlaybackNegotiation.h" - -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class JellyfinJsAdapter::Playback final : public PlaybackSource { - Q_OBJECT - -public: - explicit Playback(JellyfinJsAdapter *adapter, QObject *parent = nullptr) - : PlaybackSource(parent) - , m_adapter(adapter) - { - } - - QByteArray mediaRequestHeaders() const override - { - return m_adapter->mediaRequestHeaders(); - } - - QUrl mediaOrigin() const override - { - return m_adapter->mediaOrigin(); - } - - int playbackParallelRequests() const override - { - return 2; - } - - bool signedIn() const override - { - return m_adapter->signedIn(); - } - - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override - { - return m_adapter->trickplayTileUrl(itemId, width, tileIndex); - } - - QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override - { - return m_adapter->resolvePlayback(std::move(item), forceTranscode); - } - - QCoro::Task> fetchMediaSegments(QString itemId) override - { - return m_adapter->fetchMediaSegments(std::move(itemId)); - } - - QCoro::Task> fetchSeriesEpisodes(QString seriesId) override - { - return m_adapter->fetchEpisodes(std::move(seriesId)); - } - - QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted) override - { - return m_adapter->reportPlaybackStart(std::move(session), playbackRate, volume, muted); - } - - QCoro::Task reportPlaybackProgress(PlaybackSession session, qint64 positionTicks, bool paused, - double playbackRate, int volume, bool muted) override - { - return m_adapter->reportPlaybackProgress( - std::move(session), positionTicks, paused, playbackRate, volume, muted); - } - - QCoro::Task reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate) override - { - return m_adapter->reportPlaybackStopped(std::move(session), positionTicks, failed, playbackRate); - } - - void emitCredentialsChanged() - { - emit credentialsChanged(); - } - -private: - JellyfinJsAdapter *m_adapter; -}; - -JellyfinJsAdapter::JellyfinJsAdapter(ProviderRegistry *registry, QObject *parent) - : QObject(parent) - , m_registry(registry) - , m_playback(new Playback(this, this)) -{ -} - -JellyfinJsAdapter::~JellyfinJsAdapter() = default; - -void JellyfinJsAdapter::configure(const QString& sourceId, const QString& serverUrl, const QString& sessionToken) -{ - const bool changed = m_sourceId != sourceId || m_sessionToken != sessionToken; - m_sourceId = sourceId; - m_serverUrl = serverUrl; - m_sessionToken = sessionToken; - if (changed && m_playback) - m_playback->emitCredentialsChanged(); -} - -void JellyfinJsAdapter::clear() -{ - m_sourceId.clear(); - m_serverUrl.clear(); - m_sessionToken.clear(); - m_bitrateOverride = 0; - m_heightOverride = 0; - if (m_playback) - m_playback->emitCredentialsChanged(); -} - -void JellyfinJsAdapter::setDeviceId(const QString& deviceId) -{ - m_deviceId = deviceId; -} - -void JellyfinJsAdapter::setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs) -{ - m_videoCodecs = std::move(videoCodecs); - m_restrictVideoCodecs = restrictVideoCodecs; -} - -PlaybackSource *JellyfinJsAdapter::playback() -{ - return m_playback; -} - -bool JellyfinJsAdapter::signedIn() const -{ - return !m_sourceId.isEmpty(); -} - -QString JellyfinJsAdapter::libraryScopeKey() const -{ - return m_sourceId; -} - -QCoro::Task JellyfinJsAdapter::fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) -{ - if (!descriptor.isValid() || m_sourceId.isEmpty()) - co_return PagedMovieItems { {}, 0, std::max(0, startIndex), std::clamp(limit, 1, 200) }; - - const int effectiveStart = std::max(0, startIndex); - const int effectiveLimit = std::clamp(limit, 1, 100); - - QString operation = QStringLiteral("browse"); - QVariantMap args; - args.insert(QStringLiteral("cursor"), QString::number(effectiveStart)); - args.insert(QStringLiteral("limit"), effectiveLimit); - - switch (descriptor.kind) { - case BrowseKind::Library: - if (!descriptor.id.isEmpty()) - args.insert(QStringLiteral("parentId"), descriptor.id); - if (!descriptor.collectionType.isEmpty()) - args.insert(QStringLiteral("collectionType"), descriptor.collectionType); - break; - case BrowseKind::FolderChildren: - args.insert(QStringLiteral("parentId"), descriptor.id); - args.insert(QStringLiteral("recursive"), false); - break; - case BrowseKind::BoxSet: - args.insert(QStringLiteral("parentId"), descriptor.id); - args.insert(QStringLiteral("recursive"), false); - break; - case BrowseKind::Genre: - if (!descriptor.id.isEmpty()) - args.insert(QStringLiteral("parentId"), descriptor.id); - if (!descriptor.collectionType.isEmpty()) - args.insert(QStringLiteral("collectionType"), descriptor.collectionType); - args.insert( - QStringLiteral("filters"), QVariantMap { { QStringLiteral("Genres"), QVariantList { descriptor.name } } }); - break; - case BrowseKind::Studio: - if (!descriptor.id.isEmpty()) - args.insert(QStringLiteral("parentId"), descriptor.id); - if (!descriptor.collectionType.isEmpty()) - args.insert(QStringLiteral("collectionType"), descriptor.collectionType); - args.insert( - QStringLiteral("filters"), QVariantMap { { QStringLiteral("StudioIds"), QVariantList { descriptor.id } } }); - break; - case BrowseKind::SeriesSeasons: - operation = QStringLiteral("seasons"); - args.insert(QStringLiteral("seriesId"), descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId); - break; - case BrowseKind::SeasonEpisodes: - operation = QStringLiteral("episodes"); - args.insert(QStringLiteral("seriesId"), descriptor.seriesId); - if (!descriptor.seasonId.isEmpty()) - args.insert(QStringLiteral("seasonId"), descriptor.seasonId); - break; - case BrowseKind::Person: - operation = QStringLiteral("personItems"); - args.insert(QStringLiteral("personId"), descriptor.id); - break; - case BrowseKind::Playlist: - case BrowseKind::ArtistAlbums: - case BrowseKind::None: - break; - } - - if (descriptor.kind == BrowseKind::Library) { - if (queryOptions.contains(QStringLiteral("sortBy"))) - args.insert(QStringLiteral("sortBy"), queryOptions.value(QStringLiteral("sortBy"))); - if (queryOptions.contains(QStringLiteral("sortOrder"))) - args.insert(QStringLiteral("sortOrder"), queryOptions.value(QStringLiteral("sortOrder"))); - if (queryOptions.contains(QStringLiteral("filters"))) { - const QVariantMap rawFilters = queryOptions.value(QStringLiteral("filters")).toMap(); - QVariantMap filtered; - static const QStringList allowed = { QStringLiteral("Filters"), QStringLiteral("Genres"), - QStringLiteral("OfficialRatings"), QStringLiteral("Tags"), QStringLiteral("Years"), - QStringLiteral("StudioIds"), QStringLiteral("SeriesStatus"), QStringLiteral("VideoTypes"), - QStringLiteral("IsHd"), QStringLiteral("Is4K"), QStringLiteral("Is3D"), QStringLiteral("HasSubtitles"), - QStringLiteral("HasTrailer"), QStringLiteral("IsMissing"), QStringLiteral("IsUnaired"), - QStringLiteral("NameStartsWith"), QStringLiteral("NameLessThan") }; - for (auto it = rawFilters.begin(); it != rawFilters.end(); ++it) { - if (allowed.contains(it.key())) - filtered.insert(it.key(), it.value()); - } - if (!filtered.isEmpty()) - args.insert(QStringLiteral("filters"), filtered); - } - } - - ProviderMediaPage page - = co_await m_registry->callSourceMediaPage(m_sourceId, operation, args, QString(), effectiveLimit); - - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - - if (descriptor.kind == BrowseKind::SeriesSeasons) { - const QString seriesId = descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId; - for (MovieItem& item : items) { - if (item.seriesId.isEmpty()) - item.seriesId = seriesId; - } - } - - const int totalCount = page.total.has_value() - ? static_cast(page.total.value()) - : (page.exhausted ? effectiveStart + static_cast(items.size()) - : effectiveStart + static_cast(items.size()) + 1); - - co_return PagedMovieItems { std::move(items), totalCount, effectiveStart, effectiveLimit }; -} - -QCoro::Task JellyfinJsAdapter::fetchItemDetails(QString itemId) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return MovieItem {}; - - QVariantMap res = co_await m_registry->callSource( - m_sourceId, QStringLiteral("details"), { { QStringLiteral("itemId"), itemId } }); - const QVariantMap row = res.value(QStringLiteral("item")).toMap(); - MovieItem media; - media.id = row.value(QStringLiteral("id")).toString(); - media.title = row.value(QStringLiteral("title")).toString(); - media.sortName = row.value(QStringLiteral("sortName")).toString(); - if (media.sortName.isEmpty()) - media.sortName = media.title; - media.itemType = row.value(QStringLiteral("type")).toString(); - media.overview = row.value(QStringLiteral("overview")).toString(); - media.posterTag = row.value(QStringLiteral("posterTag")).toString(); - media.backdropTag = row.value(QStringLiteral("backdropTag")).toString(); - media.logoTag = row.value(QStringLiteral("logoTag")).toString(); - media.seriesId = row.value(QStringLiteral("seriesId")).toString(); - media.seasonId = row.value(QStringLiteral("seasonId")).toString(); - media.seriesName = row.value(QStringLiteral("seriesName")).toString(); - media.year = row.value(QStringLiteral("year")).toInt(); - media.seasonNumber = row.value(QStringLiteral("season")).toInt(); - media.episodeNumber = row.value(QStringLiteral("episode")).toInt(); - media.runtimeTicks = row.value(QStringLiteral("runtimeTicks")).toLongLong(); - media.resumeTicks = row.value(QStringLiteral("resumeTicks")).toLongLong(); - media.favorite = row.value(QStringLiteral("favorite")).toBool(); - media.played = row.value(QStringLiteral("played")).toBool(); - media.genres = row.value(QStringLiteral("genres")).toStringList(); - media.tags = row.value(QStringLiteral("tags")).toStringList(); - media.studios = row.value(QStringLiteral("studios")).toStringList(); - media.officialRating = row.value(QStringLiteral("officialRating")).toString(); - media.communityRating = row.value(QStringLiteral("communityRating")).toDouble(); - - const QVariantList people = row.value(QStringLiteral("people")).toList(); - for (const auto& pVal : people) { - const QVariantMap p = pVal.toMap(); - PersonItem person; - person.id = p.value(QStringLiteral("id")).toString(); - person.name = p.value(QStringLiteral("name")).toString(); - person.type = p.value(QStringLiteral("type")).toString(); - person.role = p.value(QStringLiteral("role")).toString(); - person.imageTag = p.value(QStringLiteral("imageTag")).toString(); - media.people.append(std::move(person)); - } - co_return media; -} - -QCoro::Task> JellyfinJsAdapter::fetchSeasons(QString seriesId) -{ - const BrowseDescriptor desc = BrowseDescriptor::seriesSeasons(seriesId); - PagedMovieItems page = co_await fetchBrowsePage(desc, 0, 100); - for (auto& item : page.items) { - if (item.seriesId.isEmpty()) - item.seriesId = seriesId; - } - co_return page.items; -} - -QCoro::Task> JellyfinJsAdapter::fetchEpisodes(QString seriesId, QString seasonId) -{ - const BrowseDescriptor desc = BrowseDescriptor::seasonEpisodes(seriesId, seasonId); - PagedMovieItems page = co_await fetchBrowsePage(desc, 0, 100); - co_return page.items; -} - -QCoro::Task> JellyfinJsAdapter::fetchResumeItems(int limit) -{ - if (m_sourceId.isEmpty()) - co_return {}; - const int effectiveLimit = std::clamp(limit, 1, 60); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("resume"), - { { QStringLiteral("limit"), effectiveLimit } }, QString(), effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task> JellyfinJsAdapter::fetchNextUpEpisodes(int limit) -{ - if (m_sourceId.isEmpty()) - co_return {}; - const int effectiveLimit = std::clamp(limit, 1, 60); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("nextUp"), - { { QStringLiteral("limit"), effectiveLimit } }, QString(), effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task> JellyfinJsAdapter::fetchLatestItems(QString parentId, int limit) -{ - if (m_sourceId.isEmpty()) - co_return {}; - const int effectiveLimit = std::clamp(limit, 1, 60); - QVariantMap args { { QStringLiteral("limit"), effectiveLimit } }; - if (!parentId.isEmpty()) - args.insert(QStringLiteral("parentId"), parentId); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage( - m_sourceId, QStringLiteral("latest"), args, QString(), effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task> JellyfinJsAdapter::fetchSimilarItems(QString itemId, int limit) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return {}; - const int effectiveLimit = std::clamp(limit, 1, 60); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("similar"), - { { QStringLiteral("itemId"), itemId }, { QStringLiteral("limit"), effectiveLimit } }, QString(), - effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task JellyfinJsAdapter::fetchItemsByPerson(QString personId, int maximumItems) -{ - PersonCredits credits; - if (personId.isEmpty() || m_sourceId.isEmpty()) - co_return credits; - const int effectiveLimit = std::clamp(maximumItems, 1, 100); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("personItems"), - { { QStringLiteral("personId"), personId }, { QStringLiteral("limit"), effectiveLimit } }, QString(), - effectiveLimit); - credits.items.reserve(page.items.size()); - for (auto& row : page.items) - credits.items.push_back(std::move(row.media)); - co_return credits; -} - -QCoro::Task> JellyfinJsAdapter::fetchLibraries() -{ - if (m_sourceId.isEmpty()) - co_return {}; - QVariantMap res = co_await m_registry->callSource(m_sourceId, QStringLiteral("libraries")); - std::vector libraries; - const QVariantList items = res.value(QStringLiteral("items")).toList(); - libraries.reserve(items.size()); - for (const auto& itemVal : items) { - const QVariantMap row = itemVal.toMap(); - LibraryItem item; - item.id = row.value(QStringLiteral("id")).toString(); - item.name = row.value(QStringLiteral("title")).toString(); - item.collectionType = row.value(QStringLiteral("collectionType")).toString(); - item.imageTag = row.value(QStringLiteral("posterTag")).toString(); - libraries.push_back(std::move(item)); - } - co_return libraries; -} - -QCoro::Task JellyfinJsAdapter::fetchLibraryFilterOptions(QString libraryId, QString collectionType) -{ - if (libraryId.isEmpty() || m_sourceId.isEmpty()) - co_return QVariantMap {}; - QVariantMap args { { QStringLiteral("parentId"), libraryId } }; - if (!collectionType.isEmpty()) - args.insert(QStringLiteral("collectionType"), collectionType); - QVariantMap filters = co_await m_registry->callSource(m_sourceId, QStringLiteral("filterOptions"), args); - QVariantMap options; - if (filters.contains(QStringLiteral("Genres"))) - options.insert(QStringLiteral("genres"), filters.value(QStringLiteral("Genres"))); - if (filters.contains(QStringLiteral("OfficialRatings"))) - options.insert(QStringLiteral("officialRatings"), filters.value(QStringLiteral("OfficialRatings"))); - if (filters.contains(QStringLiteral("Tags"))) - options.insert(QStringLiteral("tags"), filters.value(QStringLiteral("Tags"))); - if (filters.contains(QStringLiteral("Years"))) - options.insert(QStringLiteral("years"), filters.value(QStringLiteral("Years"))); - co_return options; -} - -QCoro::Task> JellyfinJsAdapter::fetchItemsByIds(QStringList itemIds) -{ - if (itemIds.isEmpty() || m_sourceId.isEmpty()) - co_return {}; - QVariantMap filters { { QStringLiteral("Ids"), itemIds.join(QLatin1Char(',')) } }; - const int effectiveLimit = std::clamp(static_cast(itemIds.size()), 1, 100); - QVariantMap args { { QStringLiteral("filters"), filters }, { QStringLiteral("limit"), effectiveLimit } }; - ProviderMediaPage page = co_await m_registry->callSourceMediaPage( - m_sourceId, QStringLiteral("browse"), args, QString(), effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task> JellyfinJsAdapter::searchItems(QString searchTerm, int limit) -{ - if (searchTerm.isEmpty() || m_sourceId.isEmpty()) - co_return {}; - const int effectiveLimit = std::clamp(limit, 1, 100); - ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_sourceId, QStringLiteral("search"), - { { QStringLiteral("query"), searchTerm }, { QStringLiteral("limit"), effectiveLimit } }, QString(), - effectiveLimit); - std::vector items; - items.reserve(page.items.size()); - for (auto& row : page.items) - items.push_back(std::move(row.media)); - co_return items; -} - -QCoro::Task> JellyfinJsAdapter::fetchSearchSuggestions(int limit) -{ - return fetchResumeItems(limit); -} - -QCoro::Task JellyfinJsAdapter::setItemFavorite(QString itemId, bool favorite) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("favorite"), - { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), favorite } }); -} - -QCoro::Task JellyfinJsAdapter::setItemPlayed(QString itemId, bool played) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("played"), - { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), played } }); -} - -QCoro::Task JellyfinJsAdapter::setItemPlaybackPosition(QString itemId, qint64 positionTicks) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("progress"), - { { QStringLiteral("itemId"), itemId }, { QStringLiteral("positionTicks"), QString::number(positionTicks) } }); -} - -QString JellyfinJsAdapter::imageUrl(const ImageRequest& request) const -{ - const QString origin = m_serverUrl; - if (origin.isEmpty() || request.itemId.isEmpty() || request.tag.isEmpty() || request.imageType.isEmpty()) - return {}; - QUrl url = serverUrlWithPath( - origin, { QStringLiteral("Items"), request.itemId, QStringLiteral("Images"), request.imageType }); - QUrlQuery query; - if (request.fillWidth > 0 && request.fillHeight > 0) { - query.addQueryItem(QStringLiteral("fillWidth"), QString::number(request.fillWidth)); - query.addQueryItem(QStringLiteral("fillHeight"), QString::number(request.fillHeight)); - } else { - query.addQueryItem(QStringLiteral("maxWidth"), QString::number(request.maxWidth)); - } - query.addQueryItem(QStringLiteral("quality"), QString::number(request.quality)); - query.addQueryItem(QStringLiteral("format"), request.format); - query.addQueryItem(QStringLiteral("tag"), request.tag); - url.setQuery(query); - return url.toString(QUrl::FullyEncoded); -} - -QCoro::Task JellyfinJsAdapter::resolvePlayback(MovieItem movie, bool forceTranscode) -{ - if (m_sourceId.isEmpty()) - throw std::runtime_error("playback_not_ready"); - - QString variantId; - if (!movie.mediaSources.isEmpty()) { - variantId = movie.mediaSources.first().id; - } else { - QVariantMap vRes = co_await m_registry->callSource( - m_sourceId, QStringLiteral("variants"), { { QStringLiteral("itemId"), movie.id } }); - const QVariantList variants = vRes.value(QStringLiteral("variants")).toList(); - if (!variants.isEmpty()) - variantId = variants.first().toMap().value(QStringLiteral("id")).toString(); - } - if (variantId.isEmpty()) - variantId = movie.id; - - qint64 sourceBitrate = 0; - for (const MediaSourceInfo& source : movie.mediaSources) - sourceBitrate = std::max(sourceBitrate, source.bitRate); - - const bool shouldTranscode - = forceTranscode || (m_bitrateOverride > 0 && sourceBitrate > 0 && m_bitrateOverride < sourceBitrate); - - QVariantMap args; - args.insert(QStringLiteral("itemId"), movie.id); - args.insert(QStringLiteral("variantId"), variantId); - args.insert(QStringLiteral("forceTranscode"), shouldTranscode); - args.insert(QStringLiteral("positionTicks"), movie.resumeTicks); - - const qint64 maxBitrate = m_bitrateOverride > 0 ? m_bitrateOverride : 120'000'000; - args.insert(QStringLiteral("maxBitrate"), maxBitrate); - if (m_heightOverride > 0) - args.insert(QStringLiteral("maxHeight"), m_heightOverride); - - const QJsonObject profile - = PlaybackNegotiation::buildDeviceProfile(maxBitrate, m_heightOverride, m_videoCodecs, m_restrictVideoCodecs); - args.insert(QStringLiteral("deviceProfile"), profile.toVariantMap()); - - QVariantMap result = co_await m_registry->callSource(m_sourceId, QStringLiteral("resolve"), args); - - PlaybackSession session; - session.itemId = movie.id; - session.title = movie.title; - session.itemType = movie.itemType; - const QVariantMap video = result.value(QStringLiteral("video")).toMap(); - session.url = video.value(QStringLiteral("url")).toString(); - session.mediaSourceId = result.value(QStringLiteral("variantId")).toString(); - session.playSessionId = result.value(QStringLiteral("playSessionId")).toString(); - session.playMethod = result.value(QStringLiteral("playMethod")).toString(); - if (session.playMethod.isEmpty()) - session.playMethod = QStringLiteral("DirectPlay"); - session.startTimeTicks = movie.resumeTicks; - session.runtimeTicks = movie.runtimeTicks; - - const QVariantList streams = result.value(QStringLiteral("streams")).toList(); - for (const auto& sVal : streams) { - const QVariantMap s = sVal.toMap(); - MediaStreamInfo info; - info.index = s.value(QStringLiteral("index")).toInt(); - info.type = s.value(QStringLiteral("type")).toString(); - info.codec = s.value(QStringLiteral("codec")).toString(); - info.language = s.value(QStringLiteral("language")).toString(); - info.title = s.value(QStringLiteral("title")).toString(); - info.displayTitle = info.title; - info.width = s.value(QStringLiteral("width")).toInt(); - info.height = s.value(QStringLiteral("height")).toInt(); - info.channels = s.value(QStringLiteral("channels")).toInt(); - info.bitRate = s.value(QStringLiteral("bitrate")).toInt(); - info.videoRange = s.value(QStringLiteral("range")).toString(); - info.videoRangeType = info.videoRange; - info.isDefault = s.value(QStringLiteral("default")).toBool(); - info.isForced = s.value(QStringLiteral("forced")).toBool(); - info.isExternal = s.value(QStringLiteral("external")).toBool(); - session.mediaStreams.append(std::move(info)); - } - - try { - session.segments = co_await fetchMediaSegments(movie.id); - } catch (...) { - } - - session.trickplay.width = 320; - co_return session; -} - -QCoro::Task> JellyfinJsAdapter::fetchMediaSegments(QString itemId) -{ - if (itemId.isEmpty() || m_sourceId.isEmpty()) - co_return {}; - QVariantMap res = co_await m_registry->callSource( - m_sourceId, QStringLiteral("segments"), { { QStringLiteral("itemId"), itemId } }); - std::vector segments; - const QVariantList list = res.value(QStringLiteral("segments")).toList(); - segments.reserve(list.size()); - for (const auto& val : list) { - const QVariantMap map = val.toMap(); - MediaSegment seg; - seg.id = map.value(QStringLiteral("Id")).toString(); - seg.type = map.value(QStringLiteral("Type")).toString(); - seg.startTicks = map.value(QStringLiteral("StartTicks")).toLongLong(); - seg.endTicks = map.value(QStringLiteral("EndTicks")).toLongLong(); - segments.push_back(std::move(seg)); - } - co_return segments; -} - -QByteArray JellyfinJsAdapter::mediaRequestHeaders() const -{ - if (m_sessionToken.isEmpty()) - return {}; - return "X-Emby-Token: " + m_sessionToken.toUtf8() + "\n"; -} - -QUrl JellyfinJsAdapter::mediaOrigin() const -{ - return QUrl(m_serverUrl); -} - -QString JellyfinJsAdapter::trickplayTileUrl(const QString& itemId, int width, int tileIndex) const -{ - if (m_serverUrl.isEmpty() || itemId.isEmpty() || width <= 0 || tileIndex < 0) - return {}; - return serverUrlWithPath(m_serverUrl, - { QStringLiteral("Videos"), itemId, QStringLiteral("Trickplay"), QString::number(width), - QStringLiteral("%1.jpg").arg(tileIndex) }) - .toString(QUrl::FullyEncoded); -} - -QCoro::Task JellyfinJsAdapter::reportPlaybackStart( - PlaybackSession session, double playbackRate, int volume, bool muted) -{ - if (m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), - { { QStringLiteral("event"), QStringLiteral("start") }, { QStringLiteral("itemId"), session.itemId }, - { QStringLiteral("variantId"), session.mediaSourceId }, - { QStringLiteral("playSessionId"), session.playSessionId }, - { QStringLiteral("positionTicks"), QString::number(session.startTimeTicks) }, - { QStringLiteral("rate"), playbackRate }, { QStringLiteral("volume"), volume }, - { QStringLiteral("muted"), muted }, { QStringLiteral("playMethod"), session.playMethod } }); -} - -QCoro::Task JellyfinJsAdapter::reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted) -{ - if (m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), - { { QStringLiteral("event"), QStringLiteral("progress") }, { QStringLiteral("itemId"), session.itemId }, - { QStringLiteral("variantId"), session.mediaSourceId }, - { QStringLiteral("playSessionId"), session.playSessionId }, - { QStringLiteral("positionTicks"), QString::number(positionTicks) }, { QStringLiteral("paused"), paused }, - { QStringLiteral("rate"), playbackRate }, { QStringLiteral("volume"), volume }, - { QStringLiteral("muted"), muted }, { QStringLiteral("playMethod"), session.playMethod } }); -} - -QCoro::Task JellyfinJsAdapter::reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool, double playbackRate) -{ - if (m_sourceId.isEmpty()) - co_return; - co_await m_registry->callSource(m_sourceId, QStringLiteral("report"), - { { QStringLiteral("event"), QStringLiteral("stop") }, { QStringLiteral("itemId"), session.itemId }, - { QStringLiteral("variantId"), session.mediaSourceId }, - { QStringLiteral("playSessionId"), session.playSessionId }, - { QStringLiteral("positionTicks"), QString::number(positionTicks) }, - { QStringLiteral("rate"), playbackRate } }); -} - -} // namespace JellyfinNative - -#include "JellyfinJsAdapter.moc" diff --git a/src/api/JellyfinJsAdapter.h b/src/api/JellyfinJsAdapter.h deleted file mode 100644 index 96556de9..00000000 --- a/src/api/JellyfinJsAdapter.h +++ /dev/null @@ -1,148 +0,0 @@ -#pragma once - -#include "../media/MediaTypes.h" -#include "../provider/ArtworkSource.h" -#include "../provider/Catalog.h" -#include "../provider/PlaybackSource.h" -#include "../provider/SearchSource.h" -#include "../provider/StreamQualityControl.h" -#include "../provider/UserItemStateSink.h" - -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class ProviderRegistry; - -// Bridges the portable JavaScript Jellyfin provider (spool.jellyfin) into the -// native core interfaces: Catalog, SearchSource, PlaybackSource, ArtworkSource, -// and UserItemStateSink. -// -// Bulk listing and search results are decoded on the worker thread into -// ProviderMediaPage without converting QVariant trees or JSON on the GUI thread. -class JellyfinJsAdapter final : public QObject, - public Catalog, - public SearchSource, - public UserItemStateSink, - public ArtworkSource, - public StreamQualityControl { - Q_OBJECT - -public: - explicit JellyfinJsAdapter(ProviderRegistry *registry, QObject *parent = nullptr); - ~JellyfinJsAdapter() override; - - void configure(const QString& sourceId, const QString& serverUrl, const QString& sessionToken); - void clear(); - void setDeviceId(const QString& deviceId); - - Catalog *catalog() - { - return this; - } - SearchSource *search() - { - return this; - } - UserItemStateSink *itemState() - { - return this; - } - ArtworkSource *artwork() - { - return this; - } - PlaybackSource *playback(); - - // Catalog - bool signedIn() const override; - QString libraryScopeKey() const override; - QCoro::Task fetchBrowsePage( - BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; - QCoro::Task fetchItemDetails(QString itemId) override; - QCoro::Task> fetchSeasons(QString seriesId) override; - QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; - QCoro::Task> fetchResumeItems(int limit = 24) override; - QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; - QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; - QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; - QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; - QCoro::Task> fetchLibraries() override; - QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; - QCoro::Task> fetchItemsByIds(QStringList itemIds) override; - - // SearchSource - QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; - QCoro::Task> fetchSearchSuggestions(int limit = 20) override; - - // UserItemStateSink - QCoro::Task setItemFavorite(QString itemId, bool favorite) override; - QCoro::Task setItemPlayed(QString itemId, bool played) override; - QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; - - // ArtworkSource - QString imageUrl(const ImageRequest& request) const override; - - StreamQualityControl *streamQuality() - { - return this; - } - - // StreamQualityControl - qint64 bitrateOverride() const override - { - return m_bitrateOverride; - } - int heightOverride() const override - { - return m_heightOverride; - } - void setOverride(qint64 bitrate, int height) override - { - m_bitrateOverride = bitrate; - m_heightOverride = height; - } - QString autoDescription() const override - { - return QStringLiteral("Direct Play"); - } - std::vector ladder(qint64 sourceBitrate, int sourceHeight = 0) const override - { - return StreamQualityControl::defaultLadder(sourceBitrate, sourceHeight); - } - void setVideoCodecCapabilities(QStringList videoCodecs, bool restrictVideoCodecs); - - // Playback - QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode); - QCoro::Task> fetchMediaSegments(QString itemId); - QByteArray mediaRequestHeaders() const; - QUrl mediaOrigin() const; - QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const; - QCoro::Task reportPlaybackStart(PlaybackSession session, double playbackRate, int volume, bool muted); - QCoro::Task reportPlaybackProgress( - PlaybackSession session, qint64 positionTicks, bool paused, double playbackRate, int volume, bool muted); - QCoro::Task reportPlaybackStopped( - PlaybackSession session, qint64 positionTicks, bool failed, double playbackRate); - -private: - class Playback; - - ProviderRegistry *m_registry = nullptr; - Playback *m_playback = nullptr; - QString m_sourceId; - QString m_serverUrl; - QString m_sessionToken; - QString m_deviceId; - qint64 m_bitrateOverride = 0; - int m_heightOverride = 0; - QStringList m_videoCodecs; - bool m_restrictVideoCodecs = false; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.cpp b/src/api/JellyfinProvider.cpp deleted file mode 100644 index 85dab1cf..00000000 --- a/src/api/JellyfinProvider.cpp +++ /dev/null @@ -1,367 +0,0 @@ -#include "JellyfinProvider.h" - -#include "../app/ArtworkService.h" -#include "../app/LibraryManagementController.h" -#include "../app/QuickConnectController.h" -#include "../app/RemoteControlController.h" -#include "../app/SessionController.h" -#include "../app/SettingsController.h" -#include "../app/SyncPlayController.h" -#include "../cache/DatabaseManager.h" -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "../discovery/DiscoveryController.h" -#include "../models/DiscoveredServerModel.h" -#include "../platform/PlatformPlaybackRuntime.h" -#include "../player/PlayerController.h" -#include "../provider/ProviderRegistry.h" -#include "JellyfinApiFacade.h" -#include "JellyfinJsAdapter.h" -#include "JellyfinSettingsBridge.h" -#if defined(JELLYFIN_NATIVE_WEBOS) -#include "../platform/webos/WebOSDeviceName.h" -#endif - -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -JellyfinProvider::JellyfinProvider(const JellyfinProviderContext& context, QObject *parent) - : Provider(parent) - , m_registry(context.registry) - , m_deviceName(context.deviceName) - , m_appVersion(context.appVersion) - , m_database(context.database) - , m_tlsTrust(context.tlsTrust) -{ - if (!m_registry) - throw std::runtime_error("javascript_provider_unavailable"); - m_jsAdapter = std::make_unique(m_registry, this); - - m_api = new JellyfinApiFacade(context.network, context.tlsTrust, this); - m_api->setDeviceIdentity({}, context.deviceName, context.appVersion); -#if defined(JELLYFIN_NATIVE_WEBOS) - // webOS reports the name the owner gave the set only on request, and - // again whenever they change it. Marshal it onto the API's thread, since - // the luna callback does not belong to us. - requestWebOSDeviceName([api = QPointer(m_api)](const QString& name) { - if (!api) - return; - QMetaObject::invokeMethod( - api, - [api, name]() { - if (api) - api->setDeviceName(name); - }, - Qt::QueuedConnection); - }); -#endif - configurePlatformPlaybackCapabilities( - [this](const QStringList& videoCodecs, bool restrictVideoCodecs) { - if (m_jsAdapter) - m_jsAdapter->setVideoCodecCapabilities(videoCodecs, restrictVideoCodecs); - }, - *this); - - m_discovery = new DiscoveryController(context.tlsTrust, this); - m_discoveredServers = new DiscoveredServerModel(this); - m_session = new SessionController(context.database, m_api, this); - m_session->setDiscoveredServers(m_discoveredServers); - m_quickConnect = new QuickConnectController(m_api, this); - m_remoteControl = new RemoteControlController(m_api, this); - - connect(m_discovery, &DiscoveryController::serverDiscovered, this, [this](const DiscoveredServer& server) { - m_discoveredServers->upsertServer(server); - cacheDiscoveredServers(); - }); - connect(m_session, &SessionController::serverRemembered, this, &JellyfinProvider::cacheDiscoveredServers); - connect(m_api, &JellyfinApiFacade::authenticationExpired, m_session, &SessionController::expireSession); - connect(m_quickConnect, &QuickConnectController::authenticated, this, - [this](const AuthSession& session) { m_session->acceptSession(session); }); - - // What the app shows for the sign-in parts: one busy flag, one error - // line, toasts for the rest. - connect(m_session, &SessionController::busyChanged, this, &Provider::busyChanged); - connect(m_session, &SessionController::errorOccurred, this, &Provider::errorOccurred); - connect(m_quickConnect, &QuickConnectController::busyChanged, this, &Provider::busyChanged); - connect(m_quickConnect, &QuickConnectController::errorOccurred, this, &Provider::errorOccurred); - connect(m_remoteControl, &RemoteControlController::errorText, this, &Provider::toastRequested); - connect(m_remoteControl, &RemoteControlController::feedbackText, this, &Provider::toastRequested); - connect(m_session, &SessionController::accountProfilesChanged, this, - [this]() { setHasDefaultProfile(!m_session->accountProfiles().isEmpty()); }); - connect(m_session, &SessionController::profileActivationStarted, this, &Provider::activationStarted); - connect(m_session, &SessionController::switchUserRequested, this, &Provider::switchUserRequested); - connect(m_session, &SessionController::logoutStarted, this, &Provider::signOutStarted); - - // The session drives the parts that only make sense while signed in. - connect(m_session, &SessionController::authenticatedChanged, this, [this](const AuthSession& auth) { - Async::runScoped( - this, configureJsSourceAsync(auth), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - - if (m_artwork) - m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); - if (m_syncPlay) - m_syncPlay->connectSocket(); - m_remoteControl->start(); - m_discovery->stop(); - setHasDefaultProfile(true); - emit sessionStarted(); - }); - connect(m_session, &SessionController::profileActivationStarted, this, [this]() { - m_quickConnect->cancel(); - leaveSessionServices(); - }); - connect(m_session, &SessionController::switchUserRequested, this, [this]() { - m_quickConnect->cancel(); - m_discovery->start(); - }); - connect(m_session, &SessionController::logoutStarted, this, [this]() { - m_quickConnect->cancel(); - leaveSessionServices(); - }); - connect(m_session, &SessionController::loggedOut, this, [this]() { - leaveSessionServices(); - if (m_jsAdapter) - m_jsAdapter->clear(); - m_jsSourceId.clear(); - if (m_artwork) - m_artwork->setAuthorizationHeader({}); - if (m_management) - m_management->clear(); - emit sessionEnded(); - resumeServerDiscovery(); - }); -} - -JellyfinProvider::~JellyfinProvider() = default; - -QString JellyfinProvider::id() const -{ - return QStringLiteral("jellyfin"); -} - -QString JellyfinProvider::displayName() const -{ - return QStringLiteral("Jellyfin"); -} - -Provider::Capabilities JellyfinProvider::capabilities() const -{ - return Auth | Discovery | Search | UserItemState | PlaybackReporting | Segments | LibraryManagement | SyncPlay - | RemoteControl | QuickConnect | PeerRelay | StreamQuality; -} - -PlaybackSource *JellyfinProvider::playback() -{ - return m_jsAdapter->playback(); -} - -Catalog *JellyfinProvider::catalog() -{ - return m_jsAdapter->catalog(); -} - -ArtworkSource *JellyfinProvider::artwork() -{ - return m_jsAdapter->artwork(); -} - -SearchSource *JellyfinProvider::search() -{ - return m_jsAdapter->search(); -} - -UserItemStateSink *JellyfinProvider::itemState() -{ - return m_jsAdapter->itemState(); -} - -StreamQualityControl *JellyfinProvider::streamQuality() -{ - return m_jsAdapter.get(); -} - -GroupPlayback *JellyfinProvider::groupPlayback() -{ - return m_syncPlay; -} - -RemotePlayback *JellyfinProvider::remotePlayback() -{ - return m_remoteControl; -} - -void JellyfinProvider::attach(const CoreServices& core) -{ - Q_ASSERT(!m_syncPlay); - m_settings = core.settings; - m_artwork = core.artwork; - m_syncPlay = new SyncPlayController(m_api, core.player, core.playQueue, m_tlsTrust, core.owner); - m_management = new LibraryManagementController(m_api, core.browse, core.owner); - m_settingsBridge = new JellyfinSettingsBridge(core.settings, m_api, core.owner); - connect(m_syncPlay, &SyncPlayController::sessionsUpdated, m_remoteControl, &RemoteControlController::applySessions); - connect(m_syncPlay, &QObject::destroyed, this, [this]() { m_syncPlay = nullptr; }); - connect(m_management, &QObject::destroyed, this, [this]() { m_management = nullptr; }); - connect(m_settingsBridge, &QObject::destroyed, this, [this]() { m_settingsBridge = nullptr; }); - - connect(m_syncPlay, &SyncPlayController::errorText, this, &Provider::toastRequested); - connect(m_management, &LibraryManagementController::errorOccurred, this, &Provider::toastRequested); - connect(m_management, &LibraryManagementController::operationSucceeded, this, &Provider::toastRequested); - connect(m_management, &LibraryManagementController::refreshRequested, this, &Provider::contentChanged); - connect(m_settingsBridge, &JellyfinSettingsBridge::errorOccurred, this, &Provider::toastRequested); - - // Commands other clients send arrive over the SyncPlay socket; they - // reach the app only while this device is set to accept them. - const auto forward = [this](void (RemotePlayback::*signal)(const QJsonObject&)) { - return [this, signal](const QJsonObject& data) { - if (m_api->remoteControlTargetEnabled()) - emit(m_remoteControl->*signal)(data); - }; - }; - connect(m_syncPlay, &SyncPlayController::remotePlayCommand, m_remoteControl, - forward(&RemotePlayback::playCommandReceived)); - connect(m_syncPlay, &SyncPlayController::remotePlaystateCommand, m_remoteControl, - forward(&RemotePlayback::playstateCommandReceived)); - connect(m_syncPlay, &SyncPlayController::remoteGeneralCommand, m_remoteControl, - forward(&RemotePlayback::generalCommandReceived)); - if (SpoolLink *link = m_remoteControl->link()) - connect(link, &SpoolLink::messageReceived, m_remoteControl, &RemotePlayback::peerMessageReceived); -} - -void JellyfinProvider::registerQmlSingletons() -{ - Q_ASSERT(m_syncPlay && m_management); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Session", m_session); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "QuickConnect", m_quickConnect); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SyncPlay", m_syncPlay); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "RemoteControl", m_remoteControl); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Management", m_management); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Discovery", m_discovery); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "DiscoveredServers", m_discoveredServers); -} - -void JellyfinProvider::shutdown() -{ - m_quickConnect->cancel(); - m_api->cancelRequests(); - m_discovery->stop(); -} - -bool JellyfinProvider::ready() const -{ - return m_session->authenticated() && m_jsAdapter && m_jsAdapter->signedIn(); -} - -QStringList JellyfinProvider::startupStorageKeys() const -{ - return SessionController::localStorageKeys(); -} - -bool JellyfinProvider::restoreFromStorage(QVariantMap values, std::vector profiles) -{ - const bool hasDefaultProfile = m_session->initializeFromStorage(std::move(values), std::move(profiles)); - setHasDefaultProfile(hasDefaultProfile); - if (!m_session->authenticated()) - resumeServerDiscovery(); - return hasDefaultProfile; -} - -void JellyfinProvider::setDeviceId(const QString& deviceId) -{ - m_deviceId = deviceId; - m_api->setDeviceId(deviceId); - if (m_jsAdapter) - m_jsAdapter->setDeviceId(deviceId); -} - -void JellyfinProvider::setLocale(const QString& bcp47) -{ - m_api->setAcceptLanguage(bcp47); -} - -bool JellyfinProvider::handleUnauthorized(const std::exception_ptr& error) -{ - return m_session->handleUnauthorized(error); -} - -QCoro::Task JellyfinProvider::configureJsSourceAsync(const AuthSession& session) -{ - if (!m_registry) - co_return; - const QString serverUrl = m_session->serverUrl(); - if (serverUrl.isEmpty() || session.userId.isEmpty() || session.accessToken.isEmpty()) - co_return; - - QVariantMap config { { QStringLiteral("server"), serverUrl }, { QStringLiteral("userId"), session.userId }, - { QStringLiteral("token"), session.accessToken }, - { QStringLiteral("deviceId"), m_deviceId.isEmpty() ? QStringLiteral("spool") : m_deviceId }, - { QStringLiteral("deviceName"), m_deviceName.isEmpty() ? QStringLiteral("Spool") : m_deviceName }, - { QStringLiteral("clientVersion"), m_appVersion.isEmpty() ? QStringLiteral("0.1.0") : m_appVersion } }; - QList origins { QUrl(serverUrl) }; - const QString sourceId = co_await m_registry->configureSource( - QStringLiteral("spool.jellyfin"), session.userId, QStringLiteral("library"), serverUrl, config, origins); - - m_jsSourceId = sourceId; - m_jsAdapter->configure(sourceId, serverUrl, session.accessToken); - if (m_artwork) - m_artwork->setAuthorizationHeader(m_api->authorizationHeader()); - m_discovery->stop(); - setHasDefaultProfile(true); - emit sessionStarted(); -} - -void JellyfinProvider::resumeServerDiscovery() -{ - Async::runScoped( - this, restoreDiscoveredServersAsync(), - [this]() { - if (!m_session->authenticated()) - m_discovery->start(); - }, - [this](const std::exception_ptr& error) { - qWarning() << "discovery: cached server load failed" << exceptionMessage(error); - if (!m_session->authenticated()) - m_discovery->start(); - }, - "discovery cache"); -} - -void JellyfinProvider::cacheDiscoveredServers() -{ - QJsonArray cache; - for (const auto& entry : m_discoveredServers->servers()) - cache.push_back(metaToJson(entry)); - m_database->saveDiscoveredServers(cache); -} - -QCoro::Task JellyfinProvider::restoreDiscoveredServersAsync() -{ - const auto servers = co_await m_database->loadDiscoveredServersAsync(); - std::vector parsed; - parsed.reserve(servers.size()); - for (const auto& value : servers) - parsed.push_back(metaFromJson(value.toObject())); - m_discoveredServers->setServers(parsed, false); -} - -void JellyfinProvider::leaveSessionServices() -{ - if (m_syncPlay) - m_syncPlay->disconnectSocket(); - m_remoteControl->stop(); -} - -void JellyfinProvider::setHasDefaultProfile(bool hasDefaultProfile) -{ - if (m_hasDefaultProfile == hasDefaultProfile) - return; - m_hasDefaultProfile = hasDefaultProfile; - emit hasDefaultProfileChanged(); -} - -} // namespace JellyfinNative diff --git a/src/api/JellyfinProvider.h b/src/api/JellyfinProvider.h deleted file mode 100644 index eb3ec8c9..00000000 --- a/src/api/JellyfinProvider.h +++ /dev/null @@ -1,151 +0,0 @@ -#pragma once - -#include "../provider/Provider.h" - -#include -#include -#include - -class QNetworkAccessManager; - -namespace JellyfinNative { - -class ArtworkService; -struct AuthSession; -class DatabaseManager; -class DiscoveredServerModel; -class DiscoveryController; -class JellyfinApiFacade; -class JellyfinJsAdapter; -class JellyfinSettingsBridge; -class LibraryManagementController; -class ProviderRegistry; -class QuickConnectController; -class RemoteControlController; -class SessionController; -class SettingsController; -class SyncPlayController; -class TlsTrustController; - -struct JellyfinProviderContext { - QNetworkAccessManager *network = nullptr; - TlsTrustController *tlsTrust = nullptr; - DatabaseManager *database = nullptr; - ProviderRegistry *registry = nullptr; - QString deviceName; - QString appVersion; -}; - -// Everything Jellyfin-specific, composed in one place: the API facade, LAN -// discovery and its cached server list, the session and its sign-in helpers, -// SyncPlay, remote control and library management. The app hands it the -// core objects those parts sit on through attach(); the provider then keeps -// the session-driven lifecycle of its own parts (discovery scans while -// nobody is signed in, the SyncPlay socket and remote-control registration -// follow the session, artwork carries the session's token) so the app never -// has to. The app sees it only as a Provider. -class JellyfinProvider final : public Provider { - Q_OBJECT - -public: - explicit JellyfinProvider(const JellyfinProviderContext& context, QObject *parent = nullptr); - ~JellyfinProvider() override; - - QString id() const override; - QString displayName() const override; - Capabilities capabilities() const override; - PlaybackSource *playback() override; - Catalog *catalog() override; - ArtworkSource *artwork() override; - SearchSource *search() override; - UserItemStateSink *itemState() override; - StreamQualityControl *streamQuality() override; - GroupPlayback *groupPlayback() override; - RemotePlayback *remotePlayback() override; - - void attach(const CoreServices& core) override; - void registerQmlSingletons() override; - void shutdown() override; - - bool ready() const override; - bool hasDefaultProfile() const override - { - return m_hasDefaultProfile; - } - QStringList startupStorageKeys() const override; - bool restoreFromStorage(QVariantMap values, std::vector profiles) override; - void setDeviceId(const QString& deviceId) override; - void setLocale(const QString& bcp47) override; - bool handleUnauthorized(const std::exception_ptr& error) override; - - JellyfinApiFacade *api() const - { - return m_api; - } - DiscoveryController *discovery() const - { - return m_discovery; - } - DiscoveredServerModel *discoveredServers() const - { - return m_discoveredServers; - } - SessionController *session() const - { - return m_session; - } - QuickConnectController *quickConnect() const - { - return m_quickConnect; - } - RemoteControlController *remoteControl() const - { - return m_remoteControl; - } - // Null until attach(). - SyncPlayController *syncPlay() const - { - return m_syncPlay; - } - LibraryManagementController *management() const - { - return m_management; - } - JellyfinSettingsBridge *settingsBridge() const - { - return m_settingsBridge; - } - -private: - // Shows the servers seen last time and scans for more while nobody is - // signed in. Runs after the stored session is known and after a sign-out. - void resumeServerDiscovery(); - void cacheDiscoveredServers(); - QCoro::Task restoreDiscoveredServersAsync(); - QCoro::Task configureJsSourceAsync(const AuthSession& session); - void leaveSessionServices(); - void setHasDefaultProfile(bool hasDefaultProfile); - - ProviderRegistry *m_registry = nullptr; - std::unique_ptr m_jsAdapter; - QString m_deviceId; - QString m_deviceName; - QString m_appVersion; - QString m_jsSourceId; - DatabaseManager *m_database = nullptr; - TlsTrustController *m_tlsTrust = nullptr; - JellyfinApiFacade *m_api = nullptr; - DiscoveryController *m_discovery = nullptr; - DiscoveredServerModel *m_discoveredServers = nullptr; - SessionController *m_session = nullptr; - QuickConnectController *m_quickConnect = nullptr; - RemoteControlController *m_remoteControl = nullptr; - SyncPlayController *m_syncPlay = nullptr; - LibraryManagementController *m_management = nullptr; - JellyfinSettingsBridge *m_settingsBridge = nullptr; - SettingsController *m_settings = nullptr; - ArtworkService *m_artwork = nullptr; - bool m_hasDefaultProfile = false; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinSession.h b/src/api/JellyfinSession.h deleted file mode 100644 index c1e9327a..00000000 --- a/src/api/JellyfinSession.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include - -namespace JellyfinNative { - -// The credentials a Jellyfin sign-in yields. Provider-owned: nothing in core -// reads a user id or server id. -struct AuthSession { - QString userId; - QString userName; - QString accessToken; - QString serverId; - - friend bool operator==(const AuthSession&, const AuthSession&) = default; -}; - -} // namespace JellyfinNative diff --git a/src/api/JellyfinSettingsBridge.cpp b/src/api/JellyfinSettingsBridge.cpp deleted file mode 100644 index ed25b2ff..00000000 --- a/src/api/JellyfinSettingsBridge.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "JellyfinSettingsBridge.h" - -#include "../app/SettingsController.h" -#include "../common/AsyncTask.h" -#include "JellyfinApiFacade.h" - -#include -#include -#include - -namespace JellyfinNative { - -JellyfinSettingsBridge::JellyfinSettingsBridge(SettingsController *settings, JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_settings(settings) - , m_api(api) -{ - connect( - m_settings, &SettingsController::playbackPreferencesChanged, m_api, &JellyfinApiFacade::setPlaybackPreferences); - connect(m_settings, &SettingsController::remoteControlTargetEnabledChanged, m_api, - &JellyfinApiFacade::setRemoteControlTargetEnabled); - connect(m_settings, &SettingsController::remoteLoadRequested, this, &JellyfinSettingsBridge::loadRemote); - connect(m_settings, &SettingsController::remoteCleared, this, [this]() { m_remoteLoadStarted = false; }); - connect(m_settings, &SettingsController::userConfigurationChanged, this, [this](const QJsonObject& configuration) { - if (!m_api->signedIn()) - return; - Async::runScoped( - this, m_api->updateUserConfiguration(configuration), []() {}, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - }); -} - -void JellyfinSettingsBridge::loadRemote() -{ - if (m_remoteLoadStarted || !m_api->signedIn()) - return; - m_remoteLoadStarted = true; - - Async::runScoped( - this, m_api->fetchCultures(), [this](const QJsonArray& cultures) { m_settings->applyRemoteCultures(cultures); }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: culture list failed" << exceptionMessage(error); - }); - - Async::runScoped( - this, m_api->fetchUserConfiguration(), - [this](const QJsonObject& configuration) { m_settings->applyRemoteUserConfiguration(configuration); }, - [](const std::exception_ptr& error) { - qWarning() << "subtitles: user configuration failed" << exceptionMessage(error); - }); -} - -} // namespace JellyfinNative diff --git a/src/api/JellyfinSettingsBridge.h b/src/api/JellyfinSettingsBridge.h deleted file mode 100644 index ef86ff5a..00000000 --- a/src/api/JellyfinSettingsBridge.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include - -namespace JellyfinNative { - -class JellyfinApiFacade; -class SettingsController; - -// The Jellyfin half of settings: pushes the playback and remote-control -// preferences into the facade, and fetches the account's cultures and user -// configuration when the controller asks for them. The controller itself -// knows nothing about the server. -class JellyfinSettingsBridge final : public QObject { - Q_OBJECT - -public: - JellyfinSettingsBridge(SettingsController *settings, JellyfinApiFacade *api, QObject *parent = nullptr); - -signals: - void errorOccurred(const QString& message); - -private: - void loadRemote(); - - SettingsController *m_settings = nullptr; - JellyfinApiFacade *m_api = nullptr; - bool m_remoteLoadStarted = false; -}; - -} // namespace JellyfinNative diff --git a/src/api/PlaybackNegotiation.cpp b/src/api/PlaybackNegotiation.cpp deleted file mode 100644 index 50b4d862..00000000 --- a/src/api/PlaybackNegotiation.cpp +++ /dev/null @@ -1,285 +0,0 @@ -#include "PlaybackNegotiation.h" - -#include -#include - -#include -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr auto kDirectPlay = "DirectPlay"; - constexpr auto kDirectStream = "DirectStream"; - constexpr auto kTranscode = "Transcode"; - const QStringList kHlsVideoCodecPreference { QStringLiteral("hevc"), QStringLiteral("h264"), QStringLiteral("av1"), - QStringLiteral("vp9") }; - - qint64 sourceBitrate(const QJsonObject& source) - { - qint64 bitrate = source.value(QStringLiteral("Bitrate")).toInteger(); - if (bitrate <= 0) - bitrate = source.value(QStringLiteral("BitRate")).toInteger(); - return bitrate; - } - - int methodRank(const QJsonObject& source, bool preferRemux, QString *method) - { - if (source.value(QStringLiteral("SupportsDirectPlay")).toBool()) { - *method = QString::fromLatin1(kDirectPlay); - return 3; - } - - const bool directStream = source.value(QStringLiteral("SupportsDirectStream")).toBool(); - const bool transcode = !source.value(QStringLiteral("TranscodingUrl")).toString().isEmpty(); - if (preferRemux && directStream) { - *method = QString::fromLatin1(kDirectStream); - return 2; - } - if (transcode) { - *method = QString::fromLatin1(kTranscode); - return 2; - } - if (directStream) { - *method = QString::fromLatin1(kDirectStream); - return 1; - } - return -1; - } - - QUrl serverRelativeUrl(const QString& serverUrl, const QString& path) - { - const QUrl candidate(path); - if (!candidate.scheme().isEmpty() || !candidate.host().isEmpty()) - return candidate; - - QUrl result(serverUrl); - QString basePath = result.path(); - while (basePath.endsWith(QLatin1Char('/'))) - basePath.chop(1); - - QString relativePath = candidate.path(); - if (!relativePath.startsWith(QLatin1Char('/'))) - relativePath.prepend(QLatin1Char('/')); - if (!basePath.isEmpty() && basePath != QStringLiteral("/") - && !relativePath.startsWith(basePath + QLatin1Char('/'))) { - relativePath.prepend(basePath); - } - result.setPath(relativePath); - result.setQuery(candidate.query()); - result.setFragment(candidate.fragment()); - return result; - } - QUrl videoStreamUrl(const QString& serverUrl, const QString& itemId, const QString& mediaSourceId, bool isStatic) - { - QUrl url(serverUrl); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(QStringLiteral("%1/Videos/%2/stream").arg(path, itemId)); - QUrlQuery query; - query.addQueryItem(QStringLiteral("static"), isStatic ? QStringLiteral("true") : QStringLiteral("false")); - query.addQueryItem(QStringLiteral("MediaSourceId"), mediaSourceId); - url.setQuery(query); - return url; - } - - QStringList normalizedCodecs(const QStringList& codecs) - { - QStringList result; - result.reserve(codecs.size()); - for (const QString& codec : codecs) { - const QString normalized = codec.trimmed().toLower(); - if (!normalized.isEmpty() && !result.contains(normalized)) - result.push_back(normalized); - } - return result; - } - - QStringList transcodeVideoCodecs(const QStringList& directVideoCodecs, bool restrictVideoCodecs) - { - if (!restrictVideoCodecs) - return kHlsVideoCodecPreference; - - const QStringList supported = normalizedCodecs(directVideoCodecs); - QStringList result; - for (const QString& preferred : kHlsVideoCodecPreference) { - if (supported.contains(preferred)) - result.push_back(preferred); - } - return result; - } - - QJsonArray localSubtitleProfiles() - { - const QStringList formats { QStringLiteral("srt"), QStringLiteral("ass"), QStringLiteral("ssa"), - QStringLiteral("vtt"), QStringLiteral("pgssub"), QStringLiteral("dvdsub") }; - QJsonArray profiles; - for (const QString& format : formats) { - profiles.push_back(QJsonObject { - { QStringLiteral("Format"), format }, - { QStringLiteral("Method"), QStringLiteral("Embed") }, - }); - profiles.push_back(QJsonObject { - { QStringLiteral("Format"), format }, - { QStringLiteral("Method"), QStringLiteral("External") }, - }); - } - return profiles; - } - -} // namespace - -PlaybackSelection PlaybackNegotiation::selectSource(const QJsonArray& mediaSources, bool preferRemux) -{ - PlaybackSelection selected; - int bestRank = -1; - qint64 bestBitrate = -1; - - for (const QJsonValue& value : mediaSources) { - const QJsonObject source = value.toObject(); - QString method; - const int rank = methodRank(source, preferRemux, &method); - const qint64 bitrate = sourceBitrate(source); - if (rank > bestRank || (rank == bestRank && bitrate > bestBitrate)) { - selected = { source, method }; - bestRank = rank; - bestBitrate = bitrate; - } - } - - if (bestRank < 0 || selected.source.isEmpty()) - throw std::runtime_error("No playable media source available"); - return selected; -} - -TrickplayInfo PlaybackNegotiation::selectTrickplay( - const QJsonObject& trickplay, const QString& mediaSourceId, int preferredWidth) -{ - QJsonObject widths; - const QJsonObject first - = trickplay.constBegin() == trickplay.constEnd() ? QJsonObject {} : trickplay.constBegin().value().toObject(); - const bool widthMap = first.contains(QStringLiteral("Width")) || first.contains(QStringLiteral("TileWidth")) - || first.contains(QStringLiteral("Interval")); - if (widthMap) { - widths = trickplay; - } else if (!mediaSourceId.isEmpty() && trickplay.contains(mediaSourceId)) { - widths = trickplay.value(mediaSourceId).toObject(); - } else if (!trickplay.isEmpty()) { - widths = first; - } - - TrickplayInfo best; - int bestDiff = std::numeric_limits::max(); - for (auto it = widths.constBegin(); it != widths.constEnd(); ++it) { - const QJsonObject info = it.value().toObject(); - const int width = info.value(QStringLiteral("Width")).toInt(); - if (width <= 0) - continue; - const int diff = std::abs(width - preferredWidth); - if (diff >= bestDiff) - continue; - bestDiff = diff; - best.width = width; - best.height = info.value(QStringLiteral("Height")).toInt(); - best.tileWidth = info.value(QStringLiteral("TileWidth")).toInt(); - best.tileHeight = info.value(QStringLiteral("TileHeight")).toInt(); - best.thumbnailCount = info.value(QStringLiteral("ThumbnailCount")).toInt(); - best.intervalMs = info.value(QStringLiteral("Interval")).toInt(); - best.bandwidth = info.value(QStringLiteral("Bandwidth")).toInt(); - } - return best; -} - -QString PlaybackNegotiation::buildUrl( - const QString& serverUrl, const QString& itemId, const PlaybackSelection& selection) -{ - const QString mediaSourceId = selection.source.value(QStringLiteral("Id")).toString(); - if (mediaSourceId.isEmpty()) - throw std::runtime_error("Selected media source has no id"); - - QUrl url; - if (selection.playMethod == QString::fromLatin1(kDirectPlay)) { - url = videoStreamUrl(serverUrl, itemId, mediaSourceId, true); - } else { - const QString responseUrl = selection.playMethod == QString::fromLatin1(kTranscode) - ? selection.source.value(QStringLiteral("TranscodingUrl")).toString() - : selection.source.value(QStringLiteral("DirectStreamUrl")).toString(); - if (!responseUrl.isEmpty()) { - url = serverRelativeUrl(serverUrl, responseUrl); - } else { - url = videoStreamUrl(serverUrl, itemId, mediaSourceId, false); - } - } - - return url.toString(QUrl::FullyEncoded); -} - -QJsonObject PlaybackNegotiation::buildDeviceProfile( - qint64 maxStreamingBitrate, int maxHeight, const QStringList& videoCodecs, bool restrictVideoCodecs) -{ - const qint64 bitrate = std::clamp(maxStreamingBitrate, 1'000'000, 1'000'000'000); - QJsonArray codecProfiles; - if (maxHeight > 0) { - codecProfiles.push_back(QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("Conditions"), - QJsonArray { QJsonObject { - { QStringLiteral("Condition"), QStringLiteral("LessThanEqual") }, - { QStringLiteral("Property"), QStringLiteral("Height") }, - { QStringLiteral("Value"), QString::number(maxHeight) }, - // Not required: a source already below the ceiling should - // direct play rather than be transcoded up to it. - { QStringLiteral("IsRequired"), false }, - } } }, - }); - } - QJsonArray directPlayProfiles; - const QStringList normalizedVideoCodecs = normalizedCodecs(videoCodecs); - if (!restrictVideoCodecs) { - directPlayProfiles.push_back(QJsonObject { { QStringLiteral("Type"), QStringLiteral("Video") } }); - } else if (!normalizedVideoCodecs.isEmpty()) { - directPlayProfiles.push_back(QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("VideoCodec"), normalizedVideoCodecs.join(QLatin1Char(',')) }, - }); - } - // Audio is always decoded to PCM by mpv. Keep this unrestricted even on - // webOS so an unsupported compressed-output format never forces a video - // transcode merely because Jellyfin could not copy the audio stream. - directPlayProfiles.push_back(QJsonObject { { QStringLiteral("Type"), QStringLiteral("Audio") } }); - - QStringList outputVideoCodecs = transcodeVideoCodecs(normalizedVideoCodecs, restrictVideoCodecs); - if (outputVideoCodecs.isEmpty()) - outputVideoCodecs.push_back(QStringLiteral("h264")); - - return { - { QStringLiteral("Name"), QStringLiteral("JellyfinNative") }, - { QStringLiteral("MaxStreamingBitrate"), bitrate }, - { QStringLiteral("MaxStaticBitrate"), bitrate }, - { QStringLiteral("MusicStreamingTranscodingBitrate"), 1'280'000 }, - { QStringLiteral("DirectPlayProfiles"), directPlayProfiles }, - { QStringLiteral("TranscodingProfiles"), - QJsonArray { - QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Video") }, - { QStringLiteral("Container"), QStringLiteral("mp4") }, - { QStringLiteral("Protocol"), QStringLiteral("hls") }, - { QStringLiteral("AudioCodec"), QStringLiteral("aac,ac3,eac3,mp3,flac,opus,dts,truehd") }, - { QStringLiteral("VideoCodec"), outputVideoCodecs.join(QLatin1Char(',')) }, - { QStringLiteral("Context"), QStringLiteral("Streaming") }, - { QStringLiteral("MaxAudioChannels"), QStringLiteral("6") }, - { QStringLiteral("MinSegments"), 2 }, - { QStringLiteral("BreakOnNonKeyFrames"), false }, - }, - } }, - { QStringLiteral("ContainerProfiles"), QJsonArray {} }, - { QStringLiteral("CodecProfiles"), codecProfiles }, - { QStringLiteral("SubtitleProfiles"), localSubtitleProfiles() }, - { QStringLiteral("ResponseProfiles"), QJsonArray {} }, - }; -} - -} // namespace JellyfinNative diff --git a/src/api/PlaybackNegotiation.h b/src/api/PlaybackNegotiation.h deleted file mode 100644 index 97ec19eb..00000000 --- a/src/api/PlaybackNegotiation.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include "../media/MediaTypes.h" - -#include -#include -#include -#include - -namespace JellyfinNative { - -struct PlaybackSelection { - QJsonObject source; - QString playMethod; -}; - -class PlaybackNegotiation final { -public: - static PlaybackSelection selectSource(const QJsonArray& mediaSources, bool preferRemux); - static QString buildUrl(const QString& serverUrl, const QString& itemId, const PlaybackSelection& selection); - static TrickplayInfo selectTrickplay( - const QJsonObject& trickplay, const QString& mediaSourceId, int preferredWidth); - // maxHeight caps the vertical resolution the server may send. It reaches - // Jellyfin as a codec-profile condition rather than a URL parameter, - // because the transcode URL is the server's to write: given the condition - // it scales the stream down and puts MaxHeight in the URL itself. A - // bitrate ceiling alone leaves the source resolution untouched, which is - // how picking 480p used to lower the bitrate and nothing else. Zero means - // no ceiling. - static QJsonObject buildDeviceProfile(qint64 maxStreamingBitrate, int maxHeight = 0, - const QStringList& videoCodecs = {}, bool restrictVideoCodecs = false); -}; - -} // namespace JellyfinNative diff --git a/src/api/ProviderBenchmark.cpp b/src/api/ProviderBenchmark.cpp deleted file mode 100644 index a78c67c7..00000000 --- a/src/api/ProviderBenchmark.cpp +++ /dev/null @@ -1,299 +0,0 @@ -#include "ProviderBenchmark.h" - -#include "JellyfinApiFacade.h" -#include "JellyfinJsAdapter.h" -#include "JellyfinProvider.h" -#include "app/SessionController.h" -#include "cache/DatabaseManager.h" -#include "provider/ProviderRegistry.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -namespace { - - struct BenchResult { - QString name; - std::vector times; - size_t items = 0; - - double avg() const - { - return times.empty() ? 0.0 : std::accumulate(times.begin(), times.end(), 0.0) / times.size(); - } - double min() const - { - return times.empty() ? 0.0 : *std::min_element(times.begin(), times.end()); - } - double max() const - { - return times.empty() ? 0.0 : *std::max_element(times.begin(), times.end()); - } - }; - -} // namespace - -QCoro::Task ProviderBenchmark::run(DatabaseManager *database, ProviderRegistry *registry, - JellyfinProvider *jellyfinProvider, const ProviderBenchmarkOptions& options) -{ - if (!jellyfinProvider || !database || !registry) { - std::cerr << "provider benchmark: invalid setup\n"; - co_return 1; - } - - SessionController *sessionCtrl = jellyfinProvider->session(); - if (!sessionCtrl || !sessionCtrl->authenticated()) { - StartupState startupState = co_await database->loadStartupStateAsync(jellyfinProvider->startupStorageKeys()); - QString deviceId = std::move(startupState.deviceId); - if (deviceId.isEmpty()) { - deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces); - database->saveDeviceId(deviceId); - } - jellyfinProvider->setDeviceId(deviceId); - jellyfinProvider->restoreFromStorage(std::move(startupState.values), std::move(startupState.profiles)); - - if (sessionCtrl && !sessionCtrl->authenticated() && !sessionCtrl->accountProfiles().isEmpty()) { - const QVariantMap profile = sessionCtrl->accountProfiles().front().toMap(); - const QString profileId = profile.value(QStringLiteral("id")).toString(); - sessionCtrl->activateProfile(profileId); - int attempts = 0; - while (!sessionCtrl->authenticated() && attempts++ < 50) { - co_await QCoro::sleepFor(std::chrono::milliseconds(20)); - } - } - } - - if (!sessionCtrl || !sessionCtrl->authenticated()) { - std::cerr << "provider benchmark: no authenticated session; sign in to run benchmark\n"; - co_return 1; - } - - JellyfinApiFacade *nativeApi = jellyfinProvider->api(); - if (!nativeApi) { - std::cerr << "provider benchmark: native API facade unavailable\n"; - co_return 1; - } - - const QString serverUrl = sessionCtrl->serverUrl(); - const AuthSession auth = nativeApi->session(); - std::cout << "\n================================================================================\n"; - std::cout << " SPOOL JELLYFIN PROVIDER PERFORMANCE BENCHMARK\n"; - std::cout << " Server: " << serverUrl.toStdString() << " | User: " << auth.userName.toStdString() << '\n'; - std::cout << " Iterations: " << options.iterations << " | Library: " << options.libraryName.toStdString() << '\n'; - std::cout << "================================================================================\n\n"; - - // Setup JS Adapter - JellyfinJsAdapter jsAdapter(registry); - QVariantMap config; - config.insert(QStringLiteral("server"), serverUrl); - config.insert(QStringLiteral("userId"), auth.userId); - config.insert(QStringLiteral("token"), auth.accessToken); - config.insert(QStringLiteral("deviceId"), - nativeApi->deviceId().isEmpty() ? QStringLiteral("spool-benchmark") : nativeApi->deviceId()); - config.insert(QStringLiteral("deviceName"), QStringLiteral("Spool Benchmark")); - config.insert(QStringLiteral("clientVersion"), QStringLiteral("0.8.1")); - - QList origins { QUrl(serverUrl) }; - const QString sourceId = co_await registry->configureSource( - QStringLiteral("spool.jellyfin"), auth.userId, QStringLiteral("library"), serverUrl, config, origins); - jsAdapter.configure(sourceId, serverUrl, auth.accessToken); - - std::vector results; - - // 1. Fetch Libraries - try { - BenchResult res; - res.name = QStringLiteral("fetchLibraries"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - // Warmup - co_await jsAdapter.fetchLibraries(); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsLibs = co_await jsAdapter.fetchLibraries(); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsLibs.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - - // Find the target library descriptor - BrowseDescriptor libDesc; - try { - auto libs = co_await jsAdapter.fetchLibraries(); - for (const auto& lib : libs) { - if (lib.name.compare(options.libraryName, Qt::CaseInsensitive) == 0) { - libDesc.id = lib.id; - libDesc.name = lib.name; - libDesc.collectionType = lib.collectionType; - libDesc.kind = BrowseKind::Library; - break; - } - } - if (!libDesc.isValid() && !libs.empty()) { - libDesc.id = libs[0].id; - libDesc.name = libs[0].name; - libDesc.collectionType = libs[0].collectionType; - libDesc.kind = BrowseKind::Library; - } - } catch (const std::exception& e) { - std::cout << "Failed to query libraries for browsing: " << e.what() << "\n"; - } - - // 2. Browse Page 1 (100 items) - if (libDesc.isValid()) { - try { - BenchResult res; - res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [0..100]"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 0, 100); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsPage.items.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - } - - // 3. Browse Page 2 (100 items) - if (libDesc.isValid()) { - try { - BenchResult res; - res.name = QStringLiteral("browsePage: ") + libDesc.name + QStringLiteral(" [100..200]"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsPage = co_await jsAdapter.fetchBrowsePage(libDesc, 100, 100); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsPage.items.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - } - - // 4. Search - try { - BenchResult res; - res.name = QStringLiteral("searchItems (\"") + options.searchQuery + QStringLiteral("\", 80)"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await jsAdapter.searchItems(options.searchQuery, 80); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsItems = co_await jsAdapter.searchItems(options.searchQuery, 80); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsItems.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - - // 5. Next Up - try { - BenchResult res; - res.name = QStringLiteral("fetchNextUpEpisodes (24)"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await jsAdapter.fetchNextUpEpisodes(24); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsItems = co_await jsAdapter.fetchNextUpEpisodes(24); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsItems.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - - // 6. Resume Items - try { - BenchResult res; - res.name = QStringLiteral("fetchResumeItems (24)"); - std::cout << "Benchmarking " << res.name.toStdString() << "..." << std::flush; - co_await jsAdapter.fetchResumeItems(24); - - for (int i = 0; i < options.iterations; ++i) { - QElapsedTimer t; - t.start(); - auto jsItems = co_await jsAdapter.fetchResumeItems(24); - res.times.push_back(t.nsecsElapsed() / 1000000.0); - res.items = jsItems.size(); - } - std::cout << " done.\n"; - results.push_back(std::move(res)); - } catch (const std::exception& e) { - std::cout << " failed: " << e.what() << "\n"; - } - - // Print summary table - std::cout << "\n---------------------------------------------------------------------------------------\n"; - printf("| %-42s | %-10s | %-10s | %-10s | %-6s |\n", "Operation", "Avg (ms)", "Min (ms)", "Max (ms)", "Items"); - std::cout << "---------------------------------------------------------------------------------------\n"; - - QJsonArray jsonResults; - for (const auto& r : results) { - printf("| %-42s | %7.2f ms | %7.2f ms | %7.2f ms | %-6zu |\n", qPrintable(r.name), r.avg(), r.min(), r.max(), - r.items); - - QJsonObject obj; - obj.insert(QStringLiteral("operation"), r.name); - obj.insert(QStringLiteral("avgMs"), r.avg()); - obj.insert(QStringLiteral("minMs"), r.min()); - obj.insert(QStringLiteral("maxMs"), r.max()); - obj.insert(QStringLiteral("items"), static_cast(r.items)); - jsonResults.append(obj); - } - std::cout << "---------------------------------------------------------------------------------------\n\n"; - - if (!options.outputPath.isEmpty()) { - QJsonObject root; - root.insert(QStringLiteral("server"), serverUrl); - root.insert(QStringLiteral("iterations"), options.iterations); - root.insert(QStringLiteral("results"), jsonResults); - QFile outFile(options.outputPath); - if (outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - outFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); - std::cout << "Benchmark report saved to: " << options.outputPath.toStdString() << '\n'; - } - } - - co_return 0; -} - -} // namespace JellyfinNative diff --git a/src/api/ProviderBenchmark.h b/src/api/ProviderBenchmark.h deleted file mode 100644 index dfe0ef0f..00000000 --- a/src/api/ProviderBenchmark.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -namespace JellyfinNative { - -class DatabaseManager; -class JellyfinProvider; -class ProviderRegistry; - -struct ProviderBenchmarkOptions { - int iterations = 5; - QString libraryName = QStringLiteral("Movies"); - QString searchQuery = QStringLiteral("The"); - QString outputPath; -}; - -class ProviderBenchmark { -public: - static QCoro::Task run(DatabaseManager *database, ProviderRegistry *registry, - JellyfinProvider *jellyfinProvider, const ProviderBenchmarkOptions& options); -}; - -} // namespace JellyfinNative diff --git a/src/app/AccountProfile.cpp b/src/app/AccountProfile.cpp deleted file mode 100644 index 5480d973..00000000 --- a/src/app/AccountProfile.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "AccountProfile.h" - -#include -#include - -namespace JellyfinNative { - -QString AccountProfile::displayLabel() const -{ - if (userName.isEmpty()) - return serverName; - if (serverName.isEmpty()) - return userName; - return userName + QStringLiteral(" · ") + serverName; -} - -QString canonicalServerUrl(const QString& input) -{ - QUrl url = QUrl::fromUserInput(input.trimmed()); - if (!url.isValid() || url.host().isEmpty()) - return {}; - - url.setScheme(url.scheme().toLower()); - url.setHost(url.host().toLower()); - if ((url.scheme() == QStringLiteral("http") && url.port() == 80) - || (url.scheme() == QStringLiteral("https") && url.port() == 443)) { - url.setPort(-1); - } - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(path); - url.setQuery({}); - url.setFragment({}); - return url.adjusted(QUrl::NormalizePathSegments).toString(QUrl::FullyEncoded); -} - -QString accountProfileId(const QString& serverId, const QString& serverUrl, const QString& userId) -{ - const QString serverIdentity - = serverId.trimmed().isEmpty() ? canonicalServerUrl(serverUrl) : serverId.trimmed().toLower(); - const QByteArray identity = serverIdentity.toUtf8() + '\0' + userId.trimmed().toUtf8(); - return QString::fromLatin1(QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex().left(24)); -} - -} // namespace JellyfinNative diff --git a/src/app/AccountProfile.h b/src/app/AccountProfile.h deleted file mode 100644 index 94c81e45..00000000 --- a/src/app/AccountProfile.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include - -namespace JellyfinNative { - -struct AccountProfile { - QString profileId; - QString serverId; - QString serverName; - QString serverUrl; - QString userId; - QString userName; - QString accessToken; - QString avatarTag; - qint64 lastUsedAt = 0; - qint64 createdAt = 0; - bool needsAuthentication = false; - - QString displayLabel() const; -}; - -QString canonicalServerUrl(const QString& input); -QString accountProfileId(const QString& serverId, const QString& serverUrl, const QString& userId); - -} // namespace JellyfinNative diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 544f3328..77c65be2 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -12,24 +12,19 @@ #include "../provider/Catalog.h" #include "../provider/GroupPlayback.h" #include "../provider/PlaybackSource.h" -#include "../provider/Provider.h" -#include "../provider/RemotePlayback.h" +#include "../provider/SourceHub.h" #include "../provider/StreamQualityControl.h" #include "BrowseSessionController.h" #include "ContentModelController.h" +#include "GroupPlaybackController.h" #include "HomeModelController.h" #include "LibraryPrefetchController.h" #include "LibraryQuery.h" #include "SearchController.h" #include "SettingsController.h" -#include "SpoolRemoteProtocol.h" #include "UserItemStateController.h" #include -#include -#include -#include -#include #include #include #include @@ -95,7 +90,7 @@ namespace { } AppController::AppController( - DatabaseManager *database, Provider *provider, ArtworkService *artwork, PlayerController *player, QObject *parent) + DatabaseManager *database, SourceHub *provider, ArtworkService *artwork, PlayerController *player, QObject *parent) : QObject(parent) , m_database(database) , m_provider(provider) @@ -113,22 +108,18 @@ AppController::AppController( m_content = new ContentModelController(m_catalog, m_prefetch, this); m_search = new SearchController(provider->search(), m_prefetch, this); m_itemState = new UserItemStateController(provider->itemState(), m_browse, m_home, m_content, m_search, this); - // The provider's session-bound parts sit on the queue, settings and - // browse state built above, and die with this object as they always did. - m_provider->attach({ this, player, m_playQueue, m_settings, m_browse, artwork }); - m_group = m_provider->groupPlayback(); - m_remote = m_provider->remotePlayback(); + m_group = new GroupPlaybackController(provider, player, m_playQueue, this); + connect(m_group, &GroupPlaybackController::errorText, this, &AppController::showToast); + connect(provider, &SourceHub::accountEvent, this, + [this](const QString& accountId, const QString& type, const QVariantMap& payload) { + if (type == QStringLiteral("remote")) + handleRemoteCommand(accountId, payload); + }); connect(m_playQueue, &PlayQueueController::successorPlaybackReady, this, [this]() { playQueueCurrent(false); }); connect(m_browse, &BrowseSessionController::reloadRequested, this, [this]() { beginBrowse(); }); connect(m_browse, &BrowseSessionController::moreItemsRequested, this, &AppController::loadMoreCurrentItems); connect(m_database, &DatabaseManager::recoveryNotice, this, &AppController::showToast); - if (m_remote) { - connect(m_remote, &RemotePlayback::peerMessageReceived, this, &AppController::handleSpoolMessage); - connect(m_remote, &RemotePlayback::playCommandReceived, this, &AppController::handleRemotePlay); - connect(m_remote, &RemotePlayback::playstateCommandReceived, this, &AppController::handleRemotePlaystate); - connect(m_remote, &RemotePlayback::generalCommandReceived, this, &AppController::handleRemoteGeneralCommand); - } - if (m_group) { + { connect(m_group, &GroupPlayback::queuePlaybackRequested, this, [this](qint64 positionTicks) { MovieItem item = m_playQueue->currentItem(); if (item.id.isEmpty()) @@ -137,14 +128,14 @@ AppController::AppController( if (m_player->sessionActive() && m_activePlaybackItem.id != item.id) m_player->stopWithReason(QStringLiteral("syncplay-group-switch")); m_activePlaybackItem = item; - setBusy(true, QStringLiteral("Joining SyncPlay playback…")); + setBusy(true, QStringLiteral("Joining the group…")); Async::runScoped( this, startPlayback(item, true), []() {}, [this](const std::exception_ptr& error) { setBusy(false); showToast(exceptionMessage(error)); }, - "SyncPlay playback startup"); + "group playback startup"); }); } connect(m_content, &ContentModelController::errorOccurred, this, &AppController::showToast); @@ -152,7 +143,6 @@ AppController::AppController( connect(m_itemState, &UserItemStateController::errorOccurred, this, &AppController::setErrorText); connect(m_settings, &SettingsController::errorOccurred, this, &AppController::showToast); connect(m_provider, &Provider::toastRequested, this, &AppController::showToast); - connect(m_provider, &Provider::busyChanged, this, &AppController::setBusy); connect(m_provider, &Provider::errorOccurred, this, &AppController::setErrorText); connect(m_provider, &Provider::contentChanged, this, [this](const QString& changedItemId) { if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) @@ -160,16 +150,6 @@ AppController::AppController( else beginBrowse(); }); - connect(m_provider, &Provider::hasDefaultProfileChanged, this, &AppController::defaultProfileChanged); - connect(m_provider, &Provider::activationStarted, this, [this]() { setErrorText({}); }); - connect(m_provider, &Provider::switchUserRequested, this, [this]() { - setBusy(false); - setErrorText({}); - }); - connect(m_provider, &Provider::signOutStarted, this, [this]() { - if (m_player->visible()) - m_player->stopWithReason(QStringLiteral("logout")); - }); connect(m_provider, &Provider::sessionStarted, this, [this]() { m_home->loadCachedPayload(); loadLibraries(); @@ -231,7 +211,7 @@ AppController::AppController( connect(m_player, &PlayerController::playbackLoadFailed, this, [this](const QString& itemId, qint64 positionTicks, const QString&, bool retryableCodecFailure, int audioStreamIndex, int subtitleStreamIndex) { - const bool syncPlayActive = inSyncPlayGroup(); + const bool syncPlayActive = inGroup(); if (itemId.isEmpty() || itemId != m_activePlaybackItem.id) return; // A quality the server cannot deliver should cost the viewer the @@ -313,54 +293,35 @@ void AppController::initialize() QCoro::Task AppController::initializeAsync() { Diagnostics::Task task(QStringLiteral("app_initialize")); - QStringList startupKeys = SettingsController::localSettingKeys(); - startupKeys.append(m_provider->startupStorageKeys()); - StartupState startupState = co_await m_database->loadStartupStateAsync(startupKeys); + StartupState startupState = co_await m_database->loadStartupStateAsync(SettingsController::localSettingKeys()); QString deviceId = std::move(startupState.deviceId); if (deviceId.isEmpty()) { deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces); m_database->saveDeviceId(deviceId); } - // Jellyfin keys a session on the device id, and delivers each websocket - // message to the one socket of that session that was last active. Two - // instances sharing the stored id therefore collapse into a single - // session: SyncPlay counts them once and their group updates land on - // whichever process spoke last. Give every extra instance its own - // identity, derived from the stored one so the first is unchanged. - // The device name is deliberately left alone: the server stores it on the - // row it looks up by access token, which both instances share, so a - // per-instance name would be rewritten on every request by whichever - // instance spoke last. + // Servers key a session on the device id, so two instances sharing one + // collapse into a single session and receive each other's socket + // messages. Every extra instance gets its own, derived from the stored + // one so the first is unchanged. const int instanceSlot = claimInstanceSlot(); if (instanceSlot > 0) { deviceId += QStringLiteral("-%1").arg(instanceSlot + 1); qInfo() << "app: another instance holds the stored device identity; running as instance" << instanceSlot + 1; } - m_provider->setDeviceId(deviceId); - m_settings->applyLocalValues(startupState.values); - m_provider->restoreFromStorage(std::move(startupState.values), std::move(startupState.profiles)); + emit deviceIdentityReady(deviceId); m_initialized = true; emit initializedChanged(); } -bool AppController::hasDefaultProfile() const -{ - return m_provider->hasDefaultProfile(); -} - void AppController::resetApplicationState() { m_remotePlaybackRequestGeneration.invalidate(); - m_remotePlaybackFingerprint.clear(); - m_settings->clearRemote(); m_prefetch->stop(); - if (m_artwork) { - m_artwork->setAuthorizationHeader({}); + if (m_artwork) m_artwork->cancelPrefetches(); - } if (m_database) m_database->invalidateHomePayloads(); m_libraries.clear(); @@ -451,15 +412,6 @@ void AppController::playFromModel(QObject *model, int index, bool fromStart) showToast(QStringLiteral("This item is no longer in the play queue.")); return; } - // Opening an item from the queue is still a play request, and it must - // land wherever every other one does. - if (remoteTargetSelected()) { - const MovieItem item = m_playQueue->itemAt(index); - if (!item.id.isEmpty()) { - playQueuedItem(item, fromStart); - return; - } - } if (!queue->playAt(index)) { showToast(QStringLiteral("This item is no longer in the play queue.")); return; @@ -562,7 +514,7 @@ void AppController::playQueueNext() playEpisodeWithContext(m_playQueue->currentItem(), 1, true); return; } - if (inSyncPlayGroup()) { + if (inGroup()) { m_group->requestNextItem(); return; } @@ -577,7 +529,7 @@ void AppController::playQueuePrevious() playEpisodeWithContext(m_playQueue->currentItem(), -1, true); return; } - if (inSyncPlayGroup()) { + if (inGroup()) { m_group->requestPreviousItem(); return; } @@ -593,18 +545,11 @@ void AppController::playQueueItem(int index) if (index == m_playQueue->currentIndex() && m_player->sessionActive()) return; - if (inSyncPlayGroup()) { + if (inGroup()) { // Jumping the group, not just this client. - m_group->requestPlayItem(queuePlaylistItemId(index)); + m_group->requestPlayItem(queueEntryId(index)); return; } - if (remoteTargetSelected()) { - const MovieItem item = m_playQueue->itemAt(index); - if (!item.id.isEmpty()) { - playQueuedItem(item, false); - return; - } - } if (!m_playQueue->playAt(index)) return; playQueueCurrent(false); @@ -615,36 +560,6 @@ bool AppController::queueEditable() const return true; } -bool AppController::inSyncPlayGroup() const -{ - return m_group && m_group->enabled(); -} - -bool AppController::remoteTargetSelected() const -{ - return m_remote && m_remote->targetSelected(); -} - -bool AppController::playRemotely( - const std::vector& items, int startIndex, const QString& command, bool fromStart) -{ - return m_remote && m_remote->playItems(items, startIndex, command, fromStart); -} - -void AppController::groupOrLocalTogglePause() -{ - if (inSyncPlayGroup()) - m_group->requestTogglePause(); - else - m_player->togglePause(); -} - -QString AppController::queuePlaylistItemId(int index) const -{ - const MovieItem item = m_playQueue->itemAt(index); - return item.playlistItemId; -} - bool AppController::previewQueueMove(int from, int to) { // Previewed locally even in a group. Waiting on a round trip per step would @@ -655,10 +570,10 @@ bool AppController::previewQueueMove(int from, int to) void AppController::commitQueueMove(int from, int to) { - if (from == to || !inSyncPlayGroup()) + if (from == to || !inGroup()) return; // The preview already left the row at `to`, so that is the entry to publish. - m_group->requestMoveItem(queuePlaylistItemId(to), to); + m_group->requestMoveItem(queueEntryId(to), to); } bool AppController::previewQueueMoveRange(int from, int count, int to) @@ -668,20 +583,20 @@ bool AppController::previewQueueMoveRange(int from, int count, int to) void AppController::commitQueueMoveRange(int from, int count, int to) { - if (from == to || count <= 0 || !inSyncPlayGroup()) + if (from == to || count <= 0 || !inGroup()) return; // The preview has already laid the block down at `to`, so publish the rows // where they now sit, top down, which is the order the group will apply. for (int offset = 0; offset < count; ++offset) - m_group->requestMoveItem(queuePlaylistItemId(to + offset), to + offset); + m_group->requestMoveItem(queueEntryId(to + offset), to + offset); } void AppController::removeQueueItem(int index) { - if (inSyncPlayGroup()) { + if (inGroup()) { // No local edit: a removal is a single action with nothing to animate, // so let the group's broadcast be the one thing that changes the queue. - m_group->requestRemoveItems({ queuePlaylistItemId(index) }); + m_group->requestRemoveItems({ queueEntryId(index) }); return; } @@ -703,8 +618,6 @@ void AppController::removeQueueItem(int index) void AppController::playNextFromItem(const MovieItem& item) { - if (playRemotely({ item }, 0, QStringLiteral("PlayNext"), false)) - return; if (enqueueForGroup(item, true)) return; if (!m_playQueue->playNext(item)) @@ -713,26 +626,12 @@ void AppController::playNextFromItem(const MovieItem& item) void AppController::addToQueueFromItem(const MovieItem& item) { - if (playRemotely({ item }, 0, QStringLiteral("PlayLast"), false)) - return; if (enqueueForGroup(item, false)) return; if (!m_playQueue->addToQueue(item)) setErrorText(QStringLiteral("This item cannot be queued.")); } -bool AppController::enqueueForGroup(const MovieItem& item, bool queueNext) -{ - if (!inSyncPlayGroup()) - return false; - if (item.id.isEmpty() || !isPlayableItem(item)) { - setErrorText(QStringLiteral("This item cannot be queued.")); - return true; - } - m_group->requestQueueItems({ item.id }, queueNext); - return true; -} - void AppController::loadMoreCurrentItems() { if (m_browse->loadingMore() || !m_browse->hasMore()) @@ -763,8 +662,6 @@ void AppController::loadMoreCurrentItems() void AppController::playQueuedItems(const std::vector& items, int startIndex, bool fromStart) { - if (playRemotely(items, startIndex, QStringLiteral("PlayNow"), fromStart)) - return; if (!m_playQueue->playNow(items, startIndex)) { showToast(QStringLiteral("This item cannot be queued.")); return; @@ -776,10 +673,6 @@ void AppController::playModel(MovieGridModel *model, bool shuffled) { if (!model) return; - if (shuffled && remoteTargetSelected()) { - playRemotely(model->movies(), 0, QStringLiteral("PlayShuffle"), false); - return; - } const std::vector& items = model->movies(); const auto firstPlayable = std::find_if( items.begin(), items.end(), [](const MovieItem& item) { return !item.id.isEmpty() && isPlayableItem(item); }); @@ -799,9 +692,6 @@ void AppController::queueEpisodicContainer(const QString& seriesId, const QStrin Async::runScoped( this, m_catalog->fetchEpisodes(seriesId, seasonId), [this, next](const std::vector& episodes) { - if (playRemotely(episodes, next ? 0 : static_cast(episodes.size()) - 1, - next ? QStringLiteral("PlayNext") : QStringLiteral("PlayLast"), false)) - return; if (episodes.empty()) { setErrorText(QStringLiteral("There is nothing here to queue.")); return; @@ -866,8 +756,6 @@ void AppController::cancelEpisodicPlaybackSelection() void AppController::playQueuedItem(const MovieItem& item, bool fromStart) { - if (playRemotely({ item }, 0, QStringLiteral("PlayNow"), fromStart)) - return; if (item.itemType == QStringLiteral("Episode") && !item.seriesId.isEmpty()) { playEpisodeWithContext(item, 0, fromStart); return; @@ -947,7 +835,7 @@ void AppController::playEpisodeWithContext(const MovieItem& episode, int directi void AppController::startQueuedPlayback(bool fromStart) { - if (!inSyncPlayGroup()) { + if (!inGroup()) { playQueueCurrent(fromStart); return; } @@ -1007,268 +895,6 @@ void AppController::playQueueCurrent(bool fromStart) "playback startup"); } -void AppController::handleRemotePlay(const QJsonObject& data) -{ - QStringList itemIds; - for (const QJsonValue& value : data.value(QStringLiteral("ItemIds")).toArray()) { - const QString id = value.toString(); - if (!id.isEmpty()) - itemIds.push_back(id); - } - if (itemIds.isEmpty()) - return; - - const QString command = data.value(QStringLiteral("PlayCommand")).toString(QStringLiteral("PlayNow")); - const int requestedIndex = data.value(QStringLiteral("StartIndex")).toInt(0); - const qint64 startTicks = data.value(QStringLiteral("StartPositionTicks")).toVariant().toLongLong(); - const QString fingerprint - = QStringLiteral("%1\n%2\n%3\n%4") - .arg(command, QString::number(requestedIndex), QString::number(startTicks), itemIds.join(QChar(0x1f))); - if (fingerprint == m_remotePlaybackFingerprint) { - qInfo() << "remote: ignored duplicate play request" << command << itemIds.size() << "items"; - return; - } - - m_remotePlaybackFingerprint = fingerprint; - const RequestGeneration::Token generation = m_remotePlaybackRequestGeneration.next(); - setBusy(true, QStringLiteral("Starting remote playback…")); - qInfo() << "remote: play" << command << itemIds.size() << "items, index" << requestedIndex; - Async::runScoped( - this, m_catalog->fetchItemsByIds(itemIds), - [this, generation, fingerprint, itemIds, command, requestedIndex, startTicks]( - const std::vector& fetched) { - if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) - return; - std::vector ordered; - ordered.reserve(static_cast(itemIds.size())); - for (const QString& id : itemIds) { - const auto found = std::find_if( - fetched.begin(), fetched.end(), [&id](const MovieItem& item) { return item.id == id; }); - if (found != fetched.end()) - ordered.push_back(*found); - } - if (ordered.empty()) { - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(QStringLiteral("The remote playback item is unavailable.")); - return; - } - - if (command == QStringLiteral("PlayNext")) { - for (auto item = ordered.rbegin(); item != ordered.rend(); ++item) - m_playQueue->playNext(*item); - m_remotePlaybackFingerprint.clear(); - setBusy(false); - return; - } - if (command == QStringLiteral("PlayLast")) { - for (const MovieItem& item : ordered) - m_playQueue->addToQueue(item); - m_remotePlaybackFingerprint.clear(); - setBusy(false); - return; - } - - const int index = std::clamp(requestedIndex, 0, static_cast(ordered.size()) - 1); - ordered[static_cast(index)].resumeTicks = std::max(0, startTicks); - const bool queued = m_playQueue->playNow(ordered, index); - if (queued && command == QStringLiteral("PlayShuffle")) - m_playQueue->setShuffled(true); - if (!queued) { - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(QStringLiteral("The remote playback item could not be queued.")); - return; - } - startQueuedPlayback(startTicks <= 0); - QTimer::singleShot(10'000, this, [this, fingerprint]() { - if (m_remotePlaybackFingerprint == fingerprint) - m_remotePlaybackFingerprint.clear(); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) - return; - m_remotePlaybackFingerprint.clear(); - setBusy(false); - showToast(exceptionMessage(error)); - }, - "remote playback request"); -} - -void AppController::handleSpoolMessage(const SpoolRemoteProtocol::Message& message) -{ - if (const auto preview = SpoolRemoteProtocol::seekPreview(message)) { - // A preview for something this client is not playing would scrub an - // overlay over the wrong picture; a teardown is always safe. - if (!preview->active || (m_player->sessionActive() && preview->itemId == m_activePlaybackItem.id)) - emit remoteSeekPreviewRequested(preview->positionTicks, preview->active); - return; - } - if (const auto join = SpoolRemoteProtocol::syncPlayJoin(message)) { - qInfo() << "remote: joining SyncPlay group" << join->groupId << "on request"; - if (m_group) - m_group->joinGroup(join->groupId); - return; - } - qInfo() << "remote: ignoring unknown Spool command" << message.command; -} - -void AppController::handleRemotePlaystate(const QJsonObject& data) -{ - const QString command = data.value(QStringLiteral("Command")).toString(); - qInfo() << "remote: playstate" << command; - if (command == QStringLiteral("Stop")) { - m_player->stopWithReason(QStringLiteral("remote-stop")); - } else if (command == QStringLiteral("Pause")) { - if (!m_player->paused()) - groupOrLocalTogglePause(); - } else if (command == QStringLiteral("Unpause")) { - if (m_player->paused()) - groupOrLocalTogglePause(); - } else if (command == QStringLiteral("PlayPause")) { - groupOrLocalTogglePause(); - } else if (command == QStringLiteral("Seek")) { - const double seconds - = static_cast(data.value(QStringLiteral("SeekPositionTicks")).toVariant().toLongLong()) - / 10'000'000.0; - if (inSyncPlayGroup()) - m_group->requestSeek(seconds); - else - m_player->seek(seconds); - } else if (command == QStringLiteral("Rewind")) { - if (inSyncPlayGroup()) - m_group->requestRelativeSeek(-10.0); - else - m_player->seekBack(); - } else if (command == QStringLiteral("FastForward")) { - if (inSyncPlayGroup()) - m_group->requestRelativeSeek(10.0); - else - m_player->seekForward(); - } else if (command == QStringLiteral("NextTrack")) { - playQueueNext(); - } else if (command == QStringLiteral("PreviousTrack")) { - playQueuePrevious(); - } -} - -void AppController::handleRemoteGeneralCommand(const QJsonObject& data) -{ - const QString command = data.value(QStringLiteral("Name")).toString(); - const QJsonObject arguments = data.value(QStringLiteral("Arguments")).toObject(); - const auto argumentInt = [&arguments](const QString& key, int fallback = 0) { - bool ok = false; - const int value = arguments.value(key).toVariant().toInt(&ok); - return ok ? value : fallback; - }; - qInfo() << "remote: general command" << command; - - if (const auto message = SpoolRemoteProtocol::decode(command, arguments)) { - // Link signalling never reaches the application; everything else is - // handled the same whether it arrived here or over the direct path. - if (!m_remote || !m_remote->handlePeerSignalling(*message)) - handleSpoolMessage(*message); - } else if (command == QStringLiteral("SetVolume")) { - m_player->setVolume(argumentInt(QStringLiteral("Volume"), m_player->volume())); - } else if (command == QStringLiteral("VolumeUp")) { - m_player->adjustVolume(5); - } else if (command == QStringLiteral("VolumeDown")) { - m_player->adjustVolume(-5); - } else if (command == QStringLiteral("Mute")) { - m_player->setMuted(true); - } else if (command == QStringLiteral("Unmute")) { - m_player->setMuted(false); - } else if (command == QStringLiteral("ToggleMute")) { - m_player->toggleMuted(); - } else if (command == QStringLiteral("SetAudioStreamIndex")) { - m_player->selectAudioStreamIndex(argumentInt(QStringLiteral("Index"), -1)); - } else if (command == QStringLiteral("SetSubtitleStreamIndex")) { - m_player->selectSubtitleStreamIndex(argumentInt(QStringLiteral("Index"), -1)); - } else if (command == QStringLiteral("SetRepeatMode")) { - const QString mode = arguments.value(QStringLiteral("RepeatMode")).toString(); - if (mode == QStringLiteral("RepeatNone") || mode == QStringLiteral("RepeatAll") - || mode == QStringLiteral("RepeatOne")) - m_remoteRepeatMode = mode; - } else if (command == QStringLiteral("SetShuffleQueue")) { - m_playQueue->setShuffled( - arguments.value(QStringLiteral("ShuffleMode")).toString() == QStringLiteral("Shuffle")); - } else if (command == QStringLiteral("SetPlaybackOrder")) { - m_playQueue->setShuffled( - arguments.value(QStringLiteral("PlaybackOrder")).toString() == QStringLiteral("Shuffle")); - } else if (command == QStringLiteral("SetMaxStreamingBitrate")) { - selectStreamingQuality(arguments.value(QStringLiteral("Bitrate")).toVariant().toLongLong(), - arguments.value(QStringLiteral("Height")).toVariant().toInt()); - } else if (command == QStringLiteral("ToggleStats")) { - m_player->toggleDebugOsd(); - } else if (command == QStringLiteral("ToggleOsd")) { - emit remoteUiActionRequested(QStringLiteral("toggle-osd")); - } else if (command == QStringLiteral("ToggleOsdMenu") || command == QStringLiteral("ToggleContextMenu")) { - emit remoteUiActionRequested(QStringLiteral("context-menu")); - } else if (command == QStringLiteral("ToggleFullscreen")) { - emit remoteUiActionRequested(QStringLiteral("fullscreen")); - } else if (command == QStringLiteral("GoHome")) { - emit remoteUiActionRequested(QStringLiteral("home")); - } else if (command == QStringLiteral("GoToSettings")) { - emit remoteUiActionRequested(QStringLiteral("settings")); - } else if (command == QStringLiteral("GoToSearch")) { - emit remoteUiActionRequested(QStringLiteral("search")); - } else if (command == QStringLiteral("DisplayContent")) { - const QString itemId = arguments.value(QStringLiteral("ItemId")).toString(); - if (!itemId.isEmpty()) { - emit remoteContentRequested(itemId, arguments.value(QStringLiteral("ItemType")).toString(), - arguments.value(QStringLiteral("ItemName")).toString()); - } - } else if (command == QStringLiteral("Play") || command == QStringLiteral("Unpause")) { - if (m_player->paused()) - groupOrLocalTogglePause(); - } else if (command == QStringLiteral("Pause")) { - if (!m_player->paused()) - groupOrLocalTogglePause(); - } else if (command == QStringLiteral("Stop")) { - m_player->stopWithReason(QStringLiteral("remote-stop")); - } else if (command == QStringLiteral("PlayNext")) { - playQueueNext(); - } else if (command == QStringLiteral("DisplayMessage")) { - const QString message = arguments.value(QStringLiteral("Text")).toString().trimmed(); - if (!message.isEmpty()) - emit remoteMessageRequested(message); - } else if (command == QStringLiteral("SendString")) { - const QString text = arguments.value(QStringLiteral("String")).toString(); - QObject *focusObject = QGuiApplication::focusObject(); - if (!text.isEmpty() && focusObject) { - QInputMethodEvent input; - input.setCommitString(text); - QCoreApplication::sendEvent(focusObject, &input); - } - } else { - const QHash keys = { - { QStringLiteral("MoveUp"), Qt::Key_Up }, - { QStringLiteral("MoveDown"), Qt::Key_Down }, - { QStringLiteral("MoveLeft"), Qt::Key_Left }, - { QStringLiteral("MoveRight"), Qt::Key_Right }, - { QStringLiteral("PageUp"), Qt::Key_PageUp }, - { QStringLiteral("PageDown"), Qt::Key_PageDown }, - { QStringLiteral("PreviousLetter"), Qt::Key_PageUp }, - { QStringLiteral("NextLetter"), Qt::Key_PageDown }, - { QStringLiteral("Select"), Qt::Key_Return }, - { QStringLiteral("Back"), Qt::Key_Back }, - { QStringLiteral("Home"), Qt::Key_Home }, - { QStringLiteral("End"), Qt::Key_End }, - { QStringLiteral("Space"), Qt::Key_Space }, - }; - const QString keyName - = command == QStringLiteral("SendKey") ? arguments.value(QStringLiteral("Key")).toString() : command; - const auto found = keys.constFind(keyName); - if (found != keys.cend() && QGuiApplication::focusWindow()) { - QKeyEvent press(QEvent::KeyPress, *found, Qt::NoModifier); - QKeyEvent release(QEvent::KeyRelease, *found, Qt::NoModifier); - QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &press); - QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &release); - } - } -} - QCoro::Task AppController::startPlayback(MovieItem playItem, bool startPaused, bool forceTranscode, int audioStreamIndex, int subtitleStreamIndex, bool restartActive) { @@ -1456,8 +1082,7 @@ void AppController::loadLibraries() }, [this](const std::exception_ptr& error) { setBusy(false); - if (!m_provider->handleUnauthorized(error)) - setErrorText(exceptionMessage(error)); + setErrorText(exceptionMessage(error)); }); } @@ -1635,23 +1260,23 @@ void AppController::handlePlaybackStopped(const QString& itemId, qint64 position qInfo() << "app: playback stopped" << itemId << positionTicks << completed; m_itemState->recordPlaybackStopped(m_activePlaybackItem, itemId, positionTicks, completed); m_playQueue->updateResumeTicks(itemId, completed ? 0 : positionTicks); - if (!completed || m_activePlaybackItem.id != itemId || inSyncPlayGroup()) { + if (!completed || m_activePlaybackItem.id != itemId || inGroup()) { setPlaybackTransition(false); return; } bool continueQueue = false; - if (m_remoteRepeatMode == QStringLiteral("RepeatOne")) { + if (m_repeatMode == QStringLiteral("RepeatOne")) { continueQueue = m_playQueue->currentIndex() >= 0; } else { continueQueue = m_playQueue->next(); - if (!continueQueue && m_remoteRepeatMode == QStringLiteral("RepeatAll") && m_playQueue->rowCount() > 0) + if (!continueQueue && m_repeatMode == QStringLiteral("RepeatAll") && m_playQueue->rowCount() > 0) continueQueue = m_playQueue->playAt(0); } if (continueQueue) { // Hold the surface across the gap while the next (or repeated) item is // negotiated and decoded. setPlaybackTransition(true); - playQueueCurrent(m_remoteRepeatMode == QStringLiteral("RepeatOne")); + playQueueCurrent(m_repeatMode == QStringLiteral("RepeatOne")); } else { setPlaybackTransition(false); m_playQueue->enqueueEpisodeSuccessors(m_activePlaybackItem); diff --git a/src/app/AppController.h b/src/app/AppController.h index c6c5dade..16e52535 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -12,7 +12,6 @@ #include "HomeModelController.h" #include "SearchController.h" #include "SettingsController.h" -#include "SpoolRemoteProtocol.h" #include #include @@ -29,18 +28,16 @@ namespace JellyfinNative { class ArtworkService; class Catalog; -class GroupPlayback; +class GroupPlaybackController; class LibraryPrefetchController; class PlaybackSource; -class Provider; -class RemotePlayback; +class SourceHub; class StreamQualityControl; class UserItemStateController; class TlsTrustController; // The composition root: it owns the source-agnostic controllers, wires them -// to the player and the queue, and talks to the media source only through -// the Provider it was handed and the interfaces that provider serves. It -// knows nothing about which provider that is. +// to the player and the queue, and reaches media only through the SourceHub, +// which stands for every enabled account at once. class AppController final : public QObject { Q_OBJECT Q_PROPERTY(bool busy MEMBER m_busy NOTIFY busyChanged) @@ -50,11 +47,10 @@ class AppController final : public QObject { Q_PROPERTY(bool playbackTransition MEMBER m_playbackTransition NOTIFY playbackTransitionChanged) Q_PROPERTY(QString busyText MEMBER m_busyText NOTIFY busyChanged) Q_PROPERTY(QString errorText MEMBER m_errorText NOTIFY errorTextChanged) - Q_PROPERTY(bool hasDefaultProfile READ hasDefaultProfile NOTIFY defaultProfileChanged) Q_PROPERTY(bool initialized READ initialized NOTIFY initializedChanged) public: - AppController(DatabaseManager *database, Provider *provider, ArtworkService *artwork, PlayerController *player, + AppController(DatabaseManager *database, SourceHub *provider, ArtworkService *artwork, PlayerController *player, QObject *parent = nullptr); ArtworkService *artwork() const @@ -97,11 +93,14 @@ class AppController final : public QObject { { return m_settings; } + GroupPlaybackController *group() + { + return m_group; + } bool initialized() const { return m_initialized; } - bool hasDefaultProfile() const; Q_INVOKABLE void initialize(); void shutdown(); @@ -153,14 +152,14 @@ class AppController final : public QObject { void playbackTransitionChanged(); void streamingQualityChanged(); void errorTextChanged(); - void defaultProfileChanged(); void initializedChanged(); void aggressiveMemoryPressure(); void toastMessage(const QString& message); void remoteUiActionRequested(const QString& action); void remoteMessageRequested(const QString& message); void remoteContentRequested(const QString& itemId, const QString& itemType, const QString& title); - void remoteSeekPreviewRequested(qint64 positionTicks, bool active); + // The stored or per-instance device identity is known; providers start. + void deviceIdentityReady(const QString& deviceId); void clearLogsRequested(); void diagnosticsReportSaved(const QString& path); @@ -185,35 +184,29 @@ class AppController final : public QObject { void playQueuedItems(const std::vector& items, int startIndex, bool fromStart = false); bool modelIsOrderedList(MovieGridModel *model) const; void playAlbumFrom(const MovieItem& track, bool fromStart); - bool inSyncPlayGroup() const; - bool remoteTargetSelected() const; - // Hands the items to the remote target when one is selected; false - // means play them here. - bool playRemotely(const std::vector& items, int startIndex, const QString& command, bool fromStart); + bool inGroup() const; void groupOrLocalTogglePause(); void setPlaybackTransition(bool transition); - QString queuePlaylistItemId(int index) const; + QString queueEntryId(int index) const; bool enqueueForGroup(const MovieItem& item, bool queueNext); void playQueuedItem(const MovieItem& item, bool fromStart = false); void playEpisodeWithContext(const MovieItem& episode, int direction, bool fromStart); void playQueueCurrent(bool fromStart = false); void startQueuedPlayback(bool fromStart = false); - void handleRemotePlay(const QJsonObject& data); - void handleSpoolMessage(const SpoolRemoteProtocol::Message& message); - void handleRemotePlaystate(const QJsonObject& data); - void handleRemoteGeneralCommand(const QJsonObject& data); + // AppControllerRemote.cpp: commands another client sent through a source. + void handleRemoteCommand(const QString& accountId, const QVariantMap& command); + void playRemoteItems(const QString& accountId, const QVariantMap& command); // Folder-like containers open their child listing; everything else plays directly. void playOrOpen(const MovieItem& item, bool fromStart = false); void handlePlaybackStopped(const QString& itemId, qint64 positionTicks, bool completed); DatabaseManager *m_database = nullptr; - Provider *m_provider = nullptr; + SourceHub *m_provider = nullptr; Catalog *m_catalog = nullptr; PlaybackSource *m_playback = nullptr; // Null when the provider lacks the matching capability. StreamQualityControl *m_quality = nullptr; - GroupPlayback *m_group = nullptr; - RemotePlayback *m_remote = nullptr; + GroupPlaybackController *m_group = nullptr; ArtworkService *m_artwork = nullptr; PlayerController *m_player = nullptr; PlayQueueController *m_playQueue = nullptr; @@ -238,12 +231,11 @@ class AppController final : public QObject { bool m_initialized = false; QString m_busyText; QString m_errorText; - QString m_remoteRepeatMode = QStringLiteral("RepeatNone"); + QString m_repeatMode = QStringLiteral("RepeatNone"); RequestGeneration m_libraryLoadGeneration; RequestGeneration m_playbackLoadGeneration; RequestGeneration m_syncPlayQueueRequestGeneration; RequestGeneration m_remotePlaybackRequestGeneration; - QString m_remotePlaybackFingerprint; bool m_shuttingDown = false; bool m_codecFallbackAttempted = false; // The ceiling a quality change moved away from, kept only until that diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp new file mode 100644 index 00000000..5ca1811d --- /dev/null +++ b/src/app/AppControllerRemote.cpp @@ -0,0 +1,183 @@ +// Remote commands and group playback glue for AppController, kept apart from +// the browsing and queue logic in AppController.cpp. +#include "AppController.h" + +#include "../common/AsyncTask.h" +#include "../player/PlayQueueController.h" +#include "../player/PlayerController.h" +#include "../provider/SourceHub.h" +#include "GroupPlaybackController.h" + +#include +#include +#include +#include +#include +#include + +namespace JellyfinNative { + +bool AppController::inGroup() const +{ + return m_group && m_group->enabled(); +} + +void AppController::groupOrLocalTogglePause() +{ + if (inGroup()) + m_group->requestTogglePause(); + else + m_player->togglePause(); +} + +QString AppController::queueEntryId(int index) const +{ + return m_playQueue->itemAt(index).playlistItemId; +} + +bool AppController::enqueueForGroup(const MovieItem& item, bool queueNext) +{ + if (!inGroup()) + return false; + if (item.id.isEmpty() || !item.isPlayable()) { + setErrorText(QStringLiteral("This item cannot be queued.")); + return true; + } + m_group->requestQueueItems({ item.id }, queueNext); + return true; +} + +// Commands arrive already translated by the provider into one vocabulary +// (see sdk/provider.d.ts, RemoteCommand), so any server's "play this on that +// device" lands here the same way. +void AppController::handleRemoteCommand(const QString& accountId, const QVariantMap& command) +{ + const QString name = command.value(QStringLiteral("command")).toString(); + const auto seconds + = [&command] { return command.value(QStringLiteral("positionTicks")).toLongLong() / 10'000'000.0; }; + qInfo() << "remote:" << name; + if (name == QStringLiteral("play")) { + playRemoteItems(accountId, command); + } else if (name == QStringLiteral("pause") || name == QStringLiteral("unpause")) { + if (m_player->paused() == (name == QStringLiteral("unpause"))) + groupOrLocalTogglePause(); + } else if (name == QStringLiteral("playPause")) { + groupOrLocalTogglePause(); + } else if (name == QStringLiteral("stop")) { + m_player->stopWithReason(QStringLiteral("remote-stop")); + } else if (name == QStringLiteral("seek")) { + inGroup() ? m_group->requestSeek(seconds()) : m_player->seek(seconds()); + } else if (name == QStringLiteral("rewind")) { + inGroup() ? m_group->requestRelativeSeek(-10.0) : m_player->seekBack(); + } else if (name == QStringLiteral("fastForward")) { + inGroup() ? m_group->requestRelativeSeek(10.0) : m_player->seekForward(); + } else if (name == QStringLiteral("next")) { + playQueueNext(); + } else if (name == QStringLiteral("previous")) { + playQueuePrevious(); + } else if (name == QStringLiteral("volume")) { + m_player->setVolume(command.value(QStringLiteral("value"), m_player->volume()).toInt()); + } else if (name == QStringLiteral("volumeStep")) { + m_player->adjustVolume(command.value(QStringLiteral("delta")).toInt()); + } else if (name == QStringLiteral("mute")) { + m_player->setMuted(command.value(QStringLiteral("value"), true).toBool()); + } else if (name == QStringLiteral("toggleMute")) { + m_player->toggleMuted(); + } else if (name == QStringLiteral("audioTrack")) { + m_player->selectAudioStreamIndex(command.value(QStringLiteral("index"), -1).toInt()); + } else if (name == QStringLiteral("subtitleTrack")) { + m_player->selectSubtitleStreamIndex(command.value(QStringLiteral("index"), -1).toInt()); + } else if (name == QStringLiteral("repeat")) { + const QString mode = command.value(QStringLiteral("mode")).toString(); + if (mode == QStringLiteral("RepeatNone") || mode == QStringLiteral("RepeatAll") + || mode == QStringLiteral("RepeatOne")) + m_repeatMode = mode; + } else if (name == QStringLiteral("shuffle")) { + m_playQueue->setShuffled(command.value(QStringLiteral("value")).toBool()); + } else if (name == QStringLiteral("quality")) { + selectStreamingQuality( + command.value(QStringLiteral("bitrate")).toLongLong(), command.value(QStringLiteral("height")).toInt()); + } else if (name == QStringLiteral("stats")) { + m_player->toggleDebugOsd(); + } else if (name == QStringLiteral("navigate")) { + // home, search, settings, osd, menu or fullscreen: the shell's to do. + emit remoteUiActionRequested(command.value(QStringLiteral("to")).toString()); + } else if (name == QStringLiteral("show")) { + const QString itemId = command.value(QStringLiteral("itemId")).toString(); + if (!itemId.isEmpty()) + emit remoteContentRequested(m_provider->scoped(accountId, itemId), + command.value(QStringLiteral("itemType")).toString(), + command.value(QStringLiteral("title")).toString()); + } else if (name == QStringLiteral("message")) { + const QString text = command.value(QStringLiteral("text")).toString().trimmed(); + if (!text.isEmpty()) + emit remoteMessageRequested(text); + } else if (name == QStringLiteral("text")) { + const QString text = command.value(QStringLiteral("value")).toString(); + if (QObject *focus = QGuiApplication::focusObject(); focus && !text.isEmpty()) { + QInputMethodEvent input; + input.setCommitString(text); + QCoreApplication::sendEvent(focus, &input); + } + } else if (name == QStringLiteral("key")) { + static const QHash keys { { QStringLiteral("up"), Qt::Key_Up }, + { QStringLiteral("down"), Qt::Key_Down }, { QStringLiteral("left"), Qt::Key_Left }, + { QStringLiteral("right"), Qt::Key_Right }, { QStringLiteral("pageUp"), Qt::Key_PageUp }, + { QStringLiteral("pageDown"), Qt::Key_PageDown }, { QStringLiteral("select"), Qt::Key_Return }, + { QStringLiteral("back"), Qt::Key_Back }, { QStringLiteral("home"), Qt::Key_Home }, + { QStringLiteral("end"), Qt::Key_End }, { QStringLiteral("space"), Qt::Key_Space } }; + const auto key = keys.constFind(command.value(QStringLiteral("name")).toString()); + if (key != keys.cend() && QGuiApplication::focusWindow()) { + QKeyEvent press(QEvent::KeyPress, *key, Qt::NoModifier); + QKeyEvent release(QEvent::KeyRelease, *key, Qt::NoModifier); + QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &press); + QCoreApplication::sendEvent(QGuiApplication::focusWindow(), &release); + } + } +} + +void AppController::playRemoteItems(const QString& accountId, const QVariantMap& command) +{ + QStringList itemIds; + for (const QVariant& id : command.value(QStringLiteral("itemIds")).toList()) + itemIds.append(m_provider->scoped(accountId, id.toString())); + itemIds.removeAll(QString()); + if (itemIds.isEmpty()) + return; + const QString mode = command.value(QStringLiteral("mode"), QStringLiteral("now")).toString(); + const int requestedIndex = command.value(QStringLiteral("index")).toInt(); + const qint64 startTicks = command.value(QStringLiteral("positionTicks")).toLongLong(); + const RequestGeneration::Token generation = m_remotePlaybackRequestGeneration.next(); + setBusy(true, QStringLiteral("Starting playback…")); + Async::runScoped( + this, m_catalog->fetchItemsByIds(itemIds), + [this, generation, mode, requestedIndex, startTicks](std::vector items) { + if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) + return; + setBusy(false); + if (items.empty()) { + showToast(QStringLiteral("That item isn't available here.")); + return; + } + if (mode == QStringLiteral("next") || mode == QStringLiteral("last")) { + m_playQueue->addToQueue(items, mode == QStringLiteral("next")); + return; + } + const int index = std::clamp(requestedIndex, 0, static_cast(items.size()) - 1); + items[size_t(index)].resumeTicks = std::max(0, startTicks); + if (!m_playQueue->playNow(items, index)) + return; + if (mode == QStringLiteral("shuffle")) + m_playQueue->setShuffled(true); + startQueuedPlayback(startTicks <= 0); + }, + [this, generation](const std::exception_ptr& error) { + if (!m_remotePlaybackRequestGeneration.isCurrent(generation)) + return; + setBusy(false); + showToast(exceptionMessage(error)); + }, + "remote playback"); +} + +} // namespace JellyfinNative diff --git a/src/app/SyncPlayClock.cpp b/src/app/GroupClock.cpp similarity index 71% rename from src/app/SyncPlayClock.cpp rename to src/app/GroupClock.cpp index cff94568..3db33c8c 100644 --- a/src/app/SyncPlayClock.cpp +++ b/src/app/GroupClock.cpp @@ -1,4 +1,4 @@ -#include "SyncPlayClock.h" +#include "GroupClock.h" #include #include @@ -12,29 +12,29 @@ namespace { } // namespace -double SyncPlayTimeMeasurement::offsetMs() const +double GroupClockSample::offsetMs() const { return ((serverRequestReceivedMs - localRequestSentMs) + (serverResponseSentMs - localResponseReceivedMs)) / 2.0; } -double SyncPlayTimeMeasurement::roundTripDelayMs() const +double GroupClockSample::roundTripDelayMs() const { return (localResponseReceivedMs - localRequestSentMs) - (serverResponseSentMs - serverRequestReceivedMs); } -double SyncPlayTimeMeasurement::pingMs() const +double GroupClockSample::pingMs() const { return std::max(0.0, roundTripDelayMs() / 2.0); } -void SyncPlayClock::reset() +void GroupClock::reset() { m_measurements.clear(); m_best = {}; m_ready = false; } -void SyncPlayClock::addMeasurement(const SyncPlayTimeMeasurement& measurement) +void GroupClock::addMeasurement(const GroupClockSample& measurement) { m_measurements.push_back(measurement); while (m_measurements.size() > kMaxMeasurements) @@ -42,28 +42,28 @@ void SyncPlayClock::addMeasurement(const SyncPlayTimeMeasurement& measurement) selectBestMeasurement(); } -bool SyncPlayClock::ready() const +bool GroupClock::ready() const { return m_ready; } -double SyncPlayClock::offsetMs() const +double GroupClock::offsetMs() const { return m_ready ? m_best.offsetMs() : 0.0; } -double SyncPlayClock::pingMs() const +double GroupClock::pingMs() const { return m_ready ? m_best.pingMs() : 0.0; } -std::int64_t SyncPlayClock::localDelayUntil(std::int64_t serverTimeMs, std::int64_t localNowMs) const +std::int64_t GroupClock::localDelayUntil(std::int64_t serverTimeMs, std::int64_t localNowMs) const { const std::int64_t localTargetMs = serverTimeMs - static_cast(std::llround(offsetMs())); return std::max(0, localTargetMs - localNowMs); } -std::int64_t SyncPlayClock::estimatePositionTicks( +std::int64_t GroupClock::estimatePositionTicks( std::int64_t positionTicks, std::int64_t serverTimeMs, std::int64_t localNowMs) const { const std::int64_t serverNowMs = localNowMs + static_cast(std::llround(offsetMs())); @@ -71,7 +71,7 @@ std::int64_t SyncPlayClock::estimatePositionTicks( return positionTicks + elapsedMs * kTicksPerMillisecond; } -void SyncPlayClock::selectBestMeasurement() +void GroupClock::selectBestMeasurement() { if (m_measurements.empty()) { m_ready = false; @@ -79,7 +79,7 @@ void SyncPlayClock::selectBestMeasurement() } m_best = *std::min_element(m_measurements.cbegin(), m_measurements.cend(), - [](const SyncPlayTimeMeasurement& left, const SyncPlayTimeMeasurement& right) { + [](const GroupClockSample& left, const GroupClockSample& right) { return left.roundTripDelayMs() < right.roundTripDelayMs(); }); m_ready = true; diff --git a/src/app/SyncPlayClock.h b/src/app/GroupClock.h similarity index 78% rename from src/app/SyncPlayClock.h rename to src/app/GroupClock.h index 81d19263..0f3d8609 100644 --- a/src/app/SyncPlayClock.h +++ b/src/app/GroupClock.h @@ -5,7 +5,7 @@ namespace JellyfinNative { -struct SyncPlayTimeMeasurement { +struct GroupClockSample { std::int64_t localRequestSentMs = 0; std::int64_t serverRequestReceivedMs = 0; std::int64_t serverResponseSentMs = 0; @@ -16,10 +16,10 @@ struct SyncPlayTimeMeasurement { double pingMs() const; }; -class SyncPlayClock final { +class GroupClock final { public: void reset(); - void addMeasurement(const SyncPlayTimeMeasurement& measurement); + void addMeasurement(const GroupClockSample& measurement); bool ready() const; double offsetMs() const; @@ -31,8 +31,8 @@ class SyncPlayClock final { private: void selectBestMeasurement(); - std::vector m_measurements; - SyncPlayTimeMeasurement m_best; + std::vector m_measurements; + GroupClockSample m_best; bool m_ready = false; }; diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp new file mode 100644 index 00000000..758dc544 --- /dev/null +++ b/src/app/GroupPlaybackController.cpp @@ -0,0 +1,788 @@ +#include "GroupPlaybackController.h" + +#include "../common/AsyncTask.h" +#include "../player/PlayQueueController.h" +#include "../player/PlayerController.h" +#include "../provider/SourceHub.h" + +#include +#include + +#include +#include +#include + +namespace JellyfinNative { + +namespace { + constexpr int kGreedyTimeSyncIntervalMs = 1'000; + constexpr int kSteadyTimeSyncIntervalMs = 60'000; + // mpv retimes real audio output, so small clock errors stay inaudible and + // a seek replaces rate correction once that would take too long. + constexpr int kMinDelaySpeedToSyncMs = 100; + constexpr int kMinDelaySkipToSyncMs = 400; + constexpr int kSpeedToSyncDurationMs = 1'000; + constexpr double kSpeedToSyncMinSpeed = 0.2; + constexpr int kSyncMethodThresholdMs = 3'000; + constexpr int kSyncCooldownMs = kSyncMethodThresholdMs / 2; + constexpr double kSpeedToSyncMinRate = 0.97; + constexpr double kSpeedToSyncMaxRate = 1.03; + constexpr int kSpeedToSyncMaxDurationMs = 10'000; + constexpr int kInternalSeekBufferingSuppressionMs = 3'000; + constexpr qint64 kTicksPerSecond = 10'000'000; + constexpr qint64 kTicksPerMillisecond = 10'000; + + qint64 ms(const QVariantMap& event, const char *key) + { + return event.value(QLatin1String(key)).toLongLong(); + } + + QString errorMessage(const QString& code) + { + static const QHash messages { + { QStringLiteral("group_missing"), QStringLiteral("That group no longer exists.") }, + { QStringLiteral("access_denied"), QStringLiteral("This account can't play what the group is watching.") }, + { QStringLiteral("create_denied"), QStringLiteral("The server didn't allow creating a group.") }, + { QStringLiteral("join_denied"), QStringLiteral("The server didn't allow joining that group.") }, + { QStringLiteral("disabled"), QStringLiteral("Watching together is turned off on this server.") }, + }; + return messages.value(code, QStringLiteral("Watch together: %1").arg(code)); + } +} // namespace + +GroupCorrection GroupDriftPolicy::evaluate(double diffMs) +{ + const double absDiffMs = std::abs(diffMs); + if (absDiffMs < kMinDelaySpeedToSyncMs) + return {}; + if (absDiffMs < kMinDelaySkipToSyncMs) { + // Keep the speed positive when the client is ahead by more than the + // correction window allows. + double speedToSyncTimeMs = kSpeedToSyncDurationMs; + if (diffMs <= -speedToSyncTimeMs * kSpeedToSyncMinSpeed) + speedToSyncTimeMs = absDiffMs / (1.0 - kSpeedToSyncMinSpeed); + const double requestedSpeed = 1.0 + diffMs / speedToSyncTimeMs; + const double speed = std::clamp(requestedSpeed, kSpeedToSyncMinRate, kSpeedToSyncMaxRate); + if (speed != requestedSpeed) { + // Recover the same distance at the bounded rate; hitting the cap + // leaves a remainder for the next correction. + speedToSyncTimeMs + = std::min(absDiffMs / std::abs(speed - 1.0), static_cast(kSpeedToSyncMaxDurationMs)); + } + return { GroupCorrection::Method::Speed, speed, static_cast(std::llround(speedToSyncTimeMs)) }; + } + return { GroupCorrection::Method::Skip, 1.0, 0 }; +} + +GroupPlaybackController::GroupPlaybackController( + SourceHub *hub, PlayerController *player, PlayQueueController *playQueue, QObject *parent) + : GroupPlayback(parent) + , m_hub(hub) + , m_player(player) + , m_playQueue(playQueue) +{ + m_timeSyncTimer.setSingleShot(true); + m_commandTimer.setSingleShot(true); + // mpv's seeking flag is often a short transition, not starvation; wait + // a second before telling the group this client is buffering. + m_bufferingDebounceTimer.setSingleShot(true); + m_bufferingDebounceTimer.setInterval(1'000); + m_speedCorrectionTimer.setSingleShot(true); + m_correctionTimer.setInterval(1'000); + connect(&m_timeSyncTimer, &QTimer::timeout, this, &GroupPlaybackController::requestTimeSync); + connect(&m_commandTimer, &QTimer::timeout, this, &GroupPlaybackController::executeScheduledCommand); + connect(&m_correctionTimer, &QTimer::timeout, this, &GroupPlaybackController::correctPlaybackDrift); + connect(&m_speedCorrectionTimer, &QTimer::timeout, this, &GroupPlaybackController::finishSpeedCorrection); + connect(&m_bufferingDebounceTimer, &QTimer::timeout, this, [this] { sendPlayerBufferingState(true); }); + connect( + m_player, &PlayerController::playbackStateChanged, this, &GroupPlaybackController::handlePlayerStateChanged); + connect(m_hub, &Provider::capabilitiesChanged, this, &GroupPlaybackController::availableChanged); + connect(m_hub, &SourceHub::accountEvent, this, + [this](const QString& account, const QString& type, const QVariantMap& payload) { + if (type == QStringLiteral("group")) + handleEvent(account, payload); + }); +} + +bool GroupPlaybackController::available() const +{ + return m_hub->capabilities().testFlag(Provider::GroupPlayback); +} + +qint64 GroupPlaybackController::serverNowMs() const +{ + return QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); +} + +void GroupPlaybackController::send(const QString& action, QVariantMap arguments, const char *failure) +{ + if (m_account.isEmpty()) + return; + arguments.insert(QStringLiteral("action"), action); + Async::runScoped( + this, m_hub->call(m_account, QStringLiteral("groupSend"), arguments), [](QVariantMap) {}, + [this, failure](const std::exception_ptr& error) { + if (failure) + emit errorText(QStringLiteral("%1: %2").arg(QLatin1String(failure), exceptionMessage(error))); + }, + "group request"); +} + +void GroupPlaybackController::refreshGroups() +{ + m_groups.clear(); + for (Provider *source : m_hub->sources()) { + if (!source->capabilities().testFlag(Provider::GroupPlayback)) + continue; + const QString account = source->id(); + const QString label = source->displayName(); + ++m_loadingGroups; + Async::runScoped( + this, m_hub->call(account, QStringLiteral("groups")), + [this, account, label](QVariantMap result) { + --m_loadingGroups; + for (const QVariant& value : result.value(QStringLiteral("items")).toList()) { + QVariantMap group = value.toMap(); + group.insert( + QStringLiteral("id"), m_hub->scoped(account, group.value(QStringLiteral("id")).toString())); + group.insert(QStringLiteral("accountId"), account); + group.insert(QStringLiteral("accountLabel"), label); + m_groups.append(group); + } + emit groupsChanged(); + }, + [this](const std::exception_ptr&) { + --m_loadingGroups; + emit groupsChanged(); + }, + "group list"); + } + emit groupsChanged(); +} + +void GroupPlaybackController::createGroup(const QString& accountId, const QString& name) +{ + Async::runScoped( + this, m_hub->call(accountId, QStringLiteral("groupCreate"), { { QStringLiteral("name"), name } }), + [this, accountId](QVariantMap) { + m_account = accountId; + beginTimeSync(); + }, + [this](const std::exception_ptr& error) { emit errorText(exceptionMessage(error)); }, "group create"); +} + +void GroupPlaybackController::joinGroup(const QString& groupId) +{ + const QString account = m_hub->accountOf(groupId); + if (account.isEmpty()) + return; + Async::runScoped( + this, + m_hub->call(account, QStringLiteral("groupJoin"), { { QStringLiteral("groupId"), SourceHub::rawId(groupId) } }), + [this, account, groupId](QVariantMap) { + m_account = account; + m_groupId = SourceHub::rawId(groupId); + m_player->setSyncPlaybackSpeed(1.0); + for (const QVariant& group : std::as_const(m_groups)) { + if (group.toMap().value(QStringLiteral("id")) == groupId) + applyInfo(group.toMap()); + } + m_joinedAtServerMs = serverNowMs(); + beginTimeSync(); + emit groupChanged(); + sendPlayerBufferingState(true); + }, + [this](const std::exception_ptr& error) { emit errorText(exceptionMessage(error)); }, "group join"); +} + +void GroupPlaybackController::leaveGroup() +{ + if (m_groupId.isEmpty()) + return; + const QString account = m_account; + clearGroup(); + Async::runScoped( + this, m_hub->call(account, QStringLiteral("groupLeave")), [](QVariantMap) { }, + [](const std::exception_ptr&) { }, "group leave"); +} + +void GroupPlaybackController::requestUnpauseWhenReady() +{ + if (!enabled()) + return; + m_queueHandoff.arm(); + setWaitingForGroupPlayback(true); +} + +void GroupPlaybackController::cancelPendingUnpause() +{ + m_queueHandoff.cancel(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); +} + +void GroupPlaybackController::requestTogglePause() +{ + if (!enabled() || !m_player->sessionActive()) + return; + if (m_player->paused()) { + requestGroupUnpause(); + } else { + // Pause here at once; the group's command then settles the position. + m_player->setPaused(true); + send(QStringLiteral("pause"), {}, "Pause"); + } +} + +void GroupPlaybackController::requestSeek(double positionSeconds) +{ + if (!enabled() || !std::isfinite(positionSeconds)) + return; + m_seekResume.arm(!m_player->paused()); + send(QStringLiteral("seek"), + { { QStringLiteral("positionTicks"), + QString::number(qint64(std::max(0.0, positionSeconds) * kTicksPerSecond)) } }, + "Seek"); +} + +void GroupPlaybackController::requestRelativeSeek(double deltaSeconds) +{ + requestSeek(m_player->positionSeconds() + deltaSeconds); +} + +void GroupPlaybackController::requestNextItem() +{ + if (enabled() && !m_entryId.isEmpty()) + send(QStringLiteral("next"), { { QStringLiteral("entryId"), m_entryId } }); +} + +void GroupPlaybackController::requestPreviousItem() +{ + if (enabled() && !m_entryId.isEmpty()) + send(QStringLiteral("previous"), { { QStringLiteral("entryId"), m_entryId } }); +} + +void GroupPlaybackController::requestMoveItem(const QString& entryId, int newIndex) +{ + if (enabled() && !entryId.isEmpty()) + send(QStringLiteral("move"), { { QStringLiteral("entryId"), entryId }, { QStringLiteral("index"), newIndex } }); +} + +void GroupPlaybackController::requestRemoveItems(const QStringList& entryIds) +{ + if (enabled() && !entryIds.isEmpty()) + send(QStringLiteral("remove"), { { QStringLiteral("entryIds"), entryIds } }); +} + +void GroupPlaybackController::requestQueueItems(const QStringList& itemIds, bool queueNext) +{ + QStringList raw; + for (const QString& id : itemIds) { + if (m_hub->accountOf(id) == m_account) + raw.append(SourceHub::rawId(id)); + } + if (enabled() && !raw.isEmpty()) + send(QStringLiteral("queue"), { { QStringLiteral("itemIds"), raw }, { QStringLiteral("next"), queueNext } }); +} + +void GroupPlaybackController::requestPlayItem(const QString& entryId) +{ + if (enabled() && !entryId.isEmpty()) + send(QStringLiteral("play"), { { QStringLiteral("entryId"), entryId } }); +} + +QCoro::Task GroupPlaybackController::publishQueue( + QStringList itemIds, int playingIndex, qint64 startPositionTicks) +{ + QStringList raw; + for (const QString& id : std::as_const(itemIds)) + raw.append(SourceHub::rawId(id)); + co_await m_hub->call(m_account, QStringLiteral("groupSend"), + { { QStringLiteral("action"), QStringLiteral("setQueue") }, { QStringLiteral("itemIds"), raw }, + { QStringLiteral("index"), playingIndex }, + { QStringLiteral("positionTicks"), QString::number(startPositionTicks) } }); +} + +void GroupPlaybackController::handleEvent(const QString& accountId, const QVariantMap& event) +{ + const QString type = event.value(QStringLiteral("type")).toString(); + if (type == QStringLiteral("connected")) { + if (accountId == m_account && enabled()) + beginTimeSync(); + return; + } + if (accountId != m_account || (m_groupId.isEmpty() && type != QStringLiteral("joined"))) + return; + if (type == QStringLiteral("joined") || type == QStringLiteral("update")) { + applyInfo(event); + // The server stamps this and then, for a group already playing, sends + // the command that brings us in line. Compare against the stamp, not a + // local estimate taken a round trip later that would discard it. + m_joinedAtServerMs = ms(event, "at") > 0 ? ms(event, "at") : serverNowMs(); + sendPlayerBufferingState(true); + } else if (type == QStringLiteral("participants")) { + m_participants = event.value(QStringLiteral("participants")).toStringList(); + emit groupChanged(); + } else if (type == QStringLiteral("state")) { + m_groupState = event.value(QStringLiteral("state")).toString(); + m_groupStateReason = event.value(QStringLiteral("reason")).toString(); + if (m_groupState == QStringLiteral("Waiting") && m_groupStateReason == QStringLiteral("Unpause")) + setWaitingForGroupPlayback(true); + else if (m_groupState == QStringLiteral("Paused") + && (m_groupStateReason == QStringLiteral("Ready") || m_groupStateReason == QStringLiteral("Pause")) + && !m_unpauseRequestPending) + setWaitingForGroupPlayback(false); + if (m_seekResume.takeWhenReady(m_groupState, m_groupStateReason)) + requestGroupUnpause(); + emit groupChanged(); + } else if (type == QStringLiteral("queue")) { + applyQueue(event); + } else if (type == QStringLiteral("command")) { + handleCommand(event); + } else if (type == QStringLiteral("left")) { + clearGroup(); + } else if (type == QStringLiteral("error")) { + emit errorText(errorMessage(event.value(QStringLiteral("code")).toString())); + } +} + +void GroupPlaybackController::applyInfo(const QVariantMap& info) +{ + const QString id = info.value(QStringLiteral("groupId")).toString(); + if (!id.isEmpty()) + m_groupId = id; + m_groupName = info.value(QStringLiteral("name"), QStringLiteral("Watch together")).toString(); + m_groupState = info.value(QStringLiteral("state")).toString(); + m_groupStateReason = info.value(QStringLiteral("reason")).toString(); + m_participants = info.value(QStringLiteral("participants")).toStringList(); + if (!m_speedCorrectionActive) + m_player->setSyncPlaybackSpeed(1.0); + emit groupChanged(); +} + +void GroupPlaybackController::handleCommand(const QVariantMap& event) +{ + const qint64 emittedAt = ms(event, "emittedAt"); + if (emittedAt > 0 && m_joinedAtServerMs > 0 && emittedAt < m_joinedAtServerMs) + return; + const QString command = event.value(QStringLiteral("command")).toString(); + const qint64 serverTimeMs = ms(event, "at"); + const qint64 positionTicks = event.value(QStringLiteral("positionTicks")).toLongLong(); + const QString entryId = event.value(QStringLiteral("entryId")).toString(); + if (!entryId.isEmpty() && !m_entryId.isEmpty() && entryId != m_entryId) + return; + m_scheduledEntryId = entryId; + const QString key = QStringLiteral("%1|%2|%3|%4").arg(command).arg(serverTimeMs).arg(positionTicks).arg(m_entryId); + if (key == m_lastCommandKey && m_commandTimer.isActive()) + return; + m_lastCommandKey = key; + m_scheduledCommand = command; + m_scheduledPositionTicks = positionTicks; + m_scheduledServerTimeMs = serverTimeMs; + m_commandDue = false; + m_commandTimer.stop(); + m_bufferingDebounceTimer.stop(); + if (command == QStringLiteral("unpause")) + setWaitingForGroupPlayback(true); + const qint64 localNowMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 delayMs = serverTimeMs <= 0 ? 0 + : m_clock.ready() ? m_clock.localDelayUntil(serverTimeMs, localNowMs) + : std::max(0, serverTimeMs - localNowMs); + m_commandTimer.start(static_cast(std::min(delayMs, std::numeric_limits::max()))); +} + +void GroupPlaybackController::applyQueue(const QVariantMap& event) +{ + const qint64 updatedAt = ms(event, "at"); + if (updatedAt > 0 && updatedAt < m_lastQueueUpdateMs) + return; + m_lastQueueUpdateMs = std::max(m_lastQueueUpdateMs, updatedAt); + const QVariantList entries = event.value(QStringLiteral("items")).toList(); + const int playingIndex = event.value(QStringLiteral("index"), -1).toInt(); + if (entries.isEmpty() || playingIndex < 0 || playingIndex >= entries.size()) + return; + QStringList itemIds; + QStringList entryIds; + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + itemIds.append(m_hub->scoped(m_account, entry.value(QStringLiteral("itemId")).toString())); + entryIds.append(entry.value(QStringLiteral("entryId")).toString()); + } + if (itemIds.contains(QString())) + return; + + const quint64 generation = ++m_queueGeneration; + m_queueHandoff.observeQueueUpdate(); + m_entryId = entryIds.at(playingIndex); + const QString selectedItemId = itemIds.at(playingIndex); + const qint64 requestedTicks = event.value(QStringLiteral("positionTicks")).toLongLong(); + + // A long queue can take seconds to fill in; start the selected item + // first, since reporting ready does not wait for the rest. + const auto hydrate = [this, generation, itemIds, entryIds, playingIndex] { + Async::runScoped( + this, m_hub->fetchItemsByIds(itemIds), + [this, generation, itemIds, entryIds, playingIndex](const std::vector& fetched) { + if (generation != m_queueGeneration) + return; + std::vector ordered; + int resolvedIndex = -1; + for (int i = 0; i < itemIds.size(); ++i) { + const auto found = std::find_if(fetched.begin(), fetched.end(), + [&](const MovieItem& item) { return item.id == itemIds.at(i); }); + if (found == fetched.end()) + continue; + MovieItem item = *found; + item.playlistItemId = entryIds.at(i); + if (i == playingIndex) + resolvedIndex = static_cast(ordered.size()); + ordered.push_back(std::move(item)); + } + // Usually the echo of an edit made here: rebuilding would tear + // down every row the queue panel is showing. + if (m_playQueue->matchesQueue(ordered, resolvedIndex)) + return; + m_playQueue->setShuffled(false); + if (resolvedIndex >= 0) + m_playQueue->playNow(ordered, resolvedIndex); + }, + [](const std::exception_ptr&) {}, "group queue"); + }; + + if (m_player->sessionActive() && m_playQueue->currentItem().id == selectedItemId) { + m_queueLoading = false; + hydrate(); + sendPlayerBufferingState(true); + sendPendingUnpause(); + return; + } + m_queueLoading = true; + setWaitingForGroupPlayback(true); + Async::runScoped( + this, m_hub->fetchItemsByIds({ selectedItemId }), + [this, generation, selectedItemId, entry = m_entryId, requestedTicks, hydrate]( + const std::vector& fetched) { + if (generation != m_queueGeneration) + return; + m_queueLoading = false; + const auto selected = std::find_if( + fetched.cbegin(), fetched.cend(), [&](const MovieItem& item) { return item.id == selectedItemId; }); + MovieItem item = selected == fetched.cend() ? MovieItem {} : *selected; + item.playlistItemId = entry; + m_playQueue->setShuffled(false); + if (item.id.isEmpty() || !m_playQueue->playNow(item)) { + setWaitingForGroupPlayback(false); + emit errorText(QStringLiteral("The group's current item isn't available here.")); + return; + } + m_waitingForPlaybackStart = true; + m_playerStateKnown = false; + send(QStringLiteral("buffering"), + { { QStringLiteral("buffering"), true }, { QStringLiteral("playing"), false }, + { QStringLiteral("positionTicks"), QString::number(std::max(0, requestedTicks)) }, + { QStringLiteral("entryId"), m_entryId }, { QStringLiteral("at"), serverNowMs() } }); + emit queuePlaybackRequested(requestedTicks); + hydrate(); + }, + [this, generation](const std::exception_ptr& error) { + if (generation != m_queueGeneration) + return; + m_queueLoading = false; + setWaitingForGroupPlayback(false); + emit errorText(exceptionMessage(error)); + }, + "group item"); +} + +void GroupPlaybackController::clearGroup() +{ + m_commandTimer.stop(); + m_speedCorrectionTimer.stop(); + m_bufferingDebounceTimer.stop(); + m_timeSyncTimer.stop(); + m_correctionTimer.stop(); + m_speedCorrectionActive = false; + m_player->clearSyncPlaybackSpeed(); + m_groupId.clear(); + m_groupName.clear(); + m_groupState.clear(); + m_groupStateReason.clear(); + m_participants.clear(); + m_entryId.clear(); + m_lastCommandKey.clear(); + m_scheduledCommand.clear(); + m_scheduledEntryId.clear(); + m_scheduledPositionTicks = 0; + m_scheduledServerTimeMs = 0; + m_joinedAtServerMs = 0; + m_queueHandoff.cancel(); + m_seekResume.cancel(); + ++m_queueGeneration; + m_queueLoading = false; + m_waitingForPlaybackStart = false; + m_commandDue = false; + m_unpauseRequestPending = false; + m_syncCorrectionAttempts = 0; + m_lastCorrectionAtMs = 0; + m_suppressSeekBufferingUntilMs = 0; + m_playerStateKnown = false; + setWaitingForGroupPlayback(false); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + emit groupChanged(); +} + +void GroupPlaybackController::executeScheduledCommand() +{ + if (m_scheduledCommand.isEmpty()) + return; + if (m_scheduledCommand != QStringLiteral("stop") + && (m_queueLoading || m_waitingForPlaybackStart || !m_player->sessionActive())) { + m_commandDue = true; + return; + } + if (!m_scheduledEntryId.isEmpty() && !m_entryId.isEmpty() && m_scheduledEntryId != m_entryId) { + m_commandDue = false; + return; + } + m_commandDue = false; + const QString command = m_scheduledCommand; + qint64 positionTicks = m_scheduledPositionTicks; + if (command == QStringLiteral("unpause") && m_scheduledServerTimeMs > 0) + positionTicks = m_clock.estimatePositionTicks( + positionTicks, m_scheduledServerTimeMs, QDateTime::currentMSecsSinceEpoch()); + const double targetSeconds = static_cast(positionTicks) / kTicksPerSecond; + const double positionDelta = std::abs(m_player->positionSeconds() - targetSeconds); + + if (command == QStringLiteral("pause")) { + m_seekResume.cancel(); + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + m_player->setPaused(true); + if (positionDelta > 0.1) + m_player->seek(targetSeconds); + } else if (command == QStringLiteral("unpause")) { + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + m_syncCorrectionAttempts = 0; + // Re-enable correction half a threshold after unpausing, so drift + // accumulated while the player spins up is not "corrected". + m_lastCorrectionAtMs = QDateTime::currentMSecsSinceEpoch(); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + if (positionDelta * 1'000.0 > kMinDelaySkipToSyncMs && !m_player->seeking()) { + m_suppressSeekBufferingUntilMs = QDateTime::currentMSecsSinceEpoch() + kInternalSeekBufferingSuppressionMs; + m_player->seek(targetSeconds); + } + m_player->setPaused(false); + } else if (command == QStringLiteral("seek")) { + finishSpeedCorrection(); + m_player->setPaused(true); + m_player->seek(targetSeconds); + QTimer::singleShot(250, this, [this] { sendPlayerBufferingState(true); }); + } else if (command == QStringLiteral("stop")) { + m_seekResume.cancel(); + finishSpeedCorrection(); + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + setPlaybackDiff(0, false); + setSyncMethod(QStringLiteral("None")); + m_player->stopWithReason(QStringLiteral("group-stop")); + } +} + +void GroupPlaybackController::correctPlaybackDrift() +{ + if (!enabled() || !m_player->sessionActive() || m_scheduledCommand != QStringLiteral("unpause") + || m_scheduledServerTimeMs <= 0 || m_player->paused() || m_player->buffering() || m_player->seeking()) { + setPlaybackDiff(0, false); + if (m_speedCorrectionActive) + finishSpeedCorrection(); + return; + } + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 expectedTicks + = m_clock.estimatePositionTicks(m_scheduledPositionTicks, m_scheduledServerTimeMs, nowMs); + const qint64 actualTicks = static_cast(m_player->estimatedPositionSeconds() * kTicksPerSecond); + const qint64 signedDiffTicks = expectedTicks - actualTicks; + setPlaybackDiff(signedDiffTicks, true); + const GroupCorrection correction + = GroupDriftPolicy::evaluate(static_cast(signedDiffTicks) / kTicksPerMillisecond); + if (correction.method == GroupCorrection::Method::None) { + m_syncCorrectionAttempts = 0; + if (!m_speedCorrectionActive) + setSyncMethod(QStringLiteral("None")); + return; + } + if (m_speedCorrectionActive) { + // A large jump should not wait for gentle rate correction to finish. + if (correction.method == GroupCorrection::Method::Speed) + return; + finishSpeedCorrection(); + } + if (nowMs - m_lastCorrectionAtMs < kSyncCooldownMs) + return; + m_lastCorrectionAtMs = nowMs; + ++m_syncCorrectionAttempts; + if (correction.method == GroupCorrection::Method::Speed) { + m_speedCorrectionActive = true; + m_player->setSyncPlaybackSpeed(correction.speed); + setSyncMethod(QStringLiteral("Speed ×%1").arg(correction.speed, 0, 'f', 2)); + m_speedCorrectionTimer.start(correction.durationMs); + return; + } + setSyncMethod(QStringLiteral("Skip (%1)").arg(m_syncCorrectionAttempts)); + m_suppressSeekBufferingUntilMs = nowMs + kInternalSeekBufferingSuppressionMs; + m_player->seek(static_cast(expectedTicks) / kTicksPerSecond); +} + +void GroupPlaybackController::finishSpeedCorrection() +{ + m_speedCorrectionTimer.stop(); + if (enabled()) + m_player->setSyncPlaybackSpeed(1.0); + if (!m_speedCorrectionActive) + return; + m_speedCorrectionActive = false; + setSyncMethod(QStringLiteral("None")); +} + +void GroupPlaybackController::handlePlayerStateChanged() +{ + if (m_waitingForPlaybackStart) { + // Buffering was already reported; say ready only once mpv has the file. + if (m_player->sessionActive() && m_player->fileLoaded()) { + m_waitingForPlaybackStart = false; + if (m_commandDue) + executeScheduledCommand(); + sendPlayerBufferingState(true); + sendPendingUnpause(); + } + return; + } + sendPlayerBufferingState(false); + sendPendingUnpause(); +} + +void GroupPlaybackController::sendPlayerBufferingState(bool force) +{ + if (!enabled() || !m_player->sessionActive()) + return; + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + const bool internalSeek = m_player->seeking() && nowMs < m_suppressSeekBufferingUntilMs; + const bool buffering = !internalSeek && (m_player->buffering() || m_player->seeking()); + if (!force && buffering && (!m_playerStateKnown || !m_lastPlayerBuffering)) { + if (!m_bufferingDebounceTimer.isActive()) + m_bufferingDebounceTimer.start(); + return; + } + if (!buffering) + m_bufferingDebounceTimer.stop(); + if (!force && m_playerStateKnown && buffering == m_lastPlayerBuffering) + return; + m_playerStateKnown = true; + m_lastPlayerBuffering = buffering; + send(QStringLiteral("buffering"), + { { QStringLiteral("buffering"), buffering }, { QStringLiteral("playing"), !m_player->paused() }, + { QStringLiteral("positionTicks"), QString::number(qint64(m_player->positionSeconds() * kTicksPerSecond)) }, + { QStringLiteral("entryId"), m_entryId }, { QStringLiteral("at"), serverNowMs() } }); +} + +void GroupPlaybackController::sendPendingUnpause() +{ + if (!enabled() + || !m_queueHandoff.canSend( + m_queueLoading, m_waitingForPlaybackStart, m_player->sessionActive(), m_player->fileLoaded())) + return; + m_queueHandoff.cancel(); + requestGroupUnpause(); +} + +void GroupPlaybackController::requestGroupUnpause() +{ + if (!enabled() || m_unpauseRequestPending) + return; + m_unpauseRequestPending = true; + setWaitingForGroupPlayback(true); + Async::runScoped( + this, + m_hub->call( + m_account, QStringLiteral("groupSend"), { { QStringLiteral("action"), QStringLiteral("unpause") } }), + [](QVariantMap) {}, + [this](const std::exception_ptr& error) { + m_unpauseRequestPending = false; + setWaitingForGroupPlayback(false); + emit errorText(exceptionMessage(error)); + }, + "group unpause"); +} + +void GroupPlaybackController::setWaitingForGroupPlayback(bool waiting) +{ + if (m_waitingForGroupPlayback != waiting) { + m_waitingForGroupPlayback = waiting; + emit syncStatusChanged(); + } +} + +void GroupPlaybackController::setPlaybackDiff(qint64 diffTicks, bool valid) +{ + const double diffMs = valid ? static_cast(diffTicks) / kTicksPerMillisecond : 0.0; + if (m_playbackDiffValid == valid && (!valid || qFuzzyCompare(m_playbackDiffMs, diffMs))) + return; + m_playbackDiffValid = valid; + m_playbackDiffMs = diffMs; + emit syncStatusChanged(); +} + +void GroupPlaybackController::setSyncMethod(const QString& method) +{ + if (m_syncMethod != method) { + m_syncMethod = method; + emit syncStatusChanged(); + } +} + +void GroupPlaybackController::beginTimeSync() +{ + m_timeSyncTimer.stop(); + m_clock.reset(); + m_greedyTimeSyncRemaining = 3; + m_correctionTimer.start(); + requestTimeSync(); +} + +void GroupPlaybackController::requestTimeSync() +{ + if (m_account.isEmpty() || m_timeSyncInFlight) + return; + m_timeSyncInFlight = true; + const qint64 sentMs = QDateTime::currentMSecsSinceEpoch(); + Async::runScoped( + this, m_hub->call(m_account, QStringLiteral("clock")), + [this, sentMs](QVariantMap response) { + m_timeSyncInFlight = false; + const qint64 receivedMs = QDateTime::currentMSecsSinceEpoch(); + const qint64 serverReceived = ms(response, "received"); + const qint64 serverSent = ms(response, "sent"); + if (serverReceived > 0 && serverSent > 0) { + m_clock.addMeasurement({ sentMs, serverReceived, serverSent, receivedMs }); + emit clockChanged(); + send(QStringLiteral("ping"), { { QStringLiteral("ms"), qint64(std::llround(m_clock.pingMs())) } }); + } + const int interval = m_greedyTimeSyncRemaining > 0 ? kGreedyTimeSyncIntervalMs : kSteadyTimeSyncIntervalMs; + m_greedyTimeSyncRemaining = std::max(0, m_greedyTimeSyncRemaining - 1); + if (enabled()) + m_timeSyncTimer.start(interval); + }, + [this](const std::exception_ptr&) { + m_timeSyncInFlight = false; + if (enabled()) + m_timeSyncTimer.start(kSteadyTimeSyncIntervalMs); + }, + "group clock"); +} + +} // namespace JellyfinNative diff --git a/src/app/SyncPlayController.h b/src/app/GroupPlaybackController.h similarity index 62% rename from src/app/SyncPlayController.h rename to src/app/GroupPlaybackController.h index 70a37498..78b3c337 100644 --- a/src/app/SyncPlayController.h +++ b/src/app/GroupPlaybackController.h @@ -1,26 +1,25 @@ #pragma once #include "../provider/GroupPlayback.h" -#include "SyncPlayClock.h" +#include "GroupClock.h" #include - -#include -#include #include #include #include #include -#include +#include +#include namespace JellyfinNative { -class JellyfinApiFacade; -class PlayerController; class PlayQueueController; -class TlsTrustController; +class PlayerController; +class SourceHub; -class SyncPlayQueueHandoff final { +// Queue handoff: after publishing a queue, unpause only once the group's own +// queue update has arrived and the file is loaded here. +class GroupQueueHandoff final { public: void arm() { @@ -47,7 +46,8 @@ class SyncPlayQueueHandoff final { bool m_queueUpdateObserved = false; }; -class SyncPlaySeekResume final { +// A seek pauses the group; resume once everyone reports ready again. +class GroupSeekResume final { public: void arm(bool groupWasPlaying) { @@ -69,7 +69,7 @@ class SyncPlaySeekResume final { bool m_pending = false; }; -struct SyncCorrection { +struct GroupCorrection { enum class Method { None, Speed, Skip }; Method method = Method::None; @@ -77,39 +77,54 @@ struct SyncCorrection { int durationMs = 0; }; -// Mirrors the strategy selection in jellyfin-web -// src/plugins/syncPlay/core/PlaybackCore.js so both clients tolerate and -// recover the same amount of drift. -class SyncPlayDriftPolicy final { +// Mirrors jellyfin-web's PlaybackCore strategy selection so mixed clients +// tolerate and recover the same drift. +class GroupDriftPolicy final { public: - // diffMs is the estimated server position minus the local position, so a - // positive value means this client is behind the group. - static SyncCorrection evaluate(double diffMs); + // Positive diffMs: this client is behind the group. + static GroupCorrection evaluate(double diffMs); }; -class SyncPlayController final : public GroupPlayback { +// Watching together, independent of whose server hosts the group. +// +// The native side keeps everything timing-critical: the clock offset from +// round-trip samples, commands scheduled against server time, drift +// correction by rate or seek, and the ready/buffering handshake. The provider +// keeps the protocol: it owns the socket and turns server messages into a +// small set of `group` events (joined, state, participants, queue, command, +// left, error) and turns the actions below into its server's requests. +class GroupPlaybackController final : public GroupPlayback { Q_OBJECT + Q_PROPERTY(QVariantList groups READ groups NOTIFY groupsChanged) + Q_PROPERTY(bool loadingGroups READ loadingGroups NOTIFY groupsChanged) + Q_PROPERTY(bool available READ available NOTIFY availableChanged) Q_PROPERTY(QString currentGroupId READ currentGroupId NOTIFY groupChanged) Q_PROPERTY(QString currentGroupName READ currentGroupName NOTIFY groupChanged) - Q_PROPERTY(QJsonArray groups READ groups NOTIFY groupsChanged) Q_PROPERTY(bool enabled READ enabled NOTIFY groupChanged) Q_PROPERTY(QStringList participants READ participants NOTIFY groupChanged) Q_PROPERTY(int participantCount READ participantCount NOTIFY groupChanged) Q_PROPERTY(QString groupState READ groupState NOTIFY groupChanged) Q_PROPERTY(QString groupStateReason READ groupStateReason NOTIFY groupChanged) - Q_PROPERTY(bool socketConnected READ socketConnected NOTIFY connectionChanged) - Q_PROPERTY(double clockOffsetMs READ clockOffsetMs NOTIFY connectionChanged) - Q_PROPERTY(double pingMs READ pingMs NOTIFY connectionChanged) - Q_PROPERTY(QString timeSyncDevice READ timeSyncDevice CONSTANT) + Q_PROPERTY(double clockOffsetMs READ clockOffsetMs NOTIFY clockChanged) + Q_PROPERTY(double pingMs READ pingMs NOTIFY clockChanged) Q_PROPERTY(bool waitingForPlayback READ waitingForPlayback NOTIFY syncStatusChanged) Q_PROPERTY(double playbackDiffMs READ playbackDiffMs NOTIFY syncStatusChanged) Q_PROPERTY(bool playbackDiffValid READ playbackDiffValid NOTIFY syncStatusChanged) Q_PROPERTY(QString syncMethod READ syncMethod NOTIFY syncStatusChanged) public: - SyncPlayController(JellyfinApiFacade *api, PlayerController *player, PlayQueueController *playQueue, - TlsTrustController *tlsTrust, QObject *parent = nullptr); + GroupPlaybackController( + SourceHub *hub, PlayerController *player, PlayQueueController *playQueue, QObject *parent = nullptr); + QVariantList groups() const + { + return m_groups; + } + bool loadingGroups() const + { + return m_loadingGroups > 0; + } + bool available() const; QString currentGroupId() const { return m_groupId; @@ -118,10 +133,6 @@ class SyncPlayController final : public GroupPlayback { { return m_groupName; } - QJsonArray groups() const - { - return m_groups; - } bool enabled() const override { return !m_groupId.isEmpty(); @@ -132,7 +143,7 @@ class SyncPlayController final : public GroupPlayback { } int participantCount() const { - return m_participants.size(); + return int(m_participants.size()); } QString groupState() const { @@ -142,7 +153,6 @@ class SyncPlayController final : public GroupPlayback { { return m_groupStateReason; } - bool socketConnected() const; double clockOffsetMs() const { return m_clock.offsetMs(); @@ -151,10 +161,6 @@ class SyncPlayController final : public GroupPlayback { { return m_clock.pingMs(); } - QString timeSyncDevice() const - { - return QStringLiteral("Jellyfin server"); - } bool waitingForPlayback() const { return m_waitingForGroupPlayback; @@ -172,10 +178,10 @@ class SyncPlayController final : public GroupPlayback { return m_syncMethod; } + // Lists the groups of every account that can host one. Q_INVOKABLE void refreshGroups(); - Q_INVOKABLE void connectSocket(); - Q_INVOKABLE void disconnectSocket(); - Q_INVOKABLE void createGroup(const QString& name); + Q_INVOKABLE void createGroup(const QString& accountId, const QString& name); + // `groupId` is scoped to its account, as refreshGroups() lists it. Q_INVOKABLE void joinGroup(const QString& groupId) override; Q_INVOKABLE void leaveGroup() override; Q_INVOKABLE void requestTogglePause() override; @@ -183,71 +189,64 @@ class SyncPlayController final : public GroupPlayback { Q_INVOKABLE void requestRelativeSeek(double deltaSeconds) override; Q_INVOKABLE void requestNextItem() override; Q_INVOKABLE void requestPreviousItem() override; - // Inside a group the queue belongs to the server. These publish an intent - // and the resulting PlayQueue broadcast is what actually changes the queue. - void requestMoveItem(const QString& playlistItemId, int newIndex) override; - void requestRemoveItems(const QStringList& playlistItemIds) override; + void requestMoveItem(const QString& entryId, int newIndex) override; + void requestRemoveItems(const QStringList& entryIds) override; void requestQueueItems(const QStringList& itemIds, bool queueNext) override; - void requestPlayItem(const QString& playlistItemId) override; + void requestPlayItem(const QString& entryId) override; void requestUnpauseWhenReady() override; void cancelPendingUnpause() override; QCoro::Task publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) override; + // A `group` event from the account hosting the group. + void handleEvent(const QString& accountId, const QVariantMap& event); + signals: void groupsChanged(); + void availableChanged(); void groupChanged(); - void connectionChanged(); + void clockChanged(); void syncStatusChanged(); void errorText(const QString& text); - void remotePlayCommand(const QJsonObject& data); - void remotePlaystateCommand(const QJsonObject& data); - void remoteGeneralCommand(const QJsonObject& data); - void sessionsUpdated(const QJsonArray& sessions); private: - void handleSocketTextMessage(const QString& message); - void handleSyncPlayCommand(const QJsonObject& data); - void handleSyncPlayGroupUpdate(const QJsonObject& data); - void applyPlayQueueUpdate(const QJsonObject& queue); - void prepareQueuePlayback(qint64 positionTicks); - void sendPendingUnpause(); - void requestGroupUnpause(); - void setWaitingForGroupPlayback(bool waiting); - void setPlaybackDiff(qint64 diffTicks, bool valid); - void setSyncMethod(const QString& method); - void applyGroupInfo(const QString& groupId, const QJsonObject& info); + void send(const QString& action, QVariantMap arguments = {}, const char *failure = nullptr); + void handleCommand(const QVariantMap& event); + void applyQueue(const QVariantMap& event); + void applyInfo(const QVariantMap& info); void clearGroup(); void executeScheduledCommand(); void correctPlaybackDrift(); void finishSpeedCorrection(); void handlePlayerStateChanged(); void sendPlayerBufferingState(bool force = false); + void sendPendingUnpause(); + void requestGroupUnpause(); + void setWaitingForGroupPlayback(bool waiting); + void setPlaybackDiff(qint64 diffTicks, bool valid); + void setSyncMethod(const QString& method); void beginTimeSync(); void requestTimeSync(); - void scheduleTimeSync(); - void reportRequestError(const QString& action, const std::exception_ptr& error); - void sendKeepAlive(); - QUrl socketUrl() const; + qint64 serverNowMs() const; - JellyfinApiFacade *m_api = nullptr; - PlayerController *m_player = nullptr; - PlayQueueController *m_playQueue = nullptr; - QWebSocket m_socket; + SourceHub *m_hub; + PlayerController *m_player; + PlayQueueController *m_playQueue; + QString m_account; QString m_groupId; QString m_groupName; QStringList m_participants; QString m_groupState; QString m_groupStateReason; - QJsonArray m_groups; - SyncPlayClock m_clock; + QVariantList m_groups; + int m_loadingGroups = 0; + GroupClock m_clock; QTimer m_timeSyncTimer; QTimer m_commandTimer; QTimer m_correctionTimer; QTimer m_speedCorrectionTimer; - QTimer m_reconnectTimer; QTimer m_bufferingDebounceTimer; - QString m_playlistItemId; - QString m_scheduledPlaylistItemId; + QString m_entryId; + QString m_scheduledEntryId; QString m_lastCommandKey; QString m_scheduledCommand; qint64 m_scheduledPositionTicks = 0; @@ -255,22 +254,17 @@ class SyncPlayController final : public GroupPlayback { qint64 m_lastCorrectionAtMs = 0; qint64 m_suppressSeekBufferingUntilMs = 0; qint64 m_joinedAtServerMs = 0; - qint64 m_lastPlayQueueUpdateMs = 0; + qint64 m_lastQueueUpdateMs = 0; double m_playbackDiffMs = 0.0; - quint64 m_playQueueGeneration = 0; + quint64 m_queueGeneration = 0; int m_greedyTimeSyncRemaining = 0; - SyncPlayQueueHandoff m_queueHandoff; - SyncPlaySeekResume m_seekResume; + GroupQueueHandoff m_queueHandoff; + GroupSeekResume m_seekResume; int m_syncCorrectionAttempts = 0; - bool m_socketDesired = false; - // Consecutive socket failures with a reconnect still wanted; reset by a - // successful connect and by giving up on the socket deliberately. - int m_socketFailures = 0; bool m_timeSyncInFlight = false; - bool m_timeSyncErrorReported = false; bool m_playerStateKnown = false; bool m_lastPlayerBuffering = false; - bool m_playQueueLoading = false; + bool m_queueLoading = false; bool m_waitingForPlaybackStart = false; bool m_commandDue = false; bool m_waitingForGroupPlayback = false; diff --git a/src/app/LibraryManagementController.cpp b/src/app/LibraryManagementController.cpp deleted file mode 100644 index 96dcf72a..00000000 --- a/src/app/LibraryManagementController.cpp +++ /dev/null @@ -1,351 +0,0 @@ -#include "LibraryManagementController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "BrowseSessionController.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { - -LibraryManagementController::LibraryManagementController( - JellyfinApiFacade *api, BrowseSessionController *browse, QObject *parent) - : QObject(parent) - , m_api(api) - , m_browse(browse) -{ -} - -void LibraryManagementController::clear() -{ - m_playlistTargets.clear(); - m_collectionTargets.clear(); - m_policyLoadStarted = false; - m_currentUserCanManagePlaylists = false; - m_currentUserCanManageCollections = false; - m_currentUserCanRenameItems = false; - m_currentUserCanDeleteItems = false; - emit policyChanged(); - emit targetsChanged(); -} - -void LibraryManagementController::loadCurrentUserPolicy() -{ - if (m_policyLoadStarted) - return; - if (!m_api || m_api->session().accessToken.isEmpty()) { - clear(); - return; - } - m_policyLoadStarted = true; - - m_currentUserCanManagePlaylists = true; - emit policyChanged(); - Async::runScoped( - this, m_api->fetchCurrentUserPolicy(), - [this](const QJsonObject& policy) { - const bool administrator = policy.value(QStringLiteral("IsAdministrator")).toBool(false); - m_currentUserCanManagePlaylists = !m_api->session().accessToken.isEmpty(); - m_currentUserCanManageCollections - = administrator || policy.value(QStringLiteral("EnableCollectionManagement")).toBool(false); - m_currentUserCanRenameItems = administrator; - m_currentUserCanDeleteItems - = administrator || policy.value(QStringLiteral("EnableContentDeletion")).toBool(false); - emit policyChanged(); - }, - [this](const std::exception_ptr& error) { - qWarning() << "management policy fetch failed" << exceptionMessage(error); - m_currentUserCanManagePlaylists = false; - m_currentUserCanManageCollections = false; - m_currentUserCanRenameItems = false; - m_currentUserCanDeleteItems = false; - emit policyChanged(); - }); -} - -void LibraryManagementController::setTargets(const QString& kind, const std::vector& items) -{ - QVariantList targets; - targets.reserve(static_cast(items.size())); - for (const MovieItem& item : items) - targets.push_back(metaToJson(item).toVariantMap()); - - if (kind == QStringLiteral("playlist")) - m_playlistTargets = targets; - else if (kind == QStringLiteral("collection")) - m_collectionTargets = targets; - emit targetsChanged(); -} - -QStringList LibraryManagementController::itemIdsFor(const MovieItem& item) const -{ - return item.id.isEmpty() ? QStringList {} : QStringList { item.id }; -} - -void LibraryManagementController::refreshAfterMutation(const QString& changedItemId) -{ - emit refreshRequested(changedItemId); - refreshTargets(QStringLiteral("playlist")); - refreshTargets(QStringLiteral("collection")); -} - -bool LibraryManagementController::authenticated() -{ - if (!m_api || m_api->session().accessToken.isEmpty()) { - emit errorOccurred(QStringLiteral("Sign in before managing library items.")); - return false; - } - return true; -} - -bool LibraryManagementController::playlistAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanManagePlaylists) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot manage playlists.")); - return false; - } - return true; -} - -bool LibraryManagementController::collectionAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanManageCollections) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot manage collections.")); - return false; - } - return true; -} - -bool LibraryManagementController::renameAllowed(const QString& itemType) -{ - if (itemType == QStringLiteral("Playlist")) - return playlistAllowed(); - if (!authenticated()) - return false; - if (!m_currentUserCanRenameItems) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot rename this item.")); - return false; - } - return true; -} - -bool LibraryManagementController::deleteAllowed() -{ - if (!authenticated()) - return false; - if (!m_currentUserCanDeleteItems) { - emit errorOccurred(QStringLiteral("Your Jellyfin user cannot delete items.")); - return false; - } - return true; -} - -void LibraryManagementController::refreshTargets(const QString& kind) -{ - if (!authenticated()) - return; - - const QString normalized - = kind == QStringLiteral("collection") ? QStringLiteral("collection") : QStringLiteral("playlist"); - const QString itemType - = normalized == QStringLiteral("collection") ? QStringLiteral("BoxSet") : QStringLiteral("Playlist"); - Async::runScoped( - this, m_api->fetchManagementTargets(itemType), - [this, normalized](const std::vector& items) { setTargets(normalized, items); }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::createPlaylistForItem(const QString& name, const MovieItem& item) -{ - if (!playlistAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - Async::runScoped( - this, m_api->createPlaylist(name, itemIds), - [this](const QString&) { - emit operationSucceeded(QStringLiteral("Playlist created")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::addItemToPlaylist(const QString& playlistId, const MovieItem& item) -{ - if (!playlistAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - if (playlistId.isEmpty() || itemIds.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and playlist first.")); - return; - } - Async::runScoped( - this, m_api->addPlaylistItems(playlistId, itemIds), - [this]() { - emit operationSucceeded(QStringLiteral("Added to playlist")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::createCollectionForItem(const QString& name, const MovieItem& item) -{ - if (!collectionAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - Async::runScoped( - this, m_api->createCollection(name, itemIds), - [this](const QString&) { - emit operationSucceeded(QStringLiteral("Collection created")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::addItemToCollection(const QString& collectionId, const MovieItem& item) -{ - if (!collectionAllowed()) - return; - const QStringList itemIds = itemIdsFor(item); - if (collectionId.isEmpty() || itemIds.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and collection first.")); - return; - } - Async::runScoped( - this, m_api->addCollectionItems(collectionId, itemIds), - [this]() { - emit operationSucceeded(QStringLiteral("Added to collection")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::removeItemFromCurrentParent(const MovieItem& movie) -{ - if (!m_browse) { - emit errorOccurred(QStringLiteral("Open a playlist or collection before removing items.")); - return; - } - - const BrowseDescriptor descriptor = m_browse->descriptor(); - if (descriptor.kind == BrowseKind::Playlist) { - if (!playlistAllowed()) - return; - if (movie.playlistItemId.isEmpty()) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be removed.")); - return; - } - Async::runScoped( - this, m_api->removePlaylistItems(descriptor.id, { movie.playlistItemId }), - [this]() { - emit operationSucceeded(QStringLiteral("Removed from playlist")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - return; - } - if (descriptor.kind == BrowseKind::BoxSet) { - if (!collectionAllowed()) - return; - if (movie.id.isEmpty()) { - emit errorOccurred(QStringLiteral("This collection item cannot be removed.")); - return; - } - Async::runScoped( - this, m_api->removeCollectionItems(descriptor.id, { movie.id }), - [this]() { - emit operationSucceeded(QStringLiteral("Removed from collection")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); - return; - } - emit errorOccurred(QStringLiteral("Open a playlist or collection before removing items.")); -} - -void LibraryManagementController::movePlaylistItemInCurrent(const MovieItem& movie, int delta) -{ - if (!playlistAllowed()) - return; - if (!m_browse) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be moved.")); - return; - } - const BrowseDescriptor descriptor = m_browse->descriptor(); - if (descriptor.kind != BrowseKind::Playlist || movie.playlistItemId.isEmpty()) { - emit errorOccurred(QStringLiteral("This playlist entry cannot be moved.")); - return; - } - - int currentIndex = -1; - const std::vector& items = m_browse->items()->movies(); - for (int i = 0; i < static_cast(items.size()); ++i) { - if (items[static_cast(i)].playlistItemId == movie.playlistItemId) { - currentIndex = i; - break; - } - } - if (currentIndex < 0) - return; - const int newIndex = std::clamp(currentIndex + delta, 0, std::max(0, static_cast(items.size()) - 1)); - if (newIndex == currentIndex) - return; - - Async::runScoped( - this, m_api->movePlaylistItem(descriptor.id, movie.playlistItemId, newIndex), - [this]() { - emit operationSucceeded(QStringLiteral("Playlist item moved")); - refreshAfterMutation(); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -void LibraryManagementController::renameItem(const MovieItem& movie, const QString& name) -{ - const QString trimmed = name.trimmed(); - if (movie.id.isEmpty() || trimmed.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item and name first.")); - return; - } - if (!renameAllowed(movie.itemType)) - return; - - auto onRenamed = [this, itemId = movie.id]() { - emit operationSucceeded(QStringLiteral("Item renamed")); - refreshAfterMutation(itemId); - }; - auto onError = [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }; - if (movie.itemType == QStringLiteral("Playlist")) { - Async::runScoped(this, m_api->updatePlaylistName(movie.id, trimmed), onRenamed, onError); - return; - } - Async::runScoped(this, m_api->renameItem(movie.id, trimmed), onRenamed, onError); -} - -void LibraryManagementController::deleteItem(const MovieItem& movie) -{ - if (movie.id.isEmpty()) { - emit errorOccurred(QStringLiteral("Choose an item before deleting.")); - return; - } - if (!deleteAllowed()) - return; - - Async::runScoped( - this, m_api->deleteItem(movie.id), - [this, itemId = movie.id]() { - emit operationSucceeded(QStringLiteral("Item deleted")); - refreshAfterMutation(itemId); - }, - [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); -} - -} // namespace JellyfinNative diff --git a/src/app/LibraryManagementController.h b/src/app/LibraryManagementController.h deleted file mode 100644 index fafc5d70..00000000 --- a/src/app/LibraryManagementController.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "../media/MediaTypes.h" - -#include -#include -#include -#include - -#include - -namespace JellyfinNative { - -class BrowseSessionController; -class JellyfinApiFacade; - -class LibraryManagementController final : public QObject { - Q_OBJECT - Q_PROPERTY(bool currentUserCanManagePlaylists MEMBER m_currentUserCanManagePlaylists NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanManageCollections MEMBER m_currentUserCanManageCollections NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanRenameItems MEMBER m_currentUserCanRenameItems NOTIFY policyChanged) - Q_PROPERTY(bool currentUserCanDeleteItems MEMBER m_currentUserCanDeleteItems NOTIFY policyChanged) - Q_PROPERTY(QVariantList playlistTargets MEMBER m_playlistTargets NOTIFY targetsChanged) - Q_PROPERTY(QVariantList collectionTargets MEMBER m_collectionTargets NOTIFY targetsChanged) - -public: - LibraryManagementController(JellyfinApiFacade *api, BrowseSessionController *browse, QObject *parent = nullptr); - - Q_INVOKABLE void clear(); - Q_INVOKABLE void loadCurrentUserPolicy(); - Q_INVOKABLE void refreshTargets(const QString& kind); - Q_INVOKABLE void createPlaylistForItem(const QString& name, const MovieItem& item); - Q_INVOKABLE void addItemToPlaylist(const QString& playlistId, const MovieItem& item); - Q_INVOKABLE void createCollectionForItem(const QString& name, const MovieItem& item); - Q_INVOKABLE void addItemToCollection(const QString& collectionId, const MovieItem& item); - Q_INVOKABLE void removeItemFromCurrentParent(const MovieItem& item); - Q_INVOKABLE void movePlaylistItemInCurrent(const MovieItem& item, int delta); - Q_INVOKABLE void renameItem(const MovieItem& item, const QString& name); - Q_INVOKABLE void deleteItem(const MovieItem& item); - -signals: - void policyChanged(); - void targetsChanged(); - void errorOccurred(const QString& errorText); - void operationSucceeded(const QString& action); - void refreshRequested(const QString& changedItemId); - -private: - void setTargets(const QString& kind, const std::vector& items); - void refreshAfterMutation(const QString& changedItemId = {}); - QStringList itemIdsFor(const MovieItem& item) const; - bool authenticated(); - bool playlistAllowed(); - bool collectionAllowed(); - bool renameAllowed(const QString& itemType); - bool deleteAllowed(); - - JellyfinApiFacade *m_api = nullptr; - BrowseSessionController *m_browse = nullptr; - QVariantList m_playlistTargets; - QVariantList m_collectionTargets; - bool m_policyLoadStarted = false; - bool m_currentUserCanManagePlaylists = false; - bool m_currentUserCanManageCollections = false; - bool m_currentUserCanRenameItems = false; - bool m_currentUserCanDeleteItems = false; -}; - -} // namespace JellyfinNative diff --git a/src/app/QuickConnectController.cpp b/src/app/QuickConnectController.cpp deleted file mode 100644 index a5930c1b..00000000 --- a/src/app/QuickConnectController.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#include "QuickConnectController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" - -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr int kPollIntervalMs = 5000; - constexpr int kMaxPollAttempts = 36; - constexpr int kMaxPollErrors = 6; - -} // namespace - -QuickConnectController::QuickConnectController(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_api(api) -{ - m_pollTimer.setInterval(kPollIntervalMs); - connect(&m_pollTimer, &QTimer::timeout, this, &QuickConnectController::poll); -} - -QString QuickConnectController::code() const -{ - return m_code; -} - -QString QuickConnectController::phase() const -{ - return m_phase; -} - -bool QuickConnectController::active() const -{ - return !m_secret.isEmpty(); -} - -void QuickConnectController::start(const QString& serverUrl) -{ - if (serverUrl.isEmpty()) { - emit errorOccurred(QStringLiteral("Enter a Jellyfin server URL first.")); - return; - } - - cancel(); - const quint64 generation = m_generation; - emit busyChanged(true, QStringLiteral("Starting Quick Connect…")); - m_api->setServerUrl(serverUrl); - - Async::runScoped( - this, m_api->quickConnectEnabled(), - [this, generation](bool enabled) { - if (generation != m_generation) - return; - if (!enabled) { - emit busyChanged(false, {}); - emit errorOccurred(QStringLiteral("Quick Connect is disabled on this server.")); - return; - } - - Async::runScoped( - this, m_api->initiateQuickConnect(), - [this, generation](const QJsonObject& result) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - m_code = result.value(QStringLiteral("Code")).toString(); - m_secret = result.value(QStringLiteral("Secret")).toString(); - m_phase = QStringLiteral("user"); - m_pollAttempts = 0; - m_pollErrors = 0; - emit changed(); - poll(); - m_pollTimer.start(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - const QString message = exceptionMessage(error); - qWarning() << "quick connect: initiate failed"; - emit errorOccurred(message); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - emit busyChanged(false, {}); - const QString message = exceptionMessage(error); - qWarning() << "quick connect: enabled check failed"; - emit errorOccurred(message); - }); -} - -void QuickConnectController::cancel() -{ - ++m_generation; - m_pollTimer.stop(); - m_code.fill(QLatin1Char('\0')); - m_code.clear(); - m_phase.clear(); - m_secret.fill(QLatin1Char('\0')); - m_secret.clear(); - m_pollAttempts = 0; - m_pollErrors = 0; - emit changed(); -} - -void QuickConnectController::poll() -{ - if (m_secret.isEmpty()) - return; - - ++m_pollAttempts; - if (m_pollAttempts > kMaxPollAttempts) { - qWarning() << "quick connect: timed out after polls" << m_pollAttempts; - cancel(); - emit errorOccurred(QStringLiteral("Quick Connect timed out.")); - return; - } - - const quint64 generation = m_generation; - Async::runScoped( - this, m_api->pollQuickConnect(m_secret), - [this, generation](const QJsonObject& result) { - if (generation != m_generation) - return; - - m_pollErrors = 0; - const bool isAuthenticated = result.value(QStringLiteral("Authenticated")).toBool(); - qInfo() << "quick connect: poll" << m_pollAttempts << "authenticated" << isAuthenticated; - if (!isAuthenticated) - return; - - m_pollTimer.stop(); - m_phase = QStringLiteral("server"); - emit changed(); - - Async::runScoped( - this, m_api->authenticateWithQuickConnect(m_secret), - [this, generation](const AuthSession& session) { - if (generation != m_generation) - return; - qInfo() << "quick connect: authenticated successfully"; - cancel(); - emit authenticated(session); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - const QString message = exceptionMessage(error); - qWarning() << "quick connect: token exchange failed"; - cancel(); - emit errorOccurred(message); - }); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_generation) - return; - - const QString message = exceptionMessage(error); - ++m_pollErrors; - qWarning() << "quick connect: poll failed" << m_pollErrors; - - if (message.contains(QStringLiteral("(401)")) || message.contains(QStringLiteral("(404)")) - || m_pollErrors >= kMaxPollErrors) { - cancel(); - emit errorOccurred(message); - return; - } - // A transient poll failure changes nothing the screen shows: the - // flow is still waiting on the user to approve the code. - }); -} - -} // namespace JellyfinNative diff --git a/src/app/QuickConnectController.h b/src/app/QuickConnectController.h deleted file mode 100644 index bb78aba2..00000000 --- a/src/app/QuickConnectController.h +++ /dev/null @@ -1,51 +0,0 @@ -#pragma once - -#include "../api/JellyfinSession.h" -#include "../media/MediaTypes.h" - -#include -#include -#include - -namespace JellyfinNative { - -class JellyfinApiFacade; - -class QuickConnectController final : public QObject { - Q_OBJECT - Q_PROPERTY(QString code READ code NOTIFY changed) - // Who the flow is waiting on: "user" until the code is approved elsewhere, - // "server" while the token is exchanged, empty when nothing is running. - Q_PROPERTY(QString phase READ phase NOTIFY changed) - Q_PROPERTY(bool active READ active NOTIFY changed) - -public: - explicit QuickConnectController(JellyfinApiFacade *api, QObject *parent = nullptr); - - QString code() const; - QString phase() const; - bool active() const; - - Q_INVOKABLE void start(const QString& serverUrl); - Q_INVOKABLE void cancel(); - -signals: - void changed(); - void busyChanged(bool busy, const QString& text); - void errorOccurred(const QString& message); - void authenticated(const JellyfinNative::AuthSession& session); - -private: - void poll(); - - JellyfinApiFacade *m_api = nullptr; - QTimer m_pollTimer; - QString m_code; - QString m_phase; - QString m_secret; - int m_pollAttempts = 0; - int m_pollErrors = 0; - quint64 m_generation = 0; -}; - -} // namespace JellyfinNative diff --git a/src/app/RemoteControlController.cpp b/src/app/RemoteControlController.cpp deleted file mode 100644 index eddc669c..00000000 --- a/src/app/RemoteControlController.cpp +++ /dev/null @@ -1,967 +0,0 @@ -#include "RemoteControlController.h" -#include "SpoolRemoteProtocol.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/MetaJson.h" -#include "../platform/PlatformRemoteMediaSession.h" - -#include -#include -#include - -#include -#include - -namespace JellyfinNative { -namespace { - - // How far the target's own report may sit from where a seek put it before - // the reading is treated as confirmation. One poll interval of playback - // plus room for the report itself being a moment stale. - constexpr qint64 kSeekConfirmToleranceTicks = 25'000'000; - // How long to keep holding an unconfirmed seek. Past this the target is - // assumed to have ignored it and its own report wins again. - constexpr qint64 kSeekConfirmTimeoutMs = 8'000; - - qint64 jsonInteger(const QJsonValue& value) - { - return value.toVariant().toLongLong(); - } - - QString streamLabel(const QJsonObject& stream) - { - QString label = stream.value(QStringLiteral("DisplayTitle")).toString().trimmed(); - if (!label.isEmpty()) - return label; - QStringList parts; - const QString language = stream.value(QStringLiteral("Language")).toString().trimmed(); - const QString codec = stream.value(QStringLiteral("Codec")).toString().trimmed().toUpper(); - if (!language.isEmpty()) - parts.push_back(language); - if (!codec.isEmpty()) - parts.push_back(codec); - return parts.isEmpty() ? QStringLiteral("Track %1").arg(stream.value(QStringLiteral("Index")).toInt() + 1) - : parts.join(QStringLiteral(" · ")); - } - - QVariantMap normalizedNowPlayingItem(const QJsonObject& object) - { - if (object.isEmpty()) - return {}; - return metaToJson(metaFromJson(object, MetaJsonKeyPolicy::PascalCase)).toVariantMap(); - } - -} // namespace - -RemoteControlController::RemoteControlController(JellyfinApiFacade *api, QObject *parent) - : RemotePlayback(parent) - , m_api(api) - , m_mediaSession(createPlatformRemoteMediaSession(this)) -{ - m_link = new SpoolLink(api, this); - m_refreshTimer.setInterval(5'000); - connect(&m_refreshTimer, &QTimer::timeout, this, &RemoteControlController::refreshTargets); - m_pendingRefreshTimer.setInterval(1'000); - connect(&m_pendingRefreshTimer, &QTimer::timeout, this, &RemoteControlController::refreshTargets); - m_pendingTimeoutTimer.setSingleShot(true); - m_pendingTimeoutTimer.setInterval(30'000); - connect(&m_pendingTimeoutTimer, &QTimer::timeout, this, [this]() { - const QString targetName = m_selectedTargetName; - m_playGeneration.invalidate(); - setPlaybackPending(false); - refreshTargets(); - emit feedbackText(QStringLiteral("Playback did not start on %1").arg(targetName)); - }); - - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::playRequested, this, [this]() { requestPaused(false); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::pauseRequested, this, [this]() { requestPaused(true); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::playPauseRequested, this, - &RemoteControlController::togglePause); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::stopRequested, this, &RemoteControlController::stopPlayback); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::nextRequested, this, &RemoteControlController::nextTrack); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::previousRequested, this, - &RemoteControlController::previousTrack); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRequested, this, - [this](qint64 positionMs) { seek(positionMs * 10'000); }); - connect(m_mediaSession.get(), &PlatformRemoteMediaSession::seekRelativeRequested, this, - [this](qint64 deltaMs) { seekRelative(deltaMs * 10'000); }); - connect( - m_mediaSession.get(), &PlatformRemoteMediaSession::volumeRequested, this, &RemoteControlController::setVolume); -} - -RemoteControlController::~RemoteControlController() = default; - -void RemoteControlController::start() -{ - if (!m_refreshTimer.isActive()) - m_refreshTimer.start(); - refreshTargets(); -} - -void RemoteControlController::stop() -{ - m_refreshTimer.stop(); - m_sessionsGeneration.invalidate(); - m_sessions.clear(); - m_targets.clear(); - clearTarget(); - emit targetsChanged(); -} - -void RemoteControlController::setBusy(bool busy) -{ - if (m_busy == busy) - return; - m_busy = busy; - emit busyChanged(); -} -void RemoteControlController::setPlaybackPending(bool pending) -{ - if (m_playbackPending == pending) - return; - m_playbackPending = pending; - if (pending) { - m_pendingTimeoutTimer.start(); - } else { - m_pendingRefreshTimer.stop(); - m_pendingTimeoutTimer.stop(); - m_pendingTargetSessionId.clear(); - m_pendingItemId.clear(); - m_pendingPreviousItemId.clear(); - m_pendingTitle.clear(); - } - emit playbackPendingChanged(); -} - -void RemoteControlController::refreshTargets() -{ - if (!m_api || m_api->session().accessToken.isEmpty() || m_busy) - return; - const RequestGeneration::Token generation = m_sessionsGeneration.current(); - setBusy(true); - Async::runScoped( - this, m_api->fetchControllableSessions(), - [this, generation](const QJsonArray& sessions) { - setBusy(false); - if (m_sessionsGeneration.isCurrent(generation)) - applySessions(sessions); - }, - [this](const std::exception_ptr& error) { - setBusy(false); - reportCommandError(QStringLiteral("refresh remote targets"), error); - }, - "remote target refresh"); -} - -void RemoteControlController::applySessions(const QJsonArray& sessions) -{ - // A pushed session snapshot supersedes an HTTP refresh already in flight. - m_sessionsGeneration.invalidate(); - QHash nextSessions; - QVariantList nextTargets; - const QString localDeviceId = m_api ? m_api->deviceId() : QString(); - QString localSessionId; - - for (const QJsonValue& value : sessions) { - const QJsonObject session = value.toObject(); - const QString id = session.value(QStringLiteral("Id")).toString(); - if (!localDeviceId.isEmpty() && session.value(QStringLiteral("DeviceId")).toString() == localDeviceId) - localSessionId = id; - if (id.isEmpty() || session.value(QStringLiteral("DeviceId")).toString() == localDeviceId) - continue; - const QJsonObject capabilities = session.value(QStringLiteral("Capabilities")).toObject(); - if (!capabilities.value(QStringLiteral("SupportsMediaControl")).toBool(false)) - continue; - - nextSessions.insert(id, session); - const QJsonObject nowPlaying = session.value(QStringLiteral("NowPlayingItem")).toObject(); - nextTargets.push_back(QVariantMap { - { QStringLiteral("sessionId"), id }, - { QStringLiteral("deviceName"), session.value(QStringLiteral("DeviceName")).toString() }, - { QStringLiteral("deviceType"), session.value(QStringLiteral("DeviceType")).toString() }, - { QStringLiteral("client"), session.value(QStringLiteral("Client")).toString() }, - { QStringLiteral("userName"), session.value(QStringLiteral("UserName")).toString() }, - { QStringLiteral("nowPlayingTitle"), nowPlaying.value(QStringLiteral("Name")).toString() }, - { QStringLiteral("appVersion"), session.value(QStringLiteral("ApplicationVersion")).toString() }, - }); - } - - std::sort(nextTargets.begin(), nextTargets.end(), [](const QVariant& left, const QVariant& right) { - return QString::localeAwareCompare(left.toMap().value(QStringLiteral("deviceName")).toString(), - right.toMap().value(QStringLiteral("deviceName")).toString()) - < 0; - }); - - if (!localSessionId.isEmpty() && localSessionId != m_localSessionId) { - m_localSessionId = localSessionId; - m_link->setLocalSessionId(localSessionId); - // A peer can only answer once it has somewhere to answer to. - if (!m_selectedSessionId.isEmpty()) - m_link->connectToPeer(m_selectedSessionId); - } - const bool targetListChanged = m_targets != nextTargets; - m_sessions = std::move(nextSessions); - m_targets = std::move(nextTargets); - if (targetListChanged) { - qInfo() << "remote control: discovered" << m_targets.size() << "controllable client(s)"; - emit targetsChanged(); - } - - if (!m_selectedSessionId.isEmpty() && !m_sessions.contains(m_selectedSessionId)) { - clearTarget(); - return; - } - if (!m_selectedSessionId.isEmpty()) - applySelectedSession(); -} - -void RemoteControlController::selectTarget(const QString& sessionId) -{ - const QString normalized = sessionId.trimmed(); - if (!m_sessions.contains(normalized)) { - emit errorText(QStringLiteral("That remote client is no longer available.")); - refreshTargets(); - return; - } - if (m_selectedSessionId == normalized) { - applySelectedSession(); - return; - } - clearSelectedState(false); - m_selectedSessionId = normalized; - m_link->connectToPeer(normalized); - applySelectedSession(true); -} - -void RemoteControlController::clearTarget() -{ - if (m_selectedSessionId.isEmpty() && m_selectedTargetName.isEmpty()) { - clearSelectedState(); - return; - } - m_link->reset(); - m_selectedSessionId.clear(); - m_selectedTargetName.clear(); - m_selectedTargetDetail.clear(); - clearSelectedState(); - emit targetChanged(); -} - -void RemoteControlController::clearPendingCommands() -{ - m_pauseGeneration.invalidate(); - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - m_pendingSeekTicks.reset(); - m_pendingSeekAtMs = 0; - m_pendingSeekDeadlineMs = 0; -} - -void RemoteControlController::clearSelectedState(bool notify) -{ - m_queueGeneration.invalidate(); - m_playGeneration.invalidate(); - clearPendingCommands(); - setPlaybackPending(false); - m_trickplayGeneration.invalidate(); - m_trickplayTimeline.clear(); - m_trickplayItemId.clear(); - m_trickplayMediaSourceId.clear(); - m_previewSentTicks = -1; - m_previewSentActive = false; - if (notify) - emit trickplayChanged(); - m_nowPlayingItem.clear(); - m_queue.clear(); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - m_supportedCommands.clear(); - m_rawQueue = {}; - m_positionTicks = 0; - m_runtimeTicks = 0; - m_stateReceivedAtMs = 0; - m_playbackRate = 1.0; - m_volume = 100; - m_paused = true; - m_muted = false; - m_shuffled = false; - m_repeatMode = QStringLiteral("RepeatNone"); - if (notify) { - if (m_mediaSession) - m_mediaSession->clear(); - emit stateChanged(); - } -} - -void RemoteControlController::applySelectedSession(bool selectionChanged) -{ - const QJsonObject session = m_sessions.value(m_selectedSessionId); - if (session.isEmpty()) - return; - - const QString previousTargetName = m_selectedTargetName; - const QString previousTargetDetail = m_selectedTargetDetail; - m_selectedTargetName = session.value(QStringLiteral("DeviceName")).toString().trimmed(); - if (m_selectedTargetName.isEmpty()) - m_selectedTargetName = session.value(QStringLiteral("Client")).toString().trimmed(); - const QString client = session.value(QStringLiteral("Client")).toString().trimmed(); - const QString user = session.value(QStringLiteral("UserName")).toString().trimmed(); - QStringList detailParts; - if (!client.isEmpty()) - detailParts.push_back(client); - if (!user.isEmpty()) - detailParts.push_back(user); - m_selectedTargetDetail = detailParts.join(QStringLiteral(" · ")); - const bool targetInfoChanged - = previousTargetName != m_selectedTargetName || previousTargetDetail != m_selectedTargetDetail; - - const QJsonObject nowPlaying = session.value(QStringLiteral("NowPlayingItem")).toObject(); - const QJsonObject playState = session.value(QStringLiteral("PlayState")).toObject(); - if (m_playbackPending && m_selectedSessionId == m_pendingTargetSessionId) { - const QString nowPlayingId = nowPlaying.value(QStringLiteral("Id")).toString(); - // Empty while the target is between items, which is most of what a - // start looks like from here. - const bool unchanged = nowPlayingId.isEmpty() || nowPlayingId == m_pendingPreviousItemId; - if (nowPlayingId == m_pendingItemId) { - setPlaybackPending(false); - } else if (unchanged) { - // The command has not landed yet. Hold the item the user chose - // rather than flicking back to the one being replaced. - if (targetInfoChanged) - emit targetChanged(); - emit stateChanged(); - return; - } else { - // Playing something nobody here asked for: the target was driven - // directly, so follow it now instead of showing a stale item for - // the rest of the timeout. - setPlaybackPending(false); - } - } - if (nowPlaying.value(QStringLiteral("Id")).toString() - != m_nowPlayingItem.value(QStringLiteral("movieId")).toString()) - clearPendingCommands(); - m_nowPlayingItem = normalizedNowPlayingItem(nowPlaying); - loadTrickplay(nowPlaying.value(QStringLiteral("Id")).toString(), - nowPlaying.value(QStringLiteral("MediaSourceId")).toString()); - const qint64 reportedTicks = jsonInteger(playState.value(QStringLiteral("PositionTicks"))); - m_runtimeTicks = jsonInteger(nowPlaying.value(QStringLiteral("RunTimeTicks"))); - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - m_playbackRate = playState.value(QStringLiteral("PlaybackRate")).toDouble(1.0); - if (m_pendingSeekTicks && nowMs <= m_pendingSeekDeadlineMs) { - // Hold the position this device asked for until the target's own - // report reaches it, so the bar stays where the user put it. - const qint64 elapsedTicks - = m_paused ? 0 : static_cast((nowMs - m_pendingSeekAtMs) * 10'000.0 * m_playbackRate); - const qint64 expected = *m_pendingSeekTicks + elapsedTicks; - if (std::llabs(reportedTicks - expected) <= kSeekConfirmToleranceTicks) { - m_pendingSeekTicks.reset(); - m_pendingSeekDeadlineMs = 0; - m_positionTicks = reportedTicks; - m_stateReceivedAtMs = nowMs; - } else { - m_positionTicks = *m_pendingSeekTicks; - m_stateReceivedAtMs = m_pendingSeekAtMs; - } - } else { - m_pendingSeekTicks.reset(); - m_pendingSeekDeadlineMs = 0; - m_positionTicks = reportedTicks; - m_stateReceivedAtMs = nowMs; - } - const bool reportedPaused = playState.value(QStringLiteral("IsPaused")).toBool(true); - if (m_pendingPaused && nowMs <= m_pendingPauseDeadlineMs) { - if (reportedPaused == *m_pendingPaused) { - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - } else - m_paused = *m_pendingPaused; - } else { - m_pendingPauseDeadlineMs = 0; - m_pendingPaused.reset(); - m_paused = reportedPaused; - } - m_muted = playState.value(QStringLiteral("IsMuted")).toBool(false); - m_volume = std::clamp(playState.value(QStringLiteral("VolumeLevel")).toInt(100), 0, 100); - m_repeatMode = playState.value(QStringLiteral("RepeatMode")).toString(QStringLiteral("RepeatNone")); - m_shuffled = playState.value(QStringLiteral("PlaybackOrder")).toString() == QStringLiteral("Shuffle"); - - const QJsonArray commands - = session.value(QStringLiteral("Capabilities")).toObject().value(QStringLiteral("SupportedCommands")).toArray(); - m_supportedCommands.clear(); - m_supportedCommands.reserve(commands.size()); - for (const QJsonValue& command : commands) { - const QString name = command.toString(); - if (!name.isEmpty()) - m_supportedCommands.push_back(name); - } - - const int selectedAudio = playState.value(QStringLiteral("AudioStreamIndex")).toInt(-1); - const int selectedSubtitle = playState.value(QStringLiteral("SubtitleStreamIndex")).toInt(-1); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - for (const QJsonValue& value : nowPlaying.value(QStringLiteral("MediaStreams")).toArray()) { - const QJsonObject stream = value.toObject(); - const int index = stream.value(QStringLiteral("Index")).toInt(-1); - QVariantMap row { - { QStringLiteral("index"), index }, - { QStringLiteral("label"), streamLabel(stream) }, - { QStringLiteral("selected"), false }, - }; - if (stream.value(QStringLiteral("Type")).toString() == QStringLiteral("Audio")) { - row.insert(QStringLiteral("selected"), index == selectedAudio); - m_audioTracks.push_back(row); - } else if (stream.value(QStringLiteral("Type")).toString() == QStringLiteral("Subtitle")) { - row.insert(QStringLiteral("selected"), index == selectedSubtitle); - m_subtitleTracks.push_back(row); - } - } - if (!m_subtitleTracks.isEmpty()) { - m_subtitleTracks.prepend(QVariantMap { - { QStringLiteral("index"), -1 }, - { QStringLiteral("label"), QStringLiteral("Off") }, - { QStringLiteral("selected"), selectedSubtitle < 0 }, - }); - } - - m_rawQueue = session.value(QStringLiteral("NowPlayingQueue")).toArray(); - refreshQueueDetails(m_rawQueue); - updateMediaSession(); - if (selectionChanged) - emit trickplayChanged(); - if (selectionChanged || targetInfoChanged) - emit targetChanged(); - emit stateChanged(); -} - -void RemoteControlController::refreshQueueDetails(const QJsonArray& rawQueue) -{ - const RequestGeneration::Token generation = m_queueGeneration.next(); - if (rawQueue.isEmpty()) { - m_queue.clear(); - emit stateChanged(); - return; - } - - QStringList ids; - ids.reserve(rawQueue.size()); - for (const QJsonValue& value : rawQueue) { - const QString id = value.toObject().value(QStringLiteral("Id")).toString(); - if (!id.isEmpty()) - ids.push_back(id); - } - if (ids.isEmpty() || !m_api) { - m_queue.clear(); - emit stateChanged(); - return; - } - - const QJsonObject selectedSession = m_sessions.value(m_selectedSessionId); - const QString currentPlaylistItemId = selectedSession.value(QStringLiteral("PlaylistItemId")).toString(); - const QString currentItemId - = selectedSession.value(QStringLiteral("NowPlayingItem")).toObject().value(QStringLiteral("Id")).toString(); - Async::runScoped( - this, m_api->fetchItemsByIds(ids), - [this, generation, rawQueue, currentPlaylistItemId, currentItemId](const std::vector& items) { - if (generation != m_queueGeneration.current()) - return; - QVariantList queue; - queue.reserve(rawQueue.size()); - for (const QJsonValue& value : rawQueue) { - const QJsonObject raw = value.toObject(); - const QString id = raw.value(QStringLiteral("Id")).toString(); - const auto item = std::find_if( - items.cbegin(), items.cend(), [&id](const MovieItem& candidate) { return candidate.id == id; }); - QVariantMap row = item == items.cend() ? QVariantMap {} : metaToJson(*item).toVariantMap(); - row.insert(QStringLiteral("movieId"), id); - row.insert(QStringLiteral("playlistItemId"), raw.value(QStringLiteral("PlaylistItemId")).toString()); - const QString playlistItemId = raw.value(QStringLiteral("PlaylistItemId")).toString(); - row.insert(QStringLiteral("current"), - !currentPlaylistItemId.isEmpty() ? playlistItemId == currentPlaylistItemId : id == currentItemId); - queue.push_back(row); - } - m_queue = std::move(queue); - emit stateChanged(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_queueGeneration.current()) - return; - reportCommandError(QStringLiteral("load remote queue"), error); - }, - "remote queue details"); -} - -void RemoteControlController::loadTrickplay(const QString& itemId, const QString& mediaSourceId) -{ - const QString normalizedMediaSourceId = mediaSourceId.trimmed(); - if (itemId == m_trickplayItemId - && (normalizedMediaSourceId.isEmpty() || normalizedMediaSourceId == m_trickplayMediaSourceId)) - return; - - m_trickplayItemId = itemId; - m_trickplayMediaSourceId = normalizedMediaSourceId; - m_trickplayTimeline.clear(); - emit trickplayChanged(); - const RequestGeneration::Token generation = m_trickplayGeneration.next(); - if (!m_api || itemId.isEmpty()) - return; - - Async::runScoped( - this, m_api->fetchTrickplayInfo(itemId, normalizedMediaSourceId), - [this, generation, itemId](const TrickplayInfo& trickplay) { - if (!m_trickplayGeneration.isCurrent(generation) || m_trickplayItemId != itemId) - return; - PlaybackSession session; - session.itemId = itemId; - session.trickplay = trickplay; - m_trickplayTimeline.setSession(session); - emit trickplayChanged(); - }, - [this, generation, itemId](const std::exception_ptr& error) { - if (!m_trickplayGeneration.isCurrent(generation) || m_trickplayItemId != itemId) - return; - qInfo() << "remote trickplay: metadata unavailable for" << itemId << ":" << exceptionMessage(error); - }, - "remote trickplay metadata"); -} - -QVariantMap RemoteControlController::trickplayForTicks(qint64 positionTicks) const -{ - QVariantMap result; - if (!m_api || !m_trickplayTimeline.trickplayAvailable()) { - result.insert(QStringLiteral("available"), false); - return result; - } - - const PlaybackTimeline::TrickplayFrame frame - = m_trickplayTimeline.trickplayFrameAt(std::max(0, positionTicks) / 10'000'000.0); - if (!frame.available) { - result.insert(QStringLiteral("available"), false); - return result; - } - result.insert(QStringLiteral("available"), true); - result.insert(QStringLiteral("url"), - m_api->trickplayTileUrl(m_trickplayItemId, m_trickplayTimeline.trickplayWidth(), frame.sheetIndex)); - result.insert(QStringLiteral("width"), frame.width); - result.insert(QStringLiteral("height"), frame.height); - result.insert(QStringLiteral("offsetX"), frame.offsetX); - result.insert(QStringLiteral("offsetY"), frame.offsetY); - result.insert(QStringLiteral("sheetWidth"), frame.sheetWidth); - result.insert(QStringLiteral("sheetHeight"), frame.sheetHeight); - return result; -} - -void RemoteControlController::previewSeek(qint64 positionTicks, bool active) -{ - if (!targetSelected() || !m_link || m_trickplayItemId.isEmpty()) - return; - const qint64 ticks = std::max(0, positionTicks); - if (ticks == m_previewSentTicks && active == m_previewSentActive) - return; - m_previewSentTicks = ticks; - m_previewSentActive = active; - // Only the newest position is worth showing, so an older one still - // waiting for the link is replaced rather than queued behind it. The - // teardown is not: it is what puts the other screen back. - m_link->send(m_selectedSessionId, SpoolRemoteProtocol::seekPreviewMessage(m_trickplayItemId, ticks, active), - active ? SpoolLink::Delivery::Coalesced : SpoolLink::Delivery::Reliable); -} - -void RemoteControlController::cancelSeekPreview() -{ - if (m_previewSentActive) - previewSeek(m_previewSentTicks, false); -} - -void RemoteControlController::requestSyncPlayJoin(const QString& groupId) -{ - if (!targetSelected() || !m_link || groupId.isEmpty()) - return; - m_link->send(m_selectedSessionId, SpoolRemoteProtocol::syncPlayJoinMessage(groupId)); -} - -bool RemoteControlController::supports(const QString& command) const -{ - return m_supportedCommands.contains(command); -} - -qint64 RemoteControlController::predictedPositionTicks() const -{ - if (m_paused || m_positionTicks <= 0 || m_stateReceivedAtMs <= 0) - return m_positionTicks; - const qint64 elapsedMs = std::max(0, QDateTime::currentMSecsSinceEpoch() - m_stateReceivedAtMs); - const qint64 predicted = m_positionTicks + static_cast(elapsedMs * 10'000.0 * m_playbackRate); - return m_runtimeTicks > 0 ? std::min(predicted, m_runtimeTicks) : predicted; -} - -void RemoteControlController::stagePendingPlayback( - const std::vector& items, qint64 startPositionTicks, const QString& command) -{ - if (command != QStringLiteral("PlayNow") && command != QStringLiteral("PlayShuffle")) - return; - - QVariantList queue; - QJsonArray rawQueue; - QVariantMap selectedItem; - queue.reserve(static_cast(items.size())); - for (int index = 0; index < static_cast(items.size()); ++index) { - const MovieItem& item = items[static_cast(index)]; - if (item.id.isEmpty() || !isPlayableItem(item)) - continue; - QVariantMap row = metaToJson(item).toVariantMap(); - row.insert(QStringLiteral("movieId"), item.id); - row.insert(QStringLiteral("current"), item.id == m_pendingItemId); - queue.push_back(row); - rawQueue.append(QJsonObject { { QStringLiteral("Id"), item.id } }); - if (item.id == m_pendingItemId) - selectedItem = row; - } - if (selectedItem.isEmpty() && !queue.isEmpty()) - selectedItem = queue.front().toMap(); - - m_queueGeneration.invalidate(); - m_nowPlayingItem = selectedItem; - m_queue = std::move(queue); - m_rawQueue = std::move(rawQueue); - m_audioTracks.clear(); - m_subtitleTracks.clear(); - m_positionTicks = std::max(0, startPositionTicks); - loadTrickplay(selectedItem.value(QStringLiteral("movieId")).toString()); - m_runtimeTicks = selectedItem.value(QStringLiteral("runtimeTicks")).toLongLong(); - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - m_playbackRate = 1.0; - m_paused = false; - updateMediaSession(); - emit stateChanged(); -} - -bool RemoteControlController::playItems( - const std::vector& items, int startIndex, const QString& command, bool fromStart) -{ - if (!targetSelected()) - return false; - QStringList ids; - int remoteStartIndex = -1; - qint64 startPositionTicks = -1; - QString title; - for (int index = 0; index < static_cast(items.size()); ++index) { - const MovieItem& item = items[static_cast(index)]; - if (item.id.isEmpty() || !isPlayableItem(item)) - continue; - if (index == startIndex) { - remoteStartIndex = ids.size(); - startPositionTicks = fromStart ? 0 : std::max(0, item.resumeTicks); - title = item.title; - } - ids.push_back(item.id); - } - if (ids.isEmpty()) { - emit errorText(QStringLiteral("This list has no remotely playable items.")); - return true; - } - if (remoteStartIndex < 0) - remoteStartIndex = 0; - // The result is the caller's, not swallowed: every caller treats a true - // here as "handled remotely, do not play locally", so reporting success - // for a command that never went out strands the user with nothing playing - // anywhere. - if (!runPlay(ids, command, remoteStartIndex, startPositionTicks, title)) - return false; - stagePendingPlayback(items, startPositionTicks, command); - return true; -} - -void RemoteControlController::beginPendingPlayback(const QString& target, const QString& itemId, const QString& title) -{ - clearPendingCommands(); - m_pendingPreviousItemId = m_nowPlayingItem.value(QStringLiteral("movieId")).toString(); - m_pendingTargetSessionId = target; - m_pendingItemId = itemId; - m_pendingTitle = title; - // Restarted for each request, so superseding one does not inherit the - // remains of the earlier request's timeout. - m_pendingTimeoutTimer.start(); - if (m_playbackPending) - return; - m_playbackPending = true; - emit playbackPendingChanged(); -} - -void RemoteControlController::playItemIds( - const QStringList& itemIds, const QString& command, int startIndex, qint64 startPositionTicks) -{ - runPlay(itemIds, command, startIndex, startPositionTicks); -} - -bool RemoteControlController::runPlay( - QStringList itemIds, QString command, int startIndex, qint64 startPositionTicks, QString pendingTitle) -{ - if (!targetSelected() || !m_api) - return false; - itemIds.removeAll(QString()); - if (itemIds.isEmpty()) - return false; - - const bool startsPlayback = command == QStringLiteral("PlayNow") || command == QStringLiteral("PlayShuffle"); - const QString target = m_selectedSessionId; - // Taking a generation retires the previous one, so a request still in - // flight stops being able to change anything here. That is what lets a - // newer choice supersede an older one: refusing it instead left the - // target playing the old item, left this device showing the old item, and - // discarded what the user actually asked for without saying so. - const RequestGeneration::Token generation = m_playGeneration.next(); - if (startsPlayback) { - const int requestedIndex = std::clamp(startIndex, 0, static_cast(itemIds.size()) - 1); - beginPendingPlayback(target, itemIds.at(requestedIndex), pendingTitle.trimmed()); - const QString subject = m_pendingTitle.isEmpty() ? QStringLiteral("playback") : m_pendingTitle; - emit feedbackText(QStringLiteral("Starting %1 on %2…").arg(subject, m_selectedTargetName)); - } - - Async::runScoped( - this, m_api->sendRemotePlay(target, std::move(itemIds), std::move(command), startPositionTicks, startIndex), - [this, generation, startsPlayback]() { - if (!m_playGeneration.isCurrent(generation)) - return; - if (startsPlayback) { - refreshTargets(); - m_pendingRefreshTimer.start(); - } else { - QTimer::singleShot(300, this, &RemoteControlController::refreshTargets); - } - }, - [this, generation, startsPlayback](const std::exception_ptr& error) { - if (!m_playGeneration.isCurrent(generation)) - return; - if (startsPlayback) { - const QString targetName = m_selectedTargetName; - setPlaybackPending(false); - refreshTargets(); - emit feedbackText(QStringLiteral("Could not start playback on %1").arg(targetName)); - } - reportCommandError(QStringLiteral("start remote playback"), error); - }, - "remote play command"); - return true; -} - -void RemoteControlController::sendPlaystate(const QString& command, qint64 seekPositionTicks) -{ - if (!targetSelected() || !m_api) - return; - const QString target = m_selectedSessionId; - const bool pauseCommand = command == QStringLiteral("Pause") || command == QStringLiteral("Unpause"); - const RequestGeneration::Token pauseGeneration - = pauseCommand ? m_pauseGeneration.next() : m_pauseGeneration.current(); - Async::runScoped( - this, m_api->sendRemotePlaystate(target, command, seekPositionTicks), - [this]() { QTimer::singleShot(250, this, &RemoteControlController::refreshTargets); }, - [this, command, pauseCommand, pauseGeneration](const std::exception_ptr& error) { - if (pauseCommand && m_pauseGeneration.isCurrent(pauseGeneration)) { - m_pendingPaused.reset(); - m_pendingPauseDeadlineMs = 0; - refreshTargets(); - } - reportCommandError(command, error); - }, - "remote playstate command"); -} - -void RemoteControlController::sendGeneralCommand(const QString& command, const QVariantMap& arguments) -{ - if (!targetSelected() || !m_api) - return; - const QString target = m_selectedSessionId; - Async::runScoped( - this, m_api->sendRemoteGeneralCommand(target, command, QJsonObject::fromVariantMap(arguments)), - [this]() { QTimer::singleShot(250, this, &RemoteControlController::refreshTargets); }, - [this, command](const std::exception_ptr& error) { reportCommandError(command, error); }, - "remote general command"); -} - -void RemoteControlController::requestPaused(bool paused) -{ - if (!targetSelected()) - return; - m_pendingPaused = paused; - m_pendingPauseDeadlineMs = QDateTime::currentMSecsSinceEpoch() + 8'000; - m_paused = paused; - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - updateMediaSession(); - emit stateChanged(); - sendPlaystate(paused ? QStringLiteral("Pause") : QStringLiteral("Unpause")); -} - -void RemoteControlController::togglePause() -{ - requestPaused(!m_paused); -} - -void RemoteControlController::stopPlayback() -{ - sendPlaystate(QStringLiteral("Stop")); -} - -void RemoteControlController::nextTrack() -{ - sendPlaystate(QStringLiteral("NextTrack")); -} - -void RemoteControlController::previousTrack() -{ - sendPlaystate(QStringLiteral("PreviousTrack")); -} - -void RemoteControlController::seek(qint64 positionTicks) -{ - const qint64 clamped = std::clamp(positionTicks, 0, m_runtimeTicks > 0 ? m_runtimeTicks : positionTicks); - m_positionTicks = clamped; - m_stateReceivedAtMs = QDateTime::currentMSecsSinceEpoch(); - m_pendingSeekTicks = clamped; - m_pendingSeekAtMs = m_stateReceivedAtMs; - m_pendingSeekDeadlineMs = m_stateReceivedAtMs + kSeekConfirmTimeoutMs; - sendPlaystate(QStringLiteral("Seek"), clamped); - updateMediaSession(); - emit stateChanged(); -} - -void RemoteControlController::seekRelative(qint64 deltaTicks) -{ - seek(std::max(0, predictedPositionTicks() + deltaTicks)); -} - -void RemoteControlController::setVolume(int volume) -{ - m_volume = std::clamp(volume, 0, 100); - sendGeneralCommand(QStringLiteral("SetVolume"), { { QStringLiteral("Volume"), m_volume } }); - updateMediaSession(); - emit stateChanged(); -} - -void RemoteControlController::adjustVolume(int delta) -{ - setVolume(m_volume + delta); -} - -void RemoteControlController::toggleMute() -{ - m_muted = !m_muted; - sendGeneralCommand(m_muted ? QStringLiteral("Mute") : QStringLiteral("Unmute")); - emit stateChanged(); -} - -void RemoteControlController::selectAudioTrack(int streamIndex) -{ - sendGeneralCommand(QStringLiteral("SetAudioStreamIndex"), { { QStringLiteral("Index"), streamIndex } }); -} - -void RemoteControlController::selectSubtitleTrack(int streamIndex) -{ - sendGeneralCommand(QStringLiteral("SetSubtitleStreamIndex"), { { QStringLiteral("Index"), streamIndex } }); -} - -void RemoteControlController::setRepeatMode(const QString& mode) -{ - m_repeatMode = mode; - sendGeneralCommand(QStringLiteral("SetRepeatMode"), { { QStringLiteral("RepeatMode"), mode } }); - emit stateChanged(); -} - -void RemoteControlController::setShuffled(bool shuffled) -{ - m_shuffled = shuffled; - sendGeneralCommand(QStringLiteral("SetShuffleQueue"), - { { QStringLiteral("ShuffleMode"), shuffled ? QStringLiteral("Shuffle") : QStringLiteral("Sorted") } }); - emit stateChanged(); -} - -void RemoteControlController::playQueueItem(int index) -{ - if (index < 0 || index >= m_rawQueue.size()) - return; - QStringList ids; - for (const QJsonValue& value : m_rawQueue) - ids.push_back(value.toObject().value(QStringLiteral("Id")).toString()); - runPlay(ids, QStringLiteral("PlayNow"), index, 0); -} - -void RemoteControlController::moveQueueItem(int from, int to) -{ - if (from < 0 || from >= m_rawQueue.size() || to < 0 || to >= m_rawQueue.size() || from == to) - return; - QJsonArray reordered = m_rawQueue; - const QJsonValue moved = reordered.takeAt(from); - reordered.insert(to, moved); - QStringList ids; - int current = 0; - const QString currentPlaylistId - = m_sessions.value(m_selectedSessionId).value(QStringLiteral("PlaylistItemId")).toString(); - for (int index = 0; index < reordered.size(); ++index) { - const QJsonObject row = reordered.at(index).toObject(); - ids.push_back(row.value(QStringLiteral("Id")).toString()); - if (!currentPlaylistId.isEmpty() && row.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId) - current = index; - } - runPlay(ids, QStringLiteral("PlayNow"), current, predictedPositionTicks()); -} - -void RemoteControlController::removeQueueItem(int index) -{ - if (index < 0 || index >= m_rawQueue.size()) - return; - QJsonArray remaining = m_rawQueue; - const QJsonObject removed = remaining.takeAt(index).toObject(); - if (remaining.isEmpty()) { - stopPlayback(); - return; - } - QStringList ids; - int current = 0; - const QString currentPlaylistId - = m_sessions.value(m_selectedSessionId).value(QStringLiteral("PlaylistItemId")).toString(); - for (int rowIndex = 0; rowIndex < remaining.size(); ++rowIndex) { - const QJsonObject row = remaining.at(rowIndex).toObject(); - ids.push_back(row.value(QStringLiteral("Id")).toString()); - if (!currentPlaylistId.isEmpty() && row.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId) - current = rowIndex; - } - const bool removedCurrent = removed.value(QStringLiteral("PlaylistItemId")).toString() == currentPlaylistId; - runPlay(ids, QStringLiteral("PlayNow"), current, removedCurrent ? 0 : predictedPositionTicks()); -} - -void RemoteControlController::updateMediaSession() -{ - if (!m_mediaSession || !targetSelected() || m_nowPlayingItem.isEmpty()) { - if (m_mediaSession) - m_mediaSession->clear(); - return; - } - RemoteMediaSessionState state; - state.title = m_nowPlayingItem.value(QStringLiteral("title")).toString(); - state.artist = m_nowPlayingItem.value(QStringLiteral("seriesName")).toString(); - if (state.artist.isEmpty()) - state.artist = m_nowPlayingItem.value(QStringLiteral("albumArtist")).toString(); - state.album = m_nowPlayingItem.value(QStringLiteral("album")).toString(); - state.targetName = m_selectedTargetName; - state.durationMs = m_runtimeTicks / 10'000; - state.positionMs = predictedPositionTicks() / 10'000; - state.playbackRate = m_playbackRate; - state.volume = m_volume; - state.playing = !m_paused; - state.canSeek = m_runtimeTicks > 0; - m_mediaSession->update(state); -} - -void RemoteControlController::reportCommandError(const QString& action, const std::exception_ptr& error) -{ - emit errorText(QStringLiteral("Could not %1: %2").arg(action, exceptionMessage(error))); -} - -bool RemoteControlController::handlePeerSignalling(const SpoolRemoteProtocol::Message& message) -{ - return m_link && m_link->handleServerMessage(message); -} - -} // namespace JellyfinNative diff --git a/src/app/RemoteControlController.h b/src/app/RemoteControlController.h deleted file mode 100644 index 7e152951..00000000 --- a/src/app/RemoteControlController.h +++ /dev/null @@ -1,269 +0,0 @@ -#pragma once - -#include "../common/RequestGeneration.h" -#include "../media/MediaTypes.h" -#include "../player/PlaybackTimeline.h" -#include "../provider/RemotePlayback.h" -#include "SpoolLink.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace JellyfinNative { - -class JellyfinApiFacade; -class PlatformRemoteMediaSession; - -class RemoteControlController final : public RemotePlayback { - Q_OBJECT - Q_PROPERTY(QVariantList targets READ targets NOTIFY targetsChanged) - Q_PROPERTY(QString selectedSessionId READ selectedSessionId NOTIFY targetChanged) - Q_PROPERTY(QString selectedTargetName READ selectedTargetName NOTIFY targetChanged) - Q_PROPERTY(QString selectedTargetDetail READ selectedTargetDetail NOTIFY targetChanged) - Q_PROPERTY(bool targetSelected READ targetSelected NOTIFY targetChanged) - Q_PROPERTY(QVariantMap nowPlayingItem READ nowPlayingItem NOTIFY stateChanged) - Q_PROPERTY(QVariantList queue READ queue NOTIFY stateChanged) - Q_PROPERTY(QVariantList audioTracks READ audioTracks NOTIFY stateChanged) - Q_PROPERTY(QVariantList subtitleTracks READ subtitleTracks NOTIFY stateChanged) - Q_PROPERTY(QStringList supportedCommands READ supportedCommands NOTIFY stateChanged) - Q_PROPERTY(qint64 positionTicks READ positionTicks NOTIFY stateChanged) - Q_PROPERTY(qint64 runtimeTicks READ runtimeTicks NOTIFY stateChanged) - Q_PROPERTY(bool paused READ paused NOTIFY stateChanged) - Q_PROPERTY(bool muted READ muted NOTIFY stateChanged) - Q_PROPERTY(int volume READ volume NOTIFY stateChanged) - Q_PROPERTY(QString repeatMode READ repeatMode NOTIFY stateChanged) - Q_PROPERTY(bool shuffled READ shuffled NOTIFY stateChanged) - Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(bool playbackPending READ playbackPending NOTIFY playbackPendingChanged) - Q_PROPERTY(QString pendingTitle READ pendingTitle NOTIFY playbackPendingChanged) - Q_PROPERTY(bool trickplayAvailable READ trickplayAvailable NOTIFY trickplayChanged) - -public: - explicit RemoteControlController(JellyfinApiFacade *api, QObject *parent = nullptr); - ~RemoteControlController() override; - - QVariantList targets() const - { - return m_targets; - } - QString selectedSessionId() const - { - return m_selectedSessionId; - } - QString selectedTargetName() const - { - return m_selectedTargetName; - } - QString selectedTargetDetail() const - { - return m_selectedTargetDetail; - } - bool targetSelected() const override - { - return !m_selectedSessionId.isEmpty(); - } - QVariantMap nowPlayingItem() const - { - return m_nowPlayingItem; - } - QVariantList queue() const - { - return m_queue; - } - QVariantList audioTracks() const - { - return m_audioTracks; - } - QVariantList subtitleTracks() const - { - return m_subtitleTracks; - } - QStringList supportedCommands() const - { - return m_supportedCommands; - } - qint64 positionTicks() const - { - return m_positionTicks; - } - qint64 runtimeTicks() const - { - return m_runtimeTicks; - } - bool paused() const - { - return m_paused; - } - bool muted() const - { - return m_muted; - } - int volume() const - { - return m_volume; - } - QString repeatMode() const - { - return m_repeatMode; - } - bool shuffled() const - { - return m_shuffled; - } - bool busy() const - { - return m_busy; - } - bool playbackPending() const - { - return m_playbackPending; - } - QString pendingTitle() const - { - return m_pendingTitle; - } - bool trickplayAvailable() const - { - return m_trickplayTimeline.trickplayAvailable(); - } - - void start(); - void stop(); - void applySessions(const QJsonArray& sessions); - bool playItems( - const std::vector& items, int startIndex, const QString& command, bool fromStart) override; - bool handlePeerSignalling(const SpoolRemoteProtocol::Message& message) override; - - Q_INVOKABLE void refreshTargets(); - Q_INVOKABLE void selectTarget(const QString& sessionId); - Q_INVOKABLE void clearTarget(); - Q_INVOKABLE bool supports(const QString& command) const; - Q_INVOKABLE qint64 predictedPositionTicks() const; - Q_INVOKABLE void playItemIds(const QStringList& itemIds, const QString& command = QStringLiteral("PlayNow"), - int startIndex = 0, qint64 startPositionTicks = -1); - Q_INVOKABLE QVariantMap trickplayForTicks(qint64 positionTicks) const; - Q_INVOKABLE void previewSeek(qint64 positionTicks, bool active); - Q_INVOKABLE void cancelSeekPreview(); - // Asks the target to join a SyncPlay group itself. Membership is the - // client's own to hold, so the group sees the device that is playing - // rather than the one holding the remote. - Q_INVOKABLE void requestSyncPlayJoin(const QString& groupId); - SpoolLink *link() const - { - return m_link; - } - Q_INVOKABLE void sendPlaystate(const QString& command, qint64 seekPositionTicks = -1); - Q_INVOKABLE void sendGeneralCommand(const QString& command, const QVariantMap& arguments = {}); - Q_INVOKABLE void togglePause(); - Q_INVOKABLE void stopPlayback(); - Q_INVOKABLE void nextTrack(); - Q_INVOKABLE void previousTrack(); - Q_INVOKABLE void seek(qint64 positionTicks); - Q_INVOKABLE void seekRelative(qint64 deltaTicks); - Q_INVOKABLE void setVolume(int volume); - Q_INVOKABLE void adjustVolume(int delta); - Q_INVOKABLE void toggleMute(); - Q_INVOKABLE void selectAudioTrack(int streamIndex); - Q_INVOKABLE void selectSubtitleTrack(int streamIndex); - Q_INVOKABLE void setRepeatMode(const QString& mode); - Q_INVOKABLE void setShuffled(bool shuffled); - Q_INVOKABLE void playQueueItem(int index); - Q_INVOKABLE void moveQueueItem(int from, int to); - Q_INVOKABLE void removeQueueItem(int index); - -signals: - void targetsChanged(); - void targetChanged(); - void stateChanged(); - void busyChanged(); - void playbackPendingChanged(); - void trickplayChanged(); - void feedbackText(const QString& text); - void errorText(const QString& text); - -private: - void setBusy(bool busy); - void setPlaybackPending(bool pending); - void requestPaused(bool paused); - void stagePendingPlayback(const std::vector& items, qint64 startPositionTicks, const QString& command); - void beginPendingPlayback(const QString& target, const QString& itemId, const QString& title); - void applySelectedSession(bool selectionChanged = false); - void clearPendingCommands(); - void clearSelectedState(bool notify = true); - void refreshQueueDetails(const QJsonArray& rawQueue); - void loadTrickplay(const QString& itemId, const QString& mediaSourceId = {}); - void updateMediaSession(); - bool runPlay( - QStringList itemIds, QString command, int startIndex, qint64 startPositionTicks, QString pendingTitle = {}); - void reportCommandError(const QString& action, const std::exception_ptr& error); - - JellyfinApiFacade *m_api = nullptr; - std::unique_ptr m_mediaSession; - QTimer m_refreshTimer; - QTimer m_pendingRefreshTimer; - QTimer m_pendingTimeoutTimer; - QHash m_sessions; - QVariantList m_targets; - QString m_selectedSessionId; - QString m_selectedTargetName; - QString m_selectedTargetDetail; - QVariantMap m_nowPlayingItem; - QVariantList m_queue; - QVariantList m_audioTracks; - QVariantList m_subtitleTracks; - QStringList m_supportedCommands; - QJsonArray m_rawQueue; - QString m_pendingTargetSessionId; - QString m_pendingItemId; - // What the target was playing when the command went out. It bounds how - // long an unconfirmed request may hold the displayed item: as soon as the - // target reports something that is neither this nor what was asked for, - // somebody has driven it directly and this device should follow. - QString m_pendingPreviousItemId; - QString m_pendingTitle; - RequestGeneration m_sessionsGeneration; - RequestGeneration m_queueGeneration; - RequestGeneration m_playGeneration; - RequestGeneration m_pauseGeneration; - RequestGeneration m_trickplayGeneration; - PlaybackTimeline m_trickplayTimeline; - QString m_trickplayItemId; - QString m_trickplayMediaSourceId; - // Pacing and coalescing belong to the transport, which knows what the - // path can carry; this only has to say what is worth sending. - SpoolLink *m_link = nullptr; - QString m_localSessionId; - qint64 m_previewSentTicks = -1; - bool m_previewSentActive = false; - qint64 m_positionTicks = 0; - qint64 m_runtimeTicks = 0; - qint64 m_stateReceivedAtMs = 0; - qint64 m_pendingPauseDeadlineMs = 0; - // Where a seek this device asked for put the other end. The server keeps - // reporting the position from before the seek until the target reports its - // own, so the bar would snap back and only jump forward seconds later. - std::optional m_pendingSeekTicks; - qint64 m_pendingSeekAtMs = 0; - qint64 m_pendingSeekDeadlineMs = 0; - double m_playbackRate = 1.0; - int m_volume = 100; - bool m_paused = true; - std::optional m_pendingPaused; - bool m_muted = false; - bool m_shuffled = false; - bool m_busy = false; - bool m_playbackPending = false; - QString m_repeatMode = QStringLiteral("RepeatNone"); -}; - -} // namespace JellyfinNative diff --git a/src/app/SessionController.cpp b/src/app/SessionController.cpp deleted file mode 100644 index 729f6d13..00000000 --- a/src/app/SessionController.cpp +++ /dev/null @@ -1,519 +0,0 @@ -#include "SessionController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../cache/DatabaseManager.h" -#include "../common/AsyncTask.h" -#include "../models/DiscoveredServerModel.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { - -SessionController::SessionController(DatabaseManager *database, JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_database(database) - , m_api(api) -{ - connect(m_api, &JellyfinApiFacade::deviceProfileChanged, this, [this]() { - if (!authenticated()) - return; - Async::runScoped( - this, m_api->postCapabilities(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "session: updated capability report failed" << exceptionMessage(error); - }); - }); -} - -QString SessionController::serverUrl() const -{ - return m_serverUrl; -} - -QString SessionController::serverName() const -{ - return m_serverName; -} - -QString SessionController::username() const -{ - return m_username; -} - -QString SessionController::password() const -{ - return m_password; -} - -bool SessionController::authenticated() const -{ - return !m_api->session().accessToken.isEmpty(); -} - -QString SessionController::activeProfileId() const -{ - return m_activeProfileId; -} - -QString SessionController::activeProfileLabel() const -{ - const auto it = std::find_if(m_profiles.cbegin(), m_profiles.cend(), - [this](const AccountProfile& profile) { return profile.profileId == m_activeProfileId; }); - return it == m_profiles.cend() ? QString() : it->displayLabel(); -} - -bool SessionController::profileSignInRequired() const -{ - return m_profileSignInRequired; -} - -QVariantList SessionController::accountProfiles() const -{ - QVariantList result; - result.reserve(static_cast(m_profiles.size())); - for (const AccountProfile& profile : m_profiles) { - const QUrl url(profile.serverUrl); - const QString host = url.host().isEmpty() ? profile.serverUrl : url.host(); - result.push_back(QVariantMap { { QStringLiteral("profileId"), profile.profileId }, - { QStringLiteral("serverName"), profile.serverName }, { QStringLiteral("serverUrl"), profile.serverUrl }, - { QStringLiteral("serverHost"), host }, { QStringLiteral("userName"), profile.userName }, - { QStringLiteral("avatarTag"), profile.avatarTag }, - { QStringLiteral("needsAuthentication"), profile.needsAuthentication || profile.accessToken.isEmpty() } }); - } - return result; -} - -QStringList SessionController::localStorageKeys() -{ - return { QStringLiteral("login/serverUrl"), QStringLiteral("login/username") }; -} - -QCoro::Task SessionController::initializeAsync() -{ - StartupState state = co_await m_database->loadStartupStateAsync(localStorageKeys()); - co_return initializeFromStorage(std::move(state.values), std::move(state.profiles)); -} - -bool SessionController::initializeFromStorage(QVariantMap values, std::vector profiles) -{ - m_serverUrl = canonicalServerUrl(values.value(QStringLiteral("login/serverUrl")).toString()); - m_username = values.value(QStringLiteral("login/username")).toString(); - m_profiles = std::move(profiles); - sortProfiles(); - - m_api->setSession({}); - emit serverUrlChanged(); - emit usernameChanged(); - emit accountProfilesChanged(); - if (m_profiles.size() == 1) - applyStoredProfile(m_profiles.front(), true); - return !m_profiles.empty(); -} - -void SessionController::setDiscoveredServers(DiscoveredServerModel *servers) -{ - m_discoveredServers = servers; -} - -void SessionController::chooseDiscoveredServer(int index) -{ - if (!m_discoveredServers) - return; - const auto server = m_discoveredServers->serverAt(index); - if (server.address.isEmpty()) - return; - setServerName(server.name); - setServerUrl(server.address); -} - -void SessionController::rememberServer(const QString& name, const QString& address) -{ - const QString normalizedAddress = address.trimmed(); - if (normalizedAddress.isEmpty()) - return; - - const QString serverName = name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(); - if (m_discoveredServers) { - m_discoveredServers->upsertServer({ normalizedAddress, serverName, normalizedAddress }); - emit serverRemembered(); - } - setServerName(serverName); - setServerUrl(normalizedAddress); -} - -void SessionController::switchUser() -{ - qInfo() << "session: switch user requested"; - emit switchUserRequested(); -} - -void SessionController::setServerUrl(const QString& serverUrl) -{ - QString normalized = canonicalServerUrl(serverUrl); - if (normalized.isEmpty()) { - normalized = serverUrl.trimmed(); - while (normalized.endsWith(QLatin1Char('/'))) - normalized.chop(1); - } - if (m_serverUrl == normalized) - return; - m_serverUrl = normalized; - emit serverUrlChanged(); -} - -void SessionController::setServerName(const QString& serverName) -{ - const QString normalized = serverName.trimmed(); - if (m_serverName == normalized) - return; - m_serverName = normalized; - emit serverNameChanged(); -} - -void SessionController::setUsername(const QString& username) -{ - if (m_username == username) - return; - m_username = username; - emit usernameChanged(); -} - -void SessionController::setPassword(const QString& password) -{ - if (m_password == password) - return; - m_password = password; - emit passwordChanged(); -} - -void SessionController::login() -{ - if (m_serverUrl.isEmpty() || m_username.isEmpty()) { - emit errorOccurred(QStringLiteral("Server and username are required.")); - return; - } - - emit busyChanged(true, QStringLiteral("Signing in…")); - m_api->setServerUrl(m_serverUrl); - auto authentication = m_api->authenticateByName(m_username, m_password); - clearPassword(); - Async::runScoped( - this, std::move(authentication), - [this](const AuthSession& session) { - emit busyChanged(false, {}); - activateSession(session, true); - }, - [this](const std::exception_ptr& error) { - emit busyChanged(false, {}); - emit errorOccurred(exceptionMessage(error)); - }); -} - -void SessionController::activateProfile(const QString& profileId) -{ - emit profileActivationStarted(); - emit busyChanged(true, QStringLiteral("Opening profile…")); - Async::runScoped( - this, activateProfileAsync(profileId), []() {}, - [this](const std::exception_ptr& error) { - emit busyChanged(false, {}); - emit errorOccurred(exceptionMessage(error)); - }, - "profile activation"); -} - -QCoro::Task SessionController::activateProfileAsync(const QString& profileId) -{ - const std::optional stored = co_await m_database->activateAccountProfileAsync(profileId); - emit busyChanged(false, {}); - if (!stored) { - emit errorOccurred(QStringLiteral("That saved account is no longer available.")); - co_return; - } - if (!applyStoredProfile(*stored, false)) - emit errorOccurred(QStringLiteral("Authentication required for this account.")); -} - -bool SessionController::applyStoredProfile(AccountProfile profile, bool persistUsage) -{ - const bool authenticationRequired = profile.needsAuthentication || profile.accessToken.isEmpty(); - if (persistUsage && !authenticationRequired) { - profile.lastUsedAt = QDateTime::currentMSecsSinceEpoch(); - m_database->upsertAccountProfile(profile); - } - - const auto local = std::find_if(m_profiles.begin(), m_profiles.end(), - [&profile](const AccountProfile& candidate) { return candidate.profileId == profile.profileId; }); - if (local != m_profiles.end()) - *local = profile; - else - m_profiles.push_back(profile); - sortProfiles(); - emit accountProfilesChanged(); - - setServerName(profile.serverName); - setServerUrl(profile.serverUrl); - setUsername(profile.userName); - clearPassword(); - if (authenticationRequired) { - if (!m_profileSignInRequired) - m_profileSignInRequired = true; - // This is also the navigation event. Re-emit when an already-expired - // profile is chosen again after returning to the profile grid. - emit profileSignInRequiredChanged(); - return false; - } - - if (m_profileSignInRequired) { - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - } - m_activeProfileId = profile.profileId; - emit activeProfileChanged(); - activateSession({ profile.userId, profile.userName, profile.accessToken, profile.serverId }, false); - return true; -} - -void SessionController::prepareProfileSignIn(const QString& profileId) -{ - const auto it = std::find_if(m_profiles.cbegin(), m_profiles.cend(), - [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (it == m_profiles.cend()) { - emit errorOccurred(QStringLiteral("That saved account is no longer available.")); - return; - } - setProfileSignInFields(*it); -} - -void SessionController::setProfileSignInFields(const AccountProfile& profile) -{ - setServerUrl(profile.serverUrl); - setServerName(profile.serverName); - setUsername(profile.userName); - clearPassword(); - if (!m_profileSignInRequired) { - m_profileSignInRequired = true; - emit profileSignInRequiredChanged(); - } -} - -void SessionController::updateProfileServer(const QString& profileId, const QString& name, const QString& url) -{ - const QString canonicalUrl = canonicalServerUrl(url); - const auto it = std::find_if(m_profiles.begin(), m_profiles.end(), - [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (it == m_profiles.end() || canonicalUrl.isEmpty()) { - emit errorOccurred(QStringLiteral("Enter a valid server address.")); - return; - } - it->serverName = name.trimmed().isEmpty() ? QStringLiteral("Jellyfin Server") : name.trimmed(); - it->serverUrl = canonicalUrl; - m_database->upsertAccountProfile(*it); - if (m_activeProfileId == profileId) { - setServerName(it->serverName); - setServerUrl(it->serverUrl); - m_api->setServerUrl(it->serverUrl); - emit activeProfileChanged(); - } - emit accountProfilesChanged(); -} - -void SessionController::removeProfile(const QString& profileId) -{ - const bool removingActive = profileId == m_activeProfileId; - const auto oldSize = m_profiles.size(); - std::erase_if(m_profiles, [&profileId](const AccountProfile& profile) { return profile.profileId == profileId; }); - if (m_profiles.size() == oldSize) - return; - m_database->removeAccountProfile(profileId); - emit accountProfilesChanged(); - if (removingActive) { - clearActiveSession(); - emit loggedOut(); - } -} - -void SessionController::clearProfiles() -{ - if (m_profiles.empty()) - return; - m_profiles.clear(); - m_database->clearAccountProfiles(); - emit accountProfilesChanged(); - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::deactivate() -{ - clearPassword(); - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::acceptSession(const AuthSession& session) -{ - activateSession(session, true); -} - -void SessionController::logout() -{ - qInfo() << "session: logout requested"; - emit logoutStarted(); - clearPassword(); - if (!m_activeProfileId.isEmpty()) { - const auto it = std::find_if(m_profiles.begin(), m_profiles.end(), - [this](const AccountProfile& profile) { return profile.profileId == m_activeProfileId; }); - if (it != m_profiles.end()) { - it->accessToken.clear(); - it->needsAuthentication = true; - m_database->expireAccountProfile(it->profileId); - emit accountProfilesChanged(); - } - } - clearActiveSession(); - emit loggedOut(); -} - -void SessionController::expireSession(const QString& message) -{ - if (m_api->session().accessToken.isEmpty()) - return; - qWarning() << "session: authentication expired"; - const QString expiredProfileId = m_activeProfileId; - logout(); - if (!expiredProfileId.isEmpty()) - prepareProfileSignIn(expiredProfileId); - emit errorOccurred(message); -} - -bool SessionController::handleUnauthorized(const std::exception_ptr& error) -{ - const QString message = exceptionMessage(error); - if (!message.contains(QStringLiteral("(401)")) - && !message.contains(QStringLiteral("Unauthorized"), Qt::CaseInsensitive)) { - return false; - } - - if (!m_api->session().accessToken.isEmpty()) - expireSession(QStringLiteral("Your Jellyfin session has expired. Sign in again.")); - return true; -} - -void SessionController::activateSession(const AuthSession& session, bool persist) -{ - clearPassword(); - if (session.accessToken.isEmpty()) { - emit errorOccurred(QStringLiteral("The server returned an empty session.")); - return; - } - - AuthSession activeSession = session; - if (activeSession.userName.isEmpty()) - activeSession.userName = m_username; - if (!activeSession.userName.isEmpty() && m_username != activeSession.userName) { - m_username = activeSession.userName; - emit usernameChanged(); - } - - m_api->setServerUrl(m_serverUrl); - m_api->setSession(activeSession); - if (m_profileSignInRequired) { - m_profileSignInRequired = false; - emit profileSignInRequiredChanged(); - } - if (persist) { - m_database->saveLoginHints(m_serverUrl, m_username); - upsertActiveProfile(activeSession); - } - - if (m_username.isEmpty() && !activeSession.userId.isEmpty()) { - Async::runScoped( - this, m_api->fetchCurrentUserName(), - [this](const QString& userName) { - const QString normalized = userName.trimmed(); - if (normalized.isEmpty() || !m_username.isEmpty()) - return; - m_username = normalized; - emit usernameChanged(); - AuthSession refreshed = m_api->session(); - refreshed.userName = normalized; - m_api->setSession(refreshed); - m_database->saveLoginHints(m_serverUrl, normalized); - upsertActiveProfile(refreshed); - }, - [](const std::exception_ptr& error) { - qWarning() << "session: user name backfill failed" << exceptionMessage(error); - }); - } - - emit authenticatedChanged(activeSession); - emit authenticatedStateChanged(); - Async::runScoped( - this, m_api->postCapabilities(), []() {}, - [](const std::exception_ptr& error) { - qWarning() << "session: capability report failed" << exceptionMessage(error); - }); -} - -void SessionController::upsertActiveProfile(const AuthSession& session) -{ - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = accountProfileId(session.serverId, m_serverUrl, session.userId); - const auto existing = std::find_if( - m_profiles.begin(), m_profiles.end(), [&id](const AccountProfile& profile) { return profile.profileId == id; }); - const qint64 createdAt = existing == m_profiles.end() ? now : existing->createdAt; - const QString avatarTag = existing == m_profiles.end() ? QString() : existing->avatarTag; - AccountProfile profile { id, session.serverId, - m_serverName.isEmpty() ? QStringLiteral("Jellyfin Server") : m_serverName, canonicalServerUrl(m_serverUrl), - session.userId, session.userName.isEmpty() ? m_username : session.userName, session.accessToken, avatarTag, now, - createdAt, false }; - if (existing == m_profiles.end()) - m_profiles.push_back(profile); - else - *existing = profile; - m_activeProfileId = id; - m_database->upsertAccountProfile(profile); - sortProfiles(); - emit activeProfileChanged(); - emit accountProfilesChanged(); -} - -void SessionController::clearActiveSession() -{ - const bool wasAuthenticated = authenticated(); - const bool hadActiveProfile = !m_activeProfileId.isEmpty(); - m_activeProfileId.clear(); - m_api->setSession({}); - clearPassword(); - if (hadActiveProfile) - emit activeProfileChanged(); - if (wasAuthenticated) - emit authenticatedStateChanged(); -} - -void SessionController::clearPassword() -{ - if (m_password.isEmpty()) - return; - m_password.fill(QLatin1Char('\0')); - m_password.clear(); - emit passwordChanged(); -} - -void SessionController::sortProfiles() -{ - std::stable_sort(m_profiles.begin(), m_profiles.end(), [](const AccountProfile& left, const AccountProfile& right) { - if (left.lastUsedAt != right.lastUsedAt) - return left.lastUsedAt > right.lastUsedAt; - return left.createdAt < right.createdAt; - }); -} - -} // namespace JellyfinNative diff --git a/src/app/SessionController.h b/src/app/SessionController.h deleted file mode 100644 index 9c99d7cf..00000000 --- a/src/app/SessionController.h +++ /dev/null @@ -1,117 +0,0 @@ -#pragma once - -#include "../api/JellyfinSession.h" -#include "../media/MediaTypes.h" -#include "AccountProfile.h" - -#include -#include -#include -#include -#include - -#include -#include - -namespace JellyfinNative { - -class DatabaseManager; -class DiscoveredServerModel; -class JellyfinApiFacade; - -class SessionController final : public QObject { - Q_OBJECT - Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) - Q_PROPERTY(QString serverName READ serverName WRITE setServerName NOTIFY serverNameChanged) - Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged) - Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged) - Q_PROPERTY(bool authenticated READ authenticated NOTIFY authenticatedStateChanged) - Q_PROPERTY(QString activeProfileId READ activeProfileId NOTIFY activeProfileChanged) - Q_PROPERTY(QString activeProfileLabel READ activeProfileLabel NOTIFY activeProfileChanged) - Q_PROPERTY(QVariantList accountProfiles READ accountProfiles NOTIFY accountProfilesChanged) - Q_PROPERTY(bool profileSignInRequired READ profileSignInRequired NOTIFY profileSignInRequiredChanged) - -public: - SessionController(DatabaseManager *database, JellyfinApiFacade *api, QObject *parent = nullptr); - - QString serverUrl() const; - QString serverName() const; - QString username() const; - QString password() const; - bool authenticated() const; - QString activeProfileId() const; - QString activeProfileLabel() const; - QVariantList accountProfiles() const; - bool profileSignInRequired() const; - - QCoro::Task initializeAsync(); - static QStringList localStorageKeys(); - bool initializeFromStorage(QVariantMap values, std::vector profiles); - // The list the server step offers; the provider owns it and caches it. - void setDiscoveredServers(DiscoveredServerModel *servers); - Q_INVOKABLE void chooseDiscoveredServer(int index); - Q_INVOKABLE void rememberServer(const QString& name, const QString& address); - // Back to the profile picker while the session is kept, so a cancel - // returns to it; the provider scans for servers meanwhile. - Q_INVOKABLE void switchUser(); - Q_INVOKABLE void setServerUrl(const QString& serverUrl); - Q_INVOKABLE void setServerName(const QString& serverName); - Q_INVOKABLE void setUsername(const QString& username); - Q_INVOKABLE void setPassword(const QString& password); - Q_INVOKABLE void login(); - Q_INVOKABLE void activateProfile(const QString& profileId); - Q_INVOKABLE void prepareProfileSignIn(const QString& profileId); - Q_INVOKABLE void updateProfileServer(const QString& profileId, const QString& name, const QString& url); - Q_INVOKABLE void removeProfile(const QString& profileId); - Q_INVOKABLE void clearProfiles(); - Q_INVOKABLE void deactivate(); - void acceptSession(const AuthSession& session); - Q_INVOKABLE void logout(); - void expireSession(const QString& message); - bool handleUnauthorized(const std::exception_ptr& error); - -signals: - void serverUrlChanged(); - void serverNameChanged(); - void usernameChanged(); - void passwordChanged(); - void busyChanged(bool busy, const QString& text); - void errorOccurred(const QString& message); - void authenticatedChanged(const JellyfinNative::AuthSession& session); - void authenticatedStateChanged(); - void activeProfileChanged(); - void accountProfilesChanged(); - void profileSignInRequiredChanged(); - void loggedOut(); - // A remembered or chosen server was added to the list. - void serverRemembered(); - // Lifecycle points the provider hangs its own parts on: sign-in code - // polling stops, the SyncPlay socket and remote-control registration are - // dropped before the session changes hands. - void profileActivationStarted(); - void switchUserRequested(); - void logoutStarted(); - -private: - QCoro::Task activateProfileAsync(const QString& profileId); - bool applyStoredProfile(AccountProfile profile, bool persistUsage); - void activateSession(const AuthSession& session, bool persist); - void upsertActiveProfile(const AuthSession& session); - void setProfileSignInFields(const AccountProfile& profile); - void clearActiveSession(); - void clearPassword(); - void sortProfiles(); - - DatabaseManager *m_database = nullptr; - JellyfinApiFacade *m_api = nullptr; - DiscoveredServerModel *m_discoveredServers = nullptr; - QString m_serverUrl; - QString m_serverName; - QString m_username; - QString m_password; - QString m_activeProfileId; - std::vector m_profiles; - bool m_profileSignInRequired = false; -}; - -} // namespace JellyfinNative diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index 0a7f94c2..947d58d7 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -10,7 +10,7 @@ #include "SettingsSchema.h" #include -#include +#include #include #include @@ -48,10 +48,12 @@ SettingsController::SettingsController( QStringList SettingsController::subtitleLanguageOptions() const { + loadSubtitleLanguages(); return m_subtitleLanguageLabels; } int SettingsController::subtitleLanguageIndex() const { + loadSubtitleLanguages(); const int index = m_subtitleLanguageCodes.indexOf(m_subtitlePreferences.language); return index >= 0 ? index : 0; } @@ -70,16 +72,10 @@ QVariantList SettingsController::settingsSchema() const // Cached for the same reason as the fonts above, and because the row set // is fixed once the provider is chosen. if (m_schema.isEmpty()) - m_schema = settingSchemaModel(!m_capabilities || m_capabilities->auth()); + m_schema = settingSchemaModel(); return m_schema; } -void SettingsController::setProviderCapabilities(const ProviderCapabilities *capabilities) -{ - m_capabilities = capabilities; - m_schema.clear(); -} - QVariantMap SettingsController::values() const { return m_values; @@ -162,9 +158,7 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) } const QVariant rawValue = storedValues.value(key); QVariant defaultValue = settingDefaultValue(spec); - if (spec.target == SettingTarget::CastButtonEnabled) - defaultValue = platformDefaultCastButtonEnabled(); - else if (spec.target == SettingTarget::RemoteControlTargetEnabled) + if (spec.target == SettingTarget::RemoteControlTargetEnabled) defaultValue = platformDefaultRemoteControlTargetEnabled(); else if (spec.target == SettingTarget::RenderQuality) defaultValue = QString::fromLatin1(platformDefaultRenderQuality()); @@ -227,64 +221,25 @@ void SettingsController::applyLocalValues(const QVariantMap& storedValues) emit remoteControlSettingsChanged(); } -void SettingsController::loadRemote() -{ - emit remoteLoadRequested(); -} - -void SettingsController::applyRemoteCultures(const QJsonArray& cultures) +void SettingsController::loadSubtitleLanguages() const { - QStringList codes { QString() }; - QStringList labels { QStringLiteral("Any language") }; - QSet seen { QString() }; - - for (const QJsonValue& value : cultures) { - const QJsonObject culture = value.toObject(); - const QString code = culture.value(QStringLiteral("ThreeLetterISOLanguageName")).toString(); - if (code.isEmpty() || seen.contains(code)) - continue; - QString label = culture.value(QStringLiteral("DisplayName")).toString(); - if (label.isEmpty()) - label = code.toUpper(); - seen.insert(code); - codes.push_back(code); - labels.push_back(label); + if (m_subtitleLanguageCodes.size() > 1) + return; + // Every language Qt knows, by display name; the preference is stored as + // the ISO 639-2 code players match against. + QList> languages; + for (int value = QLocale::Abkhazian; value <= QLocale::LastLanguage; ++value) { + const auto language = static_cast(value); + const QString code = QLocale::languageToCode(language, QLocale::ISO639Part2); + if (code.size() == 3) + languages.append({ QLocale::languageToString(language), code }); } - - if (!m_subtitlePreferences.language.isEmpty() && !seen.contains(m_subtitlePreferences.language)) { - codes.push_back(m_subtitlePreferences.language); - labels.push_back(m_subtitlePreferences.language.toUpper()); + std::sort(languages.begin(), languages.end(), + [](const auto& a, const auto& b) { return a.first.localeAwareCompare(b.first) < 0; }); + for (const auto& [label, code] : std::as_const(languages)) { + m_subtitleLanguageCodes.push_back(code); + m_subtitleLanguageLabels.push_back(label); } - - m_subtitleLanguageCodes = codes; - m_subtitleLanguageLabels = labels; - emit subtitleSettingsChanged(); -} - -void SettingsController::applyRemoteUserConfiguration(const QJsonObject& configuration) -{ - m_userConfiguration = configuration; - m_subtitlePreferences.audioLanguage = configuration.value(QStringLiteral("AudioLanguagePreference")).toString(); - const SettingSpec& languageSpec = specForKey("subtitles/language"); - const SettingSpec& modeSpec = specForKey("subtitles/mode"); - const SettingSpec& audioModeSpec = specForKey("audio/trackMode"); - setSchemaValue( - languageSpec, configuration.value(QStringLiteral("SubtitleLanguagePreference")).toString(), true, false, false); - setSchemaValue(modeSpec, configuration.value(QStringLiteral("SubtitleMode")).toString(QStringLiteral("Default")), - true, false, false); - setSchemaValue(audioModeSpec, - configuration.value(QStringLiteral("PlayDefaultAudioTrack")).toBool(true) ? QStringLiteral("Default") - : QStringLiteral("Smart"), - true, false, false); - applySubtitlePreferencesToPlayer(); - emit settingsValuesChanged(); - emit subtitleSettingsChanged(); -} - -void SettingsController::clearRemote() -{ - m_userConfiguration = {}; - emit remoteCleared(); } void SettingsController::completePlayerControlTooltipSession() @@ -422,6 +377,7 @@ void SettingsController::setUiScalePercent(int percent) } void SettingsController::setSubtitleLanguageIndex(int index) { + loadSubtitleLanguages(); if (index >= 0 && index < m_subtitleLanguageCodes.size()) setValue(QStringLiteral("subtitles/language"), m_subtitleLanguageCodes.at(index)); } @@ -474,9 +430,6 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian if (apply && m_player) m_player->setNightModeEnabled(m_nightModeEnabled); break; - case SettingTarget::CastButtonEnabled: - m_castButtonEnabled = value.toBool(); - break; case SettingTarget::RemoteControlTargetEnabled: m_remoteControlTargetEnabled = value.toBool(); emit remoteControlTargetEnabledChanged(m_remoteControlTargetEnabled); @@ -593,7 +546,6 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian case SettingTarget::AudioTrackMode: m_subtitlePreferences.audioMode = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; @@ -602,14 +554,12 @@ void SettingsController::applySchemaValue(const SettingSpec& spec, const QVarian case SettingTarget::SubtitleLanguage: m_subtitlePreferences.language = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; case SettingTarget::SubtitleMode: m_subtitlePreferences.mode = value.toString(); if (apply) { - saveSubtitleUserConfiguration(); applySubtitlePreferencesToPlayer(); } break; @@ -769,7 +719,6 @@ void SettingsController::emitSchemaSignals(const SettingSpec& spec) case SettingTarget::NightMode: emit nightModeChanged(); break; - case SettingTarget::CastButtonEnabled: case SettingTarget::RemoteControlTargetEnabled: emit remoteControlSettingsChanged(); break; @@ -933,17 +882,6 @@ void SettingsController::applyLoadedAudioDelay(const QString& output, int delayM emit audioDelayChanged(); } -void SettingsController::saveSubtitleUserConfiguration() -{ - QJsonObject configuration = m_userConfiguration; - configuration.insert(QStringLiteral("SubtitleLanguagePreference"), m_subtitlePreferences.language); - configuration.insert(QStringLiteral("SubtitleMode"), m_subtitlePreferences.mode); - const bool smartAudio = m_subtitlePreferences.audioMode == QStringLiteral("Smart"); - configuration.insert(QStringLiteral("PlayDefaultAudioTrack"), !smartAudio); - m_userConfiguration = configuration; - emit userConfigurationChanged(configuration); -} - void SettingsController::applyMpvConfigPolicy() { const MpvConfigPolicy policy = validatedPlatformMpvConfigPolicy(m_mpvConfigMode, m_mpvConfigDirectory); diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index 09db9a1c..0c6fbe3f 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -5,8 +5,6 @@ #include "../platform/MpvConfigPolicy.h" #include -#include -#include #include #include #include @@ -15,7 +13,6 @@ namespace JellyfinNative { class DatabaseManager; -class ProviderCapabilities; class ArtworkService; class PlayerController; struct SettingSpec; @@ -43,7 +40,6 @@ class SettingsController final : public QObject { Q_PROPERTY(QVariantMap values READ values NOTIFY settingsValuesChanged) Q_PROPERTY( bool playerControlTooltipsEnabled READ playerControlTooltipsEnabled NOTIFY playerControlTooltipsEnabledChanged) - Q_PROPERTY(bool castButtonEnabled READ castButtonEnabled NOTIFY remoteControlSettingsChanged) Q_PROPERTY(bool remoteControlTargetEnabled READ remoteControlTargetEnabled NOTIFY remoteControlSettingsChanged) public: @@ -82,17 +78,12 @@ class SettingsController final : public QObject { // The active provider's capabilities decide which rows exist (the // account rows need auth). Set before QML first reads the schema; the // schema is built once, on that first read. - void setProviderCapabilities(const ProviderCapabilities *capabilities); QVariantMap values() const; Q_INVOKABLE QVariant value(const QString& key) const; bool playerControlTooltipsEnabled() const { return m_playerControlTooltipSessions < 3; } - bool castButtonEnabled() const - { - return m_castButtonEnabled; - } bool remoteControlTargetEnabled() const { return m_remoteControlTargetEnabled; @@ -102,14 +93,6 @@ class SettingsController final : public QObject { void applyLocalValues(const QVariantMap& storedValues); QCoro::Task loadLocalAsync(); - // The half of settings a media source keeps for the account lives with - // that source. loadRemote() asks whoever is listening to fetch it, and - // the apply methods below take what comes back; this class never talks - // to a server itself. - Q_INVOKABLE void loadRemote(); - void clearRemote(); - void applyRemoteCultures(const QJsonArray& cultures); - void applyRemoteUserConfiguration(const QJsonObject& configuration); Q_INVOKABLE void setValue(const QString& key, const QVariant& value); Q_INVOKABLE void previewValue(const QString& key, const QVariant& value); Q_INVOKABLE void completePlayerControlTooltipSession(); @@ -134,10 +117,7 @@ class SettingsController final : public QObject { void playbackPreferencesChanged( qint64 manualMaxStreamingBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxStreamingHeight); void remoteControlTargetEnabledChanged(bool enabled); - void userConfigurationChanged(const QJsonObject& configuration); // Pulls from it. - void remoteLoadRequested(); - void remoteCleared(); void nightModeChanged(); void audioDelayChanged(); @@ -160,21 +140,19 @@ class SettingsController final : public QObject { void applyAudioDelayToPlayer(); void loadCurrentAudioDelay(); void applyLoadedAudioDelay(const QString& output, int delayMs); - void saveSubtitleUserConfiguration(); + void loadSubtitleLanguages() const; void applySubtitlePreferencesToPlayer(); void applyMpvConfigPolicy(); DatabaseManager *m_database = nullptr; PlayerController *m_player = nullptr; ArtworkService *m_artwork = nullptr; - const ProviderCapabilities *m_capabilities = nullptr; mutable QVariantList m_schema; QString m_artworkFormat = QStringLiteral("auto"); int m_artworkWebpQuality = 75; int m_artworkJpegQuality = 82; QVariantMap m_values; bool m_nightModeEnabled = false; - bool m_castButtonEnabled = true; bool m_remoteControlTargetEnabled = true; bool m_toneMappingVisualizationEnabled = false; int m_maxStreamingHeight = 0; @@ -198,9 +176,8 @@ class SettingsController final : public QObject { int m_uiScalePercent; int m_playerControlTooltipSessions = 0; SubtitlePreferences m_subtitlePreferences; - QStringList m_subtitleLanguageCodes { QString() }; - QStringList m_subtitleLanguageLabels { QStringLiteral("Any language") }; - QJsonObject m_userConfiguration; + mutable QStringList m_subtitleLanguageCodes { QString() }; + mutable QStringList m_subtitleLanguageLabels { QStringLiteral("Any language") }; QString m_redButtonAction = QStringLiteral("none"); QString m_greenButtonAction = QStringLiteral("skipBackAndEnableSubs"); QString m_yellowButtonAction = QStringLiteral("none"); diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index 44620489..06df5dde 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -61,6 +61,8 @@ namespace { constexpr SettingChoice kRailLabelChoices[] = { { "Never", "Never" }, { "On focus", "On focus" }, { "Always", "Always" } }; constexpr SettingChoice kTextRenderModeChoices[] = { { "0", "Standard" }, { "1", "Curve" } }; + constexpr SettingChoice kProviderUpdateChoices[] + = { { "auto", "Automatic" }, { "ask", "Ask first" }, { "manual", "Only when I check" } }; constexpr SettingChoice kArtworkFormatChoices[] = { { "auto", "Automatic" }, { "webp", "WebP (smaller downloads)" }, { "jpeg", "JPEG (faster to decode)" } }; constexpr SettingChoice kTechnicalMetadataChoices[] @@ -281,13 +283,6 @@ SettingSpec SettingSpec::onAndroid() const return spec; } -SettingSpec SettingSpec::withAuth() const -{ - SettingSpec spec = *this; - spec.requiresAuth = true; - return spec; -} - SettingSpec SettingSpec::whenSetTo(const char *otherKey, const char *otherValue) const { SettingSpec spec = *this; @@ -337,11 +332,8 @@ const QVector& settingSpecs() "JPEG needs a higher number than WebP to look the same", "82", 40, 100, 1, "", SettingTarget::ArtworkJpegQuality) .advanced(), - toggleSpec("remote/showCastButton", "Remote Control", "Show Cast button", - "Choose and control another Jellyfin client", true, SettingTarget::CastButtonEnabled), toggleSpec("remote/acceptCommands", "Remote Control", "Allow remote control", - "Let other Jellyfin clients play and control media on this device", true, - SettingTarget::RemoteControlTargetEnabled), + "Let your other devices play and control media here", true, SettingTarget::RemoteControlTargetEnabled), selectSpec("audio/trackMode", "Playback", "Audio track", "Which track plays when a video starts", "Default", kAudioTrackModeChoices, SettingTarget::AudioTrackMode), toggleSpec("playback/rememberSeriesAudioTrack", "Playback", "Remember audio track per series", @@ -554,11 +546,11 @@ const QVector& settingSpecs() .onAndroid(), #endif - pageSpec("session/account", "Account", "Signed in as", "", SettingType::ReadOnly).withAuth(), - pageSpec("action/switchUser", "Account", "Switch profile", "", SettingType::Action).withAuth(), - pageSpec("action/logout", "Account", "Sign out", "Keeps this profile on the device", SettingType::Action) - .withAuth(), - pageSpec("action/manageCertificates", "Account", "Remembered certificates", + pageSpec("action/accounts", "Accounts", "Accounts", "Sign in, switch or remove", SettingType::Action), + pageSpec("action/providers", "Accounts", "Providers", "Install, update or remove", SettingType::Action), + selectSpec("providers/updates", "Accounts", "Provider updates", "", "ask", kProviderUpdateChoices, + SettingTarget::External), + pageSpec("action/manageCertificates", "Accounts", "Remembered certificates", "Server certificates you chose to trust", SettingType::Action), pageSpec("action/exportDiagnostics", "Diagnostics", "Export diagnostics", @@ -651,13 +643,11 @@ QString serializedSettingValue(const SettingSpec& spec, const QVariant& value) return normalized.toString(); } -QVariantList settingSchemaModel(bool auth) +QVariantList settingSchemaModel() { QVariantList model; model.reserve(settingSpecs().size()); for (const SettingSpec& spec : settingSpecs()) { - if (spec.requiresAuth && !auth) - continue; QVariantMap row { { QStringLiteral("key"), QLatin1String(spec.key) }, { QStringLiteral("source"), spec.persisted ? QStringLiteral("settings") : QStringLiteral("page") }, { QStringLiteral("group"), QLatin1String(spec.group) }, diff --git a/src/app/SettingsSchema.h b/src/app/SettingsSchema.h index 9a54bcde..139f3e00 100644 --- a/src/app/SettingsSchema.h +++ b/src/app/SettingsSchema.h @@ -31,7 +31,6 @@ enum class SettingPlatform { enum class SettingTarget { External, NightMode, - CastButtonEnabled, RemoteControlTargetEnabled, ToneMappingVisualization, MaxStreamingHeight, @@ -133,9 +132,6 @@ struct SettingSpec { const char *dependsOnValue = ""; bool persisted = true; bool requiresHdrPlayback = false; - // Only meaningful with a source that has accounts: absent from the - // schema model when the active provider has no auth capability. - bool requiresAuth = false; // Declaration modifiers. Each returns a copy so specs read as one // expression: slider(...).advanced().onDesktop(). @@ -146,7 +142,6 @@ struct SettingSpec { SettingSpec onAndroid() const; SettingSpec whenSetTo(const char *otherKey, const char *otherValue) const; SettingSpec duringHdrPlayback() const; - SettingSpec withAuth() const; }; const QVector& settingSpecs(); @@ -154,8 +149,6 @@ const SettingSpec *findSettingSpec(const QString& key); QVariant settingDefaultValue(const SettingSpec& spec); QVariant normalizedSettingValue(const SettingSpec& spec, const QVariant& value); QString serializedSettingValue(const SettingSpec& spec, const QVariant& value); -// `auth` is the active provider's auth capability; rows that only make -// sense with an account are left out without it. -QVariantList settingSchemaModel(bool auth = true); +QVariantList settingSchemaModel(); } // namespace JellyfinNative diff --git a/src/app/SpoolLink.cpp b/src/app/SpoolLink.cpp deleted file mode 100644 index 40322d9b..00000000 --- a/src/app/SpoolLink.cpp +++ /dev/null @@ -1,606 +0,0 @@ -#include "SpoolLink.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace JellyfinNative { -namespace { - - constexpr int kKeyBytes = 32; - - // Long enough that a punched pinhole cannot be reached by chance, short - // enough that a stalled handshake gives up while the user still cares. - constexpr int kProbeIntervalMs = 250; - constexpr int kProbeGiveUpMs = 6'000; - constexpr int kKeepaliveIntervalMs = 10'000; - // Most consumer NATs hold a UDP mapping for 30 s or more; a peer that has - // answered nothing for this long is gone. - constexpr int kPeerLostMs = 32'000; - - // The floor is a display refresh: sending faster cannot change what the - // other end draws. The ceiling keeps a slow link from queueing without - // bound. - constexpr int kMinPacingMs = 16; - constexpr int kMaxPacingMs = 250; - - const QString kFromField = QStringLiteral("From"); - const QString kKeyField = QStringLiteral("Key"); - const QString kCandidatesField = QStringLiteral("Candidates"); - - // Public STUN, used only to learn this device's own reflexive address for - // IPv4. Nothing about the session is sent. - const char *kStunHost = "stun.l.google.com"; - constexpr quint16 kStunPort = 19302; - constexpr quint32 kStunCookie = 0x2112A442; - - bool isUsableCandidateAddress(const QHostAddress& address) - { - if (address.isNull() || address.isLoopback() || address.isLinkLocal() || address.isMulticast()) - return false; - // A unique-local or global v6 address is reachable between peers; a v4 - // private address only helps when both sit on the same network, which - // is exactly the case worth trying first. - return true; - } - - QByteArray stunBindingRequest(const QByteArray& transaction) - { - QByteArray request(20, '\0'); - qToBigEndian(0x0001, request.data()); - qToBigEndian(0, request.data() + 2); - qToBigEndian(kStunCookie, request.data() + 4); - std::copy(transaction.cbegin(), transaction.cend(), request.begin() + 8); - return request; - } - -} // namespace - -SpoolLink::SpoolLink(JellyfinApiFacade *api, QObject *parent) - : QObject(parent) - , m_api(api) -{ - m_clock.start(); - - m_probeTimer.setInterval(kProbeIntervalMs); - connect(&m_probeTimer, &QTimer::timeout, this, &SpoolLink::sendProbes); - - m_keepaliveTimer.setInterval(kKeepaliveIntervalMs); - connect(&m_keepaliveTimer, &QTimer::timeout, this, &SpoolLink::sendKeepalive); - - m_flushTimer.setSingleShot(true); - connect(&m_flushTimer, &QTimer::timeout, this, &SpoolLink::flushOutbox); -} - -SpoolLink::~SpoolLink() = default; - -void SpoolLink::setLocalSessionId(const QString& sessionId) -{ - m_localSessionId = sessionId; -} - -void SpoolLink::setState(State state) -{ - if (m_state == state) - return; - m_state = state; - emit stateChanged(); -} - -void SpoolLink::setRoundTripMs(int roundTripMs) -{ - if (m_roundTripMs == roundTripMs) - return; - m_roundTripMs = roundTripMs; - emit roundTripChanged(); -} - -void SpoolLink::reset() -{ - stopProbing(); - m_keepaliveTimer.stop(); - m_peerSessionId.clear(); - m_key.clear(); - m_offered = false; - m_peerCandidates.clear(); - m_peerEndpoint = {}; - m_reliableOutbox.clear(); - m_coalescedOutbox.clear(); - m_replayWindow.reset(); - m_bestRoundTripMs = -1; - m_lastPongMs = 0; - m_clockOffsetMs.reset(); - setRoundTripMs(-1); - setState(State::Idle); -} - -bool SpoolLink::ensureSocket() -{ - if (m_socket) - return true; - - m_socket = new QUdpSocket(this); - // One dual-stack socket, so a v6 path and a v4 path share a port and the - // peer only has to be told one number per address family. - if (!m_socket->bind(QHostAddress(QHostAddress::Any), 0, QAbstractSocket::DefaultForPlatform)) { - qInfo() << "spool link: could not bind a local socket:" << m_socket->errorString(); - delete m_socket; - m_socket = nullptr; - return false; - } - connect(m_socket, &QUdpSocket::readyRead, this, &SpoolLink::readDatagrams); - return true; -} - -void SpoolLink::gatherCandidates() -{ - m_localCandidates.clear(); - if (!m_socket) - return; - const quint16 port = m_socket->localPort(); - for (const QHostAddress& address : QNetworkInterface::allAddresses()) { - if (!isUsableCandidateAddress(address)) - continue; - Endpoint endpoint; - // Qt keeps the scope id on link-local and some global v6 addresses; it - // is meaningless to the peer and breaks parsing on the far side. - endpoint.address = QHostAddress(address.toString().section(QLatin1Char('%'), 0, 0)); - endpoint.port = port; - if (endpoint.isValid() && !m_localCandidates.contains(endpoint)) - m_localCandidates.push_back(endpoint); - } -} - -void SpoolLink::requestReflexiveCandidate() -{ - if (!m_socket) - return; - m_stunTransaction.resize(12); - QRandomGenerator::system()->fillRange(reinterpret_cast(m_stunTransaction.data()), 3); - // Sent from the same socket the peer will talk to, so the mapping STUN - // reports is the mapping the peer can reach. - QHostInfo::lookupHost(QString::fromLatin1(kStunHost), this, [this](const QHostInfo& info) { - if (!m_socket || info.addresses().isEmpty() || m_stunTransaction.isEmpty()) - return; - for (const QHostAddress& address : info.addresses()) { - if (address.protocol() == QAbstractSocket::IPv4Protocol) { - m_socket->writeDatagram(stunBindingRequest(m_stunTransaction), address, kStunPort); - return; - } - } - }); -} - -void SpoolLink::connectToPeer(const QString& sessionId) -{ - if (sessionId.isEmpty() || sessionId == m_localSessionId) { - reset(); - return; - } - if (sessionId == m_peerSessionId && m_state != State::Idle) - return; - - const bool sameSession = sessionId == m_peerSessionId; - reset(); - m_peerSessionId = sessionId; - if (!ensureSocket()) - return; - - gatherCandidates(); - requestReflexiveCandidate(); - - m_key.resize(kKeyBytes); - QRandomGenerator::system()->fillRange(reinterpret_cast(m_key.data()), kKeyBytes / 4); - m_offered = true; - - // Give a reflexive candidate a moment to arrive, then offer whatever was - // gathered. Local addresses alone are enough on a shared network. - QTimer::singleShot(sameSession ? 0 : 400, this, [this]() { - if (!m_peerSessionId.isEmpty() && m_offered) - sendOffer(); - }); -} - -void SpoolLink::sendOffer() -{ - if (m_localSessionId.isEmpty() || m_peerSessionId.isEmpty() || m_localCandidates.isEmpty()) - return; - QStringList candidates; - candidates.reserve(m_localCandidates.size()); - for (const Endpoint& endpoint : m_localCandidates) - candidates.push_back(endpoint.toString()); - - SpoolRemoteProtocol::Message offer { SpoolRemoteProtocol::Command::LinkOffer, - { - { kFromField, m_localSessionId }, - { kKeyField, QString::fromLatin1(m_key.toBase64()) }, - { kCandidatesField, candidates.join(QLatin1Char(' ')) }, - } }; - deliverViaServer(m_peerSessionId, offer); - beginProbing(); -} - -void SpoolLink::sendAnswer() -{ - if (m_localSessionId.isEmpty() || m_peerSessionId.isEmpty() || m_localCandidates.isEmpty()) - return; - QStringList candidates; - candidates.reserve(m_localCandidates.size()); - for (const Endpoint& endpoint : m_localCandidates) - candidates.push_back(endpoint.toString()); - - SpoolRemoteProtocol::Message answer { SpoolRemoteProtocol::Command::LinkAnswer, - { - { kFromField, m_localSessionId }, - { kCandidatesField, candidates.join(QLatin1Char(' ')) }, - } }; - deliverViaServer(m_peerSessionId, answer); -} - -bool SpoolLink::handleServerMessage(const SpoolRemoteProtocol::Message& message) -{ - const bool isOffer = message.command == SpoolRemoteProtocol::Command::LinkOffer; - const bool isAnswer = message.command == SpoolRemoteProtocol::Command::LinkAnswer; - if (!isOffer && !isAnswer) - return false; - - const QString from = message.text(kFromField); - if (from.isEmpty()) - return true; - - QList candidates; - const QStringList encoded = message.text(kCandidatesField).split(QLatin1Char(' '), Qt::SkipEmptyParts); - for (const QString& text : encoded) { - if (const std::optional endpoint = Endpoint::fromString(text)) - candidates.push_back(*endpoint); - } - if (candidates.isEmpty()) - return true; - - if (isOffer) { - const QByteArray key = QByteArray::fromBase64(message.text(kKeyField).toLatin1()); - if (key.size() != kKeyBytes) - return true; - // The offering side owns the key, so a simultaneous offer from both - // ends settles on one of them rather than on two different keys. - if (m_offered && m_localSessionId > from) - return true; - if (!ensureSocket()) - return true; - stopProbing(); - m_peerSessionId = from; - m_key = key; - m_offered = false; - m_peerEndpoint = {}; - m_replayWindow.reset(); - m_peerCandidates = candidates; - gatherCandidates(); - sendAnswer(); - beginProbing(); - return true; - } - - if (from != m_peerSessionId || m_key.isEmpty()) - return true; - m_peerCandidates = candidates; - beginProbing(); - return true; -} - -void SpoolLink::beginProbing() -{ - if (m_peerCandidates.isEmpty() || m_key.isEmpty() || !m_socket) - return; - if (m_state != State::Connected) - setState(State::Probing); - m_probeTimer.start(); - sendProbes(); - QTimer::singleShot(kProbeGiveUpMs, this, [this]() { - if (m_state == State::Probing) { - // Nothing answered. The server path stays in service, so this is a - // missed optimisation rather than a failure. - qInfo() << "spool link: no direct path to" << m_peerSessionId << "- staying on the server"; - stopProbing(); - setState(State::Idle); - } - }); -} - -void SpoolLink::stopProbing() -{ - m_probeTimer.stop(); -} - -void SpoolLink::sendProbes() -{ - if (m_state == State::Connected) { - stopProbing(); - return; - } - // Both ends probe at once: that simultaneous exchange is what opens the - // pinhole each side's firewall needs for the other's datagrams. - for (const Endpoint& candidate : m_peerCandidates) - transmit(Datagram::Probe, {}, candidate); -} - -void SpoolLink::sendKeepalive() -{ - if (m_state != State::Connected) - return; - if (m_lastPongMs > 0 && m_clock.elapsed() - m_lastPongMs > kPeerLostMs) { - qInfo() << "spool link: peer stopped answering, falling back to the server"; - const QString peer = m_peerSessionId; - reset(); - connectToPeer(peer); - return; - } - m_pingSentMs = m_clock.elapsed(); - QByteArray payload(8, '\0'); - qToBigEndian(m_pingSentMs, payload.data()); - transmit(Datagram::Ping, payload, m_peerEndpoint); -} - -bool SpoolLink::transmit(Datagram type, const QByteArray& payload, const Endpoint& to) -{ - if (!m_socket || m_key.isEmpty() || !to.isValid()) - return false; - const QByteArray datagram = SpoolLinkCodec::seal(m_key, type, m_nextSequence++, payload); - return m_socket->writeDatagram(datagram, to.address, to.port) == datagram.size(); -} - -void SpoolLink::readDatagrams() -{ - while (m_socket && m_socket->hasPendingDatagrams()) { - QByteArray buffer(int(m_socket->pendingDatagramSize()), '\0'); - QHostAddress sender; - quint16 senderPort = 0; - const qint64 read = m_socket->readDatagram(buffer.data(), buffer.size(), &sender, &senderPort); - if (read <= 0) - continue; - buffer.truncate(int(read)); - - if (buffer.size() >= 20 && (static_cast(buffer[0]) & 0xC0) == 0) { - handleStunResponse(buffer); - continue; - } - Endpoint from; - from.address = sender; - from.port = senderPort; - handleLinkDatagram(buffer, from); - } -} - -void SpoolLink::handleStunResponse(const QByteArray& datagram) -{ - if (qFromBigEndian(datagram.constData()) != 0x0101) - return; - if (datagram.mid(8, 12) != m_stunTransaction) - return; - - qsizetype offset = 20; - // The declared length, never trusted past what actually arrived. - const qsizetype length - = std::min(qFromBigEndian(datagram.constData() + 2) + 20, datagram.size()); - while (offset + 4 <= length) { - const quint16 type = qFromBigEndian(datagram.constData() + offset); - const quint16 size = qFromBigEndian(datagram.constData() + offset + 2); - const qsizetype valueAt = offset + 4; - if (valueAt + size > datagram.size()) - return; - - // XOR-MAPPED-ADDRESS. The obfuscation exists so middleboxes cannot - // rewrite the address they are reporting. - if (type == 0x0020 && size >= 8) { - const quint8 family = static_cast(datagram[valueAt + 1]); - const quint16 port - = qFromBigEndian(datagram.constData() + valueAt + 2) ^ quint16(kStunCookie >> 16); - if (family == 0x01) { - const quint32 address = qFromBigEndian(datagram.constData() + valueAt + 4) ^ kStunCookie; - Endpoint reflexive; - reflexive.address = QHostAddress(address); - reflexive.port = port; - if (reflexive.isValid() && !m_localCandidates.contains(reflexive)) { - m_localCandidates.push_back(reflexive); - qInfo() << "spool link: reflexive candidate" << reflexive.toString(); - } - } - return; - } - offset = valueAt + ((size + 3) & ~3); - } -} - -void SpoolLink::handleLinkDatagram(const QByteArray& datagram, const Endpoint& from) -{ - Datagram type {}; - QByteArray payload; - quint64 sequence = 0; - if (!SpoolLinkCodec::open(m_key, datagram, type, payload, sequence) || !m_replayWindow.accept(sequence)) - return; - - const qint64 now = m_clock.elapsed(); - switch (type) { - case Datagram::Probe: - // Answer from the address it arrived on, which is the mapping that - // actually works rather than the one the peer guessed. - transmit(Datagram::ProbeAck, {}, from); - break; - - case Datagram::ProbeAck: - if (m_state != State::Connected) { - m_peerEndpoint = from; - m_lastPongMs = now; - stopProbing(); - setState(State::Connected); - qInfo() << "spool link: direct path to" << m_peerSessionId << "via" << from.toString(); - sendKeepalive(); - flushOutbox(); - } - break; - - case Datagram::Ping: { - if (payload.size() < 8) - break; - QByteArray reply(24, '\0'); - // t1 as the peer sent it, then our receive and send instants: enough - // for the far side to solve for offset and round trip together. - std::copy(payload.cbegin(), payload.cbegin() + 8, reply.begin()); - qToBigEndian(now, reply.data() + 8); - qToBigEndian(m_clock.elapsed(), reply.data() + 16); - transmit(Datagram::Pong, reply, from); - break; - } - - case Datagram::Pong: { - if (payload.size() < 24) - break; - const qint64 t1 = qFromBigEndian(payload.constData()); - const qint64 t2 = qFromBigEndian(payload.constData() + 8); - const qint64 t3 = qFromBigEndian(payload.constData() + 16); - const qint64 t4 = now; - m_lastPongMs = now; - noteExchange((t4 - t1) - (t3 - t2), ((t2 - t1) + (t3 - t4)) / 2); - break; - } - - case Datagram::Payload: - if (const std::optional message = SpoolRemoteProtocol::deserialize(payload)) { - emit messageReceived(*message); - } - break; - } -} - -void SpoolLink::noteExchange(qint64 roundTripMs, qint64 offsetMs) -{ - if (roundTripMs < 0) - return; - setRoundTripMs(int(std::min(roundTripMs, kMaxPacingMs * 4))); - // Offset error is bounded by how unevenly the two legs were delayed, so - // the sample with the smallest round trip is the most trustworthy one - // rather than the average of many. - if (m_bestRoundTripMs < 0 || roundTripMs <= m_bestRoundTripMs) { - m_bestRoundTripMs = roundTripMs; - m_clockOffsetMs = offsetMs; - } -} - -int SpoolLink::pacingIntervalMs() const -{ - // Sending faster than the path can answer only builds a queue; slower than - // a frame cannot be seen. Between those, follow the measurement. - const int measured = m_roundTripMs >= 0 ? m_roundTripMs : kMaxPacingMs; - return std::clamp(measured, kMinPacingMs, kMaxPacingMs); -} - -void SpoolLink::send(const QString& sessionId, const SpoolRemoteProtocol::Message& message, Delivery delivery) -{ - if (sessionId.isEmpty() || !message.isValid()) - return; - // Only the selected peer has a direct path; anything else is the server's - // job and goes out immediately. - if (sessionId != m_peerSessionId) { - deliverViaServer(sessionId, message); - return; - } - - if (delivery == Delivery::Coalesced) { - const auto stale = std::find_if(m_coalescedOutbox.begin(), m_coalescedOutbox.end(), - [&message](const SpoolRemoteProtocol::Message& queued) { return queued.command == message.command; }); - if (stale != m_coalescedOutbox.end()) - *stale = message; - else - m_coalescedOutbox.push_back(message); - } else { - m_reliableOutbox.push_back(message); - } - scheduleFlush(); -} - -// Always returns through the event loop, never straight into flushOutbox(). -// A synchronous hand-back would let the two call each other without ever -// unwinding, which wedges the thread that draws the interface. -void SpoolLink::scheduleFlush() -{ - if (m_flushTimer.isActive()) - return; - const qint64 sinceLast = m_clock.elapsed() - m_lastFlushMs; - const int interval = pacingIntervalMs(); - m_flushTimer.start(int(std::max(0, interval - sinceLast))); -} - -void SpoolLink::flushOutbox() -{ - if (m_reliableOutbox.isEmpty() && m_coalescedOutbox.isEmpty()) - return; - - if (connected()) { - // The direct path has no outstanding-request limit worth enforcing: - // a datagram is gone the moment it is written. - const QList pending = m_reliableOutbox + m_coalescedOutbox; - m_reliableOutbox.clear(); - m_coalescedOutbox.clear(); - m_lastFlushMs = m_clock.elapsed(); - for (const SpoolRemoteProtocol::Message& message : pending) - transmit(Datagram::Payload, SpoolRemoteProtocol::serialize(message), m_peerEndpoint); - return; - } - - // Through the server, one request at a time. More than that and the - // replies queue behind each other and arrive long after they mattered. - // Nothing is scheduled here: the request in flight rearms the flush when - // it finishes, which is the only moment the answer can change. - if (m_serverSendInFlight) - return; - const SpoolRemoteProtocol::Message message - = m_reliableOutbox.isEmpty() ? m_coalescedOutbox.takeFirst() : m_reliableOutbox.takeFirst(); - m_lastFlushMs = m_clock.elapsed(); - deliverViaServer(m_peerSessionId, message); -} - -void SpoolLink::deliverViaServer(const QString& sessionId, const SpoolRemoteProtocol::Message& message) -{ - if (!m_api || sessionId.isEmpty()) - return; - const bool paced = sessionId == m_peerSessionId; - if (paced) - m_serverSendInFlight = true; - const qint64 sentAtMs = m_clock.elapsed(); - - Async::runScoped( - this, - m_api->sendRemoteGeneralCommand( - sessionId, SpoolRemoteProtocol::envelopeCommand(), SpoolRemoteProtocol::encode(message)), - [this, paced, sentAtMs]() { - if (!paced) - return; - m_serverSendInFlight = false; - // With no direct path, the server round trip is the pace the link - // can actually keep. - if (!connected()) - setRoundTripMs(int(m_clock.elapsed() - sentAtMs)); - if (!m_reliableOutbox.isEmpty() || !m_coalescedOutbox.isEmpty()) - scheduleFlush(); - }, - [this, paced](const std::exception_ptr&) { - if (!paced) - return; - m_serverSendInFlight = false; - if (!m_reliableOutbox.isEmpty() || !m_coalescedOutbox.isEmpty()) - scheduleFlush(); - }, - "spool link server message"); -} - -} // namespace JellyfinNative diff --git a/src/app/SpoolLink.h b/src/app/SpoolLink.h deleted file mode 100644 index e7a46c98..00000000 --- a/src/app/SpoolLink.h +++ /dev/null @@ -1,172 +0,0 @@ -#pragma once - -#include "SpoolLinkCodec.h" -#include "SpoolRemoteProtocol.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -class QUdpSocket; - -namespace JellyfinNative { - -class JellyfinApiFacade; - -// Carries Spool-to-Spool messages by whichever path is available: a direct -// datagram link to the peer when one can be opened, the Jellyfin server -// otherwise. Callers hand a message to send() and never learn which was used. -// -// Two clients in the same country talking through a server on another -// continent pay two intercontinental legs for every message. A direct path -// turns a ~500 ms round trip into a local one, which is what makes scrubbing -// feel attached to the finger and what a clock comparison needs to be worth -// anything. -// -// The peers find each other over the server, which is already the trusted, -// authenticated channel between them: it relays the shared key and the -// candidate addresses. That grants the server nothing it did not already -// have, since it can command both clients directly. -class SpoolLink final : public QObject { - Q_OBJECT - -public: - enum class State { - Idle, - Probing, - Connected, - }; - - // Reliable messages are always delivered. Coalesced ones are only worth - // sending while they are current: a newer message with the same command - // replaces one still waiting, because showing the position before last is - // worse than showing nothing. - enum class Delivery { - Reliable, - Coalesced, - }; - - explicit SpoolLink(JellyfinApiFacade *api, QObject *parent = nullptr); - ~SpoolLink() override; - - // This client's own session, so a peer knows where to answer. - void setLocalSessionId(const QString& sessionId); - QString localSessionId() const - { - return m_localSessionId; - } - - // Open (or keep) a direct path to this peer. Repeat calls for the peer - // already selected are ignored, so this is safe to call on every refresh. - void connectToPeer(const QString& sessionId); - void reset(); - - void send( - const QString& sessionId, const SpoolRemoteProtocol::Message& message, Delivery delivery = Delivery::Reliable); - - // A message the server delivered. Returns true when it was link - // signalling, which the application must not see. - bool handleServerMessage(const SpoolRemoteProtocol::Message& message); - - State state() const - { - return m_state; - } - bool connected() const - { - return m_state == State::Connected; - } - - // Round trip on whichever path is carrying messages, in milliseconds, or - // -1 before anything has been measured. - int roundTripMs() const - { - return m_roundTripMs; - } - - // How far the peer's monotonic clock reads ahead of ours, from the - // exchange with the lowest round trip seen. The basis for lining up two - // devices' playback clocks. - std::optional clockOffsetMs() const - { - return m_clockOffsetMs; - } - -signals: - void messageReceived(const SpoolRemoteProtocol::Message& message); - void stateChanged(); - void roundTripChanged(); - -private: - using Endpoint = SpoolLinkCodec::Endpoint; - using Datagram = SpoolLinkCodec::Datagram; - - bool ensureSocket(); - void gatherCandidates(); - void requestReflexiveCandidate(); - void sendOffer(); - void sendAnswer(); - void beginProbing(); - void stopProbing(); - void setState(State state); - void setRoundTripMs(int roundTripMs); - - void readDatagrams(); - void handleStunResponse(const QByteArray& datagram); - void handleLinkDatagram(const QByteArray& datagram, const Endpoint& from); - - bool transmit(Datagram type, const QByteArray& payload, const Endpoint& to); - - void sendProbes(); - void sendKeepalive(); - void noteExchange(qint64 roundTripMs, qint64 offsetMs); - - void flushOutbox(); - void scheduleFlush(); - int pacingIntervalMs() const; - void deliverViaServer(const QString& sessionId, const SpoolRemoteProtocol::Message& message); - - JellyfinApiFacade *m_api = nullptr; - QUdpSocket *m_socket = nullptr; - - QString m_localSessionId; - QString m_peerSessionId; - QByteArray m_key; - // Set on the side that generated the key, which is the side that answers - // an offer rather than making one if both happen to start at once. - bool m_offered = false; - - QList m_localCandidates; - QList m_peerCandidates; - Endpoint m_peerEndpoint; - - QTimer m_probeTimer; - QTimer m_keepaliveTimer; - QTimer m_flushTimer; - QElapsedTimer m_clock; - QByteArray m_stunTransaction; - - // Newest message per command, plus everything that must not be dropped. - QList m_reliableOutbox; - QList m_coalescedOutbox; - bool m_serverSendInFlight = false; - qint64 m_lastFlushMs = 0; - - quint64 m_nextSequence = 1; - SpoolLinkCodec::ReplayWindow m_replayWindow; - qint64 m_pingSentMs = 0; - qint64 m_lastPongMs = 0; - qint64 m_bestRoundTripMs = -1; - - State m_state = State::Idle; - int m_roundTripMs = -1; - std::optional m_clockOffsetMs; -}; - -} // namespace JellyfinNative diff --git a/src/app/SpoolLinkCodec.cpp b/src/app/SpoolLinkCodec.cpp deleted file mode 100644 index bc7f0169..00000000 --- a/src/app/SpoolLinkCodec.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "SpoolLinkCodec.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { -namespace { - - constexpr char kMagic[4] = { 'S', 'P', 'L', 'K' }; - constexpr quint8 kVersion = 1; - constexpr int kHeaderBytes = 16; - constexpr int kTagBytes = 32; - -} // namespace - -QString SpoolLinkCodec::Endpoint::toString() const -{ - return address.protocol() == QAbstractSocket::IPv6Protocol - ? QStringLiteral("[%1]:%2").arg(address.toString()).arg(port) - : QStringLiteral("%1:%2").arg(address.toString()).arg(port); -} - -std::optional SpoolLinkCodec::Endpoint::fromString(const QString& text) -{ - const int separator = text.lastIndexOf(QLatin1Char(':')); - if (separator <= 0) - return std::nullopt; - - QString host = text.left(separator); - if (host.startsWith(QLatin1Char('[')) && host.endsWith(QLatin1Char(']'))) - host = host.mid(1, host.size() - 2); - - bool ok = false; - const uint port = text.mid(separator + 1).toUInt(&ok); - if (!ok || port == 0 || port > 65535) - return std::nullopt; - - Endpoint endpoint; - if (!endpoint.address.setAddress(host)) - return std::nullopt; - endpoint.port = static_cast(port); - return endpoint; -} - -QByteArray SpoolLinkCodec::seal(const QByteArray& key, Datagram type, quint64 sequence, const QByteArray& payload) -{ - QByteArray datagram; - datagram.reserve(kHeaderBytes + payload.size() + kTagBytes); - datagram.append(kMagic, 4); - datagram.append(static_cast(kVersion)); - datagram.append(static_cast(type)); - datagram.append(2, '\0'); - - QByteArray sequenceBytes(8, '\0'); - qToBigEndian(sequence, sequenceBytes.data()); - datagram.append(sequenceBytes); - datagram.append(payload); - - QMessageAuthenticationCode code(QCryptographicHash::Sha256, key); - code.addData(datagram); - datagram.append(code.result()); - return datagram; -} - -bool SpoolLinkCodec::open( - const QByteArray& key, const QByteArray& datagram, Datagram& type, QByteArray& payload, quint64& sequence) -{ - if (datagram.size() < kHeaderBytes + kTagBytes || key.isEmpty()) - return false; - if (!std::equal(kMagic, kMagic + 4, datagram.cbegin()) || static_cast(datagram[4]) != kVersion) - return false; - - const QByteArray body = datagram.left(datagram.size() - kTagBytes); - QMessageAuthenticationCode code(QCryptographicHash::Sha256, key); - code.addData(body); - // Constant time: comparing tags with an early exit would leak how much of - // a forged one was right. - const QByteArray expected = code.result(); - const QByteArray actual = datagram.right(kTagBytes); - if (expected.size() != actual.size()) - return false; - quint8 difference = 0; - for (int index = 0; index < expected.size(); ++index) - difference |= static_cast(expected[index]) ^ static_cast(actual[index]); - if (difference != 0) - return false; - - type = static_cast(static_cast(datagram[5])); - sequence = qFromBigEndian(datagram.constData() + 8); - payload = body.mid(kHeaderBytes); - return true; -} - -void SpoolLinkCodec::ReplayWindow::reset() -{ - m_highest = 0; - m_seen = 0; -} - -bool SpoolLinkCodec::ReplayWindow::accept(quint64 sequence) -{ - constexpr quint64 kWindow = 64; - if (sequence == 0) - return false; - if (sequence > m_highest) { - const quint64 shift = sequence - m_highest; - m_seen = shift >= kWindow ? 0 : (m_seen << shift); - m_seen |= 1; - m_highest = sequence; - return true; - } - const quint64 age = m_highest - sequence; - if (age >= kWindow) - return false; - const quint64 bit = quint64(1) << age; - if (m_seen & bit) - return false; - m_seen |= bit; - return true; -} - -} // namespace JellyfinNative diff --git a/src/app/SpoolLinkCodec.h b/src/app/SpoolLinkCodec.h deleted file mode 100644 index 5b2cb56d..00000000 --- a/src/app/SpoolLinkCodec.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -namespace JellyfinNative { - -// The wire format, with no I/O in it. Kept apart from the socket so the -// framing, the authentication and the replay rule can be tested directly -// rather than through two devices and a network. -namespace SpoolLinkCodec { - - enum class Datagram : quint8 { - Probe = 1, - ProbeAck = 2, - Payload = 3, - Ping = 4, - Pong = 5, - }; - - struct Endpoint { - QHostAddress address; - quint16 port = 0; - - bool operator==(const Endpoint& other) const - { - return port == other.port && address.isEqual(other.address); - } - bool isValid() const - { - return port != 0 && !address.isNull(); - } - QString toString() const; - static std::optional fromString(const QString& text); - }; - - QByteArray seal(const QByteArray& key, Datagram type, quint64 sequence, const QByteArray& payload); - bool open( - const QByteArray& key, const QByteArray& datagram, Datagram& type, QByteArray& payload, quint64& sequence); - - // Accepts each sequence number once. A datagram that arrives out of order - // is still new; one that arrives twice never is. - class ReplayWindow { - public: - bool accept(quint64 sequence); - void reset(); - - private: - quint64 m_highest = 0; - quint64 m_seen = 0; - }; - -} // namespace SpoolLinkCodec - -} // namespace JellyfinNative diff --git a/src/app/SpoolRemoteProtocol.cpp b/src/app/SpoolRemoteProtocol.cpp deleted file mode 100644 index 813d1cfe..00000000 --- a/src/app/SpoolRemoteProtocol.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#include "SpoolRemoteProtocol.h" - -#include - -#include - -namespace JellyfinNative::SpoolRemoteProtocol { -namespace { - - const QString kProtocolKey = QStringLiteral("SpoolProtocol"); - const QString kCommandKey = QStringLiteral("SpoolCommand"); - const QString kStringKey = QStringLiteral("String"); - const QString kProtocolVersion = QStringLiteral("1"); - - bool isEnvelopeKey(const QString& key) - { - return key == kProtocolKey || key == kCommandKey || key == kStringKey; - } - - // Jellyfin rejects a command whose arguments are not all strings, so every - // field is written as one and read back with the type it was given. - QString fieldToString(const QVariant& value) - { - if (value.typeId() == QMetaType::Bool) - return value.toBool() ? QStringLiteral("true") : QStringLiteral("false"); - return value.toString(); - } - -} // namespace - -QString envelopeCommand() -{ - return QStringLiteral("SendString"); -} - -QString Message::text(const QString& key) const -{ - return fields.value(key).toString(); -} - -std::optional Message::number(const QString& key) const -{ - const auto field = fields.constFind(key); - if (field == fields.constEnd()) - return std::nullopt; - bool ok = false; - const qint64 value = field->toString().toLongLong(&ok); - if (!ok) - return std::nullopt; - return value; -} - -std::optional Message::flag(const QString& key) const -{ - const QString value = fields.value(key).toString(); - if (value == QLatin1String("true")) - return true; - if (value == QLatin1String("false")) - return false; - return std::nullopt; -} - -QJsonObject encode(const Message& message) -{ - QJsonObject arguments { - { kProtocolKey, kProtocolVersion }, - { kCommandKey, message.command }, - { kStringKey, QString() }, - }; - for (auto field = message.fields.cbegin(); field != message.fields.cend(); ++field) { - if (isEnvelopeKey(field.key())) - continue; - arguments.insert(field.key(), fieldToString(field.value())); - } - return arguments; -} - -std::optional decode(const QString& envelope, const QJsonObject& arguments) -{ - if (envelope != envelopeCommand() || arguments.value(kProtocolKey).toString() != kProtocolVersion) - return std::nullopt; - - Message message; - message.command = arguments.value(kCommandKey).toString(); - if (message.command.isEmpty()) - return std::nullopt; - - for (auto argument = arguments.constBegin(); argument != arguments.constEnd(); ++argument) { - // Anything that is not a string did not come from a Spool client, and - // reading it as one would invent a value nobody sent. - if (!argument.value().isString()) - return std::nullopt; - if (isEnvelopeKey(argument.key())) - continue; - message.fields.insert(argument.key(), argument.value().toString()); - } - return message; -} - -QByteArray serialize(const Message& message) -{ - QJsonObject fields; - for (auto field = message.fields.cbegin(); field != message.fields.cend(); ++field) - fields.insert(field.key(), fieldToString(field.value())); - const QJsonObject object { - { QStringLiteral("c"), message.command }, - { QStringLiteral("f"), fields }, - }; - return QJsonDocument(object).toJson(QJsonDocument::Compact); -} - -std::optional deserialize(const QByteArray& payload) -{ - QJsonParseError error {}; - const QJsonDocument document = QJsonDocument::fromJson(payload, &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) - return std::nullopt; - - const QJsonObject object = document.object(); - Message message; - message.command = object.value(QStringLiteral("c")).toString(); - if (message.command.isEmpty()) - return std::nullopt; - - const QJsonObject fields = object.value(QStringLiteral("f")).toObject(); - for (auto field = fields.constBegin(); field != fields.constEnd(); ++field) { - if (!field.value().isString()) - return std::nullopt; - message.fields.insert(field.key(), field.value().toString()); - } - return message; -} - -Message seekPreviewMessage(const QString& itemId, qint64 positionTicks, bool active) -{ - return Message { Command::SeekPreview, - { - { QStringLiteral("ItemId"), itemId }, - { QStringLiteral("PositionTicks"), QString::number(std::max(0, positionTicks)) }, - { QStringLiteral("Active"), active }, - } }; -} - -std::optional seekPreview(const Message& message) -{ - if (message.command != Command::SeekPreview) - return std::nullopt; - const std::optional positionTicks = message.number(QStringLiteral("PositionTicks")); - const std::optional active = message.flag(QStringLiteral("Active")); - if (!positionTicks || !active) - return std::nullopt; - - SeekPreview preview; - preview.itemId = message.text(QStringLiteral("ItemId")); - preview.positionTicks = std::max(0, *positionTicks); - preview.active = *active; - // A preview to show has to say what it is showing; one being torn down - // does not. - if (preview.active && preview.itemId.isEmpty()) - return std::nullopt; - return preview; -} - -Message syncPlayJoinMessage(const QString& groupId) -{ - return Message { Command::SyncPlayJoin, { { QStringLiteral("GroupId"), groupId } } }; -} - -std::optional syncPlayJoin(const Message& message) -{ - if (message.command != Command::SyncPlayJoin) - return std::nullopt; - SyncPlayJoin join; - join.groupId = message.text(QStringLiteral("GroupId")); - if (join.groupId.isEmpty()) - return std::nullopt; - return join; -} - -} // namespace JellyfinNative::SpoolRemoteProtocol diff --git a/src/app/SpoolRemoteProtocol.h b/src/app/SpoolRemoteProtocol.h deleted file mode 100644 index 7537762f..00000000 --- a/src/app/SpoolRemoteProtocol.h +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -namespace JellyfinNative::SpoolRemoteProtocol { - -// Jellyfin validates a GeneralCommand's name against its own enum and carries -// its arguments as a string-to-string map, so every Spool-to-Spool message -// rides inside SendString with an empty payload: a client that does not know -// Spool is told to type nothing, which is harmless. -QString envelopeCommand(); - -namespace Command { - inline const QString SeekPreview = QStringLiteral("SeekPreview"); - inline const QString SyncPlayJoin = QStringLiteral("SyncPlayJoin"); - inline const QString LinkOffer = QStringLiteral("LinkOffer"); - inline const QString LinkAnswer = QStringLiteral("LinkAnswer"); -} - -// One wire form for every command. Adding a message means naming it and -// listing its fields; the envelope, the version gate, the string-map rules and -// both transports stay exactly as they are. -struct Message { - QString command; - QVariantMap fields; - - QString text(const QString& key) const; - std::optional number(const QString& key) const; - std::optional flag(const QString& key) const; - bool isValid() const - { - return !command.isEmpty(); - } -}; - -QJsonObject encode(const Message& message); -std::optional decode(const QString& envelope, const QJsonObject& arguments); - -// The same message as a self-contained blob, for a transport that carries -// bytes rather than Jellyfin command arguments. -QByteArray serialize(const Message& message); -std::optional deserialize(const QByteArray& payload); - -// Typed views. Each one names only its own fields, because everything they -// would otherwise have in common already lives above. -struct SeekPreview { - QString itemId; - qint64 positionTicks = 0; - bool active = false; -}; -Message seekPreviewMessage(const QString& itemId, qint64 positionTicks, bool active); -std::optional seekPreview(const Message& message); - -struct SyncPlayJoin { - QString groupId; -}; -Message syncPlayJoinMessage(const QString& groupId); -std::optional syncPlayJoin(const Message& message); - -} // namespace JellyfinNative::SpoolRemoteProtocol diff --git a/src/app/SyncPlayController.cpp b/src/app/SyncPlayController.cpp deleted file mode 100644 index e0320332..00000000 --- a/src/app/SyncPlayController.cpp +++ /dev/null @@ -1,1134 +0,0 @@ -#include "SyncPlayController.h" - -#include "../api/JellyfinApiFacade.h" -#include "../common/AsyncTask.h" -#include "../common/TlsTrust.h" -#include "../media/MediaTypes.h" -#include "../player/PlayQueueController.h" -#include "../player/PlayerController.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr int kGreedyTimeSyncIntervalMs = 1'000; - constexpr int kSteadyTimeSyncIntervalMs = 60'000; - constexpr int kReconnectDelayMs = 3'000; - // A socket that drops and comes back is not news. Suspending the app tears - // the connection down underneath us -- "software caused connection abort" - // on the way out, a refusal or two on the way back in -- and the reconnect - // below is what that is for. Only say something once reconnecting has - // failed this many times running, which is long enough that the only - // survivors are faults a person could actually act on. - constexpr int kSocketFailuresBeforeReporting = 4; - // Browser clients can hide large rate changes in their media pipeline. - // mpv has to retime real audio output, so keep small clock errors inaudible - // and seek once rate correction would take too long. - constexpr int kMinDelaySpeedToSyncMs = 100; - constexpr int kMinDelaySkipToSyncMs = 400; - constexpr int kSpeedToSyncDurationMs = 1'000; - constexpr double kSpeedToSyncMinSpeed = 0.2; - constexpr int kSyncMethodThresholdMs = 3'000; - constexpr int kSyncCooldownMs = kSyncMethodThresholdMs / 2; - constexpr double kSpeedToSyncMinRate = 0.97; - constexpr double kSpeedToSyncMaxRate = 1.03; - constexpr int kSpeedToSyncMaxDurationMs = 10'000; - constexpr int kInternalSeekBufferingSuppressionMs = 3'000; - constexpr qint64 kTicksPerSecond = 10'000'000; - constexpr qint64 kTicksPerMillisecond = 10'000; - - qint64 dateTimeMs(const QJsonValue& value) - { - QDateTime time = QDateTime::fromString(value.toString(), Qt::ISODateWithMs); - if (!time.isValid()) - time = QDateTime::fromString(value.toString(), Qt::ISODate); - return time.isValid() ? time.toUTC().toMSecsSinceEpoch() : 0; - } - - QString groupErrorMessage(const QString& type) - { - if (type == QStringLiteral("GroupDoesNotExist")) - return QStringLiteral("The SyncPlay group no longer exists."); - if (type == QStringLiteral("LibraryAccessDenied")) - return QStringLiteral("This account cannot access the SyncPlay media."); - if (type == QStringLiteral("CreateGroupDenied")) - return QStringLiteral("The server denied creating a SyncPlay group."); - if (type == QStringLiteral("JoinGroupDenied")) - return QStringLiteral("The server denied joining the SyncPlay group."); - if (type == QStringLiteral("SyncPlayIsDisabled")) - return QStringLiteral("SyncPlay is disabled on this server."); - return QStringLiteral("SyncPlay update failed: %1").arg(type); - } - -} // namespace - -SyncCorrection SyncPlayDriftPolicy::evaluate(double diffMs) -{ - const double absDiffMs = std::abs(diffMs); - if (absDiffMs < kMinDelaySpeedToSyncMs) - return {}; - - if (absDiffMs < kMinDelaySkipToSyncMs) { - // Web keeps the speed positive when the client is ahead by more than - // the correction window allows. - double speedToSyncTimeMs = kSpeedToSyncDurationMs; - if (diffMs <= -speedToSyncTimeMs * kSpeedToSyncMinSpeed) - speedToSyncTimeMs = absDiffMs / (1.0 - kSpeedToSyncMinSpeed); - - const double requestedSpeed = 1.0 + diffMs / speedToSyncTimeMs; - const double speed = std::clamp(requestedSpeed, kSpeedToSyncMinRate, kSpeedToSyncMaxRate); - if (speed != requestedSpeed) { - // Recover the same distance at the bounded rate. Hitting the - // duration cap leaves a remainder for the next correction. - speedToSyncTimeMs - = std::min(absDiffMs / std::abs(speed - 1.0), static_cast(kSpeedToSyncMaxDurationMs)); - } - return { SyncCorrection::Method::Speed, speed, static_cast(std::llround(speedToSyncTimeMs)) }; - } - - return { SyncCorrection::Method::Skip, 1.0, 0 }; -} - -SyncPlayController::SyncPlayController(JellyfinApiFacade *api, PlayerController *player, PlayQueueController *playQueue, - TlsTrustController *tlsTrust, QObject *parent) - : GroupPlayback(parent) - , m_api(api) - , m_player(player) - , m_playQueue(playQueue) -{ - m_timeSyncTimer.setSingleShot(true); - m_commandTimer.setSingleShot(true); - m_reconnectTimer.setSingleShot(true); - m_reconnectTimer.setInterval(kReconnectDelayMs); - // jellyfin-mpv-shim waits one second before declaring buffering. mpv's - // seeking flag is often only a short command transition, not starvation. - m_bufferingDebounceTimer.setSingleShot(true); - m_bufferingDebounceTimer.setInterval(1'000); - m_speedCorrectionTimer.setSingleShot(true); - m_correctionTimer.setInterval(1'000); - - connect(&m_socket, &QWebSocket::connected, this, [this]() { - qInfo() << "syncplay: websocket connected"; - m_socketFailures = 0; - m_reconnectTimer.stop(); - beginTimeSync(); - m_correctionTimer.start(); - emit connectionChanged(); - }); - connect(&m_socket, &QWebSocket::disconnected, this, [this]() { - qInfo() << "syncplay: websocket disconnected"; - m_timeSyncTimer.stop(); - m_correctionTimer.stop(); - emit connectionChanged(); - if (m_socketDesired) - m_reconnectTimer.start(); - }); - connect(&m_socket, &QWebSocket::textMessageReceived, this, &SyncPlayController::handleSocketTextMessage); - if (tlsTrust) - tlsTrust->attachWebSocket(&m_socket, [this]() { return socketUrl(); }, QStringLiteral("SyncPlay")); - connect(&m_socket, &QWebSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { - const QString reason = m_socket.errorString(); - // Not while we still intend to reconnect and have not been failing at - // it: the app being backgrounded and brought back produces exactly - // this, and a toast for something the app fixes by itself a moment - // later is noise about a problem the person does not have. - if (m_socketDesired && ++m_socketFailures < kSocketFailuresBeforeReporting) { - qInfo() << "syncplay: websocket error" << reason << "(attempt" << m_socketFailures << "of" - << kSocketFailuresBeforeReporting << "before reporting)"; - return; - } - qWarning() << "syncplay: websocket error" << reason; - emit errorText(QStringLiteral("SyncPlay socket error: %1").arg(reason)); - }); - connect(&m_timeSyncTimer, &QTimer::timeout, this, &SyncPlayController::requestTimeSync); - connect(&m_commandTimer, &QTimer::timeout, this, &SyncPlayController::executeScheduledCommand); - connect(&m_correctionTimer, &QTimer::timeout, this, &SyncPlayController::correctPlaybackDrift); - connect(&m_speedCorrectionTimer, &QTimer::timeout, this, &SyncPlayController::finishSpeedCorrection); - connect(&m_reconnectTimer, &QTimer::timeout, this, &SyncPlayController::connectSocket); - connect(&m_bufferingDebounceTimer, &QTimer::timeout, this, [this]() { sendPlayerBufferingState(true); }); - if (m_player) { - connect(m_player, &PlayerController::playbackStateChanged, this, &SyncPlayController::handlePlayerStateChanged); - } -} - -bool SyncPlayController::socketConnected() const -{ - return m_socket.state() == QAbstractSocket::ConnectedState; -} - -void SyncPlayController::refreshGroups() -{ - if (!m_api) - return; - connectSocket(); - Async::runScoped( - this, m_api->fetchSyncPlayGroups(), - [this](const QJsonArray& groups) { - m_groups = groups; - if (!m_groupId.isEmpty()) { - for (const QJsonValue& value : groups) { - const QJsonObject group = value.toObject(); - if (group.value(QStringLiteral("GroupId")).toString() == m_groupId) { - applyGroupInfo(m_groupId, group); - break; - } - } - } - emit groupsChanged(); - }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("refresh groups"), error); }); -} - -void SyncPlayController::connectSocket() -{ - m_socketDesired = true; - if (!m_api || m_api->session().accessToken.isEmpty() || m_api->serverUrl().isEmpty()) { - return; - } - if (m_socket.state() == QAbstractSocket::ConnectedState) { - if (!m_clock.ready()) - beginTimeSync(); - return; - } - if (m_socket.state() == QAbstractSocket::ConnectingState) - return; - - QNetworkRequest request(socketUrl()); - // The server resolves the session behind a socket from the authorization - // header alone; the deviceId query parameter every client sends is never - // read. Without the header it falls back to the device registered against - // the token, so a second instance sharing that token would land on the - // first one's session and silently steal its group traffic. - request.setRawHeader("Authorization", m_api->authorizationHeader().toUtf8()); - request.setRawHeader("X-Emby-Token", m_api->session().accessToken.toUtf8()); - m_socket.open(request); -} - -void SyncPlayController::disconnectSocket() -{ - m_socketDesired = false; - m_socketFailures = 0; - m_reconnectTimer.stop(); - m_timeSyncTimer.stop(); - m_commandTimer.stop(); - m_correctionTimer.stop(); - m_clock.reset(); - m_timeSyncInFlight = false; - if (m_socket.state() != QAbstractSocket::UnconnectedState) - m_socket.close(); - clearGroup(); - m_groups = {}; - emit connectionChanged(); - emit groupsChanged(); -} - -void SyncPlayController::createGroup(const QString& name) -{ - if (!m_api) - return; - connectSocket(); - Async::runScoped( - this, m_api->createSyncPlayGroup(name), [this]() { refreshGroups(); }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("create group"), error); }); -} - -void SyncPlayController::joinGroup(const QString& groupId) -{ - if (!m_api || groupId.isEmpty()) - return; - connectSocket(); - Async::runScoped( - this, m_api->joinSyncPlayGroup(groupId), - [this, groupId]() { - m_groupId = groupId; - if (m_player) - m_player->setSyncPlaybackSpeed(1.0); - for (const QJsonValue& value : m_groups) { - const QJsonObject group = value.toObject(); - if (group.value(QStringLiteral("GroupId")).toString() == groupId) { - applyGroupInfo(groupId, group); - break; - } - } - m_joinedAtServerMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - emit groupChanged(); - sendPlayerBufferingState(true); - }, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("join group"), error); }); -} - -void SyncPlayController::leaveGroup() -{ - if (!m_api || m_groupId.isEmpty()) - return; - auto request = m_api->leaveSyncPlayGroup(); - clearGroup(); - Async::runScoped( - this, std::move(request), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("leave group"), error); }); -} - -void SyncPlayController::requestUnpauseWhenReady() -{ - if (!enabled()) - return; - m_queueHandoff.arm(); - setWaitingForGroupPlayback(true); -} - -void SyncPlayController::cancelPendingUnpause() -{ - m_queueHandoff.cancel(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); -} - -void SyncPlayController::requestTogglePause() -{ - if (!enabled() || !m_api || !m_player || !m_player->sessionActive()) - return; - - if (m_player->paused()) { - requestGroupUnpause(); - } else { - // Pause locally immediately, then let the scheduled server command - // establish the authoritative position. Setting an explicit state is - // idempotent when the server acknowledgement arrives before mpv's - // property-change event. - m_player->setPaused(true); - Async::runScoped( - this, m_api->syncPlayPause(), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("pause group"), error); }, - "SyncPlay pause request"); - } -} - -void SyncPlayController::requestSeek(double positionSeconds) -{ - if (!enabled() || !m_api || !m_player || !std::isfinite(positionSeconds)) - return; - m_seekResume.arm(!m_player->paused()); - const qint64 ticks = static_cast(std::max(0.0, positionSeconds) * kTicksPerSecond); - Async::runScoped( - this, m_api->syncPlaySeek(ticks), []() {}, - [this](const std::exception_ptr& error) { - m_seekResume.cancel(); - reportRequestError(QStringLiteral("seek group"), error); - }, - "SyncPlay seek request"); -} - -void SyncPlayController::requestRelativeSeek(double deltaSeconds) -{ - if (!m_player) - return; - requestSeek(m_player->positionSeconds() + deltaSeconds); -} - -void SyncPlayController::requestNextItem() -{ - if (!enabled() || !m_api || m_playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayNextItem(m_playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("next group item"), error); }, - "SyncPlay next item request"); -} - -void SyncPlayController::requestPreviousItem() -{ - if (!enabled() || !m_api || m_playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayPreviousItem(m_playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("previous group item"), error); }, - "SyncPlay previous item request"); -} - -void SyncPlayController::requestMoveItem(const QString& playlistItemId, int newIndex) -{ - if (!enabled() || !m_api || playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayMovePlaylistItem(playlistItemId, newIndex), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("move group queue item"), error); }, - "SyncPlay move playlist item request"); -} - -void SyncPlayController::requestRemoveItems(const QStringList& playlistItemIds) -{ - if (!enabled() || !m_api || playlistItemIds.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayRemoveFromPlaylist(playlistItemIds), []() {}, - [this]( - const std::exception_ptr& error) { reportRequestError(QStringLiteral("remove group queue items"), error); }, - "SyncPlay remove from playlist request"); -} - -void SyncPlayController::requestQueueItems(const QStringList& itemIds, bool queueNext) -{ - if (!enabled() || !m_api || itemIds.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlayQueue(itemIds, queueNext), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("queue items for group"), error); }, - "SyncPlay queue request"); -} - -void SyncPlayController::requestPlayItem(const QString& playlistItemId) -{ - if (!enabled() || !m_api || playlistItemId.isEmpty()) - return; - Async::runScoped( - this, m_api->syncPlaySetPlaylistItem(playlistItemId), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("play group queue item"), error); }, - "SyncPlay set playlist item request"); -} - -void SyncPlayController::handleSocketTextMessage(const QString& message) -{ - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(message.toUtf8(), &parseError); - if (!document.isObject()) { - qWarning() << "syncplay: invalid websocket message" << parseError.errorString(); - return; - } - - const QJsonObject object = document.object(); - const QString type = object.value(QStringLiteral("MessageType")).toString(); - if (type == QStringLiteral("ForceKeepAlive")) { - sendKeepAlive(); - return; - } - if (type == QStringLiteral("KeepAlive")) - return; - - const QJsonValue dataValue = object.value(QStringLiteral("Data")); - if (type == QStringLiteral("Sessions")) { - emit sessionsUpdated(dataValue.toArray()); - return; - } - - const QJsonObject data = dataValue.toObject(); - if (type == QStringLiteral("SyncPlayCommand")) - handleSyncPlayCommand(data); - else if (type == QStringLiteral("SyncPlayGroupUpdate")) - handleSyncPlayGroupUpdate(data); - else if (type == QStringLiteral("Play")) - emit remotePlayCommand(data); - else if (type == QStringLiteral("Playstate")) - emit remotePlaystateCommand(data); - else if (type == QStringLiteral("GeneralCommand")) - emit remoteGeneralCommand(data); -} - -void SyncPlayController::handleSyncPlayCommand(const QJsonObject& data) -{ - if (!m_player || m_groupId.isEmpty()) - return; - - const QString command = data.value(QStringLiteral("Command")).toString(); - const QString groupId = data.value(QStringLiteral("GroupId")).toString(); - if (!groupId.isEmpty() && groupId != m_groupId) - return; - - const qint64 emittedAtMs = dateTimeMs(data.value(QStringLiteral("EmittedAt"))); - if (emittedAtMs > 0 && m_joinedAtServerMs > 0 && emittedAtMs < m_joinedAtServerMs) { - qInfo() << "syncplay: ignoring command emitted before group join"; - return; - } - - const qint64 serverTimeMs = dateTimeMs(data.value(QStringLiteral("When"))); - const qint64 positionTicks = data.value(QStringLiteral("PositionTicks")).toVariant().toLongLong(); - const QString playlistItemId = data.value(QStringLiteral("PlaylistItemId")).toString(); - if (!playlistItemId.isEmpty() && !m_playlistItemId.isEmpty() && playlistItemId != m_playlistItemId) { - qInfo() << "syncplay: ignoring command for stale playlist item" << playlistItemId; - return; - } - m_scheduledPlaylistItemId = playlistItemId; - - const QString key - = QStringLiteral("%1|%2|%3|%4").arg(command).arg(serverTimeMs).arg(positionTicks).arg(m_playlistItemId); - if (key == m_lastCommandKey && m_commandTimer.isActive()) - return; - - m_lastCommandKey = key; - m_scheduledCommand = command; - m_scheduledPositionTicks = positionTicks; - m_scheduledServerTimeMs = serverTimeMs; - m_commandDue = false; - m_commandTimer.stop(); - m_bufferingDebounceTimer.stop(); - if (command == QStringLiteral("Unpause")) - setWaitingForGroupPlayback(true); - - const qint64 localNowMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 delayMs = serverTimeMs > 0 ? (m_clock.ready() ? m_clock.localDelayUntil(serverTimeMs, localNowMs) - : std::max(0, serverTimeMs - localNowMs)) - : 0; - m_commandTimer.start(static_cast(std::min(delayMs, std::numeric_limits::max()))); -} - -void SyncPlayController::handleSyncPlayGroupUpdate(const QJsonObject& data) -{ - const QString type = data.value(QStringLiteral("Type")).toString(); - const QString groupId = data.value(QStringLiteral("GroupId")).toString(); - const QJsonValue updateData = data.value(QStringLiteral("Data")); - - if (type == QStringLiteral("GroupJoined") || type == QStringLiteral("GroupUpdate")) { - const QJsonObject info = updateData.toObject(); - applyGroupInfo(groupId, info); - // The server stamps this payload and then, for a group that is already - // playing, immediately emits the command that brings us in line. Both - // carry server time, so compare against the payload rather than - // against a local estimate taken a round trip later — that estimate is - // always ahead of the catch-up command and would discard it. - const qint64 stampedAtMs = dateTimeMs(info.value(QStringLiteral("LastUpdatedAt"))); - m_joinedAtServerMs = stampedAtMs > 0 - ? stampedAtMs - : QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - refreshGroups(); - sendPlayerBufferingState(true); - return; - } - if (type == QStringLiteral("UserJoined")) { - const QString participant = updateData.toString(); - if (!participant.isEmpty() && !m_participants.contains(participant)) { - m_participants.push_back(participant); - emit groupChanged(); - } - return; - } - if (type == QStringLiteral("UserLeft")) { - m_participants.removeAll(updateData.toString()); - emit groupChanged(); - return; - } - if (type == QStringLiteral("StateUpdate")) { - const QJsonObject state = updateData.toObject(); - m_groupState = state.value(QStringLiteral("State")).toString(); - m_groupStateReason = state.value(QStringLiteral("Reason")).toString(); - if (m_groupState == QStringLiteral("Waiting") && m_groupStateReason == QStringLiteral("Unpause")) { - setWaitingForGroupPlayback(true); - } else if (m_groupState == QStringLiteral("Paused") - && (m_groupStateReason == QStringLiteral("Ready") || m_groupStateReason == QStringLiteral("Pause")) - && !m_unpauseRequestPending) { - setWaitingForGroupPlayback(false); - } - if (m_seekResume.takeWhenReady(m_groupState, m_groupStateReason)) { - qInfo() << "syncplay: every participant is ready after seek; requesting group-wide unpause"; - requestGroupUnpause(); - } - emit groupChanged(); - return; - } - if (type == QStringLiteral("PlayQueue")) { - applyPlayQueueUpdate(updateData.toObject()); - return; - } - if (type == QStringLiteral("GroupLeft") || type == QStringLiteral("NotInGroup")) { - // NotInGroup says we are already out, so it is terminal rather than - // something to act on repeatedly. The server sends one per message we - // address to a group we have left, and answering each of them logged a - // fresh "leaving group" every second for a membership already cleared. - if (m_groupId.isEmpty()) - return; - // Worth a line: NotInGroup means the server disagrees with us about - // membership, which otherwise looks like a client that has simply - // stopped receiving group updates. - qInfo() << "syncplay: leaving group after" << type; - clearGroup(); - return; - } - if (type == QStringLiteral("GroupDoesNotExist") || type == QStringLiteral("LibraryAccessDenied") - || type == QStringLiteral("CreateGroupDenied") || type == QStringLiteral("JoinGroupDenied") - || type == QStringLiteral("SyncPlayIsDisabled")) { - emit errorText(groupErrorMessage(type)); - return; - } - qWarning() << "syncplay: unhandled group update" << type; -} - -void SyncPlayController::applyPlayQueueUpdate(const QJsonObject& queue) -{ - if (!m_api || !m_playQueue) - return; - - const qint64 updatedAtMs = dateTimeMs(queue.value(QStringLiteral("LastUpdate"))); - if (updatedAtMs > 0 && updatedAtMs < m_lastPlayQueueUpdateMs) { - qInfo() << "syncplay: ignoring stale play queue update"; - return; - } - m_lastPlayQueueUpdateMs = std::max(m_lastPlayQueueUpdateMs, updatedAtMs); - - const QJsonArray playlist = queue.value(QStringLiteral("Playlist")).toArray(); - const int playingIndex = queue.value(QStringLiteral("PlayingItemIndex")).toInt(-1); - if (playlist.isEmpty() || playingIndex < 0 || playingIndex >= playlist.size()) { - qWarning() << "syncplay: received empty or invalid play queue"; - return; - } - - QStringList itemIds; - QStringList playlistItemIds; - itemIds.reserve(playlist.size()); - playlistItemIds.reserve(playlist.size()); - for (const QJsonValue& value : playlist) { - const QJsonObject entry = value.toObject(); - itemIds.push_back(entry.value(QStringLiteral("ItemId")).toString()); - playlistItemIds.push_back(entry.value(QStringLiteral("PlaylistItemId")).toString()); - } - if (itemIds.contains(QString())) { - qWarning() << "syncplay: play queue contains an item without an id"; - return; - } - - const quint64 generation = ++m_playQueueGeneration; - m_queueHandoff.observeQueueUpdate(); - m_playlistItemId = playlistItemIds.at(playingIndex); - const QString selectedItemId = itemIds.at(playingIndex); - const bool selectedItemAlreadyActive - = m_player && m_player->sessionActive() && m_playQueue->currentItem().id == selectedItemId; - const qint64 requestedTicks = queue.value(QStringLiteral("StartPositionTicks")).toVariant().toLongLong(); - const QString reason = queue.value(QStringLiteral("Reason")).toString(); - qInfo() << "syncplay: resolving incoming queue" << itemIds.size() << "items, index" << playingIndex << "reason" - << reason; - - // A large SyncPlay playlist can take seconds to hydrate. Resolve and start - // the selected item first; the rest of the queue is not on the critical - // path for reporting Ready or executing the scheduled play command. - const auto hydrateQueue = [this, generation, itemIds, playlistItemIds, playingIndex]() { - Async::runScoped( - this, m_api->fetchItemsByIds(itemIds), - [this, generation, itemIds, playlistItemIds, playingIndex](const std::vector& fetched) { - if (generation != m_playQueueGeneration) - return; - - std::vector ordered; - ordered.reserve(static_cast(itemIds.size())); - int resolvedIndex = -1; - for (int requestedIndex = 0; requestedIndex < itemIds.size(); ++requestedIndex) { - const auto found = std::find_if(fetched.begin(), fetched.end(), - [&](const MovieItem& item) { return item.id == itemIds.at(requestedIndex); }); - if (found == fetched.end()) - continue; - MovieItem item = *found; - item.playlistItemId = playlistItemIds.at(requestedIndex); - if (requestedIndex == playingIndex) - resolvedIndex = static_cast(ordered.size()); - ordered.push_back(std::move(item)); - } - if (m_playQueue->matchesQueue(ordered, resolvedIndex)) { - // Usually this broadcast is the echo of an edit made here. - // Rebuilding would reset the model and tear down every row - // the queue panel is showing, to arrive back where we are. - return; - } - m_playQueue->setShuffled(false); - if (resolvedIndex < 0 || !m_playQueue->playNow(ordered, resolvedIndex)) { - qWarning() << "syncplay: could not hydrate the full play queue"; - return; - } - qInfo() << "syncplay: hydrated incoming queue" << ordered.size() << "items"; - }, - [this, generation](const std::exception_ptr& error) { - if (generation == m_playQueueGeneration) - qWarning() << "syncplay: full queue hydration failed:" << exceptionMessage(error); - }, - "SyncPlay full queue hydration"); - }; - - if (selectedItemAlreadyActive) { - m_playQueueLoading = false; - qInfo() << "syncplay: selected queue item is already active; preserving playback session"; - hydrateQueue(); - sendPlayerBufferingState(true); - sendPendingUnpause(); - return; - } - - m_playQueueLoading = true; - setWaitingForGroupPlayback(true); - Async::runScoped( - this, m_api->fetchItemsByIds({ selectedItemId }), - [this, generation, selectedItemId, selectedPlaylistItemId = m_playlistItemId, requestedTicks, hydrateQueue]( - const std::vector& fetched) { - if (generation != m_playQueueGeneration) - return; - const auto selected = std::find_if( - fetched.cbegin(), fetched.cend(), [&](const MovieItem& item) { return item.id == selectedItemId; }); - if (selected == fetched.cend()) { - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - emit errorText(QStringLiteral("SyncPlay could not resolve the selected queue item.")); - return; - } - - MovieItem item = *selected; - item.playlistItemId = selectedPlaylistItemId; - m_playQueue->setShuffled(false); - if (!m_playQueue->playNow(item)) { - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - emit errorText(QStringLiteral("SyncPlay could not start the selected queue item.")); - return; - } - - m_playQueueLoading = false; - m_waitingForPlaybackStart = true; - m_playerStateKnown = false; - prepareQueuePlayback(requestedTicks); - emit queuePlaybackRequested(requestedTicks); - hydrateQueue(); - }, - [this, generation](const std::exception_ptr& error) { - if (generation != m_playQueueGeneration) - return; - m_playQueueLoading = false; - setWaitingForGroupPlayback(false); - reportRequestError(QStringLiteral("load selected queue item"), error); - }, - "SyncPlay selected item resolution"); -} - -void SyncPlayController::prepareQueuePlayback(qint64 positionTicks) -{ - if (!enabled() || !m_api) - return; - const qint64 serverNowMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - Async::runScoped( - this, - m_api->syncPlayReportBuffering(true, std::max(0, positionTicks), false, m_playlistItemId, - QDateTime::fromMSecsSinceEpoch(serverNowMs, QTimeZone(QTimeZone::UTC))), - []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("report buffering"), error); }, - "SyncPlay queue buffering report"); -} - -void SyncPlayController::applyGroupInfo(const QString& groupId, const QJsonObject& info) -{ - const QString infoGroupId = info.value(QStringLiteral("GroupId")).toString(); - m_groupId = !infoGroupId.isEmpty() ? infoGroupId : groupId; - m_groupName = info.value(QStringLiteral("GroupName")).toString(); - if (m_groupName.isEmpty()) - m_groupName = QStringLiteral("SyncPlay group"); - m_groupState = info.value(QStringLiteral("State")).toString(); - m_groupStateReason = info.value(QStringLiteral("Reason")).toString(); - if (m_player && !m_speedCorrectionActive) - m_player->setSyncPlaybackSpeed(1.0); - - QStringList participants; - for (const QJsonValue& value : info.value(QStringLiteral("Participants")).toArray()) { - const QString participant = value.toString(); - if (!participant.isEmpty()) - participants.push_back(participant); - } - m_participants = participants; - emit groupChanged(); -} - -void SyncPlayController::clearGroup() -{ - m_commandTimer.stop(); - m_speedCorrectionTimer.stop(); - m_bufferingDebounceTimer.stop(); - m_speedCorrectionActive = false; - if (m_player) - m_player->clearSyncPlaybackSpeed(); - m_groupId.clear(); - m_groupName.clear(); - m_groupState.clear(); - m_groupStateReason.clear(); - m_participants.clear(); - m_playlistItemId.clear(); - m_lastCommandKey.clear(); - m_scheduledCommand.clear(); - m_scheduledPlaylistItemId.clear(); - m_scheduledPositionTicks = 0; - m_scheduledServerTimeMs = 0; - m_joinedAtServerMs = 0; - m_queueHandoff.cancel(); - m_seekResume.cancel(); - ++m_playQueueGeneration; - m_playQueueLoading = false; - m_waitingForPlaybackStart = false; - m_commandDue = false; - m_unpauseRequestPending = false; - m_syncCorrectionAttempts = 0; - m_lastCorrectionAtMs = 0; - setWaitingForGroupPlayback(false); - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - m_suppressSeekBufferingUntilMs = 0; - m_playerStateKnown = false; - emit groupChanged(); -} - -void SyncPlayController::executeScheduledCommand() -{ - if (!m_player || m_scheduledCommand.isEmpty()) - return; - - if (m_scheduledCommand != QStringLiteral("Stop") - && (m_playQueueLoading || m_waitingForPlaybackStart || !m_player->sessionActive())) { - m_commandDue = true; - qInfo() << "syncplay: deferring" << m_scheduledCommand << "until playback is ready"; - return; - } - - if (!m_scheduledPlaylistItemId.isEmpty() && !m_playlistItemId.isEmpty() - && m_scheduledPlaylistItemId != m_playlistItemId) { - qInfo() << "syncplay: dropping scheduled command for replaced queue item"; - m_commandDue = false; - return; - } - m_commandDue = false; - - const QString command = m_scheduledCommand; - qint64 positionTicks = m_scheduledPositionTicks; - if (command == QStringLiteral("Unpause") && m_scheduledServerTimeMs > 0) { - positionTicks = m_clock.estimatePositionTicks( - positionTicks, m_scheduledServerTimeMs, QDateTime::currentMSecsSinceEpoch()); - } - const double targetSeconds = static_cast(positionTicks) / kTicksPerSecond; - const double positionDelta = std::abs(m_player->positionSeconds() - targetSeconds); - qInfo() << "syncplay: executing" << command << "positionTicks" << positionTicks << "deltaMs" - << static_cast(positionDelta * 1'000.0); - - if (command == QStringLiteral("Pause")) { - m_seekResume.cancel(); - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_player->setPaused(true); - if (positionDelta > 0.1) - m_player->seek(targetSeconds); - } else if (command == QStringLiteral("Unpause")) { - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_syncCorrectionAttempts = 0; - // Web re-enables sync half a threshold after an unpause so the drift a - // player accumulates while it spins up never triggers a correction. - m_lastCorrectionAtMs = QDateTime::currentMSecsSinceEpoch(); - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - if (positionDelta * 1'000.0 > kMinDelaySkipToSyncMs && !m_player->seeking()) { - m_suppressSeekBufferingUntilMs = QDateTime::currentMSecsSinceEpoch() + kInternalSeekBufferingSuppressionMs; - m_player->seek(targetSeconds); - } - m_player->setPaused(false); - } else if (command == QStringLiteral("Seek")) { - finishSpeedCorrection(); - m_player->setPaused(true); - m_player->seek(targetSeconds); - QTimer::singleShot(250, this, [this]() { sendPlayerBufferingState(true); }); - } else if (command == QStringLiteral("Stop")) { - m_seekResume.cancel(); - finishSpeedCorrection(); - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - m_syncCorrectionAttempts = 0; - setPlaybackDiff(0, false); - setSyncMethod(QStringLiteral("None")); - m_player->stopWithReason(QStringLiteral("syncplay-stop")); - } else { - emit errorText(QStringLiteral("Unsupported SyncPlay command: %1").arg(command)); - } -} - -void SyncPlayController::correctPlaybackDrift() -{ - if (!enabled() || !m_player || !m_player->sessionActive() || m_scheduledCommand != QStringLiteral("Unpause") - || m_scheduledServerTimeMs <= 0 || m_player->paused() || m_player->buffering() || m_player->seeking()) { - setPlaybackDiff(0, false); - if (m_speedCorrectionActive) - finishSpeedCorrection(); - return; - } - - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 expectedTicks - = m_clock.estimatePositionTicks(m_scheduledPositionTicks, m_scheduledServerTimeMs, nowMs); - const qint64 actualTicks = static_cast(m_player->estimatedPositionSeconds() * kTicksPerSecond); - const qint64 signedDiffTicks = expectedTicks - actualTicks; - const qint64 diffMs = std::abs(signedDiffTicks) / kTicksPerMillisecond; - const double signedDiffMs = static_cast(signedDiffTicks) / kTicksPerMillisecond; - const SyncCorrection correction = SyncPlayDriftPolicy::evaluate(signedDiffMs); - if (correction.method == SyncCorrection::Method::None) { - m_syncCorrectionAttempts = 0; - if (!m_speedCorrectionActive) - setSyncMethod(QStringLiteral("None")); - return; - } - - if (m_speedCorrectionActive) { - // A large discontinuity should not wait for the gentle rate - // correction to finish; restore 1x before the authoritative seek. - if (correction.method == SyncCorrection::Method::Speed) - return; - finishSpeedCorrection(); - } - - // Keep diagnostics live during correction cooldowns even though another - // correction is intentionally suppressed. - if (nowMs - m_lastCorrectionAtMs < kSyncCooldownMs) - return; - - if (correction.method == SyncCorrection::Method::Speed) { - m_lastCorrectionAtMs = nowMs; - ++m_syncCorrectionAttempts; - m_speedCorrectionActive = true; - m_player->setSyncPlaybackSpeed(correction.speed); - setSyncMethod(QStringLiteral("SpeedToSync (x%1)").arg(correction.speed, 0, 'f', 2)); - qInfo() << "syncplay: correcting playback drift by speed" << signedDiffMs << "ms rate" << correction.speed - << "durationMs" << correction.durationMs; - m_speedCorrectionTimer.start(correction.durationMs); - return; - } - - m_lastCorrectionAtMs = nowMs; - ++m_syncCorrectionAttempts; - setSyncMethod(QStringLiteral("SkipToSync (%1)").arg(m_syncCorrectionAttempts)); - qInfo() << "syncplay: correcting playback drift" << diffMs << "ms"; - m_suppressSeekBufferingUntilMs = nowMs + kInternalSeekBufferingSuppressionMs; - m_player->seek(static_cast(expectedTicks) / kTicksPerSecond); -} - -void SyncPlayController::finishSpeedCorrection() -{ - m_speedCorrectionTimer.stop(); - if (m_player && enabled()) - m_player->setSyncPlaybackSpeed(1.0); - if (!m_speedCorrectionActive) - return; - m_speedCorrectionActive = false; - setSyncMethod(QStringLiteral("None")); -} - -void SyncPlayController::handlePlayerStateChanged() -{ - if (m_waitingForPlaybackStart) { - if (m_player && m_player->sessionActive() && m_player->fileLoaded()) { - m_waitingForPlaybackStart = false; - qInfo() << "syncplay: incoming queue file is loaded and paused; reporting ready"; - if (m_commandDue) - executeScheduledCommand(); - sendPlayerBufferingState(true); - sendPendingUnpause(); - } - // prepareQueuePlayback() already reported this participant as - // buffering. Do not let intermediate player state changes publish a - // false Ready before mpv has actually loaded the file. - return; - } - sendPlayerBufferingState(false); - sendPendingUnpause(); -} - -void SyncPlayController::sendPlayerBufferingState(bool force) -{ - if (!enabled() || !m_api || !m_player || !m_player->sessionActive()) - return; - - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - const bool internalSeek = m_player->seeking() && nowMs < m_suppressSeekBufferingUntilMs; - const bool buffering = internalSeek ? false : (m_player->buffering() || m_player->seeking()); - if (!force && buffering && (!m_playerStateKnown || !m_lastPlayerBuffering)) { - if (!m_bufferingDebounceTimer.isActive()) - m_bufferingDebounceTimer.start(); - return; - } - if (!buffering) - m_bufferingDebounceTimer.stop(); - if (!force && m_playerStateKnown && buffering == m_lastPlayerBuffering) { - return; - } - m_playerStateKnown = true; - m_lastPlayerBuffering = buffering; - - const qint64 positionTicks = static_cast(m_player->positionSeconds() * kTicksPerSecond); - const bool playing = !m_player->paused(); - const qint64 serverNowMs - = QDateTime::currentMSecsSinceEpoch() + static_cast(std::llround(m_clock.offsetMs())); - Async::runScoped( - this, - m_api->syncPlayReportBuffering(buffering, positionTicks, playing, m_playlistItemId, - QDateTime::fromMSecsSinceEpoch(serverNowMs, QTimeZone(QTimeZone::UTC))), - []() {}, - [this, buffering](const std::exception_ptr& error) { - reportRequestError(buffering ? QStringLiteral("report buffering") : QStringLiteral("report ready"), error); - }, - buffering ? "SyncPlay buffering report" : "SyncPlay ready report"); -} -void SyncPlayController::sendPendingUnpause() -{ - if (!enabled() || !m_api || !m_player - || !m_queueHandoff.canSend( - m_playQueueLoading, m_waitingForPlaybackStart, m_player->sessionActive(), m_player->fileLoaded())) { - return; - } - - m_queueHandoff.cancel(); - qInfo() << "syncplay: requesting group-wide unpause"; - requestGroupUnpause(); -} - -void SyncPlayController::requestGroupUnpause() -{ - if (!enabled() || !m_api || m_unpauseRequestPending) - return; - - m_unpauseRequestPending = true; - setWaitingForGroupPlayback(true); - Async::runScoped( - this, m_api->syncPlayUnpause(), []() {}, - [this](const std::exception_ptr& error) { - m_unpauseRequestPending = false; - setWaitingForGroupPlayback(false); - reportRequestError(QStringLiteral("unpause group"), error); - }, - "SyncPlay group unpause"); -} - -void SyncPlayController::setWaitingForGroupPlayback(bool waiting) -{ - if (m_waitingForGroupPlayback == waiting) - return; - m_waitingForGroupPlayback = waiting; - emit syncStatusChanged(); -} - -void SyncPlayController::setPlaybackDiff(qint64 diffTicks, bool valid) -{ - const double diffMs = valid ? static_cast(diffTicks) / kTicksPerMillisecond : 0.0; - if (m_playbackDiffValid == valid && (!valid || qFuzzyCompare(m_playbackDiffMs, diffMs))) - return; - m_playbackDiffValid = valid; - m_playbackDiffMs = diffMs; - emit syncStatusChanged(); -} - -void SyncPlayController::setSyncMethod(const QString& method) -{ - if (m_syncMethod == method) - return; - m_syncMethod = method; - emit syncStatusChanged(); -} - -void SyncPlayController::beginTimeSync() -{ - m_timeSyncTimer.stop(); - m_clock.reset(); - m_greedyTimeSyncRemaining = 3; - m_timeSyncErrorReported = false; - requestTimeSync(); -} - -void SyncPlayController::requestTimeSync() -{ - if (!m_api || !socketConnected() || m_timeSyncInFlight) - return; - - m_timeSyncInFlight = true; - const qint64 requestSentMs = QDateTime::currentMSecsSinceEpoch(); - Async::runScoped( - this, m_api->fetchUtcTime(), - [this, requestSentMs](const QJsonObject& response) { - m_timeSyncInFlight = false; - const qint64 responseReceivedMs = QDateTime::currentMSecsSinceEpoch(); - const qint64 requestReceivedMs = dateTimeMs(response.value(QStringLiteral("RequestReceptionTime"))); - const qint64 responseSentMs = dateTimeMs(response.value(QStringLiteral("ResponseTransmissionTime"))); - if (requestReceivedMs <= 0 || responseSentMs <= 0) { - if (!m_timeSyncErrorReported) { - m_timeSyncErrorReported = true; - emit errorText(QStringLiteral("SyncPlay time response was incomplete.")); - } - scheduleTimeSync(); - return; - } - - m_clock.addMeasurement({ requestSentMs, requestReceivedMs, responseSentMs, responseReceivedMs }); - m_timeSyncErrorReported = false; - emit connectionChanged(); - - Async::runScoped( - this, m_api->syncPlayReportPing(static_cast(std::llround(m_clock.pingMs()))), []() {}, - [this](const std::exception_ptr& error) { reportRequestError(QStringLiteral("report ping"), error); }, - "SyncPlay ping report"); - scheduleTimeSync(); - }, - [this](const std::exception_ptr& error) { - m_timeSyncInFlight = false; - if (!m_timeSyncErrorReported) { - m_timeSyncErrorReported = true; - reportRequestError(QStringLiteral("synchronize clock"), error); - } - scheduleTimeSync(); - }, - "SyncPlay time sync"); -} - -void SyncPlayController::scheduleTimeSync() -{ - if (!socketConnected()) - return; - const int interval = m_greedyTimeSyncRemaining > 0 ? kGreedyTimeSyncIntervalMs : kSteadyTimeSyncIntervalMs; - m_greedyTimeSyncRemaining = std::max(0, m_greedyTimeSyncRemaining - 1); - m_timeSyncTimer.start(interval); -} - -void SyncPlayController::reportRequestError(const QString& action, const std::exception_ptr& error) -{ - const QString message = exceptionMessage(error); - qWarning() << "syncplay:" << action << "failed:" << message; - emit errorText(QStringLiteral("SyncPlay %1 failed: %2").arg(action, message)); -} - -void SyncPlayController::sendKeepAlive() -{ - if (socketConnected()) { - m_socket.sendTextMessage(QStringLiteral(R"({"MessageType":"KeepAlive"})")); - } -} - -QUrl SyncPlayController::socketUrl() const -{ - QUrl url(m_api->serverUrl()); - url.setScheme(url.scheme() == QStringLiteral("https") ? QStringLiteral("wss") : QStringLiteral("ws")); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - url.setPath(path + QStringLiteral("/socket")); - - QUrlQuery query; - if (!m_api->deviceId().isEmpty()) { - query.addQueryItem(QStringLiteral("deviceId"), m_api->deviceId()); - } - url.setQuery(query); - return url; -} - -QCoro::Task SyncPlayController::publishQueue(QStringList itemIds, int playingIndex, qint64 startPositionTicks) -{ - co_await m_api->syncPlaySetNewQueue(std::move(itemIds), playingIndex, startPositionTicks); -} - -} // namespace JellyfinNative diff --git a/src/cache/DatabaseManager.cpp b/src/cache/DatabaseManager.cpp index 9d6e3cf5..ef2250f9 100644 --- a/src/cache/DatabaseManager.cpp +++ b/src/cache/DatabaseManager.cpp @@ -439,108 +439,23 @@ class DatabaseWorker final : public QObject { removeCacheValue(nameSpace, key); } - std::vector accountProfiles() + // Sign-ins the native Jellyfin client saved, read once to carry them + // over to provider accounts. + QVariantList legacyAccounts() { - std::vector profiles; + QVariantList accounts; QSqlQuery query(m_database); if (!query.exec(QStringLiteral("SELECT profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication " - "FROM profiles ORDER BY last_used_at DESC, created_at ASC"))) { - qWarning() << "database: profile load failed" << query.lastError().text(); - return profiles; + "last_used_at FROM profiles"))) + return accounts; + while (query.next()) { + accounts.append(QVariantMap { { QStringLiteral("serverId"), query.value(1) }, + { QStringLiteral("serverName"), query.value(2) }, { QStringLiteral("server"), query.value(3) }, + { QStringLiteral("userId"), query.value(4) }, { QStringLiteral("userName"), query.value(5) }, + { QStringLiteral("lastUsed"), query.value(6) }, + { QStringLiteral("token"), CredentialStore::load(query.value(0).toString()) } }); } - while (query.next()) - profiles.push_back(accountProfileFromQuery(query)); - return profiles; - } - - std::optional activateAccountProfile(const QString& profileId) - { - if (profileId.isEmpty() || !m_database.transaction()) - return std::nullopt; - - QSqlQuery query(m_database); - query.prepare(QStringLiteral("SELECT profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication " - "FROM profiles WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec() || !query.next()) { - m_database.rollback(); - return std::nullopt; - } - - AccountProfile profile = accountProfileFromQuery(query); - profile.lastUsedAt = QDateTime::currentMSecsSinceEpoch(); - QSqlQuery touch(m_database); - touch.prepare(QStringLiteral("UPDATE profiles SET last_used_at = ? WHERE profile_id = ?")); - touch.addBindValue(profile.lastUsedAt); - touch.addBindValue(profileId); - if (!touch.exec() || !m_database.commit()) { - m_database.rollback(); - return std::nullopt; - } - return profile; - } - - void upsertAccountProfile(const AccountProfile& profile) - { - const bool credentialSaved - = !profile.accessToken.isEmpty() && CredentialStore::save(profile.profileId, profile.accessToken); - if (!credentialSaved) - CredentialStore::remove(profile.profileId); - - QSqlQuery query(m_database); - query.prepare(QStringLiteral( - "INSERT INTO profiles(profile_id, server_id, server_name, server_url, user_id, user_name, " - "avatar_tag, last_used_at, created_at, needs_authentication) " - "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(profile_id) DO UPDATE SET " - "server_id = excluded.server_id, server_name = excluded.server_name, " - "server_url = excluded.server_url, user_id = excluded.user_id, user_name = excluded.user_name, " - "avatar_tag = excluded.avatar_tag, last_used_at = excluded.last_used_at, " - "needs_authentication = excluded.needs_authentication")); - const auto bindText - = [&query](const QString& value) { query.addBindValue(value.isNull() ? QStringLiteral("") : value); }; - bindText(profile.profileId); - bindText(profile.serverId); - bindText(profile.serverName); - bindText(profile.serverUrl); - bindText(profile.userId); - bindText(profile.userName); - bindText(profile.avatarTag); - query.addBindValue(profile.lastUsedAt); - query.addBindValue(profile.createdAt); - query.addBindValue(profile.needsAuthentication || !credentialSaved); - if (!query.exec()) - qWarning() << "database: profile upsert failed" << query.lastError().text(); - } - - void expireAccountProfile(const QString& profileId) - { - QSqlQuery query(m_database); - query.prepare(QStringLiteral("UPDATE profiles SET needs_authentication = 1 WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec()) - qWarning() << "database: profile expiration failed" << query.lastError().text(); - CredentialStore::remove(profileId); - } - - void removeAccountProfile(const QString& profileId) - { - QSqlQuery query(m_database); - query.prepare(QStringLiteral("DELETE FROM profiles WHERE profile_id = ?")); - query.addBindValue(profileId); - if (!query.exec()) - qWarning() << "database: profile removal failed" << query.lastError().text(); - CredentialStore::remove(profileId); - } - - void clearAccountProfiles() - { - QSqlQuery query(m_database); - if (!query.exec(QStringLiteral("DELETE FROM profiles"))) - qWarning() << "database: profile clear failed" << query.lastError().text(); - CredentialStore::clear(); + return accounts; } void close() @@ -559,15 +474,6 @@ class DatabaseWorker final : public QObject { database = {}; QSqlDatabase::removeDatabase(connectionName); } - static AccountProfile accountProfileFromQuery(const QSqlQuery& query) - { - const QString profileId = query.value(0).toString(); - const QString accessToken = CredentialStore::load(profileId); - return { profileId, query.value(1).toString(), query.value(2).toString(), query.value(3).toString(), - query.value(4).toString(), query.value(5).toString(), accessToken, query.value(6).toString(), - query.value(7).toLongLong(), query.value(8).toLongLong(), - query.value(9).toBool() || accessToken.isEmpty() }; - } QSqlDatabase m_database; QSqlDatabase m_cacheDatabase; QString m_recoveryNotice; @@ -674,68 +580,12 @@ void DatabaseManager::shutdown() m_worker = nullptr; } -QCoro::Task DatabaseManager::loadLastServerUrlAsync() +QCoro::Task DatabaseManager::loadLegacyAccountsAsync() { if (!co_await awaitInitialization()) - co_return QString(); + co_return QVariantList {}; DatabaseWorker *worker = m_worker; - co_return co_await workerTask(worker, - [worker]() { return worker ? worker->value(QStringLiteral("login/serverUrl")).toString() : QString(); }); -} - -QCoro::Task DatabaseManager::loadLastUsernameAsync() -{ - if (!co_await awaitInitialization()) - co_return QString(); - DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker]() { return worker ? worker->value(QStringLiteral("login/username")).toString() : QString(); }); -} - -void DatabaseManager::saveLoginHints(const QString& serverUrl, const QString& username) -{ - invokeOnWorkerAsync([this, serverUrl, username]() { - m_worker->setValue(QStringLiteral("login/serverUrl"), serverUrl); - m_worker->setValue(QStringLiteral("login/username"), username); - }); -} - -QCoro::Task> DatabaseManager::loadAccountProfilesAsync() -{ - if (!co_await awaitInitialization()) - co_return std::vector {}; - DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker]() { return worker ? worker->accountProfiles() : std::vector {}; }); -} - -QCoro::Task> DatabaseManager::activateAccountProfileAsync(const QString& profileId) -{ - if (!co_await awaitInitialization()) - co_return std::nullopt; - DatabaseWorker *worker = m_worker; - co_return co_await workerTask( - worker, [worker, profileId]() { return worker ? worker->activateAccountProfile(profileId) : std::nullopt; }); -} - -void DatabaseManager::upsertAccountProfile(const AccountProfile& profile) -{ - invokeOnWorkerAsync([this, profile]() { m_worker->upsertAccountProfile(profile); }); -} - -void DatabaseManager::expireAccountProfile(const QString& profileId) -{ - invokeOnWorkerAsync([this, profileId]() { m_worker->expireAccountProfile(profileId); }); -} - -void DatabaseManager::removeAccountProfile(const QString& profileId) -{ - invokeOnWorkerAsync([this, profileId]() { m_worker->removeAccountProfile(profileId); }); -} - -void DatabaseManager::clearAccountProfiles() -{ - invokeOnWorkerAsync([this]() { m_worker->clearAccountProfiles(); }); + co_return co_await workerTask(worker, [worker]() { return worker ? worker->legacyAccounts() : QVariantList {}; }); } QCoro::Task DatabaseManager::loadDeviceIdAsync() @@ -752,34 +602,6 @@ void DatabaseManager::saveDeviceId(const QString& deviceId) invokeOnWorkerAsync([this, deviceId]() { m_worker->setValue(QStringLiteral("client/deviceId"), deviceId); }); } -QCoro::Task DatabaseManager::loadDiscoveredServersAsync() -{ - if (!co_await awaitInitialization()) - co_return QJsonArray(); - DatabaseWorker *worker = m_worker; - co_return co_await workerTask(worker, [worker]() { - if (!worker) - return QJsonArray(); - constexpr qint64 maxAgeMs = 7LL * 24 * 60 * 60 * 1000; - const QByteArray encoded = worker->cacheValue(QStringLiteral("discovery"), QStringLiteral("servers"), maxAgeMs); - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(encoded, &error); - if (error.error == QJsonParseError::NoError && document.isArray()) - return document.array(); - if (!encoded.isEmpty()) - worker->removeCacheValue(QStringLiteral("discovery"), QStringLiteral("servers")); - return QJsonArray(); - }); -} - -void DatabaseManager::saveDiscoveredServers(const QJsonArray& servers) -{ - const QByteArray encoded = QJsonDocument(servers).toJson(QJsonDocument::Compact); - constexpr qint64 ttlMs = 7LL * 24 * 60 * 60 * 1000; - saveCacheEntry(QStringLiteral("discovery"), QStringLiteral("servers"), encoded, ttlMs); - evictCacheEntries(512); -} - QCoro::Task DatabaseManager::loadHomePayloadAsync(const QString& key, int schemaVersion) { if (!co_await awaitInitialization()) @@ -833,8 +655,7 @@ QCoro::Task DatabaseManager::loadStartupStateAsync(const QStringLi co_return co_await workerTask(worker, [worker, keys, initialization]() { if (!worker || !initialization.result()) return StartupState {}; - return StartupState { worker->value(QStringLiteral("client/deviceId")).toString(), worker->values(keys), - worker->accountProfiles() }; + return StartupState { worker->value(QStringLiteral("client/deviceId")).toString(), worker->values(keys) }; }); } diff --git a/src/cache/DatabaseManager.h b/src/cache/DatabaseManager.h index ba1fe54a..468b52ff 100644 --- a/src/cache/DatabaseManager.h +++ b/src/cache/DatabaseManager.h @@ -12,7 +12,6 @@ #include #include -#include "../app/AccountProfile.h" #include "../media/MediaTypes.h" #include @@ -26,7 +25,6 @@ class DatabaseWorker; struct StartupState { QString deviceId; QVariantMap values; - std::vector profiles; }; class DatabaseManager final : public QObject { @@ -39,21 +37,12 @@ class DatabaseManager final : public QObject { bool initialize(const QString& databasePath); void shutdown(); - QCoro::Task loadLastServerUrlAsync(); - QCoro::Task loadLastUsernameAsync(); - void saveLoginHints(const QString& serverUrl, const QString& username); - - QCoro::Task> loadAccountProfilesAsync(); - QCoro::Task> activateAccountProfileAsync(const QString& profileId); - void upsertAccountProfile(const AccountProfile& profile); - void expireAccountProfile(const QString& profileId); - void removeAccountProfile(const QString& profileId); - void clearAccountProfiles(); + // Sign-ins saved by the native Jellyfin client, for the one-time move to + // provider accounts. + QCoro::Task loadLegacyAccountsAsync(); QCoro::Task loadDeviceIdAsync(); void saveDeviceId(const QString& deviceId); - QCoro::Task loadDiscoveredServersAsync(); - void saveDiscoveredServers(const QJsonArray& servers); QCoro::Task loadHomePayloadAsync(const QString& key, int schemaVersion); void saveHomePayload(const QString& key, int schemaVersion, const QJsonObject& payload); void invalidateHomePayloads(); diff --git a/src/common/AsyncTask.h b/src/common/AsyncTask.h index b4c54bab..d998a94c 100644 --- a/src/common/AsyncTask.h +++ b/src/common/AsyncTask.h @@ -6,7 +6,10 @@ #include #include +#include #include +#include +#include #include #include @@ -15,6 +18,30 @@ namespace JellyfinNative::Async { +// Runs `work` on `pool` (the global pool by default) and hands back a future +// a coroutine can await, for disk and CPU work that must stay off the GUI +// thread. A one-thread pool runs its work strictly in order. +template auto background(Work work, QThreadPool *pool = QThreadPool::globalInstance()) +{ + using Result = std::invoke_result_t; + auto promise = std::make_shared>(); + promise->start(); + QFuture future = promise->future(); + pool->start([promise, work = std::move(work)]() mutable { + try { + if constexpr (std::is_void_v) { + work(); + } else { + promise->addResult(work()); + } + } catch (...) { + promise->setException(std::current_exception()); + } + promise->finish(); + }); + return future; +} + namespace detail { template void reportFailure(const char *operation, Failure& failure, const std::exception_ptr& error) diff --git a/src/discovery/DiscoveredServer.h b/src/discovery/DiscoveredServer.h deleted file mode 100644 index bc9aed81..00000000 --- a/src/discovery/DiscoveredServer.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace JellyfinNative { - -struct DiscoveredServer { - Q_GADGET - Q_PROPERTY(QString id MEMBER id) - Q_PROPERTY(QString name MEMBER name) - Q_PROPERTY(QString address MEMBER address) - -public: - QString id; - QString name; - QString address; -}; - -} // namespace JellyfinNative - -Q_DECLARE_METATYPE(JellyfinNative::DiscoveredServer) diff --git a/src/discovery/DiscoveryController.cpp b/src/discovery/DiscoveryController.cpp deleted file mode 100644 index 77c55a0e..00000000 --- a/src/discovery/DiscoveryController.cpp +++ /dev/null @@ -1,704 +0,0 @@ -#include "DiscoveryController.h" -#include "../common/NetworkAddress.h" -#include "../common/TlsTrust.h" - -#include "../diagnostics/Diagnostics.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace JellyfinNative { - -namespace { - - constexpr quint16 kDiscoveryPort = 7359; - constexpr auto kDiscoveryPayload = "who is JellyfinServer?"; - constexpr int kHttpProbePort = 8096; - constexpr int kHttpProbeTimeoutMs = 600; - constexpr int kManualProbeTimeoutMs = 3000; - constexpr int kHttpProbeConcurrency = 8; - constexpr int kInitialHttpFallbackDelayMs = 1500; - constexpr int kMaxAutomaticHttpProbeTargets = 254; - // Sweeping neighbours is the expensive half of discovery, so only the two - // best-ranked networks get one. A shared budget instead let whichever - // interface the kernel happened to list first — routinely a docker bridge - // or a VPN tunnel — spend the whole allowance before the LAN was reached. - constexpr int kMaxScannedNetworks = 2; - constexpr int kUnicastSweepBatch = 24; - constexpr int kUnicastSweepIntervalMs = 15; - - // Names that belong to container bridges, hypervisors and VPN tunnels. - // A Jellyfin server is very unlikely to live behind one, and each costs a - // full subnet sweep, so they sort behind real network interfaces. - bool isSyntheticInterface(const QString& name) - { - static const QStringList prefixes - = { QStringLiteral("docker"), QStringLiteral("br-"), QStringLiteral("virbr"), QStringLiteral("veth"), - QStringLiteral("vmnet"), QStringLiteral("vboxnet"), QStringLiteral("tun"), QStringLiteral("tap"), - QStringLiteral("utun"), QStringLiteral("wg"), QStringLiteral("zt"), QStringLiteral("tailscale"), - QStringLiteral("podman"), QStringLiteral("cni"), QStringLiteral("flannel"), QStringLiteral("kube") }; - for (const QString& prefix : prefixes) { - if (name.startsWith(prefix, Qt::CaseInsensitive)) - return true; - } - return false; - } - - int scanPriority(const QNetworkInterface& iface, const QNetworkAddressEntry& entry) - { - int score = 0; - if (isSyntheticInterface(iface.name())) - score += 100; - if (iface.flags() & QNetworkInterface::IsPointToPoint) - score += 50; - // A /16 is a container bridge far more often than it is a home LAN. - if (entry.prefixLength() > 0 && entry.prefixLength() < 24) - score += 10; - return score; - } - - struct ScanNetwork { - QHostAddress ip; - QHostAddress netmask; - int priority = 0; - }; - - QList rankedScanNetworks() - { - QList networks; - const auto interfaces = QNetworkInterface::allInterfaces(); - for (const QNetworkInterface& iface : interfaces) { - if (!(iface.flags() & QNetworkInterface::IsUp) || !(iface.flags() & QNetworkInterface::IsRunning) - || (iface.flags() & QNetworkInterface::IsLoopBack)) { - continue; - } - - for (const QNetworkAddressEntry& entry : iface.addressEntries()) { - if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol || entry.netmask().isNull()) - continue; - networks.push_back({ entry.ip(), entry.netmask(), scanPriority(iface, entry) }); - } - } - std::stable_sort(networks.begin(), networks.end(), - [](const ScanNetwork& lhs, const ScanNetwork& rhs) { return lhs.priority < rhs.priority; }); - return networks; - } - - QUrl normalizedServerUrl(QUrl url) - { - url.setFragment({}); - url.setQuery({}); - QString path = url.path(); - while (path.endsWith(QLatin1Char('/')) && path.size() > 1) - path.chop(1); - if (path == QStringLiteral("/")) - path.clear(); - url.setPath(path); - return url; - } - - QUrl publicInfoUrl(QUrl serverUrl) - { - QString path = serverUrl.path(); - while (path.endsWith(QLatin1Char('/'))) - path.chop(1); - serverUrl.setPath(path + QStringLiteral("/System/Info/Public")); - return serverUrl; - } - - QUrl serverUrlFromPublicInfoUrl(QUrl publicInfo) - { - static const QString suffix = QStringLiteral("/System/Info/Public"); - QString path = publicInfo.path(); - if (path.endsWith(suffix)) - path.chop(suffix.size()); - publicInfo.setPath(path); - return normalizedServerUrl(publicInfo); - } - - void appendUniqueUrl(QList *urls, QSet *seen, const QUrl& candidate) - { - const QUrl normalized = normalizedServerUrl(candidate); - if (!normalized.isValid() || normalized.host().isEmpty() - || (normalized.scheme() != QStringLiteral("http") && normalized.scheme() != QStringLiteral("https"))) { - return; - } - const QString key = normalized.toString(QUrl::FullyEncoded); - if (seen->contains(key)) - return; - seen->insert(key); - urls->push_back(normalized); - } - - // Parses what the user typed the way the probe does: a bare host gains an - // http:// prefix so QUrl can split host from port and path. - QUrl parseTypedAddress(const QString& input, bool *explicitUrl) - { - QString value = input.trimmed(); - while (value.endsWith(QLatin1Char('/'))) - value.chop(1); - if (value.isEmpty()) - return {}; - - static const QRegularExpression explicitScheme( - QStringLiteral(R"(^[a-z][a-z0-9+.-]*://)"), QRegularExpression::CaseInsensitiveOption); - const bool scheme = explicitScheme.match(value).hasMatch(); - if (explicitUrl) - *explicitUrl = scheme; - const QUrl parsed(scheme ? value : QStringLiteral("http://") + value); - if (!parsed.isValid() || parsed.host().isEmpty()) - return {}; - return normalizedServerUrl(parsed); - } - - // Servers behind container networking announce the address they see - // themselves on, which is routable only inside that network. The datagram - // reached us from somewhere we can actually talk to, so that wins. - QString reachableServerAddress(const QString& advertised, const QHostAddress& sender) - { - QUrl url(advertised); - QHostAddress advertisedHost; - if (!url.isValid() || url.host().isEmpty() || sender.isNull()) - return advertised; - // Only literals are second-guessed. A name may resolve perfectly well - // for us even when it names something the server reached differently. - if (!advertisedHost.setAddress(url.host())) - return advertised; - if (advertisedHost.isEqual(sender, QHostAddress::TolerantConversion)) - return advertised; - - url.setHost(sender.toString()); - return normalizedServerUrl(url).toString(QUrl::FullyEncoded); - } - - void appendSubnetTargets(QList *targets, QSet *seen, quint32 network, quint32 broadcast, - quint32 ownAddress, int maxTargets) - { - if (broadcast <= network + 1 || targets->size() >= maxTargets) - return; - - const auto append = [&](quint32 candidate) { - if (targets->size() >= maxTargets || candidate <= network || candidate >= broadcast - || candidate == ownAddress || seen->contains(candidate)) { - return; - } - seen->insert(candidate); - targets->push_back(QHostAddress(candidate)); - }; - - append(network + 1); - append(broadcast - 1); - if (ownAddress > network + 1) - append(ownAddress - 1); - if (ownAddress + 1 < broadcast) - append(ownAddress + 1); - for (quint32 candidate = network + 1; candidate < broadcast && targets->size() < maxTargets; ++candidate) - append(candidate); - } - -} - -DiscoveryController::DiscoveryController(TlsTrustController *tlsTrust, QObject *parent) - : QObject(parent) -{ - if (tlsTrust) { - tlsTrust->attachNetworkAccessManager(&m_http, QStringLiteral("Server discovery")); - connect( - &m_http, &QNetworkAccessManager::sslErrors, this, [this](QNetworkReply *reply, const QList&) { - if (reply == m_serverProbeReply) - m_tlsRetryInput = m_serverProbeInput; - }); - connect( - tlsTrust, &TlsTrustController::decisionResolved, this, [this](const QString& source, bool trusted, bool) { - if (source != QStringLiteral("Server discovery") || m_tlsRetryInput.isEmpty()) - return; - const QString input = std::exchange(m_tlsRetryInput, {}); - if (trusted) - probeServer(input); - }); - } - connect(&m_socket, &QUdpSocket::readyRead, this, &DiscoveryController::handlePendingDatagrams); - connect(&m_rescanTimer, &QTimer::timeout, this, &DiscoveryController::sendProbe); - m_rescanTimer.setInterval(15000); - connect(&m_unicastSweepTimer, &QTimer::timeout, this, &DiscoveryController::pumpUnicastSweep); - m_unicastSweepTimer.setInterval(kUnicastSweepIntervalMs); -} - -DiscoveryController::~DiscoveryController() -{ - stop(); -} - -bool DiscoveryController::active() const -{ - return m_active; -} - -bool DiscoveryController::serverProbeActive() const -{ - return m_serverProbeReply || !m_serverProbeCandidates.isEmpty(); -} - -QList DiscoveryController::serverProbeCandidates(const QString& input) -{ - bool explicitUrl = false; - const QUrl parsed = parseTypedAddress(input, &explicitUrl); - if (parsed.isEmpty()) - return {}; - - const QString host = parsed.host(); - const bool lan = isLanHost(host); - const int suppliedPort = parsed.port(-1); - QList result; - QSet seen; - - const auto add = [&](const QString& scheme, int port) { - QUrl candidate = parsed; - candidate.setScheme(scheme); - candidate.setPort(port); - appendUniqueUrl(&result, &seen, candidate); - }; - - if (explicitUrl) { - appendUniqueUrl(&result, &seen, parsed); - if (parsed.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0) - add(QStringLiteral("http"), suppliedPort > 0 && suppliedPort != 443 ? suppliedPort : kHttpProbePort); - else if (parsed.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) == 0 && suppliedPort < 0) - add(QStringLiteral("http"), kHttpProbePort); - return result; - } - - if (lan) { - add(QStringLiteral("http"), suppliedPort > 0 ? suppliedPort : kHttpProbePort); - add(QStringLiteral("https"), suppliedPort); - if (suppliedPort < 0) - add(QStringLiteral("http"), -1); - } else { - add(QStringLiteral("https"), suppliedPort); - add(QStringLiteral("http"), suppliedPort > 0 ? suppliedPort : kHttpProbePort); - if (suppliedPort < 0) - add(QStringLiteral("http"), -1); - } - return result; -} - -bool DiscoveryController::looksLikeServerAddress(const QString& input) -{ - QString typed = input.trimmed(); - while (typed.endsWith(QLatin1Char('/'))) - typed.chop(1); - // The login screen probes while the user types, so anything that reads as - // mid-keystroke — a dangling label, hyphen or port separator — waits. - if (typed.endsWith(QLatin1Char('.')) || typed.endsWith(QLatin1Char('-')) || typed.endsWith(QLatin1Char(':'))) - return false; - - const QUrl parsed = parseTypedAddress(typed, nullptr); - if (parsed.isEmpty() || serverProbeCandidates(typed).isEmpty()) - return false; - - // An explicit port, an IP literal, localhost or a plausible public suffix - // are the shapes that can already resolve to a host. - const QString host = parsed.host(); - if (parsed.port(-1) > 0 || !QHostAddress(host).isNull()) - return true; - if (host.compare(QStringLiteral("localhost"), Qt::CaseInsensitive) == 0) - return true; - const qsizetype lastDot = host.lastIndexOf(QLatin1Char('.')); - return lastDot > 0 && host.size() - lastDot - 1 >= 2; -} - -QList DiscoveryController::httpFallbackTargets( - const QHostAddress& address, const QHostAddress& netmask, int maxTargets) -{ - QList targets; - if (maxTargets <= 0 || address.protocol() != QAbstractSocket::IPv4Protocol || !isPrivateNetworkAddress(address)) - return targets; - - const quint32 own = address.toIPv4Address(); - const quint32 mask = netmask.protocol() == QAbstractSocket::IPv4Protocol ? netmask.toIPv4Address() : 0xffffff00u; - QSet seen; - - const quint32 local24 = own & 0xffffff00u; - appendSubnetTargets(&targets, &seen, local24, local24 | 0xffu, own, maxTargets); - - const quint32 configuredNetwork = own & mask; - const quint32 configuredBroadcast = configuredNetwork | ~mask; - const quint64 configuredHosts = static_cast(configuredBroadcast) - configuredNetwork - 1; - if (configuredHosts > 0 && configuredHosts <= static_cast(maxTargets)) { - appendSubnetTargets(&targets, &seen, configuredNetwork, configuredBroadcast, own, maxTargets); - } else { - if (local24 >= 0x100u) - appendSubnetTargets(&targets, &seen, local24 - 0x100u, local24 - 1u, own, maxTargets); - if (local24 <= 0xfffffe00u) - appendSubnetTargets(&targets, &seen, local24 + 0x100u, local24 + 0x1ffu, own, maxTargets); - } - return targets; -} - -DiscoveredServer DiscoveryController::serverFromPublicInfo( - const QByteArray& payload, const QUrl& serverUrl, QString *version) -{ - const QJsonDocument document = QJsonDocument::fromJson(payload); - if (!document.isObject()) - return {}; - - const QJsonObject object = document.object(); - const QString id = object.value(QStringLiteral("Id")).toString().trimmed(); - QString name = object.value(QStringLiteral("ServerName")).toString().trimmed(); - if (name.isEmpty()) - name = object.value(QStringLiteral("Name")).toString().trimmed(); - if (id.isEmpty() || name.isEmpty()) - return {}; - if (version) - *version = object.value(QStringLiteral("Version")).toString().trimmed(); - return { id, name, normalizedServerUrl(serverUrl).toString(QUrl::FullyEncoded) }; -} - -void DiscoveryController::start() -{ - Diagnostics::Task task(QStringLiteral("discovery_start")); - if (m_active) - return; - - m_active = true; - const bool udpReady = ensureSocket(); - qInfo() << "discovery active" << m_active << "udpReady=" << udpReady << "boundPort" << m_socket.localPort() - << "error" << (udpReady ? QStringLiteral("none") : m_socket.errorString()); - emit activeChanged(); - - if (udpReady) - sendProbe(); - m_rescanTimer.start(); - QTimer::singleShot(kInitialHttpFallbackDelayMs, this, &DiscoveryController::startHttpFallbackScan); -} - -void DiscoveryController::stop() -{ - Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("discovery_stop"), - { { QStringLiteral("active"), m_active }, { QStringLiteral("inFlightHttpProbes"), m_inFlightHttpProbes } }); - cancelServerProbe(); - if (!m_active) - return; - - m_active = false; - m_rescanTimer.stop(); - m_unicastSweepTimer.stop(); - m_unicastSweepQueue.clear(); - m_socket.close(); - m_httpProbeQueue.clear(); - m_enqueuedHttpProbeTargets.clear(); - const auto replies = m_httpProbeReplies; - for (QNetworkReply *reply : replies) { - if (!reply) - continue; - reply->disconnect(this); - reply->abort(); - reply->deleteLater(); - } - m_httpProbeReplies.clear(); - m_inFlightHttpProbes = 0; - emit activeChanged(); -} - -void DiscoveryController::sendProbe() -{ - if (!m_active || m_socket.state() != QAbstractSocket::BoundState) - return; - - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), QHostAddress::Broadcast, kDiscoveryPort); - qInfo() << "discovery broadcast probe sent"; - - // Docker forwards a published UDP port for unicast only, so a server in a - // container on this machine never sees the broadcasts above. - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), QHostAddress::LocalHost, kDiscoveryPort); - - const auto interfaces = QNetworkInterface::allInterfaces(); - for (const QNetworkInterface& iface : interfaces) { - if (!(iface.flags() & QNetworkInterface::IsUp) || !(iface.flags() & QNetworkInterface::IsRunning) - || (iface.flags() & QNetworkInterface::IsLoopBack)) { - continue; - } - - for (const QNetworkAddressEntry& entry : iface.addressEntries()) { - if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol || entry.broadcast().isNull()) - continue; - - m_socket.writeDatagram(QByteArray(kDiscoveryPayload), entry.broadcast(), kDiscoveryPort); - qInfo() << "discovery interface probe sent"; - } - } - - if (!m_foundAnyServer) - sendUnicastSweep(); -} - -void DiscoveryController::sendUnicastSweep() -{ - // Container runtimes forward a published UDP port for unicast traffic - // only, so a server in one never sees the broadcasts above. Asking each - // neighbour directly costs a few hundred small datagrams — far less than - // the HTTP sweep — and the replies are ordinary unicast answers, which a - // stateful firewall lets back in without a rule of its own. - if (!m_unicastSweepQueue.isEmpty()) - return; - - const auto networks = rankedScanNetworks(); - int swept = 0; - for (const ScanNetwork& network : networks) { - if (swept >= kMaxScannedNetworks) - break; - ++swept; - const auto targets = httpFallbackTargets(network.ip, network.netmask, kMaxAutomaticHttpProbeTargets); - for (const QHostAddress& target : targets) - m_unicastSweepQueue.enqueue(target); - } - - qInfo() << "discovery unicast sweep queued" << m_unicastSweepQueue.size() << "targets across" << swept - << "networks"; - m_unicastSweepTimer.start(); - pumpUnicastSweep(); -} - -void DiscoveryController::pumpUnicastSweep() -{ - // The socket send buffer cannot take a few hundred datagrams in one go; - // past it writeDatagram simply fails and those neighbours are never asked. - // Anything that does not fit stays at the head of the queue for next tick. - int sent = 0; - while (!m_unicastSweepQueue.isEmpty() && sent < kUnicastSweepBatch) { - if (m_socket.writeDatagram(QByteArray(kDiscoveryPayload), m_unicastSweepQueue.head(), kDiscoveryPort) <= 0) - return; - m_unicastSweepQueue.dequeue(); - ++sent; - } - - if (m_unicastSweepQueue.isEmpty()) { - m_unicastSweepTimer.stop(); - qInfo() << "discovery unicast sweep drained"; - } -} - -void DiscoveryController::handlePendingDatagrams() -{ - while (m_socket.hasPendingDatagrams()) { - const QNetworkDatagram datagram = m_socket.receiveDatagram(); - if (datagram.data() == QByteArray(kDiscoveryPayload)) - continue; - - qInfo() << "discovery datagram from" << datagram.senderAddress().toString() << "bytes" - << datagram.data().size(); - const auto document = QJsonDocument::fromJson(datagram.data()); - if (!document.isObject()) { - qWarning() << "discovery datagram was not a JSON object"; - continue; - } - const auto object = document.object(); - if (!object.contains(QStringLiteral("Id")) || !object.contains(QStringLiteral("Name")) - || !object.contains(QStringLiteral("Address"))) { - qWarning() << "discovery datagram missing Id/Name/Address keys" << object.keys(); - continue; - } - - const QString advertised = object.value(QStringLiteral("Address")).toString(); - const QString address = reachableServerAddress(advertised, datagram.senderAddress()); - if (address != advertised) - qInfo() << "discovery rewrote unroutable advertised address to the datagram sender"; - qInfo() << "discovery reply accepted"; - m_foundAnyServer = true; - emit serverDiscovered({ - object.value(QStringLiteral("Id")).toString(), - object.value(QStringLiteral("Name")).toString(), - address, - }); - } -} - -void DiscoveryController::startHttpFallbackScan() -{ - Diagnostics::Task task(QStringLiteral("discovery_http_fallback_scan")); - if (!m_active || m_inFlightHttpProbes > 0 || !m_httpProbeQueue.isEmpty()) - return; - - const auto networks = rankedScanNetworks(); - - // A server published on this machine — a container forwarding 8096, or a - // plain local install — only answers on loopback or on one of this host's - // own addresses, and a neighbour sweep skips both by design. - enqueueHttpProbeTarget(QHostAddress(QHostAddress::LocalHost)); - for (const ScanNetwork& network : networks) - enqueueHttpProbeTarget(network.ip); - - int scanned = 0; - for (const ScanNetwork& network : networks) { - if (scanned >= kMaxScannedNetworks) - break; - ++scanned; - const auto targets = httpFallbackTargets(network.ip, network.netmask, kMaxAutomaticHttpProbeTargets); - for (const QHostAddress& target : targets) - enqueueHttpProbeTarget(target); - } - - qInfo() << "http discovery queued" << m_httpProbeQueue.size() << "targets across" << scanned << "networks"; - pumpHttpProbeQueue(); -} - -bool DiscoveryController::ensureSocket() -{ - if (m_socket.state() == QAbstractSocket::BoundState) - return true; - - return m_socket.bind( - QHostAddress::AnyIPv4, kDiscoveryPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); -} - -void DiscoveryController::enqueueHttpProbeTarget(const QHostAddress& address) -{ - const QString key = address.toString(); - if (m_enqueuedHttpProbeTargets.contains(key)) - return; - - m_enqueuedHttpProbeTargets.insert(key); - m_httpProbeQueue.enqueue(address); -} - -void DiscoveryController::pumpHttpProbeQueue() -{ - while (m_inFlightHttpProbes < kHttpProbeConcurrency && !m_httpProbeQueue.isEmpty()) { - const QHostAddress address = m_httpProbeQueue.dequeue(); - const QString host = address.toString(); - const QString serverUrl = QStringLiteral("http://%1:%2").arg(host).arg(kHttpProbePort); - - QNetworkRequest request(QUrl(serverUrl + QStringLiteral("/System/Info/Public"))); - request.setTransferTimeout(kHttpProbeTimeoutMs); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("JellyfinNativeDiscovery/0.1.0")); - - QNetworkReply *reply = m_http.get(request); - m_httpProbeReplies.insert(reply); - ++m_inFlightHttpProbes; - Diagnostics::logEvent(QStringLiteral("network"), QStringLiteral("discovery_probe_begin"), - { { QStringLiteral("host"), host }, { QStringLiteral("inFlight"), m_inFlightHttpProbes } }); - - connect(reply, &QNetworkReply::finished, this, [this, reply, serverUrl, host]() { - m_httpProbeReplies.remove(reply); - if (reply->error() == QNetworkReply::NoError) { - const QByteArray payload = reply->readAll(); - qInfo() << "http discovery reply accepted"; - handleHttpProbeResult(serverUrl, payload); - } - - reply->deleteLater(); - m_inFlightHttpProbes = qMax(0, m_inFlightHttpProbes - 1); - m_enqueuedHttpProbeTargets.remove(host); - Diagnostics::logEvent(QStringLiteral("network"), QStringLiteral("discovery_probe_end"), - { { QStringLiteral("host"), host }, { QStringLiteral("inFlight"), m_inFlightHttpProbes } }); - pumpHttpProbeQueue(); - }); - } -} - -void DiscoveryController::handleHttpProbeResult(const QString& serverUrl, const QByteArray& payload) -{ - const DiscoveredServer server = serverFromPublicInfo(payload, QUrl(serverUrl), nullptr); - if (server.id.isEmpty()) - return; - emit serverDiscovered(server); -} - -void DiscoveryController::probeServer(const QString& input) -{ - cancelServerProbe(); - m_serverProbeInput = input.trimmed(); - const QList candidates = serverProbeCandidates(m_serverProbeInput); - for (const QUrl& candidate : candidates) - m_serverProbeCandidates.enqueue(candidate); - if (m_serverProbeCandidates.isEmpty()) { - emit serverProbeFailed(m_serverProbeInput, QStringLiteral("Enter a valid Jellyfin server address.")); - return; - } - emit serverProbeActiveChanged(); - startNextServerProbe(); -} - -void DiscoveryController::cancelServerProbe() -{ - const bool wasActive = serverProbeActive(); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - if (m_serverProbeReply) { - m_serverProbeReply->disconnect(this); - m_serverProbeReply->abort(); - m_serverProbeReply->deleteLater(); - m_serverProbeReply = nullptr; - } - if (wasActive) - emit serverProbeActiveChanged(); -} - -void DiscoveryController::startNextServerProbe() -{ - if (m_serverProbeReply || m_serverProbeCandidates.isEmpty()) { - if (!m_serverProbeReply && m_serverProbeCandidates.isEmpty()) - finishServerProbe(true); - return; - } - - const QUrl serverUrl = m_serverProbeCandidates.dequeue(); - QNetworkRequest request(publicInfoUrl(serverUrl)); - request.setTransferTimeout(kManualProbeTimeoutMs); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, - QCoreApplication::applicationName() + QLatin1Char('/') + QCoreApplication::applicationVersion()); - - QNetworkReply *reply = m_http.get(request); - m_serverProbeReply = reply; - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - if (m_serverProbeReply != reply) - return; - m_serverProbeReply = nullptr; - const QByteArray payload = reply->error() == QNetworkReply::NoError ? reply->readAll() : QByteArray {}; - reply->deleteLater(); - - QString version; - const DiscoveredServer server - = serverFromPublicInfo(payload, serverUrlFromPublicInfoUrl(reply->url()), &version); - if (!server.id.isEmpty()) { - const QString input = m_serverProbeInput; - const bool plainHttp = QUrl(server.address).scheme() == QStringLiteral("http"); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - emit serverProbeActiveChanged(); - // Deliberately not announced as a discovered server: the screen - // already shows this result next to the address that produced it, - // and listing it again reads as two servers. It joins the list - // when the user actually picks it. - emit serverProbeSucceeded(input, server, version, plainHttp); - return; - } - startNextServerProbe(); - }); -} - -void DiscoveryController::finishServerProbe(bool notifyFailure) -{ - const QString input = m_serverProbeInput; - const bool wasActive = serverProbeActive() || !input.isEmpty(); - m_serverProbeCandidates.clear(); - m_serverProbeInput.clear(); - if (wasActive) - emit serverProbeActiveChanged(); - if (notifyFailure) - emit serverProbeFailed(input, QStringLiteral("No Jellyfin server responded at that address.")); -} - -} // namespace JellyfinNative diff --git a/src/discovery/DiscoveryController.h b/src/discovery/DiscoveryController.h deleted file mode 100644 index a4796db6..00000000 --- a/src/discovery/DiscoveryController.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include "DiscoveredServer.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace JellyfinNative { - -class TlsTrustController; -class DiscoveryController final : public QObject { - Q_OBJECT - Q_PROPERTY(bool active READ active NOTIFY activeChanged) - Q_PROPERTY(bool serverProbeActive READ serverProbeActive NOTIFY serverProbeActiveChanged) - -public: - explicit DiscoveryController(TlsTrustController *tlsTrust, QObject *parent = nullptr); - ~DiscoveryController() override; - - bool active() const; - bool serverProbeActive() const; - - static QList serverProbeCandidates(const QString& input); - // True once the typed text names an address worth probing on its own, so - // the login screen can connect as you type instead of waiting for Enter. - Q_INVOKABLE static bool looksLikeServerAddress(const QString& input); - static QList httpFallbackTargets( - const QHostAddress& address, const QHostAddress& netmask, int maxTargets = 254); - static DiscoveredServer serverFromPublicInfo(const QByteArray& payload, const QUrl& serverUrl, QString *version); - - Q_INVOKABLE void start(); - Q_INVOKABLE void stop(); - Q_INVOKABLE void probeServer(const QString& input); - Q_INVOKABLE void cancelServerProbe(); - -signals: - void activeChanged(); - void serverProbeActiveChanged(); - void serverDiscovered(const JellyfinNative::DiscoveredServer& server); - void serverProbeSucceeded( - const QString& input, const JellyfinNative::DiscoveredServer& server, const QString& version, bool plainHttp); - void serverProbeFailed(const QString& input, const QString& message); - -private slots: - void sendProbe(); - void handlePendingDatagrams(); - void startHttpFallbackScan(); - -private: - bool ensureSocket(); - void sendUnicastSweep(); - void pumpUnicastSweep(); - void enqueueHttpProbeTarget(const QHostAddress& address); - void pumpHttpProbeQueue(); - void handleHttpProbeResult(const QString& serverUrl, const QByteArray& payload); - void startNextServerProbe(); - void finishServerProbe(bool notifyFailure); - - QUdpSocket m_socket; - QTimer m_rescanTimer; - QTimer m_unicastSweepTimer; - QQueue m_unicastSweepQueue; - QNetworkAccessManager m_http; - QQueue m_httpProbeQueue; - QSet m_enqueuedHttpProbeTargets; - QSet m_httpProbeReplies; - QQueue m_serverProbeCandidates; - QNetworkReply *m_serverProbeReply = nullptr; - QString m_serverProbeInput; - QString m_tlsRetryInput; - int m_inFlightHttpProbes = 0; - bool m_active = false; - bool m_foundAnyServer = false; -}; - -} // namespace JellyfinNative diff --git a/src/main.cpp b/src/main.cpp index 0fcd7838..61ce4d93 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,9 @@ -#include "api/JellyfinProvider.h" -#include "api/ProviderBenchmark.h" #include "app/AppController.h" #include "app/ArtworkImageProvider.h" #include "app/ArtworkService.h" #include "app/CpuTopology.h" #include "app/GraphicsStartup.h" +#include "app/GroupPlaybackController.h" #include "app/LocalizationManager.h" #include "app/MemoryBudget.h" #include "app/RouterController.h" @@ -28,9 +27,11 @@ #include "platform/ScreenSaverInhibitor.h" #include "player/MpvVideoItem.h" #include "player/PlayerController.h" -#include "provider/Provider.h" +#include "provider/ProviderCapabilities.h" #include "provider/ProviderQmlCache.h" #include "provider/ProviderRegistry.h" +#include "provider/ProviderStore.h" +#include "provider/SourceHub.h" #include "providers/local/LocalProvider.h" #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) #include "platform/UpdateController.h" @@ -71,8 +72,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -641,68 +644,39 @@ int main(int argc, char **argv) } JellyfinNative::TlsTrustController tlsTrust; - // Every provider the build knows about is registered; the one the app - // runs on is chosen from the command line or the environment until the - // first-run picker exists. Providers outlive the player and the app - // controller, which hold their parts by pointer, so they are declared - // before them. - JellyfinNative::ProviderRegistry providers; - providers.setNetworkAccessManager(networkAccessManager); - const QString providersDir = QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers")); - QDir().mkpath(providersDir); - providers.setProvidersDirectory(providersDir); - providers.scanInstalledModules(); - - if (!providers.hasModule(QStringLiteral("spool.jellyfin")) - && QFile::exists(QStringLiteral(":/providers/spool.jellyfin/logic/provider.mjs"))) { - providers.registerModule( - QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); - } - try { - QCoro::waitFor(providers.restoreSources(&database)); - } catch (const std::exception& error) { - qWarning("Portable source metadata could not be restored: %s", error.what()); - } - - const QString requestedProvider - = optionValue(arguments, QStringLiteral("--provider"), "SPOOL_PROVIDER", QStringLiteral("jellyfin")); - - auto jellyfin = std::make_unique( - JellyfinNative::JellyfinProviderContext { networkAccessManager, &tlsTrust, &database, &providers, - capabilities.deviceName, QString::fromLatin1(kAppVersion) }); - providers.add(jellyfin.get()); - auto local - = std::make_unique(optionValue(arguments, QStringLiteral("--library-root"), - "SPOOL_LOCAL_LIBRARY", QStandardPaths::writableLocation(QStandardPaths::MoviesLocation))); - providers.add(local.get()); - if (!providers.setActive(requestedProvider)) { - logLine("provider: unknown provider %s; using %s", qPrintable(requestedProvider), qPrintable(jellyfin->id())); - providers.setActive(jellyfin.get()); - } - JellyfinNative::Provider *provider = providers.active(); - logLine("provider: %s", qPrintable(provider->id())); - - if (arguments.contains(QStringLiteral("--benchmark-providers")) - || qEnvironmentVariableIsSet("SPOOL_BENCHMARK_PROVIDERS") || qgetenv("SPOOL_BENCH") == "providers") { - JellyfinNative::ProviderBenchmarkOptions options; - options.iterations - = optionValue(arguments, QStringLiteral("--iterations"), "SPOOL_BENCH_ITERATIONS", QStringLiteral("5")) - .toInt(); - if (options.iterations <= 0) - options.iterations = 5; - const QString lib = optionValue(arguments, QStringLiteral("--library"), "SPOOL_BENCH_LIBRARY"); - if (!lib.isEmpty()) - options.libraryName = lib; - const QString q = optionValue(arguments, QStringLiteral("--query"), "SPOOL_BENCH_QUERY"); - if (!q.isEmpty()) - options.searchQuery = q; - options.outputPath = optionValue(arguments, QStringLiteral("--output"), "SPOOL_BENCH_OUTPUT"); - - const int exitCode - = QCoro::waitFor(JellyfinNative::ProviderBenchmark::run(&database, &providers, jellyfin.get(), options)); - JellyfinNative::Diagnostics::shutdown(); - return exitCode; + // Accounts outlive the player and the app controller, which hold their + // parts by pointer, so the registry and the hub come first. + JellyfinNative::ProviderRegistry providers(&database); + providers.setInstallDirectory(QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers"))); + providers.loadModules(); +#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) + { + JellyfinNative::ProviderManifest folder; + folder.id = QStringLiteral("spool.local"); + folder.name = QStringLiteral("This computer"); + folder.summary = QStringLiteral("Videos and music in a folder"); + folder.publisher = QStringLiteral("Spool"); + folder.version = QString::fromLatin1(kAppVersion); + folder.capabilities = { QStringLiteral("search"), QStringLiteral("userState") }; + const QString root = optionValue(arguments, QStringLiteral("--library-root"), "SPOOL_LOCAL_LIBRARY", + QStandardPaths::writableLocation(QStandardPaths::MoviesLocation)); + providers.addNativeModule(folder, + [root](const QString& accountId, const QVariantMap& configuration, + QObject *parent) -> JellyfinNative::Provider * { + return new JellyfinNative::LocalProvider( + accountId, configuration.value(QStringLiteral("folder"), root).toString(), parent); + }); } +#endif + JellyfinNative::SourceHub hub(&providers); + JellyfinNative::ProviderCapabilities providerCapabilities; + QObject::connect(&hub, &JellyfinNative::Provider::capabilitiesChanged, &providerCapabilities, + [&hub, &providerCapabilities] { providerCapabilities.setFlags(hub.capabilities()); }); + JellyfinNative::configurePlatformPlaybackCapabilities( + [&hub](const QStringList& codecs, bool restrict) { hub.setVideoCodecs(codecs, restrict); }, hub); + JellyfinNative::ProviderStore store(&providers, &database, networkAccessManager, + QUrl(optionValue(arguments, QStringLiteral("--provider-store"), "SPOOL_PROVIDER_STORE", + QStringLiteral("https://spool-player.github.io/spool-providers/")))); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, @@ -712,10 +686,10 @@ int main(int argc, char **argv) qmlImageCachePath + QStringLiteral("/artwork"), memoryBudget.qmlImageDiskCacheBytes, memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, &tlsTrust); artworkService->setUiWidth(window.width()); - artworkService->setSource(provider->artwork()); + artworkService->setSource(hub.artwork()); auto player = std::make_unique( - &window, provider->playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); + &window, hub.playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); JellyfinNative::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { @@ -736,8 +710,13 @@ int main(int argc, char **argv) updateApplicationIcon(); #endif auto controller - = std::make_unique(&database, provider, artworkService.get(), player.get()); - controller->settings()->setProviderCapabilities(providers.capabilities()); + = std::make_unique(&database, &hub, artworkService.get(), player.get()); + QObject::connect(controller->settings(), &JellyfinNative::SettingsController::playbackPreferencesChanged, &hub, + &JellyfinNative::SourceHub::setPlaybackPreferences); + QObject::connect(&providers, &JellyfinNative::ProviderRegistry::problem, controller.get(), + &JellyfinNative::AppController::toastMessage); + QObject::connect(&store, &JellyfinNative::ProviderStore::problem, controller.get(), + &JellyfinNative::AppController::toastMessage); #if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) // Settings own the update preference; platform installers own installation. QObject::connect(controller->settings(), &JellyfinNative::SettingsController::automaticUpdatesChanged, @@ -806,20 +785,23 @@ int main(int argc, char **argv) // Parented to the engine so it outlives every incubator and dies with it. window.engine()->setIncubationController(new BoostedIncubationController(window.engine())); + // Provider screens (sign-in, settings, pickers) are compiled one at a time + // after the first frame, so opening one never waits on the QML compiler. auto *providerQmlCache = new JellyfinNative::ProviderQmlCache(window.engine()); - QList providerQmlSources; - for (const QString& root : - { QStringLiteral(":/qt/qml/JellyfinWebOS/qml/providers"), QStringLiteral(":/providers") }) { - QDirIterator providerQmlFiles(root, { QStringLiteral("*.qml") }, QDir::Files, QDirIterator::Subdirectories); - while (providerQmlFiles.hasNext()) - providerQmlSources.append(QUrl(QStringLiteral("qrc") + providerQmlFiles.next())); - } - providerQmlCache->addSources(providerQmlSources); + const auto warmProviderComponents = [providerQmlCache, &providers] { + QList sources; + for (const QString& id : providers.moduleIds()) { + const JellyfinNative::ProviderModule *module = providers.module(id); + for (const QString& file : module->manifest.ui) + sources.append(module->file(file)); + } + providerQmlCache->addSources(sources); + }; + warmProviderComponents(); + QObject::connect( + &providers, &JellyfinNative::ProviderRegistry::modulesChanged, providerQmlCache, warmProviderComponents); QObject::connect(providerQmlCache, &JellyfinNative::ProviderQmlCache::finished, &app, - [providerQmlCache, expected = providerQmlSources.size()] { - logLine("provider QML: warmed %d/%lld components", providerQmlCache->retainedCount(), - static_cast(expected)); - }); + [providerQmlCache] { logLine("provider QML: warmed %d components", providerQmlCache->retainedCount()); }); QObject::connect( &window, &QQuickWindow::frameSwapped, providerQmlCache, [providerQmlCache] { providerQmlCache->start(); }, Qt::SingleShotConnection); @@ -836,19 +818,32 @@ int main(int argc, char **argv) [](QQuickView::Status status) { logLine("view status changed: %d", static_cast(status)); }); auto localization = std::make_unique(); localization->attachToEngine(window.engine()); - provider->setLocale(localization->bcp47Locale()); - QObject::connect(localization.get(), &JellyfinNative::LocalizationManager::localeChanged, provider, - [provider, loc = localization.get()]() { provider->setLocale(loc->bcp47Locale()); }); - QString initialRoute; - if (requestedProvider == QStringLiteral("local")) { - initialRoute = QStringLiteral("home"); - } else if (!providers.hasInstalledProviders()) { - initialRoute = QStringLiteral("providerPicker"); - } else { - initialRoute = provider->capabilities().testFlag(JellyfinNative::Provider::Auth) ? QStringLiteral("login") - : QStringLiteral("home"); - } - auto router = std::make_unique(initialRoute); + // Providers start once the device identity they present is known: in + // parallel, off the GUI thread, after the first frame is on its way. + QObject::connect(controller.get(), &JellyfinNative::AppController::deviceIdentityReady, &providers, + [&providers, &store, &capabilities, &tlsTrust, loc = localization.get(), settings = controller->settings()]( + const QString& deviceId) { + providers.setRuntimeEnvironment( + { { QStringLiteral("id"), deviceId }, { QStringLiteral("name"), capabilities.deviceName }, + { QStringLiteral("app"), QStringLiteral("Spool") }, + { QStringLiteral("version"), QString::fromLatin1(kAppVersion) }, + { QStringLiteral("platform"), QSysInfo::productType() }, + { QStringLiteral("locale"), loc->bcp47Locale() } }, + { [&tlsTrust](QNetworkAccessManager *network) { + tlsTrust.attachNetworkAccessManager(network, QStringLiteral("Provider")); + }, + [&tlsTrust](QWebSocket *socket, QUrl url) { + tlsTrust.attachWebSocket(socket, [url] { return url; }, QStringLiteral("Provider")); + } }); + JellyfinNative::Async::runScoped( + &providers, providers.restore(), [] { }, [](const std::exception_ptr&) { }, "provider restore"); + // Checked once the launch has settled, so it never competes with + // the first page for the network. + QTimer::singleShot(8000, &store, [&store, settings] { + store.checkForUpdates(settings->value(QStringLiteral("providers/updates")).toString()); + }); + }); + auto router = std::make_unique(QStringLiteral("home")); JellyfinNative::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); applicationHooks.settings = controller->settings(); @@ -890,28 +885,30 @@ int main(int argc, char **argv) QStringLiteral("splashCoreWidthFraction"), static_cast(SPOOL_SPLASH_CORE_WIDTH_FRACTION)); platformInfo->insert(QStringLiteral("splashCoreAspect"), static_cast(SPOOL_SPLASH_CORE_ASPECT)); platformInfo->insert(QStringLiteral("splashImageUrl"), splashImageUrl(appRootPath)); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "App", controller.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ProviderCapabilities", providers.capabilities()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Sources", &providers); - provider->registerQmlSingletons(); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Art", artworkService.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Browse", controller->browse()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Home", controller->home()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Content", controller->content()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "ItemState", controller->itemState()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Search", controller->search()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Libraries", controller->libraries()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "TlsTrust", &tlsTrust); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Settings", controller->settings()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Player", controller->player()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "PlayQueue", controller->playQueue()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Router", router.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "NativeWindow", &window); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "InputLatency", &inputLatencyMonitor); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "SystemPerformance", &systemPerformanceMonitor); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "I18n", localization.get()); - qmlRegisterSingletonInstance("JellyfinWebOS", 1, 0, "Platform", platformInfo); - qmlRegisterType("JellyfinWebOS", 1, 0, "MpvVideoItem"); + qmlRegisterSingletonInstance("Spool", 1, 0, "App", controller.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "ProviderCapabilities", &providerCapabilities); + qmlRegisterSingletonInstance("Spool", 1, 0, "Providers", &providers); + qmlRegisterSingletonInstance("Spool", 1, 0, "Sources", &hub); + qmlRegisterSingletonInstance("Spool", 1, 0, "Store", &store); + qmlRegisterSingletonInstance("Spool", 1, 0, "Group", controller->group()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Art", artworkService.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Browse", controller->browse()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Home", controller->home()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Content", controller->content()); + qmlRegisterSingletonInstance("Spool", 1, 0, "ItemState", controller->itemState()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Search", controller->search()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Libraries", controller->libraries()); + qmlRegisterSingletonInstance("Spool", 1, 0, "TlsTrust", &tlsTrust); + qmlRegisterSingletonInstance("Spool", 1, 0, "Settings", controller->settings()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Player", controller->player()); + qmlRegisterSingletonInstance("Spool", 1, 0, "PlayQueue", controller->playQueue()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Router", router.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "NativeWindow", &window); + qmlRegisterSingletonInstance("Spool", 1, 0, "InputLatency", &inputLatencyMonitor); + qmlRegisterSingletonInstance("Spool", 1, 0, "SystemPerformance", &systemPerformanceMonitor); + qmlRegisterSingletonInstance("Spool", 1, 0, "I18n", localization.get()); + qmlRegisterSingletonInstance("Spool", 1, 0, "Platform", platformInfo); + qmlRegisterType("Spool", 1, 0, "MpvVideoItem"); // Start asynchronous device, settings, account, and discovery reads before // QML construction. A sole saved account is resolved before routing begins. controller->initialize(); @@ -923,7 +920,7 @@ int main(int argc, char **argv) JellyfinNative::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("load_qml")); // Load through the module registry so AOT QML units are used and // constrained devices do not retain compiler buffers. - window.loadFromModule("JellyfinWebOS", "Main"); + window.loadFromModule("Spool", "Main"); } if (window.status() == QQuickView::Error) { logQmlWarnings(window.errors()); @@ -975,8 +972,8 @@ int main(int argc, char **argv) // with what each one cost written out, so page-switch cost is a number in // CI rather than an impression. JellyfinNative::RenderBenchmarkHooks benchmarkHooks; - benchmarkHooks.providerId = provider ? provider->id() : QString(); - benchmarkHooks.providerRuntime = (provider == jellyfin.get()) ? QStringLiteral("js") : QStringLiteral("native"); + benchmarkHooks.providerId = QStringList(providers.moduleIds()).join(QLatin1Char(',')); + benchmarkHooks.providerRuntime = QStringLiteral("js"); benchmarkHooks.libraries = controller->libraries(); benchmarkHooks.openLibrary = [controller = controller.get()](int index) { controller->openLibrary(index); }; benchmarkHooks.outstandingArtworkRequests diff --git a/src/models/DiscoveredServerModel.cpp b/src/models/DiscoveredServerModel.cpp deleted file mode 100644 index 57735f4f..00000000 --- a/src/models/DiscoveredServerModel.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#include "DiscoveredServerModel.h" - -namespace JellyfinNative { - -DiscoveredServerModel::DiscoveredServerModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int DiscoveredServerModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return static_cast(m_servers.size()); -} - -QVariant DiscoveredServerModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) - return {}; - - const auto& server = m_servers[static_cast(index.row())]; - switch (role) { - case IdRole: - return server.id; - case NameRole: - return server.name; - case AddressRole: - return server.address; - case OnlineRole: - return m_onlineServers.contains(server.id) || m_onlineServers.contains(server.address); - default: - return {}; - } -} - -QHash DiscoveredServerModel::roleNames() const -{ - return { - { IdRole, "serverId" }, - { NameRole, "name" }, - { AddressRole, "address" }, - { OnlineRole, "online" }, - }; -} - -void DiscoveredServerModel::setServers(const std::vector& servers, bool online) -{ - beginResetModel(); - m_servers.clear(); - m_onlineServers.clear(); - for (const DiscoveredServer& server : servers) { - // An entry with no address cannot be connected to, so it would only - // ever draw as a nameless row the user can select and get nothing from. - if (server.address.isEmpty()) - continue; - m_servers.push_back(server); - if (online) { - m_onlineServers.insert(server.id); - m_onlineServers.insert(server.address); - } - } - endResetModel(); -} - -void DiscoveredServerModel::upsertServer(const DiscoveredServer& server, bool online) -{ - if (server.address.isEmpty()) - return; - if (online) { - m_onlineServers.insert(server.id); - m_onlineServers.insert(server.address); - } - for (int i = 0; i < rowCount(); ++i) { - auto& existing = m_servers[static_cast(i)]; - if (existing.id == server.id || existing.address == server.address) { - DiscoveredServer updated = server; - if ((updated.id.isEmpty() || updated.id == updated.address) && !existing.id.isEmpty()) - updated.id = existing.id; - existing = updated; - emit dataChanged(index(i, 0), index(i, 0)); - return; - } - } - - const int newRow = rowCount(); - beginInsertRows({}, newRow, newRow); - m_servers.push_back(server); - endInsertRows(); -} - -void DiscoveredServerModel::clear() -{ - beginResetModel(); - m_servers.clear(); - m_onlineServers.clear(); - endResetModel(); -} - -DiscoveredServer DiscoveredServerModel::serverAt(int index) const -{ - if (index < 0 || index >= rowCount()) - return {}; - return m_servers[static_cast(index)]; -} - -std::vector DiscoveredServerModel::servers() const -{ - return m_servers; -} - -} // namespace JellyfinNative diff --git a/src/models/DiscoveredServerModel.h b/src/models/DiscoveredServerModel.h deleted file mode 100644 index 55ba2b2d..00000000 --- a/src/models/DiscoveredServerModel.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "../discovery/DiscoveredServer.h" - -#include -#include - -#include - -namespace JellyfinNative { - -class DiscoveredServerModel final : public QAbstractListModel { - Q_OBJECT - -public: - enum Roles { - IdRole = Qt::UserRole + 1, - NameRole, - AddressRole, - OnlineRole, - }; - - explicit DiscoveredServerModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role) const override; - QHash roleNames() const override; - - void setServers(const std::vector& servers, bool online = true); - void upsertServer(const DiscoveredServer& server, bool online = true); - void clear(); - DiscoveredServer serverAt(int index) const; - std::vector servers() const; - -private: - std::vector m_servers; - QSet m_onlineServers; -}; - -} // namespace JellyfinNative diff --git a/src/platform/PlatformSettingsPolicy.h b/src/platform/PlatformSettingsPolicy.h index e0205121..793e7533 100644 --- a/src/platform/PlatformSettingsPolicy.h +++ b/src/platform/PlatformSettingsPolicy.h @@ -40,7 +40,6 @@ bool platformSupportsDirectVideoOutput(); // to it after a viewer has watched the first few seconds stutter. const char *platformDefaultVideoOutput(); bool platformUsesPerOutputAudioDelay(); -bool platformDefaultCastButtonEnabled(); bool platformDefaultRemoteControlTargetEnabled(); QString normalizedPlatformAudioRoute(const QString& output); QString platformAudioRouteDisplayName(const QString& output); diff --git a/src/platform/android/AndroidPlatform.cpp b/src/platform/android/AndroidPlatform.cpp index dc7b6a9e..b544e73c 100644 --- a/src/platform/android/AndroidPlatform.cpp +++ b/src/platform/android/AndroidPlatform.cpp @@ -245,10 +245,6 @@ bool platformUsesPerOutputAudioDelay() { return false; } -bool platformDefaultCastButtonEnabled() -{ - return !platformCapabilities().isTV; -} bool platformDefaultRemoteControlTargetEnabled() { return platformCapabilities().isTV; diff --git a/src/platform/linux/LinuxSettingsPolicy.cpp b/src/platform/linux/LinuxSettingsPolicy.cpp index d913ce3e..43f11e6b 100644 --- a/src/platform/linux/LinuxSettingsPolicy.cpp +++ b/src/platform/linux/LinuxSettingsPolicy.cpp @@ -60,10 +60,6 @@ bool platformUsesPerOutputAudioDelay() { return false; } -bool platformDefaultCastButtonEnabled() -{ - return true; -} bool platformDefaultRemoteControlTargetEnabled() { return true; diff --git a/src/platform/macos/MacOSSettingsPolicy.cpp b/src/platform/macos/MacOSSettingsPolicy.cpp index b09a72f9..6742eb35 100644 --- a/src/platform/macos/MacOSSettingsPolicy.cpp +++ b/src/platform/macos/MacOSSettingsPolicy.cpp @@ -57,10 +57,6 @@ bool platformUsesPerOutputAudioDelay() { return false; } -bool platformDefaultCastButtonEnabled() -{ - return true; -} bool platformDefaultRemoteControlTargetEnabled() { return true; diff --git a/src/platform/webos/WebOSSettingsPolicy.cpp b/src/platform/webos/WebOSSettingsPolicy.cpp index dc02986b..509e91e2 100644 --- a/src/platform/webos/WebOSSettingsPolicy.cpp +++ b/src/platform/webos/WebOSSettingsPolicy.cpp @@ -55,10 +55,6 @@ bool platformUsesPerOutputAudioDelay() { return true; } -bool platformDefaultCastButtonEnabled() -{ - return false; -} bool platformDefaultRemoteControlTargetEnabled() { return true; diff --git a/src/platform/windows/WindowsSettingsPolicy.cpp b/src/platform/windows/WindowsSettingsPolicy.cpp index e8c4bdbe..e0e9217b 100644 --- a/src/platform/windows/WindowsSettingsPolicy.cpp +++ b/src/platform/windows/WindowsSettingsPolicy.cpp @@ -57,10 +57,6 @@ bool platformUsesPerOutputAudioDelay() { return false; } -bool platformDefaultCastButtonEnabled() -{ - return true; -} bool platformDefaultRemoteControlTargetEnabled() { return true; diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp new file mode 100644 index 00000000..fd08a6cf --- /dev/null +++ b/src/provider/PortableProvider.cpp @@ -0,0 +1,424 @@ +#include "PortableProvider.h" + +#include "ProviderRegistry.h" + +#include + +#include + +namespace JellyfinNative { + +namespace { + QString fill(QString pattern, std::initializer_list> values) + { + for (const auto& [name, value] : values) + pattern.replace(QLatin1Char('{') + QLatin1String(name) + QLatin1Char('}'), + QString::fromLatin1(QUrl::toPercentEncoding(value))); + return pattern; + } + + QByteArray headerLines(const QVariantMap& headers) + { + QByteArray lines; + for (auto it = headers.cbegin(); it != headers.cend(); ++it) { + const QByteArray value = it.value().toString().toUtf8(); + if (!value.contains('\n') && !value.contains('\r') && !it.key().contains(QLatin1Char('\n'))) + lines += it.key().toUtf8() + ": " + value + '\n'; + } + return lines; + } + + MediaStreamInfo streamFrom(const QVariantMap& s) + { + MediaStreamInfo info; + info.index = s.value(QStringLiteral("index"), -1).toInt(); + info.type = s.value(QStringLiteral("type")).toString(); + info.codec = s.value(QStringLiteral("codec")).toString(); + info.profile = s.value(QStringLiteral("profile")).toString(); + info.language = s.value(QStringLiteral("language")).toString(); + info.title = s.value(QStringLiteral("title")).toString(); + info.displayTitle = info.title; + info.width = s.value(QStringLiteral("width")).toInt(); + info.height = s.value(QStringLiteral("height")).toInt(); + info.frameRate = s.value(QStringLiteral("frameRate")).toDouble(); + info.channels = s.value(QStringLiteral("channels")).toInt(); + info.sampleRate = s.value(QStringLiteral("sampleRate")).toInt(); + info.bitRate = s.value(QStringLiteral("bitrate")).toInt(); + info.bitDepth = s.value(QStringLiteral("bitDepth")).toInt(); + info.videoRange = s.value(QStringLiteral("range")).toString(); + info.videoRangeType = s.value(QStringLiteral("rangeType"), info.videoRange).toString(); + info.isDefault = s.value(QStringLiteral("default")).toBool(); + info.isForced = s.value(QStringLiteral("forced")).toBool(); + info.isExternal = s.value(QStringLiteral("external")).toBool(); + info.isInterlaced = s.value(QStringLiteral("interlaced")).toBool(); + return info; + } + + std::vector segmentsFrom(const QVariantList& rows) + { + std::vector segments; + for (const QVariant& value : rows) { + const QVariantMap row = value.toMap(); + segments.push_back({ row.value(QStringLiteral("id")).toString(), + row.value(QStringLiteral("type")).toString(), row.value(QStringLiteral("startTicks")).toLongLong(), + row.value(QStringLiteral("endTicks")).toLongLong() }); + } + return segments; + } + + QString cursorFor(int startIndex) + { + return startIndex > 0 ? QString::number(startIndex) : QString(); + } +} // namespace + +class PortableProvider::Playback final : public PlaybackSource { +public: + explicit Playback(PortableProvider *owner) + : PlaybackSource(owner) + , m_owner(owner) + { + } + + QByteArray mediaRequestHeaders() const override + { + return m_headers; + } + QUrl mediaOrigin() const override + { + return m_origin; + } + int playbackParallelRequests() const override + { + return 2; + } + bool signedIn() const override + { + return true; + } + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override + { + if (m_owner->m_trickplayTemplate.isEmpty() || itemId.isEmpty() || width <= 0 || tileIndex < 0) + return {}; + return fill(m_owner->m_trickplayTemplate, + { { "itemId", itemId }, { "width", QString::number(width) }, { "index", QString::number(tileIndex) }, + { "variantId", m_variantId } }); + } + + QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override + { + QVariantMap args = m_owner->m_playbackContext; + args.insert(QStringLiteral("itemId"), item.id); + if (!item.mediaSources.isEmpty()) + args.insert(QStringLiteral("variantId"), item.mediaSources.constFirst().id); + args.insert(QStringLiteral("positionTicks"), QString::number(item.resumeTicks)); + args.insert(QStringLiteral("forceTranscode"), forceTranscode); + QVariantMap result = co_await m_owner->call(QStringLiteral("resolve"), args); + // A provider that offers a choice (releases, files, mirrors) hands + // the viewer its own picker and resolves again with the answer. + if (result.contains(QStringLiteral("pick"))) { + const QVariantMap choice = co_await m_owner->m_registry->pick( + m_owner->m_accountId, result.value(QStringLiteral("pick")).toMap()); + if (choice.isEmpty()) + throw std::runtime_error("playback_cancelled"); + args.insert(choice); + result = co_await m_owner->call(QStringLiteral("resolve"), args); + } + + PlaybackSession session; + session.itemId = item.id; + session.title = item.title; + session.itemType = item.itemType; + session.url = result.value(QStringLiteral("url")).toString(); + if (session.url.isEmpty()) + throw std::runtime_error("playback_unavailable"); + session.mediaSourceId = result.value(QStringLiteral("variantId")).toString(); + session.playSessionId = result.value(QStringLiteral("playSessionId")).toString(); + session.playMethod = result.value(QStringLiteral("playMethod"), QStringLiteral("DirectPlay")).toString(); + session.container = result.value(QStringLiteral("container")).toString(); + session.startTimeTicks = item.resumeTicks; + session.runtimeTicks = item.runtimeTicks; + for (const QVariant& stream : result.value(QStringLiteral("streams")).toList()) + session.mediaStreams.append(streamFrom(stream.toMap())); + session.segments = segmentsFrom(result.value(QStringLiteral("segments")).toList()); + const QVariantMap trickplay = result.value(QStringLiteral("trickplay")).toMap(); + session.trickplay = { trickplay.value(QStringLiteral("width")).toInt(), + trickplay.value(QStringLiteral("height")).toInt(), trickplay.value(QStringLiteral("columns")).toInt(), + trickplay.value(QStringLiteral("rows")).toInt(), trickplay.value(QStringLiteral("count")).toInt(), + trickplay.value(QStringLiteral("intervalMs")).toInt(), 0 }; + + const QByteArray headers = headerLines(result.value(QStringLiteral("headers")).toMap()); + const QUrl origin = QUrl(session.url).adjusted(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment); + m_variantId = session.mediaSourceId; + if (headers != m_headers || origin != m_origin) { + m_headers = headers; + m_origin = origin; + emit credentialsChanged(); + } + co_return session; + } + + QCoro::Task> fetchMediaSegments(QString itemId) override + { + const QVariantMap result + = co_await m_owner->call(QStringLiteral("segments"), { { QStringLiteral("itemId"), itemId } }); + co_return segmentsFrom(result.value(QStringLiteral("segments")).toList()); + } + + QCoro::Task> fetchSeriesEpisodes(QString seriesId) override + { + return m_owner->fetchEpisodes(std::move(seriesId)); + } + + QCoro::Task reportPlaybackStart(PlaybackSession session, double rate, int volume, bool muted) override + { + return report(QStringLiteral("start"), session, session.startTimeTicks, + { { QStringLiteral("rate"), rate }, { QStringLiteral("volume"), volume }, + { QStringLiteral("muted"), muted } }); + } + QCoro::Task reportPlaybackProgress( + PlaybackSession session, qint64 positionTicks, bool paused, double rate, int volume, bool muted) override + { + return report(QStringLiteral("progress"), session, positionTicks, + { { QStringLiteral("paused"), paused }, { QStringLiteral("rate"), rate }, + { QStringLiteral("volume"), volume }, { QStringLiteral("muted"), muted } }); + } + QCoro::Task reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool failed, double rate) override + { + return report(QStringLiteral("stop"), session, positionTicks, + { { QStringLiteral("failed"), failed }, { QStringLiteral("rate"), rate } }); + } + +private: + QCoro::Task report(QString event, PlaybackSession session, qint64 positionTicks, QVariantMap args) + { + if (!m_owner->m_capabilities.testFlag(PlaybackReporting)) + co_return; + args.insert(QStringLiteral("event"), event); + args.insert(QStringLiteral("itemId"), session.itemId); + args.insert(QStringLiteral("variantId"), session.mediaSourceId); + args.insert(QStringLiteral("playSessionId"), session.playSessionId); + args.insert(QStringLiteral("playMethod"), session.playMethod); + args.insert(QStringLiteral("positionTicks"), QString::number(positionTicks)); + args.insert(QStringLiteral("audioStreamIndex"), session.audioStreamIndex); + args.insert(QStringLiteral("subtitleStreamIndex"), session.subtitleStreamIndex); + co_await m_owner->call(QStringLiteral("report"), args); + } + + PortableProvider *m_owner; + QByteArray m_headers; + QUrl m_origin; + QString m_variantId; +}; + +PortableProvider::PortableProvider(ProviderRegistry *registry, QString accountId, QString label, + Capabilities capabilities, const QVariantMap& description, QObject *parent) + : Provider(parent) + , m_registry(registry) + , m_accountId(std::move(accountId)) + , m_label(std::move(label)) + , m_capabilities(capabilities) + , m_artworkTemplate(description.value(QStringLiteral("artwork")).toString()) + , m_trickplayTemplate(description.value(QStringLiteral("trickplay")).toString()) + , m_playback(new Playback(this)) +{ +} + +PortableProvider::~PortableProvider() = default; + +PlaybackSource *PortableProvider::playback() +{ + return m_playback; +} + +QCoro::Task PortableProvider::call(QString operation, QVariantMap arguments) +{ + return m_registry->callSource(m_accountId, std::move(operation), std::move(arguments)); +} + +QCoro::Task> PortableProvider::list(QString operation, QVariantMap arguments, int limit) +{ + limit = std::clamp(limit, 1, 100); + arguments.insert(QStringLiteral("limit"), limit); + ProviderMediaPage page + = co_await m_registry->callSourceMediaPage(m_accountId, std::move(operation), arguments, limit); + co_return std::move(page.items); +} + +QCoro::Task PortableProvider::fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) +{ + startIndex = std::max(0, startIndex); + limit = std::clamp(limit, 1, 100); + QString operation = QStringLiteral("browse"); + QVariantMap args { { QStringLiteral("cursor"), cursorFor(startIndex) }, { QStringLiteral("limit"), limit } }; + switch (descriptor.kind) { + case BrowseKind::Library: + case BrowseKind::Genre: + case BrowseKind::Studio: + if (!descriptor.id.isEmpty() && descriptor.kind == BrowseKind::Library) + args.insert(QStringLiteral("parentId"), descriptor.id); + args.insert(QStringLiteral("collectionType"), descriptor.collectionType); + if (descriptor.kind == BrowseKind::Genre) + args.insert(QStringLiteral("genre"), descriptor.name); + if (descriptor.kind == BrowseKind::Studio) + args.insert(QStringLiteral("studio"), descriptor.name); + for (const char *key : { "sortBy", "sortOrder", "filters" }) { + const QString name = QLatin1String(key); + if (queryOptions.contains(name)) + args.insert(name, queryOptions.value(name)); + } + break; + case BrowseKind::FolderChildren: + case BrowseKind::BoxSet: + case BrowseKind::Playlist: + case BrowseKind::ArtistAlbums: + args.insert(QStringLiteral("parentId"), descriptor.id); + args.insert(QStringLiteral("recursive"), false); + break; + case BrowseKind::SeriesSeasons: + operation = QStringLiteral("seasons"); + args.insert(QStringLiteral("seriesId"), descriptor.seriesId.isEmpty() ? descriptor.id : descriptor.seriesId); + break; + case BrowseKind::SeasonEpisodes: + operation = QStringLiteral("episodes"); + args.insert(QStringLiteral("seriesId"), descriptor.seriesId); + args.insert(QStringLiteral("seasonId"), descriptor.seasonId); + break; + case BrowseKind::Person: + operation = QStringLiteral("personItems"); + args.insert(QStringLiteral("personId"), descriptor.id); + break; + case BrowseKind::None: + co_return PagedMovieItems { {}, 0, startIndex, limit }; + } + + ProviderMediaPage page = co_await m_registry->callSourceMediaPage(m_accountId, operation, args, limit); + const int count = static_cast(page.items.size()); + // Core pages by offset; a provider whose cursor is not an offset still + // pages correctly as long as it reports exhaustion. + const int total = page.total ? static_cast(*page.total) : startIndex + count + (page.exhausted ? 0 : 1); + co_return PagedMovieItems { std::move(page.items), total, startIndex, limit }; +} + +QCoro::Task PortableProvider::fetchItemDetails(QString itemId) +{ + return m_registry->callSourceItem(m_accountId, QStringLiteral("details"), { { QStringLiteral("itemId"), itemId } }); +} + +QCoro::Task> PortableProvider::fetchSeasons(QString seriesId) +{ + auto seasons = co_await list(QStringLiteral("seasons"), { { QStringLiteral("seriesId"), seriesId } }, 100); + for (MovieItem& season : seasons) { + if (season.seriesId.isEmpty()) + season.seriesId = seriesId; + } + co_return seasons; +} + +QCoro::Task> PortableProvider::fetchEpisodes(QString seriesId, QString seasonId) +{ + return list(QStringLiteral("episodes"), + { { QStringLiteral("seriesId"), seriesId }, { QStringLiteral("seasonId"), seasonId } }, 100); +} + +QCoro::Task> PortableProvider::fetchResumeItems(int limit) +{ + return list(QStringLiteral("resume"), {}, limit); +} + +QCoro::Task> PortableProvider::fetchNextUpEpisodes(int limit) +{ + return list(QStringLiteral("nextUp"), {}, limit); +} + +QCoro::Task> PortableProvider::fetchLatestItems(QString parentId, int limit) +{ + return list(QStringLiteral("latest"), { { QStringLiteral("parentId"), parentId } }, limit); +} + +QCoro::Task> PortableProvider::fetchSimilarItems(QString itemId, int limit) +{ + return list(QStringLiteral("similar"), { { QStringLiteral("itemId"), itemId } }, limit); +} + +QCoro::Task PortableProvider::fetchItemsByPerson(QString personId, int maximumItems) +{ + PersonCredits credits; + credits.items = co_await list( + QStringLiteral("personItems"), { { QStringLiteral("personId"), personId } }, std::min(maximumItems, 100)); + co_return credits; +} + +QCoro::Task> PortableProvider::fetchLibraries() +{ + const QVariantMap result = co_await call(QStringLiteral("libraries")); + std::vector libraries; + for (const QVariant& value : result.value(QStringLiteral("items")).toList()) { + const QVariantMap row = value.toMap(); + libraries.push_back({ row.value(QStringLiteral("id")).toString(), row.value(QStringLiteral("title")).toString(), + row.value(QStringLiteral("collectionType")).toString(), + row.value(QStringLiteral("posterTag")).toString() }); + } + co_return libraries; +} + +QCoro::Task PortableProvider::fetchLibraryFilterOptions(QString libraryId, QString collectionType) +{ + return call(QStringLiteral("filterOptions"), + { { QStringLiteral("parentId"), libraryId }, { QStringLiteral("collectionType"), collectionType } }); +} + +QCoro::Task> PortableProvider::fetchItemsByIds(QStringList itemIds) +{ + if (itemIds.isEmpty()) + co_return {}; + co_return co_await list(QStringLiteral("items"), { { QStringLiteral("ids"), itemIds } }, int(itemIds.size())); +} + +QCoro::Task> PortableProvider::searchItems(QString searchTerm, int limit) +{ + return list(QStringLiteral("search"), { { QStringLiteral("query"), searchTerm } }, limit); +} + +QCoro::Task> PortableProvider::fetchSearchSuggestions(int limit) +{ + return list(QStringLiteral("resume"), {}, limit); +} + +QCoro::Task PortableProvider::setItemFavorite(QString itemId, bool favorite) +{ + co_await call( + QStringLiteral("favorite"), { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), favorite } }); +} + +QCoro::Task PortableProvider::setItemPlayed(QString itemId, bool played) +{ + co_await call( + QStringLiteral("played"), { { QStringLiteral("itemId"), itemId }, { QStringLiteral("value"), played } }); +} + +QCoro::Task PortableProvider::setItemPlaybackPosition(QString itemId, qint64 positionTicks) +{ + co_await call(QStringLiteral("progress"), + { { QStringLiteral("itemId"), itemId }, { QStringLiteral("positionTicks"), QString::number(positionTicks) } }); +} + +QString PortableProvider::imageUrl(const ImageRequest& request) const +{ + if (request.tag.isEmpty()) + return {}; + // A provider with no template sends absolute image URLs as the tag. + if (m_artworkTemplate.isEmpty()) + return request.tag.startsWith(QStringLiteral("https://")) || request.tag.startsWith(QStringLiteral("http://")) + ? request.tag + : QString(); + const int width = request.fillWidth > 0 ? request.fillWidth : request.maxWidth; + return fill(m_artworkTemplate, + { { "itemId", request.itemId }, { "type", request.imageType }, { "tag", request.tag }, + { "width", QString::number(width) }, { "height", QString::number(request.fillHeight) }, + { "quality", QString::number(request.quality) }, { "format", request.format } }); +} + +} // namespace JellyfinNative diff --git a/src/provider/PortableProvider.h b/src/provider/PortableProvider.h new file mode 100644 index 00000000..19ee4846 --- /dev/null +++ b/src/provider/PortableProvider.h @@ -0,0 +1,118 @@ +#pragma once + +#include "ArtworkSource.h" +#include "Catalog.h" +#include "PlaybackSource.h" +#include "Provider.h" +#include "SearchSource.h" +#include "UserItemStateSink.h" + +namespace JellyfinNative { + +class ProviderRegistry; + +// One account on a JavaScript provider, seen as native interfaces. Listing +// and details are decoded on the provider's worker; image and trickplay URLs +// are filled from templates the provider described once, so drawing artwork +// never calls into JS. +class PortableProvider final : public Provider, + public Catalog, + public SearchSource, + public UserItemStateSink, + public ArtworkSource { + Q_OBJECT + +public: + // `description` is the source's describe() result. + PortableProvider(ProviderRegistry *registry, QString accountId, QString label, Capabilities capabilities, + const QVariantMap& description, QObject *parent = nullptr); + ~PortableProvider() override; + + QString id() const override + { + return m_accountId; + } + QString displayName() const override + { + return m_label; + } + Capabilities capabilities() const override + { + return m_capabilities; + } + PlaybackSource *playback() override; + Catalog *catalog() override + { + return this; + } + ArtworkSource *artwork() override + { + return this; + } + SearchSource *search() override + { + return m_capabilities.testFlag(Search) ? this : nullptr; + } + UserItemStateSink *itemState() override + { + return this; + } + bool ready() const override + { + return true; + } + + bool signedIn() const override + { + return true; + } + QString libraryScopeKey() const override + { + return m_accountId; + } + QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; + QCoro::Task fetchItemDetails(QString itemId) override; + QCoro::Task> fetchSeasons(QString seriesId) override; + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; + QCoro::Task> fetchResumeItems(int limit = 24) override; + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; + QCoro::Task> fetchLibraries() override; + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchItemsByIds(QStringList itemIds) override; + + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + + QCoro::Task setItemFavorite(QString itemId, bool favorite) override; + QCoro::Task setItemPlayed(QString itemId, bool played) override; + QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; + + QString imageUrl(const ImageRequest& request) const override; + + // Group playback and anything else a provider exposes by name. + QCoro::Task call(QString operation, QVariantMap arguments = {}); + // Quality ceiling and decodable codecs, merged into every resolve call. + void setPlaybackContext(QVariantMap context) + { + m_playbackContext = std::move(context); + } + +private: + class Playback; + QCoro::Task> list(QString operation, QVariantMap arguments, int limit); + + ProviderRegistry *m_registry; + QString m_accountId; + QString m_label; + Capabilities m_capabilities; + QString m_artworkTemplate; + QString m_trickplayTemplate; + QVariantMap m_playbackContext; + Playback *m_playback = nullptr; +}; + +} // namespace JellyfinNative diff --git a/src/provider/Provider.h b/src/provider/Provider.h index d573571b..853030c6 100644 --- a/src/provider/Provider.h +++ b/src/provider/Provider.h @@ -1,99 +1,58 @@ #pragma once -#include "../app/AccountProfile.h" - #include #include #include -#include #include -#include -#include - namespace JellyfinNative { -class ArtworkService; class ArtworkSource; -class BrowseSessionController; class Catalog; -class GroupPlayback; -class PlayQueueController; class PlaybackSource; -class PlayerController; -class RemotePlayback; class SearchSource; -class SettingsController; class StreamQualityControl; class UserItemStateSink; -// A source of media the app can sit on: a Jellyfin server, a debrid account, -// a folder of files. The shell and pages are the same whichever one is -// active; what differs is which optional capabilities the source serves, and -// every provider-specific control in shared QML is gated on one of these -// through the ProviderCapabilities singleton the registry publishes. +// A source of media: one account on a server, a debrid service, a folder of +// files. The app itself sees exactly one Provider, the SourceHub, which +// routes to every enabled account; each account is a Provider too. // -// The app talks to a provider only through the interfaces returned here and -// the signals below. A provider that needs nothing from the session-shaped -// hooks (no login, no server) leaves the defaults in place: it is ready from -// the start and never signs out. +// Optional interfaces are null without the matching capability, and shared +// QML gates each optional control on the ProviderCapabilities singleton. class Provider : public QObject { Q_OBJECT public: enum Capability : unsigned { - // The provider supplies its own login flow and session (Session). - Auth = 1u << 0, - // Servers can be found on the local network (Discovery, DiscoveredServers). - Discovery = 1u << 1, - // Text search and suggestions (Search). - Search = 1u << 2, - // Favourite, played and playback position live on the source (ItemState). - UserItemState = 1u << 3, - // Playback start, progress and stop are reported back to the source. - PlaybackReporting = 1u << 4, + Search = 1u << 0, + // Favourite, played and resume position live on the source. + UserItemState = 1u << 1, + // Playback start, progress and stop are reported back. + PlaybackReporting = 1u << 2, // Intro and credit markers for the skip cards. - Segments = 1u << 5, - // Playlists, collections, rename and delete (Management). - LibraryManagement = 1u << 6, - // Group watch with a shared clock (SyncPlay). - SyncPlay = 1u << 7, - // Casting to and driving other sessions of the same source (RemoteControl). - RemoteControl = 1u << 8, - // Sign-in by code on another device (QuickConnect). - QuickConnect = 1u << 9, - // A datagram path between two Spool instances relayed by the source. - PeerRelay = 1u << 10, - // The source can be asked for a lower bitrate or resolution than it - // holds, so the player offers a quality ladder. - StreamQuality = 1u << 11, + Segments = 1u << 3, + // Watching together on a shared clock. + GroupPlayback = 1u << 4, + // Other clients can send this one play and transport commands. + RemoteControl = 1u << 5, + // A lower bitrate or resolution than stored can be requested. + StreamQuality = 1u << 6, + // Scrubbing previews. + Trickplay = 1u << 7, }; Q_DECLARE_FLAGS(Capabilities, Capability) Q_FLAG(Capabilities) - // The core objects a provider's session-bound parts sit on. Anything the - // provider creates from these is parented to `owner`, which must not - // outlive `player`. - struct CoreServices { - QObject *owner = nullptr; - PlayerController *player = nullptr; - PlayQueueController *playQueue = nullptr; - SettingsController *settings = nullptr; - BrowseSessionController *browse = nullptr; - ArtworkService *artwork = nullptr; - }; - using QObject::QObject; virtual QString id() const = 0; virtual QString displayName() const = 0; virtual Capabilities capabilities() const = 0; - // Every provider serves these three. virtual PlaybackSource *playback() = 0; virtual Catalog *catalog() = 0; virtual ArtworkSource *artwork() = 0; - // Null unless the matching capability is set. virtual SearchSource *search() { return nullptr; @@ -106,73 +65,22 @@ class Provider : public QObject { { return nullptr; } - virtual GroupPlayback *groupPlayback() - { - return nullptr; - } - virtual RemotePlayback *remotePlayback() - { - return nullptr; - } - // Called once the core objects exist, before anything is played. - virtual void attach(const CoreServices&) { } - // Registers the QML singletons only this provider's own QML reaches - // (Session, SyncPlay and the like). Shared QML never names them without - // first checking the matching capability. - virtual void registerQmlSingletons() = 0; - virtual void shutdown() { } - - // Whether the catalog can be used right now. A source that needs a - // session reports false until it has one and emits sessionStarted() when - // it does; a source that needs none is ready from construction. + // Whether the catalog can be used now; sessionStarted() follows once it + // can. virtual bool ready() const = 0; - // Whether a stored account will sign in without the viewer's help. The - // shell holds its first route until this settles. - virtual bool hasDefaultProfile() const - { - return true; - } - // The local settings keys the provider wants read before the first - // frame, handed back through restoreFromStorage() with the saved - // accounts. Returns what hasDefaultProfile() will report. - virtual QStringList startupStorageKeys() const - { - return {}; - } - virtual bool restoreFromStorage(QVariantMap, std::vector) - { - return true; - } - // The identity this instance presents to a server, unique per running - // instance on the same machine. - virtual void setDeviceId(const QString&) { } - // The viewer's BCP 47 locale, for sources that localise their strings. - virtual void setLocale(const QString&) { } - // True when the error meant the session is gone and the provider has - // already started signing out, so the caller need not report it. - virtual bool handleUnauthorized(const std::exception_ptr&) - { - return false; - } + virtual void shutdown() { } signals: void capabilitiesChanged(); - // ready() flipped: the catalog became usable, or stopped being so. void sessionStarted(); void sessionEnded(); - // The viewer is opening a stored account, switching accounts, or - // signing out; the app clears what it was showing for the last one. - void activationStarted(); - void switchUserRequested(); - void signOutStarted(); - void hasDefaultProfileChanged(); - void busyChanged(bool busy, const QString& text); void errorOccurred(const QString& message); void toastRequested(const QString& message); - // Something the provider did changed the library; an empty id means - // "whatever is on screen". + // Something changed the library; an empty id means "whatever is shown". void contentChanged(const QString& changedItemId); + // Everything else the source pushes: group state, remote commands. + void sourceEvent(const QString& type, const QVariantMap& payload); }; Q_DECLARE_OPERATORS_FOR_FLAGS(Provider::Capabilities) diff --git a/src/provider/ProviderCapabilities.h b/src/provider/ProviderCapabilities.h new file mode 100644 index 00000000..f1e8e6f5 --- /dev/null +++ b/src/provider/ProviderCapabilities.h @@ -0,0 +1,75 @@ +#pragma once + +#include "Provider.h" + +namespace JellyfinNative { + +// What the enabled accounts can do between them, as the ProviderCapabilities +// QML singleton. Shared QML binds each optional control to one of these. +class ProviderCapabilities final : public QObject { + Q_OBJECT + Q_PROPERTY(bool search READ search NOTIFY changed) + Q_PROPERTY(bool userItemState READ userItemState NOTIFY changed) + Q_PROPERTY(bool playbackReporting READ playbackReporting NOTIFY changed) + Q_PROPERTY(bool segments READ segments NOTIFY changed) + Q_PROPERTY(bool groupPlayback READ groupPlayback NOTIFY changed) + Q_PROPERTY(bool remoteControl READ remoteControl NOTIFY changed) + Q_PROPERTY(bool streamQuality READ streamQuality NOTIFY changed) + Q_PROPERTY(bool trickplay READ trickplay NOTIFY changed) + +public: + using QObject::QObject; + + Provider::Capabilities flags() const + { + return m_flags; + } + void setFlags(Provider::Capabilities flags) + { + if (m_flags != flags) { + m_flags = flags; + emit changed(); + } + } + + bool search() const + { + return m_flags.testFlag(Provider::Search); + } + bool userItemState() const + { + return m_flags.testFlag(Provider::UserItemState); + } + bool playbackReporting() const + { + return m_flags.testFlag(Provider::PlaybackReporting); + } + bool segments() const + { + return m_flags.testFlag(Provider::Segments); + } + bool groupPlayback() const + { + return m_flags.testFlag(Provider::GroupPlayback); + } + bool remoteControl() const + { + return m_flags.testFlag(Provider::RemoteControl); + } + bool streamQuality() const + { + return m_flags.testFlag(Provider::StreamQuality); + } + bool trickplay() const + { + return m_flags.testFlag(Provider::Trickplay); + } + +signals: + void changed(); + +private: + Provider::Capabilities m_flags; +}; + +} // namespace JellyfinNative diff --git a/src/provider/ProviderMediaPage.cpp b/src/provider/ProviderMediaPage.cpp index b07d59dd..70a631cf 100644 --- a/src/provider/ProviderMediaPage.cpp +++ b/src/provider/ProviderMediaPage.cpp @@ -6,12 +6,11 @@ #include #include #include -#include namespace JellyfinNative::Detail { namespace { - constexpr qsizetype maximumTextBytes = 4 * 1024 * 1024; - constexpr double maximumSafeInteger = 9007199254740991.0; + constexpr qsizetype kMaximumTextBytes = 4 * 1024 * 1024; + constexpr double kMaximumSafeInteger = 9007199254740991.0; [[noreturn]] void invalid() { @@ -24,35 +23,40 @@ namespace { return value.isNull() || value.isUndefined(); } - void object(const QJSValue& value) + void requireObject(const QJSValue& value) { if (!value.isObject() || value.isArray() || value.isCallable() || value.isQObject() || value.isError()) invalid(); } - qint64 integer(const QJSValue& value, qint64 fallback = 0) + quint32 arrayLength(const QJSValue& value, quint32 maximum) + { + if (!value.isArray()) + invalid(); + const quint32 length = value.property(QStringLiteral("length")).toUInt(); + if (length > maximum) + invalid(); + return length; + } + + // Exact ticks and byte counts can exceed JS's safe integers; providers send + // those as decimal strings rather than rounded doubles. + qint64 integer(const QJSValue& value) { if (absent(value)) - return fallback; + return 0; if (value.isNumber()) { const double number = value.toNumber(); - if (!std::isfinite(number) || number < 0 || number > maximumSafeInteger || std::trunc(number) != number) + if (!std::isfinite(number) || number < 0 || number > kMaximumSafeInteger || std::trunc(number) != number) invalid(); return static_cast(number); } - // Exact ticks/byte counts can exceed JS's safe integer range. Providers - // must transmit those as decimal strings, never as rounded doubles. if (!value.isString()) invalid(); const QString decimal = value.toString(); - if (decimal.isEmpty() || decimal.size() > 19) - invalid(); - for (const QChar digit : decimal) { - if (digit < QLatin1Char('0') || digit > QLatin1Char('9')) - invalid(); - } - bool ok = false; - const qint64 result = decimal.toLongLong(&ok); + bool ok = decimal.size() <= 19 && !decimal.isEmpty() + && std::all_of(decimal.begin(), decimal.end(), [](QChar c) { return c.isDigit(); }); + const qint64 result = ok ? decimal.toLongLong(&ok) : 0; if (!ok) invalid(); return result; @@ -94,127 +98,186 @@ namespace { if (!value.isString()) invalid(); QString result = value.toString(); - if (result.size() > maximumLength) - invalid(); - textBytes += result.size() * sizeof(QChar); - if (textBytes > maximumTextBytes) + if (result.size() > maximumLength || (textBytes += result.size() * sizeof(QChar)) > kMaximumTextBytes) invalid(); return result; } + QString field(const QJSValue& row, const char *name, qsizetype maximumLength = 4096) + { + return text(row.property(QLatin1String(name)), maximumLength); + } + QStringList strings(const QJSValue& value) { if (absent(value)) return {}; - if (!value.isArray()) - invalid(); - const quint32 length = value.property(QStringLiteral("length")).toUInt(); - if (length > 256) - invalid(); QStringList result; + const quint32 length = arrayLength(value, 256); result.reserve(length); for (quint32 i = 0; i < length; ++i) result.append(text(value.property(i), 4096)); return result; } - ProviderMediaItem item(const QJSValue& row, const QString& sourceId) + MediaStreamInfo stream(const QJSValue& row) { - object(row); - ProviderMediaItem result; - result.sourceId = sourceId; // Ignore any source identity in provider data. - MovieItem& media = result.media; - media.id = text(row.property(QStringLiteral("id")), 1024); + requireObject(row); + MediaStreamInfo stream; + stream.index = smallInteger(row.property(QStringLiteral("index"))); + stream.type = field(row, "type", 32); + stream.codec = field(row, "codec", 64); + stream.profile = field(row, "profile", 128); + stream.language = field(row, "language", 64); + stream.title = field(row, "title"); + stream.displayTitle = stream.title; + stream.width = smallInteger(row.property(QStringLiteral("width"))); + stream.height = smallInteger(row.property(QStringLiteral("height"))); + stream.frameRate = number(row.property(QStringLiteral("frameRate"))); + stream.bitRate = smallInteger(row.property(QStringLiteral("bitrate"))); + stream.bitDepth = smallInteger(row.property(QStringLiteral("bitDepth"))); + stream.channels = smallInteger(row.property(QStringLiteral("channels"))); + stream.sampleRate = smallInteger(row.property(QStringLiteral("sampleRate"))); + stream.videoRange = field(row, "range", 32); + stream.videoRangeType = field(row, "rangeType", 32); + if (stream.videoRangeType.isEmpty()) + stream.videoRangeType = stream.videoRange; + stream.isDefault = boolean(row.property(QStringLiteral("default"))); + stream.isForced = boolean(row.property(QStringLiteral("forced"))); + stream.isExternal = boolean(row.property(QStringLiteral("external"))); + stream.isInterlaced = boolean(row.property(QStringLiteral("interlaced"))); + return stream; + } + + MediaSourceInfo variant(const QJSValue& row) + { + requireObject(row); + MediaSourceInfo variant; + variant.id = field(row, "id", 1024); + if (variant.id.isEmpty()) + invalid(); + variant.name = field(row, "label"); + variant.path = field(row, "filename"); + variant.container = field(row, "container", 64); + variant.size = integer(row.property(QStringLiteral("sizeBytes"))); + variant.bitRate = smallInteger(row.property(QStringLiteral("bitrate"))); + variant.runtimeTicks = integer(row.property(QStringLiteral("runtimeTicks"))); + const QJSValue streams = row.property(QStringLiteral("streams")); + if (!absent(streams)) { + const quint32 length = arrayLength(streams, 256); + for (quint32 i = 0; i < length; ++i) + variant.streams.append(stream(streams.property(i))); + } + return variant; + } + + MovieItem item(const QJSValue& row) + { + requireObject(row); + MovieItem media; + media.id = field(row, "id", 1024); if (media.id.isEmpty()) invalid(); - media.title = text(row.property(QStringLiteral("title"))); - media.sortName = text(row.property(QStringLiteral("sortName"))); + media.title = field(row, "title"); + media.sortName = field(row, "sortName"); if (media.sortName.isEmpty()) media.sortName = media.title; - media.itemType = text(row.property(QStringLiteral("type")), 128); - media.overview = text(row.property(QStringLiteral("overview"))); - media.posterTag = text(row.property(QStringLiteral("posterTag")), 4096); - media.backdropTag = text(row.property(QStringLiteral("backdropTag")), 4096); - media.logoTag = text(row.property(QStringLiteral("logoTag")), 4096); - media.seriesId = text(row.property(QStringLiteral("seriesId")), 1024); - media.seasonId = text(row.property(QStringLiteral("seasonId")), 1024); - media.seriesName = text(row.property(QStringLiteral("seriesName"))); + media.itemType = field(row, "type", 128); + media.overview = field(row, "overview", 64 * 1024); + media.posterTag = field(row, "posterTag"); + media.backdropTag = field(row, "backdropTag"); + media.logoTag = field(row, "logoTag"); + media.bannerTag = field(row, "bannerTag"); + media.thumbTag = field(row, "thumbTag"); + media.seriesPrimaryImageTag = field(row, "seriesPosterTag"); + media.albumPrimaryImageTag = field(row, "albumPosterTag"); + media.seriesId = field(row, "seriesId", 1024); + media.seasonId = field(row, "seasonId", 1024); + media.seriesName = field(row, "seriesName"); + media.album = field(row, "album"); + media.albumId = field(row, "albumId", 1024); + media.albumArtist = field(row, "albumArtist"); media.year = smallInteger(row.property(QStringLiteral("year"))); media.seasonNumber = smallInteger(row.property(QStringLiteral("season"))); media.episodeNumber = smallInteger(row.property(QStringLiteral("episode"))); media.runtimeTicks = integer(row.property(QStringLiteral("runtimeTicks"))); media.resumeTicks = integer(row.property(QStringLiteral("resumeTicks"))); + media.playCount = smallInteger(row.property(QStringLiteral("playCount"))); + media.recursiveItemCount = smallInteger(row.property(QStringLiteral("childCount"))); media.favorite = boolean(row.property(QStringLiteral("favorite"))); media.played = boolean(row.property(QStringLiteral("played"))); + media.isVirtualItem = boolean(row.property(QStringLiteral("virtual"))); + media.locationType = media.isVirtualItem ? QStringLiteral("Virtual") : QString(); + media.dateCreated = field(row, "dateCreated", 64); + media.datePlayed = field(row, "datePlayed", 64); + media.dateLastContentAdded = field(row, "dateUpdated", 64); + media.premiereDate = field(row, "premiereDate", 64); + media.endDate = field(row, "endDate", 64); + media.status = field(row, "status", 64); media.genres = strings(row.property(QStringLiteral("genres"))); media.tags = strings(row.property(QStringLiteral("tags"))); media.studios = strings(row.property(QStringLiteral("studios"))); - media.officialRating = text(row.property(QStringLiteral("officialRating")), 1024); + media.officialRating = field(row, "officialRating", 1024); media.communityRating = number(row.property(QStringLiteral("communityRating"))); + media.criticRating = number(row.property(QStringLiteral("criticRating"))); const QJSValue ids = row.property(QStringLiteral("externalIds")); if (!absent(ids)) { - object(ids); + requireObject(ids); QJSValueIterator iterator(ids); - int count = 0; - while (iterator.hasNext()) { + for (int count = 0; iterator.hasNext(); ++count) { iterator.next(); - if (++count > 64 || iterator.name().isEmpty() || iterator.name().size() > 128) + if (count >= 64) invalid(); const QString key = iterator.name().trimmed().toLower(); const QString id = text(iterator.value(), 1024); - if (key.isEmpty()) - invalid(); - if (id.isEmpty()) - continue; - if (result.externalIds.contains(key) && result.externalIds.value(key) != id) - invalid(); - result.externalIds.insert(key, id); + if (key == QStringLiteral("imdb")) + media.imdbId = id; + else if (key == QStringLiteral("tmdb")) + media.tmdbId = id; + } + } + const QJSValue links = row.property(QStringLiteral("links")); + if (!absent(links)) { + const quint32 length = arrayLength(links, 32); + for (quint32 i = 0; i < length; ++i) { + const QJSValue link = links.property(i); + requireObject(link); + media.externalUrls.append({ field(link, "name"), field(link, "url") }); } } - media.imdbId = result.externalIds.value(QStringLiteral("imdb")); - media.tmdbId = result.externalIds.value(QStringLiteral("tmdb")); - const QJSValue people = row.property(QStringLiteral("people")); if (!absent(people)) { - if (!people.isArray()) - invalid(); - const quint32 length = people.property(QStringLiteral("length")).toUInt(); - if (length > 512) - invalid(); + const quint32 length = arrayLength(people, 512); media.people.reserve(length); for (quint32 i = 0; i < length; ++i) { const QJSValue person = people.property(i); - object(person); - PersonItem entry; - entry.id = text(person.property(QStringLiteral("id")), 1024); - entry.name = text(person.property(QStringLiteral("name"))); - entry.type = text(person.property(QStringLiteral("type")), 128); - entry.role = text(person.property(QStringLiteral("role"))); - entry.imageTag = text(person.property(QStringLiteral("imageTag")), 4096); - media.people.append(std::move(entry)); + requireObject(person); + media.people.append({ field(person, "id", 1024), field(person, "name"), field(person, "type", 128), + field(person, "role"), field(person, "imageTag") }); } } - return result; + const QJSValue variants = row.property(QStringLiteral("variants")); + if (!absent(variants)) { + const quint32 length = arrayLength(variants, 64); + for (quint32 i = 0; i < length; ++i) + media.mediaSources.append(variant(variants.property(i))); + } + return media; } }; } // namespace -ProviderMediaPage readProviderMediaPage(const QJSValue& value, const QString& sourceId, int maximumItems) +ProviderMediaPage readProviderMediaPage(const QJSValue& value, int maximumItems) { - if (sourceId.isEmpty() || sourceId.size() > 256 || maximumItems < 1 || maximumItems > 1000) - invalid(); - object(value); + requireObject(value); const QJSValue rows = value.property(QStringLiteral("items")); const QJSValue exhausted = value.property(QStringLiteral("exhausted")); - if (!rows.isArray() || !exhausted.isBool()) - invalid(); - const quint32 length = rows.property(QStringLiteral("length")).toUInt(); - if (length > static_cast(maximumItems)) + if (!exhausted.isBool()) invalid(); + const quint32 length = arrayLength(rows, static_cast(std::clamp(maximumItems, 1, 1000))); Reader reader; ProviderMediaPage result; - result.sourceId = sourceId; result.exhausted = exhausted.toBool(); const QJSValue cursor = value.property(QStringLiteral("cursor")); if (!absent(cursor)) { @@ -229,7 +292,13 @@ ProviderMediaPage readProviderMediaPage(const QJSValue& value, const QString& so result.total = integer(total); result.items.reserve(length); for (quint32 i = 0; i < length; ++i) - result.items.push_back(reader.item(rows.property(i), sourceId)); + result.items.push_back(reader.item(rows.property(i))); return result; } + +MovieItem readProviderItem(const QJSValue& value) +{ + return Reader().item(value); +} + } // namespace JellyfinNative::Detail diff --git a/src/provider/ProviderMediaPage.h b/src/provider/ProviderMediaPage.h index 16213da4..33989dee 100644 --- a/src/provider/ProviderMediaPage.h +++ b/src/provider/ProviderMediaPage.h @@ -2,7 +2,6 @@ #include "../media/MediaTypes.h" -#include #include #include @@ -12,28 +11,20 @@ class QJSValue; namespace JellyfinNative { -// Experimental native-owned listing data. Keep the source with each item when -// aggregating pages; MovieItem::id alone is only meaningful within that source. -// This is not a stable SDK or a complete details/playback descriptor. -struct ProviderMediaItem { - QString sourceId; - MovieItem media; - QMap externalIds; -}; - +// One page of a provider listing, decoded on the worker. IDs are the +// provider's own; the hub scopes them to their source. struct ProviderMediaPage { - QString sourceId; - std::vector items; + std::vector items; std::optional cursor; std::optional total; bool exhausted = false; }; namespace Detail { -// Invoke only on the owning script engine's thread. Reads the normalized -// provider schema directly, not Jellyfin JSON, and never retains JS values. -// The caller supplies trusted source identity and an explicit page-size bound. -ProviderMediaPage readProviderMediaPage(const QJSValue& value, const QString& sourceId, int maximumItems = 100); + // Worker-thread only: reads the normalized provider schema (not any + // backend's JSON) straight into native values and keeps no JS values. + ProviderMediaPage readProviderMediaPage(const QJSValue& value, int maximumItems = 100); + MovieItem readProviderItem(const QJSValue& value); } } // namespace JellyfinNative diff --git a/src/provider/ProviderPackage.cpp b/src/provider/ProviderPackage.cpp index 9cdeb4dd..b154bc27 100644 --- a/src/provider/ProviderPackage.cpp +++ b/src/provider/ProviderPackage.cpp @@ -1,398 +1,295 @@ #include "ProviderPackage.h" +#include "../../third_party/zstd/bounded_zstd.h" + #include +#include #include #include #include #include #include #include +#include #include - -#include "puff.h" -#include +#include namespace JellyfinNative { namespace { - constexpr qint64 kMaxArchiveBytes = 16 * 1024 * 1024; - constexpr qint64 kMaxExpandedBytes = 32 * 1024 * 1024; - constexpr qint64 kMaxFileBytes = 8 * 1024 * 1024; + constexpr qsizetype kMaxArchiveBytes = 16 * 1024 * 1024; + constexpr qsizetype kMaxExpandedBytes = 32 * 1024 * 1024; + constexpr qsizetype kMaxFileBytes = 8 * 1024 * 1024; constexpr int kMaxFiles = 512; -#pragma pack(push, 1) - struct ZipEOCD { - uint32_t signature; - uint16_t diskNumber; - uint16_t cdStartDisk; - uint16_t numEntriesThisDisk; - uint16_t numEntries; - uint32_t cdSize; - uint32_t cdOffset; - uint16_t commentLen; - }; - - struct ZipCDHeader { - uint32_t signature; - uint16_t versionMadeBy; - uint16_t versionNeeded; - uint16_t flags; - uint16_t method; - uint16_t modTime; - uint16_t modDate; - uint32_t crc32; - uint32_t compressedSize; - uint32_t uncompressedSize; - uint16_t filenameLen; - uint16_t extraLen; - uint16_t commentLen; - uint16_t diskNumStart; - uint16_t internalAttr; - uint32_t externalAttr; - uint32_t localHeaderOffset; - }; - - struct ZipLocalHeader { - uint32_t signature; - uint16_t versionNeeded; - uint16_t flags; - uint16_t method; - uint16_t modTime; - uint16_t modDate; - uint32_t crc32; - uint32_t compressedSize; - uint32_t uncompressedSize; - uint16_t filenameLen; - uint16_t extraLen; - }; -#pragma pack(pop) + bool fail(QString *error, const QString& message) + { + if (error) + *error = message; + return false; + } - bool isValidPackagePath(const QString& path) + bool validPath(const QString& path) { - if (path.isEmpty() || path.startsWith(QLatin1Char('/')) || path.contains(QLatin1Char('\\')) - || path.contains(QLatin1Char(':')) || path.contains(QStringLiteral(".."))) { + static const QSet extensions { QStringLiteral("mjs"), QStringLiteral("js"), QStringLiteral("qml"), + QStringLiteral("json"), QStringLiteral("png"), QStringLiteral("jpg"), QStringLiteral("svg"), + QStringLiteral("webp"), QStringLiteral("ttf"), QStringLiteral("otf"), QStringLiteral("txt"), + QStringLiteral("md"), QStringLiteral("map") }; + if (path.isEmpty() || path.size() > 200 || path.startsWith(QLatin1Char('/')) || path.contains(QLatin1Char('\\')) + || path.contains(QLatin1Char(':'))) return false; - } - static const QSet allowedExtensions = { QStringLiteral("mjs"), QStringLiteral("js"), - QStringLiteral("qml"), QStringLiteral("json"), QStringLiteral("png"), QStringLiteral("jpg"), - QStringLiteral("jpeg"), QStringLiteral("svg"), QStringLiteral("webp"), QStringLiteral("ttf"), - QStringLiteral("otf"), QStringLiteral("txt"), QStringLiteral("map") }; - const QString ext = QFileInfo(path).suffix().toLower(); - if (ext.isEmpty()) { - const QString fileName = QFileInfo(path).fileName(); - if (fileName != QStringLiteral("LICENSE") && fileName != QStringLiteral("NOTICE")) + for (const QString& part : path.split(QLatin1Char('/'))) { + if (part.isEmpty() || part.startsWith(QLatin1Char('.')) || part.endsWith(QLatin1Char(' '))) return false; - } else if (!allowedExtensions.contains(ext)) { - return false; } - - const auto parts = path.split(QLatin1Char('/')); - for (const auto& part : parts) { - if (part.isEmpty() || part == QStringLiteral(".") || part.endsWith(QLatin1Char('.')) - || part.endsWith(QLatin1Char(' '))) { - return false; - } - } - return true; + const QFileInfo info(path); + return extensions.contains(info.suffix().toLower()) || info.fileName() == QStringLiteral("LICENSE") + || info.fileName() == QStringLiteral("NOTICE"); } - bool hasNativeMagic(const QByteArray& data) + bool nativeBinary(const QByteArray& data) { - if (data.size() < 4) - return false; - const auto *bytes = reinterpret_cast(data.constData()); - // ELF: \x7fELF - if (bytes[0] == 0x7f && bytes[1] == 'E' && bytes[2] == 'L' && bytes[3] == 'F') - return true; - // DOS / PE: MZ - if (bytes[0] == 'M' && bytes[1] == 'Z') - return true; - // Mach-O - if ((bytes[0] == 0xca && bytes[1] == 0xfe && bytes[2] == 0xba && bytes[3] == 0xbe) - || (bytes[0] == 0xce && bytes[1] == 0xfa && bytes[2] == 0xed && bytes[3] == 0xfe) - || (bytes[0] == 0xcf && bytes[1] == 0xfa && bytes[2] == 0xed && bytes[3] == 0xfe)) - return true; - // WebAssembly: \0asm - if (bytes[0] == 0x00 && bytes[1] == 'a' && bytes[2] == 's' && bytes[3] == 'm') - return true; - return false; + return data.startsWith("\x7f" + "ELF") + || data.startsWith("MZ") || data.startsWith(QByteArray("\0asm", 4)) || data.startsWith("\xca\xfe\xba\xbe") + || data.startsWith("\xce\xfa\xed\xfe") || data.startsWith("\xcf\xfa\xed\xfe"); } - void setError(QString *errorOut, const QString& message) + std::optional octal(const char *field, int length) { - if (errorOut) - *errorOut = message; + qint64 value = 0; + int i = 0; + while (i < length && field[i] == ' ') + ++i; + for (; i < length && field[i] >= '0' && field[i] <= '7'; ++i) + value = value * 8 + (field[i] - '0'); + if (i < length && field[i] != '\0' && field[i] != ' ') + return std::nullopt; + return value; } -} // namespace -std::optional ProviderPackage::parseAndValidate( - const QByteArray& zipBytes, QString *errorMessage) -{ - if (zipBytes.isEmpty()) { - setError(errorMessage, QStringLiteral("Package archive is empty")); - return std::nullopt; - } - if (zipBytes.size() > kMaxArchiveBytes) { - setError(errorMessage, QStringLiteral("Package archive exceeds maximum size limit (16MB)")); - return std::nullopt; + QString text(const char *field, int length) + { + return QString::fromUtf8(field, qstrnlen(field, length)); } - if (zipBytes.size() < static_cast(sizeof(ZipEOCD))) { - setError(errorMessage, QStringLiteral("Archive is too small to be a valid ZIP file")); - return std::nullopt; + bool readTar(const QByteArray& tar, QMap& files, QString *error) + { + qsizetype offset = 0; + qsizetype expanded = 0; + QSet folded; + while (offset + 512 <= tar.size()) { + const char *header = tar.constData() + offset; + if (std::all_of(header, header + 512, [](char c) { return c == 0; })) + return true; + qint64 checksum = 0; + for (int i = 0; i < 512; ++i) + checksum += (i >= 148 && i < 156) ? ' ' : static_cast(header[i]); + const auto stored = octal(header + 148, 8); + const auto size = octal(header + 124, 12); + if (!stored || *stored != checksum || !size || *size < 0) + return fail(error, QStringLiteral("Corrupt archive header")); + const QString prefix = text(header + 345, 155); + QString name = text(header + 0, 100); + if (!prefix.isEmpty()) + name = prefix + QLatin1Char('/') + name; + if (name.startsWith(QStringLiteral("./"))) + name = name.mid(2); + const char type = header[156]; + offset += 512; + if (type == '5') { + if (*size != 0) + return fail(error, QStringLiteral("Corrupt directory entry")); + continue; + } + if (type != '0' && type != '\0') + return fail(error, QStringLiteral("Links and special files are not allowed: %1").arg(name)); + if (!validPath(name)) + return fail(error, QStringLiteral("Forbidden path in package: %1").arg(name)); + if (*size > kMaxFileBytes || (expanded += *size) > kMaxExpandedBytes || files.size() >= kMaxFiles) + return fail(error, QStringLiteral("Package is too large")); + if (offset + *size > tar.size()) + return fail(error, QStringLiteral("Truncated archive")); + if (folded.contains(name.toCaseFolded())) + return fail(error, QStringLiteral("Duplicate path in package: %1").arg(name)); + folded.insert(name.toCaseFolded()); + QByteArray data = tar.mid(offset, *size); + if (nativeBinary(data)) + return fail(error, QStringLiteral("Native binaries are not allowed: %1").arg(name)); + files.insert(name, std::move(data)); + offset += (*size + 511) / 512 * 512; + } + return fail(error, QStringLiteral("Truncated archive")); } - // Locate End of Central Directory record - const auto *data = reinterpret_cast(zipBytes.constData()); - const qint64 dataSize = zipBytes.size(); - const ZipEOCD *eocd = nullptr; - const uint8_t *search = data + dataSize - sizeof(ZipEOCD); - const uint8_t *minSearch = data + std::max(0, dataSize - sizeof(ZipEOCD) - 65536); - - while (search >= minSearch) { - if (*reinterpret_cast(search) == 0x06054b50) { - eocd = reinterpret_cast(search); - break; - } - --search; + QString string(const QJsonObject& object, const char *key) + { + return object.value(QLatin1String(key)).toString().trimmed(); } +} // namespace - if (!eocd) { - setError(errorMessage, QStringLiteral("End of central directory not found in ZIP")); +std::optional ProviderManifest::parse(const QByteArray& json, QString *error) +{ + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(json, &parseError); + const QJsonObject root = document.object(); + if (!document.isObject()) { + fail(error, QStringLiteral("manifest.json is not a JSON object")); return std::nullopt; } - - if (eocd->numEntries > kMaxFiles) { - setError(errorMessage, QStringLiteral("Package contains too many files")); + if (root.value(QStringLiteral("format")).toInt() != 2 || string(root, "api") != QStringLiteral("0.2")) { + fail(error, QStringLiteral("This provider was built for a different version of Spool")); return std::nullopt; } + static const QRegularExpression idPattern(QStringLiteral("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$")); + static const QRegularExpression versionPattern(QStringLiteral("^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.]+)?$")); + ProviderManifest manifest; + manifest.id = string(root, "id"); + manifest.name = string(root, "name"); + manifest.version = string(root, "version"); + manifest.publisher = string(root, "publisher"); + manifest.summary = string(root, "summary"); + manifest.icon = string(root, "icon"); + manifest.entry = string(root, "entry"); + manifest.homepage = string(root, "homepage"); + for (const QJsonValue& value : root.value(QStringLiteral("capabilities")).toArray()) + manifest.capabilities.append(value.toString()); + for (const QJsonValue& value : root.value(QStringLiteral("origins")).toArray()) + manifest.origins.append(value.toString()); + const QJsonObject ui = root.value(QStringLiteral("ui")).toObject(); + for (auto it = ui.begin(); it != ui.end(); ++it) + manifest.ui.insert(it.key(), it.value().toString()); + for (const QJsonValue& value : root.value(QStringLiteral("actions")).toArray()) { + const QJsonObject action = value.toObject(); + if (!action.value(QStringLiteral("id")).toString().isEmpty() + && !action.value(QStringLiteral("label")).toString().isEmpty()) + manifest.actions.append(action.toVariantMap()); + } - if (eocd->cdOffset + eocd->cdSize > static_cast(dataSize)) { - setError(errorMessage, QStringLiteral("Corrupt ZIP central directory offset")); + const bool pathsValid = std::all_of(manifest.ui.cbegin(), manifest.ui.cend(), + [](const QString& path) { return validPath(path) && path.endsWith(QStringLiteral(".qml")); }); + if (!idPattern.match(manifest.id).hasMatch() || manifest.id.size() > 128 || manifest.name.isEmpty() + || manifest.name.size() > 64 || manifest.summary.size() > 120 + || !versionPattern.match(manifest.version).hasMatch() || !validPath(manifest.entry) + || !manifest.entry.endsWith(QStringLiteral(".mjs")) || (!manifest.icon.isEmpty() && !validPath(manifest.icon)) + || !pathsValid) { + fail(error, QStringLiteral("manifest.json is missing a required field or has an invalid one")); return std::nullopt; } + return manifest; +} - ProviderPackageContents package; - qint64 totalExpandedBytes = 0; - QSet caseFoldedNames; - - const uint8_t *cdPtr = data + eocd->cdOffset; - for (int i = 0; i < eocd->numEntries; ++i) { - if (cdPtr + sizeof(ZipCDHeader) > data + dataSize) { - setError(errorMessage, QStringLiteral("Corrupt central directory record")); - return std::nullopt; - } - const auto *cd = reinterpret_cast(cdPtr); - if (cd->signature != 0x02014b50) { - setError(errorMessage, QStringLiteral("Invalid central directory header signature")); - return std::nullopt; - } - - if (cdPtr + sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen > data + dataSize) { - setError(errorMessage, QStringLiteral("Truncated central directory entry")); - return std::nullopt; - } - - const QString entryPath - = QString::fromUtf8(reinterpret_cast(cdPtr + sizeof(ZipCDHeader)), cd->filenameLen); - - // Skip directory-only records if empty - if (entryPath.endsWith(QLatin1Char('/'))) { - cdPtr += sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen; - continue; - } - - if (!isValidPackagePath(entryPath)) { - setError(errorMessage, QStringLiteral("Invalid or forbidden path in package: %1").arg(entryPath)); - return std::nullopt; - } - - const QString folded = entryPath.toCaseFolded(); - if (caseFoldedNames.contains(folded)) { - setError(errorMessage, QStringLiteral("Case-colliding paths detected: %1").arg(entryPath)); - return std::nullopt; - } - caseFoldedNames.insert(folded); +namespace ProviderPackage { - if (cd->uncompressedSize > kMaxFileBytes) { - setError(errorMessage, QStringLiteral("File %1 exceeds maximum allowed size (8MB)").arg(entryPath)); - return std::nullopt; - } - - totalExpandedBytes += cd->uncompressedSize; - if (totalExpandedBytes > kMaxExpandedBytes) { - setError(errorMessage, QStringLiteral("Package total expanded size exceeds limit (32MB)")); + std::optional read(const QByteArray& archive, QString *error) + { + if (archive.isEmpty() || archive.size() > kMaxArchiveBytes) { + fail(error, QStringLiteral("Package is empty or too large")); return std::nullopt; } - - if (cd->localHeaderOffset + sizeof(ZipLocalHeader) > static_cast(dataSize)) { - setError(errorMessage, QStringLiteral("Invalid local header offset for %1").arg(entryPath)); + // The expanded tar holds every file plus a header per file and padding. + const size_t ceiling = kMaxExpandedBytes + (kMaxFiles + 2) * 1024; + const size_t declared = spool_zstd_content_size(archive.constData(), archive.size()); + QByteArray tar(static_cast(std::min(declared, ceiling)), Qt::Uninitialized); + size_t written = 0; + if (spool_zstd_decompress(archive.constData(), archive.size(), tar.data(), tar.size(), &written) != 0) { + fail(error, QStringLiteral("Package is not a valid .tar.zst archive")); return std::nullopt; } + tar.truncate(static_cast(written)); - const auto *loc = reinterpret_cast(data + cd->localHeaderOffset); - if (loc->signature != 0x04034b50) { - setError(errorMessage, QStringLiteral("Invalid local header signature for %1").arg(entryPath)); + ProviderPackageContents package; + if (!readTar(tar, package.files, error)) return std::nullopt; - } - - const uint8_t *fileData - = data + cd->localHeaderOffset + sizeof(ZipLocalHeader) + loc->filenameLen + loc->extraLen; - if (fileData + cd->compressedSize > data + dataSize) { - setError(errorMessage, QStringLiteral("Truncated file data for %1").arg(entryPath)); + const auto manifest = ProviderManifest::parse(package.files.value(QStringLiteral("manifest.json")), error); + if (!manifest) return std::nullopt; - } - - QByteArray uncompressed; - uncompressed.resize(cd->uncompressedSize); - - if (cd->method == 0) { - // Stored - if (cd->compressedSize != cd->uncompressedSize) { - setError(errorMessage, QStringLiteral("Mismatched stored size for %1").arg(entryPath)); - return std::nullopt; - } - std::memcpy(uncompressed.data(), fileData, cd->uncompressedSize); - } else if (cd->method == 8) { - // Deflated - unsigned long destLen = cd->uncompressedSize; - unsigned long srcLen = cd->compressedSize; - const int ret = puff(reinterpret_cast(uncompressed.data()), &destLen, fileData, &srcLen); - if (ret != 0 || destLen != cd->uncompressedSize) { - setError(errorMessage, QStringLiteral("Corrupt compressed stream for %1").arg(entryPath)); + package.manifest = *manifest; + QStringList required { manifest->entry }; + required += manifest->ui.values(); + if (!manifest->icon.isEmpty()) + required.append(manifest->icon); + for (const QString& path : std::as_const(required)) { + if (!package.files.contains(path)) { + fail(error, QStringLiteral("manifest.json names a missing file: %1").arg(path)); return std::nullopt; } - } else { - setError(errorMessage, QStringLiteral("Unsupported compression method for %1").arg(entryPath)); - return std::nullopt; } + return package; + } - const uint32_t calculatedCrc - = calculateCrc32(reinterpret_cast(uncompressed.constData()), uncompressed.size()); - if (calculatedCrc != cd->crc32) { - setError(errorMessage, QStringLiteral("CRC-32 mismatch for %1").arg(entryPath)); + std::optional install(const ProviderPackageContents& package, const QString& root, QString *error) + { + const QDir providerDir(QDir(root).filePath(package.manifest.id)); + const QString target = providerDir.filePath(package.manifest.version); + const QString staging + = providerDir.filePath(QStringLiteral(".staging-") + QUuid::createUuid().toString(QUuid::Id128)); + const auto abandon = [&](const QString& message) -> std::optional { + QDir(staging).removeRecursively(); + fail(error, message); return std::nullopt; + }; + if (!QDir().mkpath(staging)) + return abandon(QStringLiteral("Could not create %1").arg(staging)); + for (auto it = package.files.cbegin(); it != package.files.cend(); ++it) { + const QString path = QDir(staging).filePath(it.key()); + QDir().mkpath(QFileInfo(path).path()); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(it.value()) != it.value().size() || !file.commit()) + return abandon(QStringLiteral("Could not write %1").arg(it.key())); } - - if (hasNativeMagic(uncompressed)) { - setError(errorMessage, QStringLiteral("Native executable binary rejected in %1").arg(entryPath)); - return std::nullopt; + // Versions are immutable: reinstalling the same one replaces it whole. + QDir(target).removeRecursively(); + if (!QDir().rename(staging, target)) + return abandon( + QStringLiteral("Could not activate %1 %2").arg(package.manifest.id, package.manifest.version)); + // Only the newest version stays; the running engine has already read + // its module and components, so an older directory is dead weight. + for (const QString& other : providerDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + if (other != package.manifest.version) + QDir(providerDir.filePath(other)).removeRecursively(); } - - package.files.insert(entryPath, uncompressed); - cdPtr += sizeof(ZipCDHeader) + cd->filenameLen + cd->extraLen + cd->commentLen; - } - - if (!package.files.contains(QStringLiteral("manifest.json"))) { - setError(errorMessage, QStringLiteral("Package is missing manifest.json")); - return std::nullopt; - } - - const QJsonDocument manifestDoc = QJsonDocument::fromJson(package.files.value(QStringLiteral("manifest.json"))); - if (!manifestDoc.isObject()) { - setError(errorMessage, QStringLiteral("manifest.json is not a valid JSON object")); - return std::nullopt; - } - const QJsonObject manifest = manifestDoc.object(); - - if (manifest.value(QStringLiteral("format")).toInt() != 1 - || manifest.value(QStringLiteral("api")).toString() != QStringLiteral("0.1")) { - setError(errorMessage, QStringLiteral("Unsupported manifest format or API revision")); - return std::nullopt; + return target; } - static const QRegularExpression idPattern(QStringLiteral("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$")); - package.id = manifest.value(QStringLiteral("id")).toString(); - if (!idPattern.match(package.id).hasMatch()) { - setError(errorMessage, QStringLiteral("Invalid provider ID in manifest")); - return std::nullopt; - } - - package.version = manifest.value(QStringLiteral("version")).toString(); - if (package.version.isEmpty()) { - setError(errorMessage, QStringLiteral("Missing version in manifest")); - return std::nullopt; - } - - package.entryPoint = manifest.value(QStringLiteral("entry")).toString(); - if (!package.entryPoint.endsWith(QStringLiteral(".mjs")) || !package.files.contains(package.entryPoint)) { - setError(errorMessage, QStringLiteral("Missing or invalid JavaScript entry point in manifest")); - return std::nullopt; - } - - return package; -} - -QString ProviderPackage::install( - const ProviderPackageContents& package, const QString& targetDirectory, QString *errorMessage) -{ - if (package.id.isEmpty() || package.files.isEmpty()) { - setError(errorMessage, QStringLiteral("Invalid package to install")); - return QString(); - } - - const QDir baseDir(targetDirectory); - const QString destPath = baseDir.filePath(package.id); - const QDir destDir(destPath); - if (!destDir.mkpath(QStringLiteral("."))) { - setError(errorMessage, QStringLiteral("Could not create provider directory: %1").arg(destPath)); - return QString(); - } - - for (auto it = package.files.cbegin(); it != package.files.cend(); ++it) { - const QString relativeFilePath = it.key(); - const QString targetFilePath = destDir.filePath(relativeFilePath); - const QFileInfo fileInfo(targetFilePath); - destDir.mkpath(fileInfo.path()); - - QFile file(targetFilePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - setError(errorMessage, QStringLiteral("Failed to write package file: %1").arg(targetFilePath)); - return QString(); + QMap installedVersions(const QString& root) + { + QMap result; + const QDir base(root); + for (const QString& id : base.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QDir providerDir(base.filePath(id)); + QString newest; + for (const QString& version : providerDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + if (!version.startsWith(QLatin1Char('.')) && (newest.isEmpty() || compareVersions(version, newest) > 0)) + newest = version; + } + if (!newest.isEmpty()) + result.insert(id, providerDir.filePath(newest)); } - file.write(it.value()); - file.close(); + return result; } - return destDir.filePath(package.entryPoint); -} - -uint32_t ProviderPackage::calculateCrc32(const uint8_t *data, size_t length) -{ - static const uint32_t table[256] = { 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, - 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, - 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, - 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, - 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, - 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, - 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, - 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, - 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, - 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, - 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, - 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, - 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, - 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, - 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, - 0x0ead5439, 0x9dd264af, 0x04db3515, 0x73dc0583, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, - 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, - 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, - 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, - 0xd8062bca, 0xaf011b5c, 0x360847e6, 0x410f7770, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, - 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, - 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, - 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, - 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, - 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, - 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, - 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, - 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, - 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d }; - uint32_t crc = 0xFFFFFFFF; - for (size_t i = 0; i < length; ++i) { - crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + int compareVersions(const QString& left, const QString& right) + { + const auto split = [](const QString& version) { + const qsizetype dash = version.indexOf(QLatin1Char('-')); + return std::pair { version.left(dash < 0 ? version.size() : dash).split(QLatin1Char('.')), + dash < 0 ? QString() : version.mid(dash + 1) }; + }; + const auto [leftCore, leftPre] = split(left); + const auto [rightCore, rightPre] = split(right); + for (qsizetype i = 0; i < std::max(leftCore.size(), rightCore.size()); ++i) { + const int a = i < leftCore.size() ? leftCore.at(i).toInt() : 0; + const int b = i < rightCore.size() ? rightCore.at(i).toInt() : 0; + if (a != b) + return a < b ? -1 : 1; + } + if (leftPre == rightPre) + return 0; + if (leftPre.isEmpty() || rightPre.isEmpty()) + return leftPre.isEmpty() ? 1 : -1; + return leftPre < rightPre ? -1 : 1; } - return crc ^ 0xFFFFFFFF; -} +} // namespace ProviderPackage } // namespace JellyfinNative diff --git a/src/provider/ProviderPackage.h b/src/provider/ProviderPackage.h index 40af0898..c624707d 100644 --- a/src/provider/ProviderPackage.h +++ b/src/provider/ProviderPackage.h @@ -3,33 +3,61 @@ #include #include #include +#include +#include + #include namespace JellyfinNative { -struct ProviderPackageContents { +// manifest.json, format 2. Everything the host needs to list, install and +// mount a provider without running any of its code. +struct ProviderManifest { QString id; + QString name; QString version; - QString entryPoint; - QMap files; + QString publisher; + QString summary; + QString icon; + QString entry; + QString homepage; + QStringList capabilities; + // Origins every account may reach besides the ones it was configured + // with; "*" allows any HTTP(S) origin. + QStringList origins; + // Role (login, settings, picker, ...) to a QML file inside the package. + QMap ui; + // Item menu entries the provider handles: {id, label, icon, types}. + // Declared here so opening the menu never waits on the provider. + QVariantList actions; + + bool needsAccount() const + { + return ui.contains(QStringLiteral("login")); + } + static std::optional parse(const QByteArray& json, QString *error = nullptr); }; -class ProviderPackage final { -public: - // Safely validates and decompresses an in-memory ZIP package without - // executing any code. Enforces size limits, path sanitization, allowed - // extensions, executable-payload rejection, and manifest schema rules. - static std::optional parseAndValidate( - const QByteArray& zipBytes, QString *errorMessage = nullptr); - - // Extracts validated package files into targetDirectory/package.id, - // creating parent directories as necessary. Returns the full local path - // of the JavaScript entry point on success, or an empty string on error. - static QString install( - const ProviderPackageContents& package, const QString& targetDirectory, QString *errorMessage = nullptr); - - // Calculates standard IEEE 802.3 32-bit CRC. - static uint32_t calculateCrc32(const uint8_t *data, size_t length); +struct ProviderPackageContents { + ProviderManifest manifest; + QMap files; }; +// A provider ships as one ustar archive compressed with zstd. Reading never +// executes anything: paths, types, sizes and the manifest are validated and +// native binaries are rejected before a byte touches the disk. +namespace ProviderPackage { + std::optional read(const QByteArray& archive, QString *error = nullptr); + // Writes /// through a staging directory so a failed + // install never leaves a half-written version behind. Returns the + // version directory. + std::optional install( + const ProviderPackageContents& package, const QString& root, QString *error = nullptr); + // The newest installed version directory of every provider under root. + QMap installedVersions(const QString& root); + // -1, 0 or 1 comparing dotted numeric versions; a pre-release suffix + // sorts before its release. + int compareVersions(const QString& left, const QString& right); +} + } // namespace JellyfinNative diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index ea83e67d..0864ecf8 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -1,63 +1,763 @@ #include "ProviderRegistry.h" +#include "../cache/DatabaseManager.h" +#include "../common/AsyncTask.h" +#include "../platform/CredentialStore.h" +#include "PortableProvider.h" +#include "ProviderUiContext.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + #include namespace JellyfinNative { -ProviderCapabilities::ProviderCapabilities(QObject *parent) +namespace { + const QString kAccountsKey = QStringLiteral("providers/accounts/2"); + + const QHash& capabilityNames() + { + static const QHash names { + { QStringLiteral("search"), Provider::Search }, + { QStringLiteral("userState"), Provider::UserItemState }, + { QStringLiteral("reporting"), Provider::PlaybackReporting }, + { QStringLiteral("segments"), Provider::Segments }, + { QStringLiteral("groupPlayback"), Provider::GroupPlayback }, + { QStringLiteral("remoteControl"), Provider::RemoteControl }, + { QStringLiteral("streamQuality"), Provider::StreamQuality }, + { QStringLiteral("trickplay"), Provider::Trickplay }, + }; + return names; + } + + Provider::Capabilities capabilitiesOf(const ProviderManifest& manifest) + { + Provider::Capabilities flags; + for (const QString& name : manifest.capabilities) + flags |= capabilityNames().value(name, Provider::Capability {}); + return flags; + } + + QUrl originOf(const QUrl& url) + { + if (url.toString() == QStringLiteral("*")) + return url; + QUrl origin; + origin.setScheme(url.scheme()); + origin.setHost(url.host()); + origin.setPort(url.port()); + return origin; + } + + QJsonObject toJson(const ProviderAccount& account) + { + QJsonArray origins; + for (const QUrl& origin : account.origins) + origins.append(origin.toString()); + return { { QStringLiteral("id"), account.id }, { QStringLiteral("module"), account.module }, + { QStringLiteral("key"), account.key }, { QStringLiteral("group"), account.group }, + { QStringLiteral("label"), account.label }, { QStringLiteral("detail"), account.detail }, + { QStringLiteral("enabled"), account.enabled }, { QStringLiteral("origins"), origins }, + { QStringLiteral("lastUsed"), account.lastUsed } }; + } + + ProviderAccount fromJson(const QJsonObject& row) + { + ProviderAccount account; + account.id = row.value(QStringLiteral("id")).toString(); + account.module = row.value(QStringLiteral("module")).toString(); + account.key = row.value(QStringLiteral("key")).toString(); + account.group = row.value(QStringLiteral("group")).toString(); + account.label = row.value(QStringLiteral("label")).toString(); + account.detail = row.value(QStringLiteral("detail")).toString(); + account.enabled = row.value(QStringLiteral("enabled")).toBool(true); + for (const QJsonValue& origin : row.value(QStringLiteral("origins")).toArray()) + account.origins.append(QUrl(origin.toString())); + account.lastUsed = row.value(QStringLiteral("lastUsed")).toInteger(); + return account; + } +} // namespace + +ProviderRegistry::ProviderRegistry(DatabaseManager *database, QObject *parent) : QObject(parent) + , m_database(database) +{ + m_credentialPool.setMaxThreadCount(1); +} + +ProviderRegistry::~ProviderRegistry() +{ + for (const QString& id : m_running.keys()) + stop(id); +} + +void ProviderRegistry::setRuntimeEnvironment(QVariantMap device, ScriptRuntime::NetworkHooks hooks) { + m_device = std::move(device); + m_hooks = std::move(hooks); } -void ProviderCapabilities::setFlags(Provider::Capabilities flags) +void ProviderRegistry::setInstallDirectory(const QString& path) { - if (m_flags == flags) + m_installDirectory = path; +} + +void ProviderRegistry::registerPackage(ProviderManifest manifest, QUrl root, bool bundled) +{ + const QString id = manifest.id; + const auto existing = m_modules.constFind(id); + if (existing != m_modules.cend()) { + if (bundled || ProviderPackage::compareVersions(manifest.version, existing->manifest.version) <= 0) + return; + } + ProviderModule module; + module.overridesBundled = existing != m_modules.cend() && (existing->bundled || existing->overridesBundled); + module.manifest = std::move(manifest); + module.root = std::move(root); + module.bundled = bundled; + module.runtime = existing != m_modules.cend() ? existing->runtime : nullptr; + m_modules.insert(id, std::move(module)); +} + +void ProviderRegistry::loadModules() +{ + QDirIterator bundled(QStringLiteral(":/providers"), QDir::Dirs | QDir::NoDotAndDotDot); + while (bundled.hasNext()) { + const QString path = bundled.next(); + QFile file(path + QStringLiteral("/manifest.json")); + if (!file.open(QIODevice::ReadOnly)) + continue; + if (auto manifest = ProviderManifest::parse(file.readAll())) + registerPackage(std::move(*manifest), QUrl(QStringLiteral("qrc") + path + QLatin1Char('/')), true); + } + const auto installed = ProviderPackage::installedVersions(m_installDirectory); + for (auto it = installed.cbegin(); it != installed.cend(); ++it) { + QFile file(it.value() + QStringLiteral("/manifest.json")); + if (!file.open(QIODevice::ReadOnly)) + continue; + QString error; + if (auto manifest = ProviderManifest::parse(file.readAll(), &error)) + registerPackage(std::move(*manifest), QUrl::fromLocalFile(it.value() + QLatin1Char('/')), false); + else + qWarning("providers: skipping %s: %s", qPrintable(it.key()), qPrintable(error)); + } + emit modulesChanged(); +} + +void ProviderRegistry::addNativeModule(ProviderManifest manifest, ProviderModule::NativeFactory factory) +{ + ProviderModule module; + module.manifest = std::move(manifest); + module.native = std::move(factory); + module.bundled = true; + m_modules.insert(module.manifest.id, std::move(module)); + emit modulesChanged(); +} + +const ProviderModule *ProviderRegistry::module(const QString& id) const +{ + const auto found = m_modules.constFind(id); + return found == m_modules.cend() ? nullptr : &*found; +} + +QStringList ProviderRegistry::moduleIds() const +{ + return m_modules.keys(); +} + +ProviderAccount *ProviderRegistry::account(const QString& id) +{ + const auto found = std::find_if(m_accounts.begin(), m_accounts.end(), [&](const auto& a) { return a.id == id; }); + return found == m_accounts.end() ? nullptr : &*found; +} + +ScriptRuntime *ProviderRegistry::runtimeFor(ProviderModule& module) +{ + if (!module.runtime && !module.native) { + const QUrl entry = module.file(module.manifest.entry); + module.runtime + = new ScriptRuntime(entry.isLocalFile() ? entry.toLocalFile() : entry.toString(), m_device, m_hooks, this); + const QString id = module.manifest.id; + connect(module.runtime, &ScriptRuntime::event, this, &ProviderRegistry::handleEvent); + connect(module.runtime, &ScriptRuntime::interrupted, this, [this, id] { handleInterrupted(id); }); + } + return module.runtime; +} + +QCoro::Task ProviderRegistry::restore() +{ + QPointer guard(this); + const QString stored = co_await m_database->loadSettingAsync(kAccountsKey); + if (!guard) + co_return; + const QJsonObject root = QJsonDocument::fromJson(stored.toUtf8()).object(); + std::vector accounts; + for (const QJsonValue& row : root.value(QStringLiteral("accounts")).toArray()) { + ProviderAccount account = fromJson(row.toObject()); + if (!QUuid(account.id).isNull() && m_modules.contains(account.module)) + accounts.push_back(std::move(account)); + } + // Configuration holds credentials, so it lives in the platform's + // credential store; the keychain can block, so it is read off-thread. + accounts = co_await Async::background( + [accounts]() mutable { + for (ProviderAccount& account : accounts) + account.configuration + = QJsonDocument::fromJson(CredentialStore::load(account.id).toUtf8()).object().toVariantMap(); + return accounts; + }, + &m_credentialPool); + if (!guard) + co_return; + m_accounts = std::move(accounts); + // One-time: carry over the Jellyfin sign-ins the native client kept, so + // an upgrade signs nobody out. + if (stored.isEmpty() && m_modules.contains(QStringLiteral("spool.jellyfin"))) { + const QVariantList legacy = co_await m_database->loadLegacyAccountsAsync(); + if (!guard) + co_return; + QHash newestPerServer; + for (const QVariant& value : legacy) { + const QVariantMap row = value.toMap(); + const QString server = row.value(QStringLiteral("serverId")).toString(); + newestPerServer[server] + = std::max(newestPerServer.value(server), row.value(QStringLiteral("lastUsed")).toLongLong()); + } + for (const QVariant& value : legacy) { + QVariantMap row = value.toMap(); + if (row.value(QStringLiteral("token")).toString().isEmpty()) + continue; + ProviderAccount account; + account.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + account.module = QStringLiteral("spool.jellyfin"); + account.group = row.value(QStringLiteral("serverId")).toString(); + account.key = row.value(QStringLiteral("userId")).toString() + QLatin1Char('@') + account.group; + account.label = row.value(QStringLiteral("userName")).toString(); + account.detail = row.value(QStringLiteral("serverName")).toString(); + account.lastUsed = row.take(QStringLiteral("lastUsed")).toLongLong(); + account.enabled = account.lastUsed == newestPerServer.value(account.group); + account.origins = { originOf(QUrl(row.value(QStringLiteral("server")).toString())) }; + account.configuration = row; + m_accounts.push_back(std::move(account)); + } + if (!m_accounts.empty()) + persist(true); + } + m_restored = true; + emit restoredChanged(); + emit accountsChanged(); + for (const ProviderAccount& account : m_accounts) { + if (account.enabled) + Async::runScoped(this, start(account.id), [] { }, [](const std::exception_ptr&) { }, "provider start"); + } +} + +QCoro::Task ProviderRegistry::start(QString accountId) +{ + ProviderAccount *entry = account(accountId); + if (!entry || m_running.contains(accountId) || !m_modules.contains(entry->module)) + co_return; + ProviderModule& module = m_modules[entry->module]; + const quint64 generation = ++m_nextGeneration; + if (module.native) { + Provider *provider = module.native(accountId, entry->configuration, this); + m_running.insert(accountId, { module.manifest.id, generation, provider, {}, false }); + emit sourceStarted(provider); + emit accountsChanged(); + co_return; + } + QList origins = entry->origins; + for (const QString& origin : module.manifest.origins) + origins.append(QUrl(origin)); + m_running.insert(accountId, { module.manifest.id, generation, nullptr, origins, false }); + const QString label = entry->label; + const Provider::Capabilities capabilities = capabilitiesOf(module.manifest); + ScriptRuntime *runtime = runtimeFor(module); + QPointer guard(this); + QVariantMap description; + try { + co_await runtime->addSource(accountId, entry->configuration, origins); + description = co_await runtime->call(accountId, QStringLiteral("describe")); + } catch (const std::exception& error) { + if (!guard) + co_return; + qWarning("providers: %s did not start: %s", qPrintable(accountId), error.what()); + if (m_running.value(accountId).generation == generation) + stop(accountId); + emit problem(QStringLiteral("Couldn't connect to %1").arg(label)); + co_return; + } + if (!guard || m_running.value(accountId).generation != generation) + co_return; + auto *provider = new PortableProvider(this, accountId, label, capabilities, description, this); + m_running[accountId].provider = provider; + emit sourceStarted(provider); + emit accountsChanged(); +} + +void ProviderRegistry::stop(const QString& accountId) +{ + const auto running = m_running.find(accountId); + if (running == m_running.end()) return; - m_flags = flags; - emit changed(); + const Running state = running.value(); + m_running.erase(running); + if (ScriptRuntime *runtime = m_modules.value(state.module).runtime) + runtime->removeSource(accountId); + if (state.provider) { + emit sourceStopped(accountId); + state.provider->shutdown(); + state.provider->deleteLater(); + } +} + +void ProviderRegistry::restartModule(const QString& moduleId) +{ + for (const QString& id : m_running.keys()) { + if (m_running.value(id).module == moduleId) + stop(id); + } + ProviderModule& module = m_modules[moduleId]; + delete std::exchange(module.runtime, nullptr); + module.failed = false; + for (const ProviderAccount& account : m_accounts) { + if (account.enabled && account.module == moduleId) + Async::runScoped(this, start(account.id), [] { }, [](const std::exception_ptr&) { }, "provider restart"); + } +} + +void ProviderRegistry::persist(bool credentials) +{ + QJsonArray rows; + QHash secrets; + for (const ProviderAccount& account : m_accounts) { + rows.append(toJson(account)); + secrets.insert(account.id, + QString::fromUtf8( + QJsonDocument(QJsonObject::fromVariantMap(account.configuration)).toJson(QJsonDocument::Compact))); + } + if (credentials) { + QStringList removed = std::exchange(m_removedAccounts, {}); + // One thread, so saves land in the order they were made. + Async::background( + [secrets, removed] { + for (auto it = secrets.cbegin(); it != secrets.cend(); ++it) + CredentialStore::save(it.key(), it.value()); + for (const QString& id : removed) + CredentialStore::remove(id); + }, + &m_credentialPool); + } + m_database->saveSetting(kAccountsKey, + QString::fromUtf8( + QJsonDocument(QJsonObject { { QStringLiteral("format"), 2 }, { QStringLiteral("accounts"), rows } }) + .toJson(QJsonDocument::Compact))); } -void ProviderRegistry::add(Provider *provider) +QCoro::Task ProviderRegistry::install(ProviderPackageContents package) { - if (!provider || std::find(m_providers.begin(), m_providers.end(), provider) != m_providers.end()) + const QString root = m_installDirectory; + // Disk writes stay off the GUI thread. + QString error; + const auto directory = co_await Async::background( + [package, root, &error] { return ProviderPackage::install(package, root, &error); }); + if (!directory) + throw std::runtime_error(error.toStdString()); + const QString id = package.manifest.id; + const ScriptRuntime *before = m_modules.contains(id) ? m_modules[id].runtime : nullptr; + registerPackage(package.manifest, QUrl::fromLocalFile(*directory + QLatin1Char('/')), false); + if (before || m_modules[id].runtime) + restartModule(id); + emit modulesChanged(); + emit accountsChanged(); +} + +QCoro::Task ProviderRegistry::uninstall(QString moduleId) +{ + const ProviderModule *existing = module(moduleId); + if (!existing || (existing->bundled && !existing->overridesBundled) || existing->native) + co_return; + for (const QString& id : m_running.keys()) { + if (m_running.value(id).module == moduleId) + stop(id); + } + delete std::exchange(m_modules[moduleId].runtime, nullptr); + const bool hadBundled = existing->overridesBundled; + m_modules.remove(moduleId); + const QString directory = QDir(m_installDirectory).filePath(moduleId); + co_await Async::background([directory] { return QDir(directory).removeRecursively(); }); + if (hadBundled) { + loadModules(); + restartModule(moduleId); + } else { + for (const ProviderAccount& account : m_accounts) { + if (account.module == moduleId) + m_removedAccounts.append(account.id); + } + std::erase_if(m_accounts, [&](const ProviderAccount& account) { return account.module == moduleId; }); + persist(true); + } + emit modulesChanged(); + emit accountsChanged(); +} + +template QCoro::Task ProviderRegistry::guarded(QString sourceId, Call call) +{ + const auto running = m_running.constFind(sourceId); + ScriptRuntime *runtime = running == m_running.cend() ? nullptr : m_modules.value(running->module).runtime; + if (!runtime) + throw std::runtime_error("source_unavailable"); + const quint64 generation = running->generation; + QPointer guard(this); + std::optional result; + try { + result = co_await call(runtime); + } catch (const std::exception& error) { + // The server no longer accepts this account's credentials: say so + // once, and let the accounts page offer to sign in again. + if (guard && QByteArray(error.what()) == "http_401" && !m_expired.contains(sourceId)) { + m_expired.insert(sourceId); + if (const ProviderAccount *entry = account(sourceId)) + emit problem(QStringLiteral("Sign in to %1 again").arg(entry->label)); + emit accountsChanged(); + } + throw; + } + if (!guard || m_running.value(sourceId).generation != generation) + throw std::runtime_error("source_changed"); + co_return std::move(*result); +} + +QCoro::Task ProviderRegistry::callSource( + QString sourceId, QString operation, QVariantMap arguments, QString scope) +{ + return guarded( + sourceId, [=](ScriptRuntime *runtime) { return runtime->call(sourceId, operation, arguments, scope); }); +} + +QCoro::Task ProviderRegistry::callSourceMediaPage( + QString sourceId, QString operation, QVariantMap arguments, int maximumItems) +{ + return guarded(sourceId, [=](ScriptRuntime *runtime) { + return runtime->callMediaPage(sourceId, operation, arguments, {}, maximumItems); + }); +} + +QCoro::Task ProviderRegistry::callSourceItem(QString sourceId, QString operation, QVariantMap arguments) +{ + return guarded( + sourceId, [=](ScriptRuntime *runtime) { return runtime->callItem(sourceId, operation, arguments); }); +} + +void ProviderRegistry::cancelSourceScope(const QString& sourceId, const QString& scope) +{ + const auto running = m_running.constFind(sourceId); + if (running != m_running.cend()) { + if (ScriptRuntime *runtime = m_modules.value(running->module).runtime) + runtime->cancelScope(sourceId, scope); + } +} + +bool ProviderRegistry::sourceRunning(const QString& sourceId) const +{ + return m_running.contains(sourceId); +} + +void ProviderRegistry::handleEvent(const QString& sourceId, const QString& type, const QVariantMap& payload) +{ + const Running running = m_running.value(sourceId); + if (type == QStringLiteral("configuration")) { + // A refreshed token or a moved server; the running source already + // has it, so this only has to survive the next launch. + if (!running.draft) + updateConfiguration(sourceId, payload); + return; + } + if (!running.provider) + return; + if (type == QStringLiteral("changed")) + emit running.provider->contentChanged(payload.value(QStringLiteral("itemId")).toString()); + else + emit running.provider->sourceEvent(type, payload); +} + +void ProviderRegistry::handleInterrupted(const QString& moduleId) +{ + ProviderModule& module = m_modules[moduleId]; + module.failed = true; + for (const QString& id : m_running.keys()) { + if (m_running.value(id).module == moduleId) + stop(id); + } + emit problem(QStringLiteral("%1 stopped responding and was turned off").arg(module.manifest.name)); + emit modulesChanged(); + emit accountsChanged(); +} + +QVariantList ProviderRegistry::modules() const +{ + QVariantList list; + for (const ProviderModule& module : m_modules) { + const auto count = std::count_if(m_accounts.begin(), m_accounts.end(), + [&](const auto& account) { return account.module == module.manifest.id; }); + list.append(QVariantMap { { QStringLiteral("id"), module.manifest.id }, + { QStringLiteral("name"), module.manifest.name }, { QStringLiteral("summary"), module.manifest.summary }, + { QStringLiteral("version"), module.manifest.version }, + { QStringLiteral("publisher"), module.manifest.publisher }, + { QStringLiteral("homepage"), module.manifest.homepage }, + { QStringLiteral("iconUrl"), module.file(module.manifest.icon) }, + { QStringLiteral("bundled"), module.bundled && !module.overridesBundled }, + { QStringLiteral("removable"), !module.native && (!module.bundled || module.overridesBundled) }, + { QStringLiteral("needsAccount"), module.manifest.needsAccount() }, + { QStringLiteral("accountCount"), int(count) }, { QStringLiteral("failed"), module.failed } }); + } + std::sort(list.begin(), list.end(), [](const QVariant& a, const QVariant& b) { + return a.toMap() + .value(QStringLiteral("name")) + .toString() + .localeAwareCompare(b.toMap().value(QStringLiteral("name")).toString()) + < 0; + }); + return list; +} + +QVariantList ProviderRegistry::accounts() const +{ + QVariantList list; + for (const ProviderAccount& account : m_accounts) { + const ProviderModule *owner = module(account.module); + list.append(QVariantMap { { QStringLiteral("id"), account.id }, { QStringLiteral("moduleId"), account.module }, + { QStringLiteral("providerName"), owner ? owner->manifest.name : account.module }, + { QStringLiteral("iconUrl"), owner ? owner->file(owner->manifest.icon) : QUrl() }, + { QStringLiteral("label"), account.label }, { QStringLiteral("detail"), account.detail }, + { QStringLiteral("group"), account.group }, { QStringLiteral("enabled"), account.enabled }, + { QStringLiteral("running"), bool(m_running.value(account.id).provider) }, + { QStringLiteral("needsSignIn"), m_expired.contains(account.id) }, + { QStringLiteral("hasSettings"), owner && owner->manifest.ui.contains(QStringLiteral("settings")) } }); + } + return list; +} + +QUrl ProviderRegistry::componentUrl(const QString& moduleId, const QString& role) const +{ + const ProviderModule *owner = module(moduleId); + return owner ? owner->file(owner->manifest.ui.value(role)) : QUrl(); +} + +ProviderUiContext *ProviderRegistry::createContext( + const QString& sourceId, const QString& role, const QString& moduleId) +{ + const QUrl component = componentUrl(moduleId, role); + if (component.isEmpty()) + return nullptr; + auto *context = new ProviderUiContext(this, sourceId, moduleId, role, component); + connect(this, &ProviderRegistry::sourceStopped, context, [context](const QString& id) { + if (id == context->sourceId()) + context->close(); + }); + return context; +} + +QObject *ProviderRegistry::beginSetup(const QString& moduleId) +{ + ProviderModule *owner = m_modules.contains(moduleId) ? &m_modules[moduleId] : nullptr; + if (!owner) + return nullptr; + if (!owner->manifest.needsAccount()) { + const auto existing = std::find_if( + m_accounts.begin(), m_accounts.end(), [&](const auto& account) { return account.module == moduleId; }); + const QString id = existing != m_accounts.end() + ? existing->id + : finishSetup({}, + { { QStringLiteral("module"), moduleId }, { QStringLiteral("account"), QStringLiteral("default") }, + { QStringLiteral("label"), owner->manifest.name } }); + useAccount(id); + emit accountAdded(id); + return nullptr; + } + const QString draftId = QStringLiteral("setup-") + QUuid::createUuid().toString(QUuid::WithoutBraces); + QList origins; + for (const QString& origin : owner->manifest.origins) + origins.append(QUrl(origin)); + m_running.insert(draftId, { moduleId, ++m_nextGeneration, nullptr, origins, true }); + Async::runScoped( + this, runtimeFor(*owner)->addSource(draftId, {}, origins), [](QVariantMap) {}, + [this, draftId](const std::exception_ptr&) { + m_running.remove(draftId); + emit problem(QStringLiteral("This provider could not start")); + }, + "provider setup"); + return createContext(draftId, QStringLiteral("login"), moduleId); +} + +QCoro::Task ProviderRegistry::allowSetupOrigin(QString draftId, QUrl url) +{ + const auto running = m_running.find(draftId); + if (running == m_running.end() || !running->draft || url.host().isEmpty() + || (url.scheme() != QStringLiteral("http") && url.scheme() != QStringLiteral("https"))) + throw std::runtime_error("origin_denied"); + const QUrl origin = originOf(url); + if (running->origins.contains(origin)) + co_return; + running->origins.append(origin); + running->generation = ++m_nextGeneration; + ScriptRuntime *runtime = m_modules.value(running->module).runtime; + // Origins are fixed when a source is created; a setup source keeps no + // state worth saving, so recreate it with the server the viewer named. + runtime->removeSource(draftId); + co_await runtime->addSource(draftId, {}, running->origins); +} + +QString ProviderRegistry::finishSetup(const QString& draftId, const QVariantMap& result) +{ + const Running draft = m_running.value(draftId); + const QString moduleId = draft.draft ? draft.module : result.value(QStringLiteral("module")).toString(); + const QString key = result.value(QStringLiteral("account")).toString().left(256); + if (!m_modules.contains(moduleId) || key.isEmpty()) + return {}; + auto existing = std::find_if(m_accounts.begin(), m_accounts.end(), + [&](const auto& account) { return account.module == moduleId && account.key == key; }); + if (existing == m_accounts.end()) { + ProviderAccount created; + created.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + created.module = moduleId; + created.key = key; + m_accounts.push_back(std::move(created)); + existing = std::prev(m_accounts.end()); + } + existing->group = result.value(QStringLiteral("group")).toString().left(256); + existing->label = result.value(QStringLiteral("label")).toString().left(128); + existing->detail = result.value(QStringLiteral("detail")).toString().left(256); + existing->configuration = result.value(QStringLiteral("configuration")).toMap(); + // Origins come from what the viewer typed or picked, never from the + // provider's own claim about where it should be allowed to go. + existing->origins = draft.origins; + for (const QString& origin : m_modules[moduleId].manifest.origins) + existing->origins.removeAll(QUrl(origin)); + const QString id = existing->id; + m_expired.remove(id); + stop(id); + persist(true); + if (draft.draft) { + useAccount(id); + emit accountAdded(id); + } + return id; +} + +void ProviderRegistry::updateConfiguration(const QString& accountId, const QVariantMap& changes) +{ + ProviderAccount *entry = account(accountId); + if (!entry) + return; + entry->configuration.insert(changes); + persist(true); +} + +void ProviderRegistry::restartAccount(const QString& accountId) +{ + stop(accountId); + if (const ProviderAccount *entry = account(accountId); entry && entry->enabled) + Async::runScoped(this, start(accountId), [] { }, [](const std::exception_ptr&) { }, "provider restart"); +} + +void ProviderRegistry::endContext(const QString& sourceId) +{ + const auto running = m_running.constFind(sourceId); + if (running == m_running.cend() || !running->draft) return; - m_providers.push_back(provider); + if (ScriptRuntime *runtime = m_modules.value(running->module).runtime) + runtime->removeSource(sourceId); + m_running.remove(sourceId); } -Provider *ProviderRegistry::provider(const QString& id) const +QObject *ProviderRegistry::openSettings(const QString& accountId) { - const auto it = std::find_if( - m_providers.begin(), m_providers.end(), [&id](const Provider *candidate) { return candidate->id() == id; }); - return it == m_providers.end() ? nullptr : *it; + const ProviderAccount *entry = account(accountId); + return entry && m_running.value(accountId).provider + ? createContext(accountId, QStringLiteral("settings"), entry->module) + : nullptr; } -bool ProviderRegistry::setActive(const QString& id) +QObject *ProviderRegistry::openPicker(const QString& accountId, const QVariantMap& arguments) { - Provider *candidate = provider(id); - if (!candidate) - return false; - setActive(candidate); - return true; + const ProviderAccount *entry = account(accountId); + ProviderUiContext *context = entry ? createContext(accountId, QStringLiteral("picker"), entry->module) : nullptr; + if (context) + context->setArguments(arguments); + return context; } -void ProviderRegistry::setActive(Provider *provider) +QCoro::Task ProviderRegistry::pick(QString accountId, QVariantMap arguments) { - if (m_active == provider) + auto *context = qobject_cast(openPicker(accountId, arguments)); + if (!context) + throw std::runtime_error("picker_unavailable"); + emit componentRequested(context); + const auto [result, cancelled] = co_await qCoro(context, &ProviderUiContext::finished); + co_return cancelled ? QVariantMap {} : result; +} + +void ProviderRegistry::useAccount(const QString& accountId) +{ + ProviderAccount *chosen = account(accountId); + if (!chosen) return; - add(provider); - QObject::disconnect(m_activeCapabilities); - m_active = provider; - if (m_active) { - m_activeCapabilities - = connect(m_active, &Provider::capabilitiesChanged, this, &ProviderRegistry::refreshCapabilities); + chosen->enabled = true; + chosen->lastUsed = QDateTime::currentMSecsSinceEpoch(); + const QString module = chosen->module; + const QString group = chosen->group; + for (ProviderAccount& other : m_accounts) { + if (other.id != accountId && !group.isEmpty() && other.module == module && other.group == group + && other.enabled) { + other.enabled = false; + stop(other.id); + } + } + persist(); + emit accountsChanged(); + Async::runScoped(this, start(accountId), [] { }, [](const std::exception_ptr&) { }, "provider start"); +} + +void ProviderRegistry::setAccountEnabled(const QString& accountId, bool enabled) +{ + if (enabled) { + useAccount(accountId); + return; + } + if (ProviderAccount *entry = account(accountId)) { + entry->enabled = false; + stop(accountId); + persist(); + emit accountsChanged(); } - refreshCapabilities(); - emit activeChanged(); } -void ProviderRegistry::refreshCapabilities() +void ProviderRegistry::removeAccount(const QString& accountId) { - m_capabilities.setFlags(m_active ? m_active->capabilities() : Provider::Capabilities {}); + const auto forget = [this, accountId] { + stop(accountId); + std::erase_if(m_accounts, [&](const ProviderAccount& account) { return account.id == accountId; }); + m_removedAccounts.append(accountId); + persist(true); + emit accountsChanged(); + }; + if (!m_running.value(accountId).provider) + return forget(); + // Best effort: let the server end the session too before it is dropped. + Async::runScoped( + this, callSource(accountId, QStringLiteral("signOut")), [forget](QVariantMap) { forget(); }, + [forget](const std::exception_ptr&) { forget(); }, "provider sign out"); } } // namespace JellyfinNative diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 8038c21d..06dfe74f 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -1,195 +1,190 @@ #pragma once -#include "Provider.h" #include "ProviderMediaPage.h" +#include "ProviderPackage.h" +#include "ScriptRuntime.h" + #include +#include +#include +#include +#include +#include #include #include -#include - -#include -#include +#include #include -class QNetworkAccessManager; - namespace JellyfinNative { class DatabaseManager; +class PortableProvider; +class Provider; +class ProviderUiContext; + +struct ProviderModule { + ProviderManifest manifest; + // qrc:/providers// for bundled packages, a version directory for + // installed ones; empty for native modules. + QUrl root; + bool bundled = false; + // A newer installed package shadows the bundled one until removed. + bool overridesBundled = false; + using NativeFactory + = std::function; + NativeFactory native; + ScriptRuntime *runtime = nullptr; + bool failed = false; + + QUrl file(const QString& relative) const + { + return relative.isEmpty() || root.isEmpty() ? QUrl() : root.resolved(QUrl(relative)); + } +}; + +// One sign-in on one provider. `group` names accounts that are alternatives +// to one another, such as users of one server: using one sets the others +// aside, while accounts in different groups are shown together. +struct ProviderAccount { + QString id; + QString module; + QString key; + QString group; + QString label; + QString detail; + bool enabled = true; + QVariantMap configuration; + QList origins; + qint64 lastUsed = 0; +}; -// The active provider's capability flags as twelve booleans, registered in -// QML as the ProviderCapabilities singleton. The property names are the -// contract with shared QML: a gated control binds to one of them by name. -class ProviderCapabilities final : public QObject { +// Every provider the app can run and every account signed in to one. +// Accounts persist with their configuration (credentials included, as the +// old per-server profiles did) and each enabled one runs as a Provider that +// SourceHub routes to. +class ProviderRegistry final : public QObject { Q_OBJECT - Q_PROPERTY(bool auth READ auth NOTIFY changed) - Q_PROPERTY(bool discovery READ discovery NOTIFY changed) - Q_PROPERTY(bool search READ search NOTIFY changed) - Q_PROPERTY(bool userItemState READ userItemState NOTIFY changed) - Q_PROPERTY(bool playbackReporting READ playbackReporting NOTIFY changed) - Q_PROPERTY(bool segments READ segments NOTIFY changed) - Q_PROPERTY(bool libraryManagement READ libraryManagement NOTIFY changed) - Q_PROPERTY(bool syncPlay READ syncPlay NOTIFY changed) - Q_PROPERTY(bool remoteControl READ remoteControl NOTIFY changed) - Q_PROPERTY(bool quickConnect READ quickConnect NOTIFY changed) - Q_PROPERTY(bool peerRelay READ peerRelay NOTIFY changed) - Q_PROPERTY(bool streamQuality READ streamQuality NOTIFY changed) + Q_PROPERTY(QVariantList modules READ modules NOTIFY modulesChanged) + Q_PROPERTY(QVariantList accounts READ accounts NOTIFY accountsChanged) + Q_PROPERTY(bool restored READ restored NOTIFY restoredChanged) + Q_PROPERTY(bool hasAccounts READ hasAccounts NOTIFY accountsChanged) public: - explicit ProviderCapabilities(QObject *parent = nullptr); + explicit ProviderRegistry(DatabaseManager *database, QObject *parent = nullptr); + ~ProviderRegistry() override; - Provider::Capabilities flags() const + void setRuntimeEnvironment(QVariantMap device, ScriptRuntime::NetworkHooks hooks); + void setInstallDirectory(const QString& path); + QString installDirectory() const { - return m_flags; + return m_installDirectory; } - void setFlags(Provider::Capabilities flags); + // Registers every package bundled at qrc:/providers/ and every package + // installed on disk; the newer version of a provider wins. + void loadModules(); + void addNativeModule(ProviderManifest manifest, ProviderModule::NativeFactory factory); + const ProviderModule *module(const QString& id) const; + QStringList moduleIds() const; - bool auth() const - { - return m_flags.testFlag(Provider::Auth); - } - bool discovery() const - { - return m_flags.testFlag(Provider::Discovery); - } - bool search() const - { - return m_flags.testFlag(Provider::Search); - } - bool userItemState() const - { - return m_flags.testFlag(Provider::UserItemState); - } - bool playbackReporting() const - { - return m_flags.testFlag(Provider::PlaybackReporting); - } - bool segments() const - { - return m_flags.testFlag(Provider::Segments); - } - bool libraryManagement() const + // Reads the saved accounts and starts the enabled ones in parallel. + QCoro::Task restore(); + bool restored() const { - return m_flags.testFlag(Provider::LibraryManagement); + return m_restored; } - bool syncPlay() const + bool hasAccounts() const { - return m_flags.testFlag(Provider::SyncPlay); + return !m_accounts.empty(); } - bool remoteControl() const + const std::vector& accountList() const { - return m_flags.testFlag(Provider::RemoteControl); - } - bool quickConnect() const - { - return m_flags.testFlag(Provider::QuickConnect); - } - bool peerRelay() const - { - return m_flags.testFlag(Provider::PeerRelay); - } - bool streamQuality() const - { - return m_flags.testFlag(Provider::StreamQuality); + return m_accounts; } -signals: - void changed(); - -private: - Provider::Capabilities m_flags; -}; + // Replaces a module's code in place: running accounts restart on the new + // version without the app restarting. + QCoro::Task install(ProviderPackageContents package); + QCoro::Task uninstall(QString moduleId); -// Every provider the build knows about, and the one the app is running on. -// Providers are added by whoever constructs them and are not owned here. -class ProviderRegistry final : public QObject { - Q_OBJECT - Q_PROPERTY(QVariantList configuredSources READ configuredSources NOTIFY configuredSourcesChanged) - Q_PROPERTY(bool isInstalling READ isInstalling NOTIFY installStatusChanged) - Q_PROPERTY(QString installStatus READ installStatus NOTIFY installStatusChanged) - Q_PROPERTY(int installProgress READ installProgress NOTIFY installStatusChanged) - Q_PROPERTY(QString installError READ installError NOTIFY installStatusChanged) - Q_PROPERTY(QVariantList availableProviders READ availableProviders NOTIFY availableProvidersChanged) - Q_PROPERTY(bool hasInstalledProviders READ hasInstalledProviders NOTIFY installedProvidersChanged) - -public: - explicit ProviderRegistry(QObject *parent = nullptr); - ~ProviderRegistry() override; - - // Portable modules are process-wide; configured source factories are - // independent accounts/servers. Browsing selection never owns their life. - void registerModule(const QString& moduleId, const QString& entryPoint); - QCoro::Task restoreSources(DatabaseManager *database); - QCoro::Task configureSource(QString moduleId, QString accountId, QString sourceKey, QString label, - QVariantMap configuration, QList authorisedOrigins); QCoro::Task callSource( QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}); QCoro::Task callSourceMediaPage( - QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}, int maximumItems = 100); + QString sourceId, QString operation, QVariantMap arguments = {}, int maximumItems = 100); + QCoro::Task callSourceItem(QString sourceId, QString operation, QVariantMap arguments = {}); void cancelSourceScope(const QString& sourceId, const QString& scope); - QCoro::Task setSourceEnabled(QString sourceId, bool enabled); - QCoro::Task removeSource(QString sourceId); - QVariantList configuredSources() const; - - void setNetworkAccessManager(QNetworkAccessManager *network); - void setProvidersDirectory(const QString& path); - QString providersDirectory() const; - void scanInstalledModules(); - bool hasModule(const QString& moduleId) const; - QCoro::Task downloadAndInstallProvider(QString moduleId, QUrl url = QUrl()); - - bool isInstalling() const; - QString installStatus() const; - int installProgress() const; - QString installError() const; - QVariantList availableProviders() const; - bool hasInstalledProviders() const; - - Q_INVOKABLE bool isInstalled(const QString& moduleId) const; - Q_INVOKABLE void installProvider(const QString& moduleId); - Q_INVOKABLE bool switchProvider(const QString& providerId); - - void add(Provider *provider); - Provider *provider(const QString& id) const; - const std::vector& providers() const - { - return m_providers; - } - - // Returns false when no provider carries that id; the active one is - // then unchanged. - bool setActive(const QString& id); - void setActive(Provider *provider); - Provider *active() const - { - return m_active; - } - ProviderCapabilities *capabilities() - { - return &m_capabilities; - } + bool sourceRunning(const QString& sourceId) const; + + QVariantList modules() const; + QVariantList accounts() const; + + // Mounting provider QML. beginSetup returns a context for the module's + // login component, or null after adding an account straight away for a + // provider that needs no sign-in. + Q_INVOKABLE QObject *beginSetup(const QString& moduleId); + Q_INVOKABLE QObject *openSettings(const QString& accountId); + Q_INVOKABLE QObject *openPicker(const QString& accountId, const QVariantMap& arguments); + // Shows the account's picker component and waits for the viewer's + // choice; empty when they back out. + QCoro::Task pick(QString accountId, QVariantMap arguments); + Q_INVOKABLE QUrl componentUrl(const QString& moduleId, const QString& role) const; + Q_INVOKABLE void useAccount(const QString& accountId); + Q_INVOKABLE void setAccountEnabled(const QString& accountId, bool enabled); + Q_INVOKABLE void removeAccount(const QString& accountId); + + // Called by ProviderUiContext. + QCoro::Task allowSetupOrigin(QString draftId, QUrl origin); + QString finishSetup(const QString& draftId, const QVariantMap& result); + void updateConfiguration(const QString& accountId, const QVariantMap& changes); + void restartAccount(const QString& accountId); + void endContext(const QString& sourceId); signals: - void activeChanged(); - void configuredSourcesChanged(); - void sourceRemoved(const QString& sourceId); - void installStatusChanged(); - void availableProvidersChanged(); - void installedProvidersChanged(); - void providerInstalled(const QString& moduleId); + void modulesChanged(); + void accountsChanged(); + void restoredChanged(); + void sourceStarted(JellyfinNative::Provider *provider); + void sourceStopped(const QString& accountId); + void accountAdded(const QString& accountId); + void problem(const QString& message); + // A provider component the shell should mount now (a picker). + void componentRequested(QObject *context); private: - void refreshCapabilities(); - void refreshSourceSnapshot(); - QCoro::Task persistSources(); - struct PortableState; - std::unique_ptr m_portable; - - std::vector m_providers; - Provider *m_active = nullptr; - QMetaObject::Connection m_activeCapabilities; - ProviderCapabilities m_capabilities; + struct Running { + QString module; + quint64 generation = 0; + QPointer provider; + QList origins; + bool draft = false; + }; + + ProviderAccount *account(const QString& id); + ScriptRuntime *runtimeFor(ProviderModule& module); + QCoro::Task start(QString accountId); + void stop(const QString& accountId); + void restartModule(const QString& moduleId); + // Account metadata goes to the database; configuration, which holds + // credentials, goes to the platform credential store when it changed. + void persist(bool credentials = false); + void registerPackage(ProviderManifest manifest, QUrl root, bool bundled); + void handleEvent(const QString& sourceId, const QString& type, const QVariantMap& payload); + void handleInterrupted(const QString& moduleId); + ProviderUiContext *createContext(const QString& sourceId, const QString& role, const QString& moduleId); + template QCoro::Task guarded(QString sourceId, Call call); + + QPointer m_database; + QString m_installDirectory; + QVariantMap m_device; + ScriptRuntime::NetworkHooks m_hooks; + QHash m_modules; + std::vector m_accounts; + QHash m_running; + quint64 m_nextGeneration = 0; + bool m_restored = false; + QStringList m_removedAccounts; + QSet m_expired; + QThreadPool m_credentialPool; }; } // namespace JellyfinNative diff --git a/src/provider/ProviderStore.cpp b/src/provider/ProviderStore.cpp new file mode 100644 index 00000000..252be616 --- /dev/null +++ b/src/provider/ProviderStore.cpp @@ -0,0 +1,423 @@ +#include "ProviderStore.h" + +#include "../cache/DatabaseManager.h" +#include "../common/AsyncTask.h" +#include "ProviderPackage.h" +#include "ProviderRegistry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace JellyfinNative { + +namespace { + const QString kOriginsKey = QStringLiteral("providers/origins/1"); + constexpr qint64 kCatalogLimit = 2 * 1024 * 1024; + constexpr qint64 kPackageLimit = 16 * 1024 * 1024; + + QString text(const QVariantMap& map, const char *key) + { + return map.value(QLatin1String(key)).toString(); + } + + // A catalogue or feed entry is only usable with every field an install + // needs; anything else is skipped rather than half-shown. + bool validEntry(const QVariantMap& entry) + { + static const QRegularExpression sha(QStringLiteral("^[0-9a-f]{64}$")); + const QUrl url(text(entry, "url")); + return !text(entry, "id").isEmpty() && !text(entry, "name").isEmpty() && !text(entry, "version").isEmpty() + && url.scheme() == QStringLiteral("https") && sha.match(text(entry, "sha256")).hasMatch(); + } +} // namespace + +ProviderStore::ProviderStore(ProviderRegistry *registry, DatabaseManager *database, QNetworkAccessManager *network, + QUrl catalogBase, QObject *parent) + : QObject(parent) + , m_registry(registry) + , m_database(database) + , m_network(network) + , m_catalogBase(std::move(catalogBase)) +{ + connect(registry, &ProviderRegistry::modulesChanged, this, &ProviderStore::catalogChanged); +} + +QUrl ProviderStore::feedUrlFor(const QString& input) +{ + QUrl url = QUrl::fromUserInput(input.trimmed()); + if (!url.isValid() || url.host().isEmpty()) + return {}; + url.setScheme(QStringLiteral("https")); + QString path = url.path(); + if (path.endsWith(QStringLiteral(".json")) || path.endsWith(QStringLiteral(".tar.zst"))) + return url; + if (path.endsWith(QStringLiteral(".git"))) + path.chop(4); + const QString host = url.host().toLower(); + QStringList parts = path.split(QLatin1Char('/'), Qt::SkipEmptyParts); + // A project page: releases publish spool-provider.json as an asset, and + // both forges keep a stable link to the latest release's copy. + if (host == QStringLiteral("github.com") && parts.size() >= 2) + return QUrl(QStringLiteral("https://github.com/%1/%2/releases/latest/download/spool-provider.json") + .arg(parts.at(0), parts.at(1))); + if (host == QStringLiteral("gitlab.com") || host.startsWith(QStringLiteral("gitlab."))) { + const qsizetype marker = parts.indexOf(QStringLiteral("-")); + if (marker >= 0) + parts = parts.mid(0, marker); + if (parts.size() >= 2) + return QUrl(QStringLiteral("https://%1/%2/-/releases/permalink/latest/downloads/spool-provider.json") + .arg(host, parts.join(QLatin1Char('/')))); + } + // Anything else is a static site (GitHub or GitLab Pages, a CDN) that + // serves the file at its root. + url.setPath( + (path.endsWith(QLatin1Char('/')) ? path : path + QLatin1Char('/')) + QStringLiteral("spool-provider.json")); + return url; +} + +QCoro::Task ProviderStore::fetch(QUrl url, qint64 limit) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool")); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setTransferTimeout(20000); + QNetworkReply *reply = m_network->get(request); + connect(reply, &QNetworkReply::downloadProgress, reply, [reply, limit](qint64 received, qint64) { + if (received > limit) + reply->abort(); + }); + co_await reply; + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) + throw std::runtime_error(status == 404 ? "not_found" : "download_failed"); + co_return reply->readAll(); +} + +QCoro::Task ProviderStore::fetchCatalog(QString name) +{ + const QByteArray data = co_await fetch(m_catalogBase.resolved(QUrl(name)), kCatalogLimit); + const QJsonObject root = QJsonDocument::fromJson(data).object(); + QVariantList entries; + for (const QJsonValue& value : root.value(QStringLiteral("providers")).toArray()) { + QVariantMap entry = value.toObject().toVariantMap(); + if (!validEntry(entry)) + continue; + if (!text(entry, "icon").isEmpty()) + entry.insert(QStringLiteral("iconUrl"), m_catalogBase.resolved(QUrl(text(entry, "icon")))); + entries.append(entry); + } + co_return entries; +} + +void ProviderStore::refresh(bool includeCommunity) +{ + const auto load = [this](QString name, bool official) { + ++m_loading; + emit catalogChanged(); + Async::runScoped( + this, fetchCatalog(name), + [this, official](QVariantList entries) { + --m_loading; + m_error.clear(); + if (official) + m_official = std::move(entries); + else + m_community = std::move(entries); + emit catalogChanged(); + }, + [this](const std::exception_ptr&) { + --m_loading; + m_error = QStringLiteral("Can't reach the provider store right now"); + emit catalogChanged(); + }, + "provider catalogue"); + }; + load(QStringLiteral("official.json"), true); + if (includeCommunity) + load(QStringLiteral("index.json"), false); +} + +QVariantList ProviderStore::annotate(const QVariantList& entries) const +{ + QVariantList result; + for (const QVariant& value : entries) { + QVariantMap entry = value.toMap(); + const QString id = text(entry, "id"); + const ProviderModule *module = m_registry ? m_registry->module(id) : nullptr; + entry.insert(QStringLiteral("installed"), module != nullptr); + entry.insert(QStringLiteral("installedVersion"), module ? module->manifest.version : QString()); + entry.insert(QStringLiteral("compatible"), text(entry, "api") == QLatin1String(kApi)); + entry.insert(QStringLiteral("updateAvailable"), + module && ProviderPackage::compareVersions(text(entry, "version"), module->manifest.version) > 0); + entry.insert(QStringLiteral("busy"), m_busy.value(id).toString()); + if (module && !entry.contains(QStringLiteral("iconUrl"))) + entry.insert(QStringLiteral("iconUrl"), module->file(module->manifest.icon)); + result.append(entry); + } + return result; +} + +QVariantList ProviderStore::official() const +{ + QVariantList entries = m_official; + QStringList listed; + for (const QVariant& entry : std::as_const(entries)) + listed.append(entry.toMap().value(QStringLiteral("id")).toString()); + // Bundled providers are official too, and must show offline. + if (m_registry) { + for (const QVariant& value : m_registry->modules()) { + const QVariantMap module = value.toMap(); + const QString id = text(module, "id"); + if (listed.contains(id) || m_origins.value(id).channel == QStringLiteral("community") + || m_origins.value(id).channel == QStringLiteral("url")) + continue; + if (module.value(QStringLiteral("bundled")).toBool() + || m_origins.value(id).channel == QStringLiteral("official")) { + QVariantMap entry = module; + entry.insert(QStringLiteral("api"), QLatin1String(kApi)); + entries.append(entry); + } + } + } + return annotate(entries); +} + +QVariantList ProviderStore::community() const +{ + QVariantList entries; + QStringList listed; + for (const QVariant& value : m_community) { + if (!value.toMap().value(QStringLiteral("official")).toBool()) { + entries.append(value); + listed.append(text(value.toMap(), "id")); + } + } + // Providers added by URL sit with the community ones, marked as such. + if (m_registry) { + for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) { + const ProviderModule *module = m_registry->module(it.key()); + if (it->channel != QStringLiteral("url") || !module || listed.contains(it.key())) + continue; + entries.append(QVariantMap { { QStringLiteral("id"), it.key() }, + { QStringLiteral("name"), module->manifest.name }, + { QStringLiteral("summary"), module->manifest.summary }, + { QStringLiteral("version"), module->manifest.version }, { QStringLiteral("api"), QLatin1String(kApi) }, + { QStringLiteral("publisher"), it->feed.host() }, { QStringLiteral("fromUrl"), true } }); + } + } + return annotate(entries); +} + +QVariantMap ProviderStore::entryFor(const QString& id) const +{ + for (const QVariantList *list : { &m_official, &m_community, &m_updates }) { + for (const QVariant& value : *list) { + if (text(value.toMap(), "id") == id && validEntry(value.toMap())) + return value.toMap(); + } + } + return {}; +} + +void ProviderStore::setBusy(const QString& id, const QString& state) +{ + if (state.isEmpty()) + m_busy.remove(id); + else + m_busy.insert(id, state); + emit busyChanged(); + emit catalogChanged(); +} + +void ProviderStore::install(const QString& id) +{ + const QVariantMap entry = entryFor(id); + if (entry.isEmpty() || m_busy.contains(id)) + return; + const bool official = std::any_of( + m_official.begin(), m_official.end(), [&id](const QVariant& value) { return text(value.toMap(), "id") == id; }); + Async::runScoped( + this, installEntry(entry, { official ? QStringLiteral("official") : QStringLiteral("community"), {} }), [] { }, + [](const std::exception_ptr&) { }, "provider install"); +} + +void ProviderStore::update(const QString& id) +{ + const QVariantMap entry = entryFor(id); + if (entry.isEmpty() || m_busy.contains(id)) + return; + const Origin origin = m_origins.value(id, { QStringLiteral("official"), {} }); + Async::runScoped(this, installEntry(entry, origin), [] { }, [](const std::exception_ptr&) { }, "provider update"); +} + +void ProviderStore::updateAll() +{ + for (const QVariant& value : QVariantList(m_updates)) + update(text(value.toMap(), "id")); +} + +void ProviderStore::uninstall(const QString& id) +{ + m_origins.remove(id); + saveOrigins(); + Async::runScoped(this, m_registry->uninstall(id), [] { }, [](const std::exception_ptr&) { }, "provider remove"); +} + +void ProviderStore::addFromUrl(const QString& input) +{ + const QUrl feed = feedUrlFor(input); + if (feed.isEmpty()) { + emit problem(QStringLiteral("That doesn't look like a link")); + return; + } + const auto add = [](ProviderStore *self, QUrl feed) -> QCoro::Task { + self->setBusy(QStringLiteral("url"), QStringLiteral("downloading")); + try { + const QByteArray data = co_await self->fetch(feed, kCatalogLimit); + const QVariantMap entry = QJsonDocument::fromJson(data).object().toVariantMap(); + if (!validEntry(entry)) + throw std::runtime_error("invalid_feed"); + self->setBusy(QStringLiteral("url"), {}); + co_await self->installEntry(entry, { QStringLiteral("url"), feed }); + } catch (const std::exception& error) { + self->setBusy(QStringLiteral("url"), {}); + emit self->problem(QByteArray(error.what()) == "not_found" + ? QStringLiteral("No spool-provider.json was found there") + : QStringLiteral("That link isn't a Spool provider")); + } + }; + Async::runScoped(this, add(this, feed), [] { }, [](const std::exception_ptr&) { }, "provider add"); +} + +QCoro::Task ProviderStore::installEntry(QVariantMap entry, Origin origin) +{ + const QString id = text(entry, "id"); + const QString name = text(entry, "name"); + if (text(entry, "api") != QLatin1String(kApi)) { + emit problem(QStringLiteral("%1 needs a different version of Spool").arg(name)); + co_return; + } + QPointer guard(this); + setBusy(id, QStringLiteral("downloading")); + try { + const QByteArray archive = co_await fetch(QUrl(text(entry, "url")), kPackageLimit); + if (!guard) + co_return; + setBusy(id, QStringLiteral("installing")); + const QString expected = text(entry, "sha256"); + // Hashing and unpacking stay off the GUI thread. + const auto package + = co_await Async::background([archive, expected]() -> std::optional { + if (QCryptographicHash::hash(archive, QCryptographicHash::Sha256).toHex() != expected.toLatin1()) + return std::nullopt; + return ProviderPackage::read(archive); + }); + if (!guard) + co_return; + if (!package || package->manifest.id != id || package->manifest.version != text(entry, "version")) + throw std::runtime_error("package_mismatch"); + co_await m_registry->install(*package); + if (!guard) + co_return; + m_origins.insert(id, origin); + saveOrigins(); + m_updates.erase(std::remove_if(m_updates.begin(), m_updates.end(), + [&id](const QVariant& value) { return text(value.toMap(), "id") == id; }), + m_updates.end()); + emit updatesChanged(); + setBusy(id, {}); + emit installed(id, name); + } catch (const std::exception& error) { + if (!guard) + co_return; + setBusy(id, {}); + qWarning("providers: installing %s failed: %s", qPrintable(id), error.what()); + emit problem(QByteArray(error.what()) == "package_mismatch" + ? QStringLiteral("%1 didn't match what was published, so it wasn't installed").arg(name) + : QStringLiteral("Couldn't download %1").arg(name)); + } +} + +void ProviderStore::checkForUpdates(const QString& policy) +{ + Async::runScoped(this, checkAsync(policy), [] { }, [](const std::exception_ptr&) { }, "provider update check"); +} + +QCoro::Task ProviderStore::checkAsync(QString policy) +{ + QPointer guard(this); + if (!m_originsLoaded) { + const QString stored = co_await m_database->loadSettingAsync(kOriginsKey); + if (!guard) + co_return; + const QJsonObject root = QJsonDocument::fromJson(stored.toUtf8()).object(); + for (auto it = root.begin(); it != root.end(); ++it) { + const QJsonObject origin = it.value().toObject(); + m_origins.insert(it.key(), + { origin.value(QStringLiteral("channel")).toString(), + QUrl(origin.value(QStringLiteral("feed")).toString()) }); + } + m_originsLoaded = true; + } + const bool wantsCommunity = std::any_of(m_origins.begin(), m_origins.end(), + [](const Origin& origin) { return origin.channel == QStringLiteral("community"); }); + // One small file for first-party providers; the full index only when + // something from it is installed. + try { + m_official = co_await fetchCatalog(QStringLiteral("official.json")); + if (wantsCommunity && guard) + m_community = co_await fetchCatalog(QStringLiteral("index.json")); + } catch (const std::exception&) { + co_return; // Offline: nothing to offer, nothing to report. + } + if (!guard) + co_return; + QVariantList updates; + for (const QString& id : m_registry->moduleIds()) { + const ProviderModule *module = m_registry->module(id); + const Origin origin = m_origins.value(id, { QStringLiteral("official"), {} }); + QVariantMap latest; + if (origin.channel == QStringLiteral("url")) { + try { + latest = QJsonDocument::fromJson(co_await fetch(origin.feed, kCatalogLimit)).object().toVariantMap(); + } catch (const std::exception&) { + continue; + } + if (!guard) + co_return; + } else { + for (const QVariant& value : origin.channel == QStringLiteral("community") ? m_community : m_official) { + if (text(value.toMap(), "id") == id) + latest = value.toMap(); + } + } + if (module && validEntry(latest) && text(latest, "api") == QLatin1String(kApi) + && ProviderPackage::compareVersions(text(latest, "version"), module->manifest.version) > 0) + updates.append(latest); + } + m_updates = updates; + emit updatesChanged(); + emit catalogChanged(); + if (policy == QStringLiteral("auto")) + updateAll(); +} + +void ProviderStore::saveOrigins() +{ + QJsonObject root; + for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) + root.insert(it.key(), + QJsonObject { + { QStringLiteral("channel"), it->channel }, { QStringLiteral("feed"), it->feed.toString() } }); + m_database->saveSetting(kOriginsKey, QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact))); +} + +} // namespace JellyfinNative diff --git a/src/provider/ProviderStore.h b/src/provider/ProviderStore.h new file mode 100644 index 00000000..e3398f11 --- /dev/null +++ b/src/provider/ProviderStore.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; + +namespace JellyfinNative { + +class DatabaseManager; +class ProviderRegistry; + +// Where providers come from and how they stay current. +// +// Two small JSON files on the store's GitHub Pages site describe everything: +// official.json lists first-party providers, index.json every provider in +// the store. Each entry names one release: its version, the archive URL and +// that archive's SHA-256, so a download is only installed when it is exactly +// what the store reviewed. A provider added by URL instead publishes the same +// entry itself, as a spool-provider.json release asset or any static file, +// and is kept current from there. +class ProviderStore final : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantList official READ official NOTIFY catalogChanged) + Q_PROPERTY(QVariantList community READ community NOTIFY catalogChanged) + Q_PROPERTY(bool loading READ loading NOTIFY catalogChanged) + Q_PROPERTY(QString error READ error NOTIFY catalogChanged) + Q_PROPERTY(QVariantList updates READ updates NOTIFY updatesChanged) + Q_PROPERTY(QVariantMap busy READ busy NOTIFY busyChanged) + +public: + static constexpr auto kApi = "0.2"; + + ProviderStore(ProviderRegistry *registry, DatabaseManager *database, QNetworkAccessManager *network, + QUrl catalogBase, QObject *parent = nullptr); + + QVariantList official() const; + QVariantList community() const; + bool loading() const + { + return m_loading > 0; + } + QString error() const + { + return m_error; + } + QVariantList updates() const + { + return m_updates; + } + // Provider id to what is happening to it: "downloading", "installing". + QVariantMap busy() const + { + return m_busy; + } + + // official.json always; index.json too when asked for or when something + // from it is installed. + Q_INVOKABLE void refresh(bool includeCommunity = true); + Q_INVOKABLE void install(const QString& id); + // A GitHub or GitLab project URL, or a direct link to a provider's + // spool-provider.json anywhere else. + Q_INVOKABLE void addFromUrl(const QString& url); + Q_INVOKABLE void update(const QString& id); + Q_INVOKABLE void updateAll(); + Q_INVOKABLE void uninstall(const QString& id); + // At launch: check every installed provider against where it came from, + // then install, prompt or wait according to the updates preference. + void checkForUpdates(const QString& policy); + + static QUrl feedUrlFor(const QString& input); + +signals: + void catalogChanged(); + void updatesChanged(); + void busyChanged(); + void installed(const QString& id, const QString& name); + void problem(const QString& message); + +private: + struct Origin { + QString channel; // official, community or url + QUrl feed; + }; + + QCoro::Task fetch(QUrl url, qint64 limit); + QCoro::Task fetchCatalog(QString name); + QCoro::Task installEntry(QVariantMap entry, Origin origin); + QCoro::Task checkAsync(QString policy); + QVariantList annotate(const QVariantList& entries) const; + QVariantMap entryFor(const QString& id) const; + void setBusy(const QString& id, const QString& state); + void saveOrigins(); + + QPointer m_registry; + QPointer m_database; + QNetworkAccessManager *m_network; + QUrl m_catalogBase; + QVariantList m_official; + QVariantList m_community; + QVariantList m_updates; + QVariantMap m_busy; + QHash m_origins; + QString m_error; + int m_loading = 0; + bool m_originsLoaded = false; +}; + +} // namespace JellyfinNative diff --git a/src/provider/ProviderUiContext.cpp b/src/provider/ProviderUiContext.cpp index 35613ec9..1232a0d7 100644 --- a/src/provider/ProviderUiContext.cpp +++ b/src/provider/ProviderUiContext.cpp @@ -2,6 +2,7 @@ #include "ProviderRegistry.h" +#include #include #include #include @@ -107,42 +108,41 @@ void ProviderListModel::cancelPending() m_position = 0; } -ProviderUiContext::ProviderUiContext(ProviderRegistry *registry, QString sourceId, QQmlEngine *engine) - : QObject(engine) +ProviderUiContext::ProviderUiContext( + ProviderRegistry *registry, QString sourceId, QString moduleId, QString role, QUrl component) + : QObject(registry) , m_registry(registry) - , m_engine(engine) , m_sourceId(std::move(sourceId)) + , m_moduleId(std::move(moduleId)) + , m_role(std::move(role)) + , m_component(std::move(component)) , m_scope(QUuid::createUuid().toString(QUuid::WithoutBraces)) , m_rows(this) { QQmlEngine::setObjectOwnership(this, QQmlEngine::CppOwnership); connect(&m_rows, &ProviderListModel::committed, this, [this] { const quint64 request = std::exchange(m_listRequest, 0); - auto result = std::exchange(m_listResult, {}); - settle(request, result, true); - }); - connect(registry, &ProviderRegistry::sourceRemoved, this, [this](const QString& id) { - if (id == m_sourceId) - close(); - }); - connect(registry, &QObject::destroyed, this, &ProviderUiContext::close); - connect(registry, &ProviderRegistry::configuredSourcesChanged, this, [this] { - if (!m_registry) - return; - const auto sources = m_registry->configuredSources(); - const bool available = std::any_of(sources.begin(), sources.end(), [this](const QVariant& value) { - const auto source = value.toMap(); - return source.value(QStringLiteral("sourceId")) == m_sourceId - && source.value(QStringLiteral("available")).toBool(); - }); - if (!available) - close(); + settle(request, std::exchange(m_listResult, {}), true); }); } ProviderUiContext::~ProviderUiContext() { - close(); + if (!m_closed) + finish({}, true); +} + +QJSValue ProviderUiContext::promise(Pending *pending) +{ + QJSEngine *engine = qjsEngine(this); + if (!engine) + return {}; + QJSValue bundle = engine->evaluate( + QStringLiteral("(function() { let ok, no; const p = new Promise(function(a, b) { ok = a; no = b; });" + " return {promise: p, resolve: ok, reject: no}; })()")); + pending->resolve = bundle.property(QStringLiteral("resolve")); + pending->reject = bundle.property(QStringLiteral("reject")); + return bundle.property(QStringLiteral("promise")); } QJSValue ProviderUiContext::request(const QString& operation, const QVariantMap& arguments) @@ -158,109 +158,117 @@ QJSValue ProviderUiContext::requestList(const QString& operation, const QVariant QJSValue ProviderUiContext::begin(const QString& operation, const QVariantMap& arguments, bool list, bool append) { Q_ASSERT(thread() == QThread::currentThread()); - if (!m_engine) - return {}; - QJSValue bundle = m_engine->evaluate(QStringLiteral(R"JS( - (function() { - let resolve, reject; - const promise = new Promise(function(ok, fail) { resolve = ok; reject = fail; }); - return {promise: promise, resolve: resolve, reject: reject}; - })() - )JS")); - QJSValue promise = bundle.property(QStringLiteral("promise")); + Pending pending; + QJSValue result = promise(&pending); if (m_closed || !m_registry || m_pending.size() >= 8 || (list && m_listRequest)) { - bundle.property(QStringLiteral("reject")).call({ QJSValue(QStringLiteral("action_unavailable")) }); - return promise; + pending.reject.call({ QJSValue(QStringLiteral("action_unavailable")) }); + return result; } const quint64 id = ++m_next; - m_pending.insert(id, { bundle.property(QStringLiteral("resolve")), bundle.property(QStringLiteral("reject")) }); + m_pending.insert(id, pending); if (list) m_listRequest = id; QPointer guard(this); m_registry->callSource(m_sourceId, operation, arguments, m_scope) .then( - [guard, id, list, append](QVariantMap result) { - if (guard) - guard->resolved(id, std::move(result), list, append); + [guard, id, list, append](QVariantMap value) { + if (!guard || guard->m_closed || !guard->m_pending.contains(id)) + return; + if (!list) + return guard->settle(id, value, true); + const QVariant items = value.take(QStringLiteral("items")); + if (items.metaType().id() != QMetaType::QVariantList || !guard->m_rows.enqueue(items.toList(), append)) + return guard->settle(id, {}, false); + guard->m_listResult = std::move(value); }, - [guard, id](const std::exception&) { - if (guard) - guard->rejected(id); + [guard, id](const std::exception& error) { + if (!guard) + return; + if (guard->m_listRequest == id) { + guard->m_listRequest = 0; + guard->m_rows.cancelPending(); + } + guard->settle(id, { { QStringLiteral("error"), QString::fromLatin1(error.what()) } }, false); }); - return promise; -} - -void ProviderUiContext::resolved(quint64 id, QVariantMap value, bool list, bool append) -{ - if (m_closed || !m_pending.contains(id)) - return; - if (list) { - const QVariant items = value.take(QStringLiteral("items")); - if (items.metaType().id() != QMetaType::QVariantList || !m_rows.enqueue(items.toList(), append)) { - rejected(id); - return; - } - m_listResult = std::move(value); - } else { - settle(id, value, true); - } + return result; } -void ProviderUiContext::rejected(quint64 id) +QJSValue ProviderUiContext::allowOrigin(const QString& url) { - if (m_listRequest == id) { - m_listRequest = 0; - m_listResult.clear(); - m_rows.cancelPending(); + Pending pending; + QJSValue result = promise(&pending); + if (m_closed || !m_registry || m_role != QStringLiteral("login")) { + pending.reject.call({ QJSValue(QStringLiteral("origin_denied")) }); + return result; } - settle(id, {}, false); + const quint64 id = ++m_next; + m_pending.insert(id, pending); + QPointer guard(this); + m_registry->allowSetupOrigin(m_sourceId, QUrl::fromUserInput(url)) + .then( + [guard, id] { + if (guard) + guard->settle(id, {}, true); + }, + [guard, id](const std::exception&) { + if (guard) + guard->settle(id, { { QStringLiteral("error"), QStringLiteral("origin_denied") } }, false); + }); + return result; } void ProviderUiContext::settle(quint64 id, const QVariantMap& value, bool success) { - if (!m_engine || !m_pending.contains(id)) + QJSEngine *engine = qjsEngine(this); + if (!engine || !m_pending.contains(id)) return; const Pending pending = m_pending.take(id); int values = 0; qsizetype bytes = 0; - success = success && smallResult(value, values, bytes); - if (success) - pending.resolve.call({ m_engine->toScriptValue(value) }); + if (success && smallResult(value, values, bytes)) + pending.resolve.call({ engine->toScriptValue(value) }); else - pending.reject.call({ QJSValue(QStringLiteral("action_cancelled_or_failed")) }); + // Errors are stable codes such as http_401 or invalid_credentials, + // which the component maps to its own words. + pending.reject.call( + { QJSValue(value.value(QStringLiteral("error"), QStringLiteral("request_failed")).toString()) }); } -void ProviderUiContext::cancelRequests() +void ProviderUiContext::finish(const QVariantMap& result, bool cancelled) { + m_closed = true; if (m_registry) m_registry->cancelSourceScope(m_sourceId, m_scope); m_rows.cancelPending(); m_listRequest = 0; - m_listResult.clear(); - const auto ids = m_pending.keys(); - for (quint64 id : ids) + for (const quint64 id : m_pending.keys()) settle(id, {}, false); + if (m_registry) + m_registry->endContext(m_sourceId); + emit closedChanged(); + emit finished(result, cancelled); } void ProviderUiContext::close() { if (m_closed) return; - m_closed = true; - cancelRequests(); - emit closedChanged(); - emit finished({}, true); + finish({}, true); + deleteLater(); } void ProviderUiContext::complete(const QVariantMap& result) { - if (m_closed) + if (m_closed || !m_registry) return; - m_closed = true; - cancelRequests(); - emit closedChanged(); - QVariantMap scopedResult = result; - scopedResult.insert(QStringLiteral("sourceId"), m_sourceId); - emit finished(scopedResult, false); + if (m_role == QStringLiteral("login")) { + if (m_registry->finishSetup(m_sourceId, result).isEmpty()) + return; + } else if (m_role == QStringLiteral("settings") && result.contains(QStringLiteral("configuration"))) { + m_registry->updateConfiguration(m_sourceId, result.value(QStringLiteral("configuration")).toMap()); + m_registry->restartAccount(m_sourceId); + } + finish(result, false); + deleteLater(); } } // namespace JellyfinNative diff --git a/src/provider/ProviderUiContext.h b/src/provider/ProviderUiContext.h index fd0f1963..f8a6e452 100644 --- a/src/provider/ProviderUiContext.h +++ b/src/provider/ProviderUiContext.h @@ -6,15 +6,14 @@ #include #include #include +#include #include -class QQmlEngine; - namespace JellyfinNative { class ProviderRegistry; -// Values are already native-owned when they arrive here. Role reads never -// enter a provider engine; insertion is bounded per GUI event-loop turn. +// Rows from a provider list, already native-owned. Role reads never enter a +// provider engine; insertion is bounded per GUI event-loop turn. class ProviderListModel final : public QAbstractListModel { Q_OBJECT public: @@ -36,20 +35,47 @@ class ProviderListModel final : public QAbstractListModel { QTimer m_commitTimer; }; -// A single mounted action, bound by native code to one persistent source. -// QML sees this facade, never a worker QObject or the global registry. +// What a mounted provider component (login, settings, picker) is given: its +// own source to call, nothing else. It settles once, with complete() or +// close(), and closing cancels whatever it still has in flight. class ProviderUiContext final : public QObject { Q_OBJECT Q_PROPERTY(QString sourceId READ sourceId CONSTANT) + Q_PROPERTY(QString moduleId READ moduleId CONSTANT) + Q_PROPERTY(QString role READ role CONSTANT) + Q_PROPERTY(QUrl component READ component CONSTANT) + Q_PROPERTY(QVariantMap arguments READ arguments CONSTANT) Q_PROPERTY(bool closed READ closed NOTIFY closedChanged) Q_PROPERTY(JellyfinNative::ProviderListModel *rows READ rows CONSTANT) + public: - ProviderUiContext(ProviderRegistry *registry, QString sourceId, QQmlEngine *engine); + ProviderUiContext(ProviderRegistry *registry, QString sourceId, QString moduleId, QString role, QUrl component); ~ProviderUiContext() override; + QString sourceId() const { return m_sourceId; } + QString moduleId() const + { + return m_moduleId; + } + QString role() const + { + return m_role; + } + QUrl component() const + { + return m_component; + } + QVariantMap arguments() const + { + return m_arguments; + } + void setArguments(QVariantMap arguments) + { + m_arguments = std::move(arguments); + } bool closed() const { return m_closed; @@ -58,8 +84,16 @@ class ProviderUiContext final : public QObject { { return &m_rows; } + Q_INVOKABLE QJSValue request(const QString& operation, const QVariantMap& arguments = {}); + // Moves `items` into `rows`; the promise resolves with the rest. Q_INVOKABLE QJSValue requestList(const QString& operation, const QVariantMap& arguments = {}, bool append = false); + // Setup only: lets this source reach the server the viewer typed or + // picked. Resolves once calls to it can be made. + Q_INVOKABLE QJSValue allowOrigin(const QString& url); + // login: {account, label, detail?, group?, configuration} + // settings: {configuration} to save and reconnect with, or {} + // picker: the arguments to resolve again with Q_INVOKABLE void complete(const QVariantMap& result); Q_INVOKABLE void close(); @@ -68,20 +102,22 @@ class ProviderUiContext final : public QObject { void finished(const QVariantMap& result, bool cancelled); private: - QJSValue begin(const QString& operation, const QVariantMap& arguments, bool list, bool append); - void resolved(quint64 id, QVariantMap value, bool list, bool append); - void rejected(quint64 id); - void settle(quint64 id, const QVariantMap& value, bool success); - void cancelRequests(); - struct Pending { QJSValue resolve; QJSValue reject; }; + QJSValue begin(const QString& operation, const QVariantMap& arguments, bool list, bool append); + QJSValue promise(Pending *pending); + void settle(quint64 id, const QVariantMap& value, bool success); + void finish(const QVariantMap& result, bool cancelled); + QPointer m_registry; - QPointer m_engine; QString m_sourceId; + QString m_moduleId; + QString m_role; + QUrl m_component; QString m_scope; + QVariantMap m_arguments; bool m_closed = false; quint64 m_next = 0; QHash m_pending; diff --git a/src/provider/RemotePlayback.h b/src/provider/RemotePlayback.h deleted file mode 100644 index 6a916337..00000000 --- a/src/provider/RemotePlayback.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include "../app/SpoolRemoteProtocol.h" -#include "../media/MediaTypes.h" - -#include -#include -#include - -#include - -namespace JellyfinNative { - -// Playing on another client of the same source, and being played on by one. -// Outgoing: while a target is selected, every play request goes there -// instead of the local queue. Incoming: commands other clients send arrive -// as the signals below, in the source's own command envelope, already -// filtered by whether this device accepts them. -class RemotePlayback : public QObject { - Q_OBJECT - -public: - using QObject::QObject; - - virtual bool targetSelected() const = 0; - // Sends the items to the selected target. False when no target is - // selected, so the caller plays them locally. - virtual bool playItems(const std::vector& items, int startIndex, const QString& command, bool fromStart) - = 0; - // A Spool-to-Spool message that arrived through the source. True when it - // was the peer link's own signalling and has been consumed. - virtual bool handlePeerSignalling(const SpoolRemoteProtocol::Message& message) = 0; - -signals: - void playCommandReceived(const QJsonObject& data); - void playstateCommandReceived(const QJsonObject& data); - void generalCommandReceived(const QJsonObject& data); - // A Spool-to-Spool message that arrived over the direct peer link. - void peerMessageReceived(const JellyfinNative::SpoolRemoteProtocol::Message& message); -}; - -} // namespace JellyfinNative diff --git a/src/provider/ScriptBridge.cpp b/src/provider/ScriptBridge.cpp new file mode 100644 index 00000000..d4a9572c --- /dev/null +++ b/src/provider/ScriptBridge.cpp @@ -0,0 +1,474 @@ +#include "ScriptBridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace JellyfinNative { + +namespace { + constexpr qsizetype kMaxResponseBytes = 8 * 1024 * 1024; + constexpr qsizetype kMaxRequestBytes = 1024 * 1024; + constexpr int kMaxConcurrentRequests = 4; + constexpr int kMaxTimers = 16; + constexpr int kMaxSockets = 4; + constexpr int kMaxDiscoveryReplies = 64; + + void rejectWith(QJSEngine *engine, QJSValue& reject, const char *code) + { + reject.call({ engine->toScriptValue(QString::fromLatin1(code)) }); + } + + int defaultPort(const QUrl& url) + { + const QString scheme = url.scheme(); + return url.port(scheme == QStringLiteral("https") || scheme == QStringLiteral("wss") ? 443 : 80); + } + + bool secure(const QString& scheme) + { + return scheme == QStringLiteral("https") || scheme == QStringLiteral("wss"); + } +} // namespace + +ScriptWatchdog::ScriptWatchdog(QJSEngine *engine) + : m_engine(engine) + , m_thread([this] { run(); }) +{ +} + +ScriptWatchdog::~ScriptWatchdog() +{ + { + std::lock_guard lock(m_mutex); + m_stopping = true; + m_engine->setInterrupted(true); + } + m_changed.notify_one(); + m_thread.join(); +} + +void ScriptWatchdog::arm() +{ + std::lock_guard lock(m_mutex); + m_armed = true; + m_deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + m_changed.notify_one(); +} + +void ScriptWatchdog::disarm() +{ + std::lock_guard lock(m_mutex); + m_armed = false; + m_changed.notify_one(); +} + +void ScriptWatchdog::run() +{ + std::unique_lock lock(m_mutex); + while (!m_stopping) { + m_changed.wait(lock, [this] { return m_stopping || m_armed; }); + if (m_stopping) + break; + const auto deadline = m_deadline; + if (!m_changed.wait_until( + lock, deadline, [this, deadline] { return m_stopping || !m_armed || m_deadline != deadline; })) { + m_engine->setInterrupted(true); + m_armed = false; + } + } +} + +namespace { + QVariant own( + const QJSValue& value, int& nodes, qsizetype& bytes, int maximumNodes, qsizetype maximumBytes, int depth) + { + if (++nodes > maximumNodes || depth > 20 || bytes > maximumBytes) + throw std::runtime_error("result_limit"); + if (value.isNull() || value.isUndefined()) + return {}; + if (value.isBool()) + return value.toBool(); + if (value.isNumber()) { + const double number = value.toNumber(); + if (!std::isfinite(number) || std::abs(number) > 9007199254740991.0) + throw std::runtime_error("unsafe_number"); + return number; + } + if (value.isString()) { + const QString text = value.toString(); + if ((bytes += text.size() * sizeof(QChar)) > maximumBytes) + throw std::runtime_error("result_limit"); + return text; + } + if (value.isCallable() || value.isQObject() || !value.isObject()) + throw std::runtime_error("invalid_result"); + if (value.isArray()) { + const quint32 length = value.property(QStringLiteral("length")).toUInt(); + if (length > 10000) + throw std::runtime_error("result_limit"); + QVariantList list; + list.reserve(length); + for (quint32 i = 0; i < length; ++i) + list.append(own(value.property(i), nodes, bytes, maximumNodes, maximumBytes, depth + 1)); + return list; + } + QVariantMap map; + QJSValueIterator iterator(value); + while (iterator.hasNext()) { + iterator.next(); + bytes += iterator.name().size() * sizeof(QChar); + map.insert(iterator.name(), own(iterator.value(), nodes, bytes, maximumNodes, maximumBytes, depth + 1)); + } + return map; + } +} // namespace + +QVariant ownScriptValue(const QJSValue& value, int maximumNodes, qsizetype maximumBytes) +{ + int nodes = 0; + qsizetype bytes = 0; + return own(value, nodes, bytes, maximumNodes, maximumBytes, 0); +} + +bool ScriptAccess::allows(const QUrl& url) const +{ + if (!url.isValid() || !url.userInfo().isEmpty() || url.host().isEmpty()) + return false; + const QString scheme = url.scheme(); + if (scheme != QStringLiteral("https") && scheme != QStringLiteral("http") && scheme != QStringLiteral("wss") + && scheme != QStringLiteral("ws")) + return false; + return std::any_of(origins.begin(), origins.end(), [&url](const QUrl& origin) { + return origin.toString() == QStringLiteral("*") + || (secure(origin.scheme()) == secure(url.scheme()) && origin.host() == url.host() + && defaultPort(origin) == defaultPort(url)); + }); +} + +ScriptRequests::ScriptRequests(ScriptAccess *access, QObject *parent) + : QObject(parent) + , m_access(access) +{ +} + +ScriptRequests::~ScriptRequests() +{ + release(); +} + +void ScriptRequests::release() +{ + for (QNetworkReply *reply : std::exchange(m_replies, {})) { + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + qDeleteAll(std::exchange(m_pending, {})); +} + +void ScriptRequests::http(const QString& address, const QVariantMap& options, QJSValue resolve, QJSValue reject) +{ + QJSEngine *engine = m_access->engine; + const QUrl url(address); + if (url.scheme().startsWith(QStringLiteral("ws")) || !m_access->allows(url) + || m_replies.size() >= kMaxConcurrentRequests) + return rejectWith(engine, reject, "request_denied"); + const QByteArray method = options.value(QStringLiteral("method"), QStringLiteral("GET")).toString().toLatin1(); + if (method != "GET" && method != "POST" && method != "DELETE" && method != "PUT" && method != "PATCH" + && method != "HEAD") + return rejectWith(engine, reject, "method_denied"); + const QByteArray body = options.value(QStringLiteral("body")).toString().toUtf8(); + if (body.size() > kMaxRequestBytes) + return rejectWith(engine, reject, "request_limit"); + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + request.setTransferTimeout(10000); + const QVariantMap headers = options.value(QStringLiteral("headers")).toMap(); + for (auto it = headers.cbegin(); it != headers.cend(); ++it) { + const QByteArray name = it.key().toLatin1(); + const QByteArray value = it.value().toString().toUtf8(); + if (name.contains('\r') || name.contains('\n') || value.contains('\r') || value.contains('\n') + || name.compare("host", Qt::CaseInsensitive) == 0 + || name.compare("content-length", Qt::CaseInsensitive) == 0) + return rejectWith(engine, reject, "header_denied"); + request.setRawHeader(name, value); + } + QNetworkReply *reply = m_access->network->sendCustomRequest(request, method, body); + reply->setReadBufferSize(256 * 1024); + m_replies.insert(reply); + auto buffer = std::make_shared(); + const auto take = [this, reply, buffer]() { + const QByteArray chunk = reply->readAll(); + if (buffer->size() + chunk.size() > kMaxResponseBytes) + return false; + buffer->append(chunk); + return true; + }; + connect(reply, &QIODevice::readyRead, this, [this, take] { + if (!take()) + emit overflowed(); + }); + connect(reply, &QNetworkReply::finished, this, [this, reply, take, buffer, resolve, reject]() mutable { + m_replies.remove(reply); + reply->deleteLater(); + if (!take()) { + emit overflowed(); + return; + } + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QJSEngine *engine = m_access->engine; + if (status == 0 || (status < 400 && reply->error() != QNetworkReply::NoError)) + return rejectWith(engine, reject, "network_error"); + QJSValue response = engine->newObject(); + response.setProperty(QStringLiteral("status"), status); + response.setProperty(QStringLiteral("body"), QString::fromUtf8(*buffer)); + const QByteArray location = reply->rawHeader("Location"); + if (!location.isEmpty()) + response.setProperty(QStringLiteral("location"), QString::fromUtf8(location)); + resolve.call({ response }); + }); +} + +void ScriptRequests::delay(int milliseconds, QJSValue resolve, QJSValue reject) +{ + if (milliseconds < 0 || milliseconds > 60000 || m_pending.size() >= kMaxTimers) + return rejectWith(m_access->engine, reject, "timer_limit"); + auto *timer = new QTimer(this); + timer->setSingleShot(true); + m_pending.insert(timer); + connect(timer, &QTimer::timeout, this, [this, timer, resolve]() mutable { + m_pending.remove(timer); + timer->deleteLater(); + resolve.call(); + }); + timer->start(milliseconds); +} + +void ScriptRequests::discover(int port, const QString& message, int timeoutMs, QJSValue resolve, QJSValue reject) +{ + if (port < 1 || port > 65535 || message.size() > 1024 || timeoutMs < 100 || timeoutMs > 5000 + || m_pending.size() >= kMaxTimers) + return rejectWith(m_access->engine, reject, "discovery_denied"); + auto *socket = new QUdpSocket(this); + if (!socket->bind(QHostAddress::AnyIPv4, 0)) { + socket->deleteLater(); + return rejectWith(m_access->engine, reject, "discovery_unavailable"); + } + m_pending.insert(socket); + // Every interface's own broadcast address, since the limited broadcast + // does not leave the default route on some platforms. + QSet targets { QHostAddress::Broadcast }; + for (const QNetworkInterface& interface : QNetworkInterface::allInterfaces()) { + if (!(interface.flags() & QNetworkInterface::IsUp) || (interface.flags() & QNetworkInterface::IsLoopBack)) + continue; + for (const QNetworkAddressEntry& entry : interface.addressEntries()) { + if (!entry.broadcast().isNull()) + targets.insert(entry.broadcast()); + } + } + const QByteArray payload = message.toUtf8(); + for (const QHostAddress& target : std::as_const(targets)) + socket->writeDatagram(payload, target, static_cast(port)); + auto replies = std::make_shared(); + connect(socket, &QUdpSocket::readyRead, socket, [socket, replies] { + while (socket->hasPendingDatagrams()) { + const QNetworkDatagram datagram = socket->receiveDatagram(8192); + if (replies->size() < kMaxDiscoveryReplies) { + replies->append(QVariantMap { { QStringLiteral("address"), datagram.senderAddress().toString() }, + { QStringLiteral("text"), QString::fromUtf8(datagram.data()) } }); + } + } + }); + QTimer::singleShot(timeoutMs, socket, [this, socket, replies, resolve]() mutable { + m_pending.remove(socket); + socket->deleteLater(); + resolve.call({ m_access->engine->toScriptValue(*replies) }); + }); +} + +ScriptOperation::ScriptOperation( + ScriptAccess *access, std::shared_ptr sink, QString scope, QObject *parent) + : QObject(parent) + , m_access(access) + , m_sink(std::move(sink)) + , m_scope(std::move(scope)) + , m_requests(access, this) +{ + m_deadline.setSingleShot(true); + connect(&m_deadline, &QTimer::timeout, this, [this] { cancel("operation_timeout"); }); + connect(&m_requests, &ScriptRequests::overflowed, this, [this] { cancel("response_limit"); }); + m_deadline.start(15000); +} + +ScriptOperation::~ScriptOperation() +{ + if (!m_settled) + m_sink->reject("runtime_shutdown"); +} + +void ScriptOperation::cancel(const char *code) +{ + if (m_settled) + return; + m_settled = true; + m_sink->reject(code); + release(); +} + +void ScriptOperation::resolve(const QJSValue& result) +{ + if (m_settled) + return; + try { + if (!result.isObject() || result.isArray() || result.isError()) + throw std::runtime_error("invalid_result"); + m_sink->prepare(result); + if (m_access->engine->isInterrupted()) + throw std::runtime_error("script_interrupted"); + m_settled = true; + m_sink->complete(); + release(); + } catch (const std::exception&) { + cancel("invalid_result"); + } +} + +void ScriptOperation::reject(const QJSValue& error) +{ + // Provider exceptions can carry tokens and response bodies. Only a short + // code the provider chose on purpose (an Error whose message looks like + // one) crosses to native code; anything else becomes provider_error. + const QString message = error.isError() ? error.property(QStringLiteral("message")).toString() : error.toString(); + static const QRegularExpression code(QStringLiteral("^[a-z][a-z0-9_]{0,47}$")); + const QByteArray stable = code.match(message).hasMatch() ? message.toLatin1() : QByteArrayLiteral("provider_error"); + if (m_settled) + return; + m_settled = true; + m_sink->reject(stable.constData()); + release(); +} + +void ScriptOperation::http(const QString& url, const QVariantMap& options, QJSValue resolve, QJSValue reject) +{ + if (!m_settled) + m_requests.http(url, options, std::move(resolve), std::move(reject)); +} + +void ScriptOperation::delay(int milliseconds, QJSValue resolve, QJSValue reject) +{ + if (!m_settled) + m_requests.delay(std::min(milliseconds, 10000), std::move(resolve), std::move(reject)); +} + +void ScriptOperation::discover(int port, const QString& message, int timeoutMs, QJSValue resolve, QJSValue reject) +{ + if (!m_settled) + m_requests.discover(port, message, timeoutMs, std::move(resolve), std::move(reject)); +} + +void ScriptOperation::release() +{ + m_deadline.stop(); + m_requests.release(); + deleteLater(); +} + +ScriptSourceHost::ScriptSourceHost(ScriptAccess access, EventSink events, QObject *parent) + : QObject(parent) + , m_access(std::move(access)) + , m_events(std::move(events)) + , m_requests(&m_access, this) +{ +} + +ScriptSourceHost::~ScriptSourceHost() +{ + for (const auto& socket : std::as_const(m_sockets)) { + if (socket) { + disconnect(socket, nullptr, this, nullptr); + socket->abort(); + delete socket; + } + } +} + +void ScriptSourceHost::emitEvent(const QString& type, const QJSValue& payload) +{ + if (type.isEmpty() || type.size() > 64) + return; + try { + m_events(type, payload.isUndefined() ? QVariantMap {} : ownScriptValue(payload, 5000, 256 * 1024).toMap()); + } catch (const std::exception&) { + qWarning("provider: dropped an oversized or invalid %s event", qPrintable(type)); + } +} + +void ScriptSourceHost::http(const QString& url, const QVariantMap& options, QJSValue resolve, QJSValue reject) +{ + m_requests.http(url, options, std::move(resolve), std::move(reject)); +} + +void ScriptSourceHost::delay(int milliseconds, QJSValue resolve, QJSValue reject) +{ + m_requests.delay(milliseconds, std::move(resolve), std::move(reject)); +} + +int ScriptSourceHost::socket( + const QString& address, const QVariantMap& headers, QJSValue onOpen, QJSValue onMessage, QJSValue onClose) +{ + const QUrl url(address); + for (auto it = m_sockets.begin(); it != m_sockets.end();) + it = it.value().isNull() ? m_sockets.erase(it) : std::next(it); + if (!url.scheme().startsWith(QStringLiteral("ws")) || !m_access.allows(url) || m_sockets.size() >= kMaxSockets) + return 0; + QNetworkRequest request(url); + for (auto it = headers.cbegin(); it != headers.cend(); ++it) { + const QByteArray value = it.value().toString().toUtf8(); + if (value.contains('\r') || value.contains('\n')) + return 0; + request.setRawHeader(it.key().toLatin1(), value); + } + auto *socket = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this); + socket->setMaxAllowedIncomingMessageSize(4 * 1024 * 1024); + if (m_access.socketHook) + m_access.socketHook(socket, url); + const int id = m_nextSocket++; + m_sockets.insert(id, socket); + connect(socket, &QWebSocket::connected, this, [onOpen]() mutable { onOpen.call(); }); + connect(socket, &QWebSocket::textMessageReceived, this, + [this, onMessage](const QString& text) mutable { onMessage.call({ m_access.engine->toScriptValue(text) }); }); + connect(socket, &QWebSocket::disconnected, this, [this, id, socket, onClose]() mutable { + m_sockets.remove(id); + socket->deleteLater(); + onClose.call({ static_cast(socket->closeCode()) }); + }); + socket->open(request); + return id; +} + +void ScriptSourceHost::socketSend(int id, const QString& text) +{ + if (QWebSocket *socket = m_sockets.value(id); socket && text.size() <= 1024 * 1024) + socket->sendTextMessage(text); +} + +void ScriptSourceHost::socketClose(int id) +{ + if (QWebSocket *socket = m_sockets.value(id)) + socket->close(); +} + +} // namespace JellyfinNative diff --git a/src/provider/ScriptBridge.h b/src/provider/ScriptBridge.h new file mode 100644 index 00000000..230973a3 --- /dev/null +++ b/src/provider/ScriptBridge.h @@ -0,0 +1,149 @@ +#pragma once + +// Worker-thread internals of ScriptRuntime: the objects provider JS calls +// back into. Nothing here is used outside ScriptRuntime.cpp and its tests. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QWebSocket; + +namespace JellyfinNative { + +// Interrupts JS that runs longer than 500 ms without returning to the event +// loop. Qt permits setInterrupted() from another thread. +class ScriptWatchdog { +public: + explicit ScriptWatchdog(QJSEngine *engine); + ~ScriptWatchdog(); + void arm(); + void disarm(); + +private: + void run(); + QJSEngine *m_engine; + std::mutex m_mutex; + std::condition_variable m_changed; + bool m_stopping = false; + bool m_armed = false; + std::chrono::steady_clock::time_point m_deadline; + std::thread m_thread; +}; + +// Converts a JS value to an owned native value in one bounded walk; also +// rejects cycles, functions and unsafe numbers. +QVariant ownScriptValue(const QJSValue& value, int maximumNodes = 50000, qsizetype maximumBytes = 4 * 1024 * 1024); + +// Result delivery for one operation, type-erased per operation (not per row). +struct ScriptResultSink { + virtual ~ScriptResultSink() = default; + virtual void prepare(const QJSValue& value) = 0; + virtual void complete() = 0; + virtual void reject(const char *code) = 0; +}; + +// What every source may reach and how. Shared by a source's host and its +// operations; both check against the same origin list. +struct ScriptAccess { + QJSEngine *engine = nullptr; + QNetworkAccessManager *network = nullptr; + QList origins; + std::function socketHook; + bool allows(const QUrl& url) const; +}; + +// Native requests a JS caller owns: HTTP, timers and LAN discovery, all +// cancelled together when the owner releases them. +class ScriptRequests final : public QObject { + Q_OBJECT +public: + ScriptRequests(ScriptAccess *access, QObject *parent); + ~ScriptRequests() override; + void http(const QString& address, const QVariantMap& options, QJSValue resolve, QJSValue reject); + void delay(int milliseconds, QJSValue resolve, QJSValue reject); + void discover(int port, const QString& message, int timeoutMs, QJSValue resolve, QJSValue reject); + void release(); + +signals: + // A response exceeded the size limit; the owner fails as a whole. + void overflowed(); + +private: + ScriptAccess *m_access; + QSet m_replies; + QSet m_pending; +}; + +// One provider operation: settles exactly once, within 15 seconds. +class ScriptOperation final : public QObject { + Q_OBJECT +public: + ScriptOperation(ScriptAccess *access, std::shared_ptr sink, QString scope, QObject *parent); + ~ScriptOperation() override; + const QString& scope() const + { + return m_scope; + } + void cancel(const char *code); + Q_INVOKABLE void resolve(const QJSValue& result); + Q_INVOKABLE void reject(const QJSValue& error); + Q_INVOKABLE void http(const QString& url, const QVariantMap& options, QJSValue resolve, QJSValue reject); + Q_INVOKABLE void delay(int milliseconds, QJSValue resolve, QJSValue reject); + Q_INVOKABLE void discover(int port, const QString& message, int timeoutMs, QJSValue resolve, QJSValue reject); + +private: + void release(); + ScriptAccess *m_access; + std::shared_ptr m_sink; + QString m_scope; + ScriptRequests m_requests; + QTimer m_deadline; + bool m_settled = false; +}; + +// Services that live as long as a source: websockets, events and timers. +// This is how a provider keeps a server connection open for live updates, +// group playback and remote commands. +class ScriptSourceHost final : public QObject { + Q_OBJECT +public: + using EventSink = std::function; + ScriptSourceHost(ScriptAccess access, EventSink events, QObject *parent); + ~ScriptSourceHost() override; + ScriptAccess *access() + { + return &m_access; + } + Q_INVOKABLE void emitEvent(const QString& type, const QJSValue& payload); + Q_INVOKABLE void http(const QString& url, const QVariantMap& options, QJSValue resolve, QJSValue reject); + Q_INVOKABLE void delay(int milliseconds, QJSValue resolve, QJSValue reject); + Q_INVOKABLE int socket( + const QString& url, const QVariantMap& headers, QJSValue onOpen, QJSValue onMessage, QJSValue onClose); + Q_INVOKABLE void socketSend(int id, const QString& text); + Q_INVOKABLE void socketClose(int id); + +private: + ScriptAccess m_access; + EventSink m_events; + ScriptRequests m_requests; + QHash> m_sockets; + int m_nextSocket = 1; +}; + +} // namespace JellyfinNative diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index a6af3393..8dabeab9 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -1,575 +1,342 @@ #include "ScriptRuntime.h" +#include "ScriptBridge.h" + #include #include -#include #include -#include -#include #include -#include -#include #include -#include #include -#include -#include #include -#include -#include -#include -#include #include #include -#include -#include namespace JellyfinNative { namespace { - using Completion = std::shared_ptr>; - - template - void fail(const std::shared_ptr>& completion, const char *code) + template void fail(const std::shared_ptr>& promise, const char *code) { - completion->setException(std::make_exception_ptr(std::runtime_error(code))); - completion->finish(); + promise->setException(std::make_exception_ptr(std::runtime_error(code))); + promise->finish(); } - template - QCoro::Task awaitResult(QFuture future) + template QCoro::Task awaitResult(QFuture future) { auto result = qCoro(future); co_return co_await result.takeResult(); } - // Type erasure is per operation, not per item/property. Both small RPCs - // and bulk native pages use the same cancellation/HTTP/watchdog lifecycle. - struct ResultSink { - virtual ~ResultSink() = default; - virtual void prepare(const QJSValue& value) = 0; - virtual void complete() = 0; - virtual void reject(const char *code) = 0; - }; - using OperationCompletion = std::shared_ptr; - - template - class TypedResultSink final : public ResultSink { + template class TypedSink final : public ScriptResultSink { public: - TypedResultSink(std::shared_ptr> promise, Decoder decoder) - : promise(std::move(promise)) - , decoder(std::move(decoder)) + TypedSink(std::shared_ptr> promise, Decoder decoder) + : m_promise(std::move(promise)) + , m_decoder(std::move(decoder)) { } void prepare(const QJSValue& value) override { - result.emplace(decoder(value)); + m_result.emplace(m_decoder(value)); } void complete() override { - promise->addResult(std::move(result.value())); - promise->finish(); - result.reset(); + m_promise->addResult(std::move(*m_result)); + m_promise->finish(); } void reject(const char *code) override { - fail(promise, code); + fail(m_promise, code); } private: - std::shared_ptr> promise; - Decoder decoder; - std::optional result; + std::shared_ptr> m_promise; + Decoder m_decoder; + std::optional m_result; }; - template - OperationCompletion resultSink(std::shared_ptr> promise, Decoder decoder) - { - return std::make_shared>(std::move(promise), std::move(decoder)); - } - - // Sleeps while JS is idle. Qt explicitly permits setInterrupted() from another - // thread. The mutex protects engine lifetime, not JS heap access. - class ScriptWatchdog { - public: - explicit ScriptWatchdog(QJSEngine *engine) - : m_engine(engine) - , m_thread([this] { run(); }) - { + // Promise syntax is the baseline: no Node/browser globals are provided and + // none are required. Source-level services outlive any one operation. + constexpr auto kGlue = R"JS((function() { + function promised(start) { + return new Promise(function(resolve, reject) { start(resolve, reject); }); } - ~ScriptWatchdog() - { - { - std::lock_guard lock(m_mutex); - m_stopping = true; - m_engine->setInterrupted(true); - } - m_changed.notify_one(); - m_thread.join(); - } - void arm() - { - std::lock_guard lock(m_mutex); - m_armed = true; - m_deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); - m_changed.notify_one(); - } - void disarm() - { - std::lock_guard lock(m_mutex); - m_armed = false; - m_changed.notify_one(); - } - void interrupt() - { - std::lock_guard lock(m_mutex); - m_engine->setInterrupted(true); - } - - private: - void run() - { - std::unique_lock lock(m_mutex); - while (!m_stopping) { - m_changed.wait(lock, [this] { return m_stopping || m_armed; }); - if (m_stopping) - break; - const auto deadline = m_deadline; - if (!m_changed.wait_until(lock, deadline, - [this, deadline] { return m_stopping || !m_armed || m_deadline != deadline; })) { - m_engine->setInterrupted(true); - m_armed = false; + function sourceHost(bridge, device) { + return Object.freeze({ + device: device, + http: function(url, options) { + return promised(function(ok, no) { bridge.http(String(url), options || {}, ok, no); }); + }, + delay: function(ms) { + return promised(function(ok, no) { bridge.delay(ms | 0, ok, no); }); + }, + emit: function(type, payload) { bridge.emitEvent(String(type), payload); }, + socket: function(url, options) { + const socket = {onopen: null, onmessage: null, onclose: null}; + const id = bridge.socket(String(url), (options && options.headers) || {}, + function() { if (socket.onopen) socket.onopen(); }, + function(text) { if (socket.onmessage) socket.onmessage(text); }, + function(code) { if (socket.onclose) socket.onclose(code); }); + if (!id) + throw new Error('socket_denied'); + socket.send = function(text) { bridge.socketSend(id, String(text)); }; + socket.close = function() { bridge.socketClose(id); }; + return socket; } - } - } - QJSEngine *m_engine; - std::mutex m_mutex; - std::condition_variable m_changed; - bool m_stopping = false; - bool m_armed = false; - std::chrono::steady_clock::time_point m_deadline; - std::thread m_thread; - }; - - // Validation and ownership conversion in one walk; no JSON stringify/parse - // round-trip, no JS object retained by native models. Limits also reject cycles. - QVariant ownValue(const QJSValue& value, int& nodes, qsizetype& bytes, int depth = 0) - { - if (++nodes > 50000 || depth > 20 || bytes > 4 * 1024 * 1024) - throw std::runtime_error("result_limit"); - if (value.isNull() || value.isUndefined()) - return {}; - if (value.isBool()) - return value.toBool(); - if (value.isNumber()) { - const double number = value.toNumber(); - if (!std::isfinite(number) || std::abs(number) > 9007199254740991.0) - throw std::runtime_error("unsafe_number"); - return number; - } - if (value.isString()) { - const QString text = value.toString(); - bytes += text.size() * sizeof(QChar); - if (bytes > 4 * 1024 * 1024) - throw std::runtime_error("result_limit"); - return text; - } - if (value.isCallable() || value.isQObject() || !value.isObject()) - throw std::runtime_error("invalid_result"); - if (value.isArray()) { - const quint32 length = value.property(QStringLiteral("length")).toUInt(); - if (length > 10000) - throw std::runtime_error("result_limit"); - QVariantList list; - list.reserve(length); - for (quint32 i = 0; i < length; ++i) - list.append(ownValue(value.property(i), nodes, bytes, depth + 1)); - return list; - } - QVariantMap map; - QJSValueIterator iterator(value); - while (iterator.hasNext()) { - iterator.next(); - bytes += iterator.name().size() * sizeof(QChar); - map.insert(iterator.name(), ownValue(iterator.value(), nodes, bytes, depth + 1)); - } - return map; - } - - bool sameOrigin(const QUrl& left, const QUrl& right) - { - return left.scheme() == right.scheme() && left.host() == right.host() - && left.port(left.scheme() == QStringLiteral("https") ? 443 : 80) - == right.port(right.scheme() == QStringLiteral("https") ? 443 : 80); - } -} // namespace - -class ScriptOperation final : public QObject { - Q_OBJECT -public: - QString scope; - ScriptOperation(QJSEngine *engine, QNetworkAccessManager *network, ScriptWatchdog *watchdog, OperationCompletion completion, - QList origins, QObject *parent) - : QObject(parent) - , engine(engine) - , network(network) - , watchdog(watchdog) - , completion(std::move(completion)) - , origins(std::move(origins)) - { - deadline.setSingleShot(true); - deadline.setInterval(15000); - connect(&deadline, &QTimer::timeout, this, [this] { cancel("operation_timeout"); }); - deadline.start(); - } - ~ScriptOperation() override - { - if (!settled) - completion->reject("runtime_shutdown"); - } - void cancel(const char *code) - { - if (settled) - return; - settled = true; - completion->reject(code); - release(); - } - Q_INVOKABLE void resolve(const QJSValue& result) - { - if (settled) - return; - try { - if (!result.isObject() || result.isArray() || result.isError()) - throw std::runtime_error("invalid_result"); - completion->prepare(result); - if (engine->isInterrupted()) - throw std::runtime_error("script_interrupted"); - settled = true; - completion->complete(); - release(); - } catch (const std::exception&) { - cancel("invalid_result"); - } - } - Q_INVOKABLE void reject(const QJSValue&) - { - // Provider exceptions can carry tokens and response bodies. The public - // error is a stable code, never the untrusted exception's string value. - cancel("provider_error"); - } - Q_INVOKABLE void delay(int milliseconds, QJSValue resolveCallback, QJSValue rejectCallback) - { - if (settled) - return; - if (milliseconds < 0 || milliseconds > 10000 || timers.size() >= 16) { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("timer_limit")) }); - return; - } - auto *timer = new QTimer(this); - timer->setSingleShot(true); - timers.insert(timer); - connect(timer, &QTimer::timeout, this, [this, timer, resolveCallback]() mutable { - timers.remove(timer); - timer->deleteLater(); - if (settled) - return; - watchdog->arm(); - resolveCallback.call(); - if (engine->isInterrupted()) - cancel("script_interrupted"); - }); - timer->start(milliseconds); - } - Q_INVOKABLE void request( - const QString& address, const QVariantMap& options, QJSValue resolveCallback, QJSValue rejectCallback) - { - if (settled) - return; - const QUrl url(address); - const bool allowed = url.isValid() && url.userInfo().isEmpty() - && (url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("http")) - && std::any_of( - origins.begin(), origins.end(), [&url](const QUrl& origin) { return sameOrigin(url, origin); }); - if (!allowed || replies.size() >= 4) { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("request_denied")) }); - return; - } - const QByteArray method = options.value(QStringLiteral("method"), QStringLiteral("GET")).toString().toLatin1(); - if (method != "GET" && method != "POST" && method != "DELETE" && method != "PUT" && method != "PATCH") { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("method_denied")) }); - return; - } - const QByteArray body = options.value(QStringLiteral("body")).toString().toUtf8(); - if (body.size() > 1024 * 1024) { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("request_limit")) }); - return; - } - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - request.setAttribute(QNetworkRequest::CookieLoadControlAttribute, QNetworkRequest::Manual); - request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); - request.setTransferTimeout(10000); - const QVariantMap headers = options.value(QStringLiteral("headers")).toMap(); - for (auto it = headers.cbegin(); it != headers.cend(); ++it) { - const QByteArray name = it.key().toLatin1(); - const QByteArray value = it.value().toString().toUtf8(); - if (name.contains('\r') || name.contains('\n') || value.contains('\r') || value.contains('\n') - || name.compare("host", Qt::CaseInsensitive) == 0 - || name.compare("content-length", Qt::CaseInsensitive) == 0) { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("header_denied")) }); - return; - } - request.setRawHeader(name, value); - } - QNetworkReply *reply = network->sendCustomRequest(request, method, body); - reply->setReadBufferSize(256 * 1024); - replies.insert(reply); - auto buffer = std::make_shared(); - connect(reply, &QIODevice::readyRead, this, [this, reply, buffer] { - const QByteArray chunk = reply->readAll(); - if (buffer->size() + chunk.size() > 8 * 1024 * 1024) { - cancel("response_limit"); - return; - } - buffer->append(chunk); - }); - connect( - reply, &QNetworkReply::finished, this, [this, reply, buffer, resolveCallback, rejectCallback]() mutable { - replies.remove(reply); - reply->deleteLater(); - if (settled) - return; - const QByteArray finalBytes = reply->readAll(); - if (buffer->size() + finalBytes.size() > 8 * 1024 * 1024) { - cancel("response_limit"); - return; - } - buffer->append(finalBytes); - const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - watchdog->arm(); - if (status == 0 || (status < 400 && reply->error() != QNetworkReply::NoError)) { - rejectCallback.call({ engine->toScriptValue(QStringLiteral("network_error")) }); - } else { - QJSValue response = engine->newObject(); - response.setProperty(QStringLiteral("status"), status); - response.setProperty(QStringLiteral("body"), QString::fromUtf8(*buffer)); - resolveCallback.call({ response }); - } - if (engine->isInterrupted()) - cancel("script_interrupted"); }); - } - -private: - void release() - { - deadline.stop(); - const auto pendingTimers = std::exchange(timers, {}); - for (QTimer *timer : pendingTimers) { - timer->stop(); - timer->deleteLater(); } - const auto pending = std::exchange(replies, {}); - for (QNetworkReply *reply : pending) { - disconnect(reply, nullptr, this, nullptr); - reply->abort(); - reply->deleteLater(); - } - deleteLater(); - } - QJSEngine *engine; - QNetworkAccessManager *network; - ScriptWatchdog *watchdog; - OperationCompletion completion; - QList origins; - QTimer deadline; - QSet replies; - QSet timers; - bool settled = false; -}; - -class ScriptWorker final : public QObject { -public: - explicit ScriptWorker(QString entryPoint) - : entryPoint(std::move(entryPoint)) - { - } - void initialize() - { - engine = std::make_unique(); - watchdog = std::make_unique(engine.get()); - network = std::make_unique(); - auto *dispatcher = QAbstractEventDispatcher::instance(); - connect(dispatcher, &QAbstractEventDispatcher::awake, this, [this] { - if (watchdog) - watchdog->arm(); - }); - connect(dispatcher, &QAbstractEventDispatcher::aboutToBlock, this, [this] { - if (!watchdog) - return; - watchdog->disarm(); - if (engine->isInterrupted()) { - for (auto& source : sources) - for (ScriptOperation *operation : source.operations) - operation->cancel("script_interrupted"); - } - }); - watchdog->arm(); - const QUrl entryUrl(entryPoint); - // importModule's entry argument is a file name, unlike an ES import's - // URL specifier. Native Qt resource files use :/ rather than qrc:/. - const QString moduleFile = entryUrl.scheme() == QStringLiteral("qrc") ? QLatin1Char(':') + entryUrl.path() - : entryUrl.isLocalFile() ? entryUrl.toLocalFile() - : entryPoint; - module = engine->importModule(moduleFile); - // Promise syntax is the portable baseline; no Node/browser globals or - // async-function syntax is required from the bundled Qt JS engine. - invoke = engine->evaluate(QStringLiteral(R"JS( - (function(source, method, args, bridge) { + return { + create: function(module, configuration, bridge, device) { + return module.createSource(configuration, sourceHost(bridge, device)); + }, + call: function(source, method, args, operation, bridge, device) { const host = Object.freeze({ + device: device, http: function(url, options) { - return new Promise(function(resolve, reject) { - bridge.request(url, options || {}, resolve, reject); - }); + return promised(function(ok, no) { operation.http(String(url), options || {}, ok, no); }); + }, + delay: function(ms) { + if (!Number.isInteger(ms) || ms < 0 || ms > 10000) + return Promise.reject(new Error('timer_limit')); + return promised(function(ok, no) { operation.delay(ms, ok, no); }); }, - delay: function(milliseconds) { - if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > 10000) - return Promise.reject("timer_limit"); - return new Promise(function(resolve, reject) { - bridge.delay(milliseconds, resolve, reject); + discover: function(options) { + options = options || {}; + return promised(function(ok, no) { + operation.discover(options.port | 0, String(options.message || ''), + options.timeout === undefined ? 1500 : options.timeout | 0, ok, no); }); - } + }, + emit: function(type, payload) { bridge.emitEvent(String(type), payload); } }); try { Promise.resolve(source[method](args, host)).then( - function(value) { bridge.resolve(value); }, - function(error) { bridge.reject(error); }); - } catch (error) { bridge.reject(error); } - }) - )JS")); + function(value) { operation.resolve(value); }, + function(error) { operation.reject(error); }); + } catch (error) { operation.reject(error); } + } + }; + })())JS"; +} // namespace + +class ScriptWorker final : public QObject { +public: + using EventSink = std::function; + + ScriptWorker(QString entryPoint, QVariantMap device, ScriptRuntime::NetworkHooks hooks, EventSink events, + std::function interrupted) + : m_entryPoint(std::move(entryPoint)) + , m_device(std::move(device)) + , m_hooks(std::move(hooks)) + , m_events(std::move(events)) + , m_interrupted(std::move(interrupted)) + { } - void add(QString id, QVariantMap config, QList origins, const Completion& completion) + + void add(const QString& id, const QVariantMap& configuration, const QList& origins, + const std::shared_ptr>& promise) { - if (!engine) + if (!m_engine) initialize(); - if (engine->isInterrupted() || module.isError()) { - fail(completion, "module_unavailable"); - return; + if (m_engine->isInterrupted() || m_module.isError() + || !m_module.property(QStringLiteral("createSource")).isCallable()) + return fail(promise, "module_unavailable"); + if (id.isEmpty() || m_sources.contains(id) || m_sources.size() >= 32) + return fail(promise, "source_conflict_or_limit"); + ScriptAccess access { m_engine.get(), m_network.get(), origins, m_hooks.socket }; + auto *host = new ScriptSourceHost( + access, + [events = m_events, id](const QString& type, const QVariantMap& payload) { events(id, type, payload); }, + this); + QJSEngine::setObjectOwnership(host, QJSEngine::CppOwnership); + const QJSValue bridge = m_engine->newQObject(host); + m_watchdog->arm(); + const QJSValue object = m_glue.property(QStringLiteral("create")) + .call({ m_module, m_engine->toScriptValue(configuration), bridge, m_jsDevice }); + if (object.isError() || !object.isObject() || m_engine->isInterrupted()) { + delete host; + checkInterrupted(); + return fail(promise, "source_initialization_failed"); } - if (id.isEmpty() || sources.contains(id) || sources.size() >= 16) { - fail(completion, "source_conflict_or_limit"); - return; - } - watchdog->arm(); - QJSValue source = module.property(QStringLiteral("createSource")).call({ engine->toScriptValue(config) }); - if (source.isError() || !source.isObject() || engine->isInterrupted()) { - fail(completion, "source_initialization_failed"); - return; - } - sources.insert(id, { source, std::move(origins), {} }); - completion->addResult(QVariantMap { { QStringLiteral("sourceId"), id } }); - completion->finish(); + m_sources.insert(id, { object, bridge, host, {} }); + promise->addResult(QVariantMap { { QStringLiteral("sourceId"), id } }); + promise->finish(); } + void call(const QString& id, const QString& method, const QVariantMap& args, const QString& scope, - const OperationCompletion& completion) + const std::shared_ptr& sink) { - auto source = sources.find(id); - if (!engine || engine->isInterrupted() || source == sources.end()) { - completion->reject("source_unavailable"); - return; - } - if (source->operations.size() >= 8 || activeOperations >= 32) { - completion->reject("operation_limit"); - return; - } + const auto source = m_sources.find(id); + if (!m_engine || m_engine->isInterrupted() || source == m_sources.end()) + return sink->reject("source_unavailable"); + if (source->operations.size() >= 8 || m_activeOperations >= 32) + return sink->reject("operation_limit"); if (method.startsWith(QLatin1Char('_')) || !source->object.hasOwnProperty(method) - || !source->object.property(method).isCallable()) { - completion->reject("unsupported_operation"); - return; - } - auto *operation - = new ScriptOperation(engine.get(), network.get(), watchdog.get(), completion, source->origins, this); - operation->scope = scope; + || !source->object.property(method).isCallable()) + return sink->reject("unsupported_operation"); + auto *operation = new ScriptOperation(source->host->access(), sink, scope, this); source->operations.insert(operation); - ++activeOperations; + ++m_activeOperations; connect(operation, &QObject::destroyed, this, [this, id, operation] { - --activeOperations; - auto source = sources.find(id); - if (source != sources.end()) - source->operations.remove(operation); + --m_activeOperations; + if (const auto found = m_sources.find(id); found != m_sources.end()) + found->operations.remove(operation); }); QJSEngine::setObjectOwnership(operation, QJSEngine::CppOwnership); - watchdog->arm(); - invoke.call({ source->object, QJSValue(method), engine->toScriptValue(args), engine->newQObject(operation) }); - if (engine->isInterrupted()) { - for (auto& source : sources) { - for (ScriptOperation *pending : source.operations) - pending->cancel("script_interrupted"); - } - } + m_watchdog->arm(); + m_glue.property(QStringLiteral("call")) + .call({ source->object, QJSValue(method), m_engine->toScriptValue(args), m_engine->newQObject(operation), + source->bridge, m_jsDevice }); + checkInterrupted(); } + void cancelScope(const QString& id, const QString& scope) { - const auto source = sources.constFind(id); - if (scope.isEmpty() || source == sources.cend()) + const auto source = m_sources.constFind(id); + if (scope.isEmpty() || source == m_sources.cend()) return; for (ScriptOperation *operation : source->operations) { - if (operation->scope == scope) + if (operation->scope() == scope) operation->cancel("action_cancelled"); } } + void remove(const QString& id) { - auto source = sources.find(id); - if (source == sources.end()) + const auto source = m_sources.find(id); + if (source == m_sources.end()) return; - for (ScriptOperation *operation : source->operations) + for (ScriptOperation *operation : std::as_const(source->operations)) operation->cancel("source_removed"); - sources.erase(source); + delete source->host; + m_sources.erase(source); } + void shutdown() { - const auto ids = sources.keys(); - for (const auto& id : ids) + for (const QString& id : m_sources.keys()) remove(id); - // Destroy bridges before the engine and its JS callbacks. - const auto operations = findChildren(QString(), Qt::FindDirectChildrenOnly); - qDeleteAll(operations); - invoke = QJSValue(); - module = QJSValue(); - watchdog.reset(); - network.reset(); - engine.reset(); + // Bridges hold JS callbacks: destroy them before the engine. + qDeleteAll(findChildren(QString(), Qt::FindDirectChildrenOnly)); + m_glue = {}; + m_module = {}; + m_jsDevice = {}; + m_watchdog.reset(); + m_network.reset(); + m_engine.reset(); } private: struct Source { QJSValue object; - QList origins; + QJSValue bridge; + ScriptSourceHost *host = nullptr; QSet operations; }; - QString entryPoint; - std::unique_ptr engine; - std::unique_ptr watchdog; - std::unique_ptr network; - QJSValue module; - QJSValue invoke; - QHash sources; - int activeOperations = 0; + + void initialize() + { + m_engine = std::make_unique(); + m_watchdog = std::make_unique(m_engine.get()); + m_network = std::make_unique(); + if (m_hooks.network) + m_hooks.network(m_network.get()); + // Every callback into JS runs from an event this thread wakes for, so + // arming on wake and disarming before sleep covers timers, replies, + // socket messages and Promise continuations alike. + auto *dispatcher = QAbstractEventDispatcher::instance(); + connect(dispatcher, &QAbstractEventDispatcher::awake, this, [this] { + if (m_watchdog) + m_watchdog->arm(); + }); + connect(dispatcher, &QAbstractEventDispatcher::aboutToBlock, this, [this] { + if (m_watchdog) { + m_watchdog->disarm(); + checkInterrupted(); + } + }); + m_watchdog->arm(); + const QUrl entry(m_entryPoint); + // importModule takes a file name: qrc:/x is :/x and file URLs are paths. + const QString file = entry.scheme() == QStringLiteral("qrc") ? QLatin1Char(':') + entry.path() + : entry.isLocalFile() ? entry.toLocalFile() + : m_entryPoint; + m_module = m_engine->importModule(file); + m_glue = m_engine->evaluate(QString::fromLatin1(kGlue)); + m_jsDevice = m_engine->toScriptValue(m_device); + m_engine->globalObject() + .property(QStringLiteral("Object")) + .property(QStringLiteral("freeze")) + .call({ m_jsDevice }); + } + + void checkInterrupted() + { + if (!m_engine || !m_engine->isInterrupted() || m_reportedInterrupt) + return; + m_reportedInterrupt = true; + for (auto& source : m_sources) { + for (ScriptOperation *operation : std::as_const(source.operations)) + operation->cancel("script_interrupted"); + } + m_interrupted(); + } + + QString m_entryPoint; + QVariantMap m_device; + ScriptRuntime::NetworkHooks m_hooks; + EventSink m_events; + std::function m_interrupted; + std::unique_ptr m_engine; + std::unique_ptr m_watchdog; + std::unique_ptr m_network; + QJSValue m_module; + QJSValue m_glue; + QJSValue m_jsDevice; + QHash m_sources; + int m_activeOperations = 0; + bool m_reportedInterrupt = false; }; struct ScriptRuntime::Private { QThread thread; - ScriptWorker *worker; + ScriptWorker *worker = nullptr; std::atomic queued = 0; + + template + QCoro::Task submit(QString sourceId, QString method, QVariantMap arguments, QString scope, Decoder decoder) + { + auto promise = std::make_shared>(); + promise->start(); + auto future = promise->future(); + if (queued.fetch_add(1) >= 64) { + --queued; + fail(promise, "queue_limit"); + return awaitResult(std::move(future)); + } + auto sink = std::make_shared>(promise, std::move(decoder)); + QMetaObject::invokeMethod( + worker, + [this, sourceId = std::move(sourceId), method = std::move(method), arguments = std::move(arguments), + scope = std::move(scope), sink] { + --queued; + worker->call(sourceId, method, arguments, scope, sink); + }, + Qt::QueuedConnection); + return awaitResult(std::move(future)); + } }; -ScriptRuntime::ScriptRuntime(QString entryPoint, QObject *parent) +ScriptRuntime::ScriptRuntime(QString entryPoint, QVariantMap device, NetworkHooks hooks, QObject *parent) : QObject(parent) , d(std::make_unique()) { - d->worker = new ScriptWorker(std::move(entryPoint)); + d->worker = new ScriptWorker( + std::move(entryPoint), std::move(device), std::move(hooks), + [this](const QString& sourceId, const QString& type, const QVariantMap& payload) { + QMetaObject::invokeMethod( + this, [this, sourceId, type, payload] { emit event(sourceId, type, payload); }, Qt::QueuedConnection); + }, + [this] { QMetaObject::invokeMethod(this, [this] { emit interrupted(); }, Qt::QueuedConnection); }); d->worker->moveToThread(&d->thread); connect(&d->thread, &QThread::finished, d->worker, &QObject::deleteLater); d->thread.setObjectName(QStringLiteral("provider-js")); @@ -585,78 +352,35 @@ ScriptRuntime::~ScriptRuntime() QCoro::Task ScriptRuntime::addSource(QString sourceId, QVariantMap configuration, QList origins) { - auto completion = std::make_shared>(); - completion->start(); - auto future = completion->future(); - if (d->queued.fetch_add(1) >= 64) { - --d->queued; - fail(completion, "queue_limit"); - return awaitResult(std::move(future)); - } + auto promise = std::make_shared>(); + promise->start(); + auto future = promise->future(); QMetaObject::invokeMethod( d->worker, - [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), configuration = std::move(configuration), - origins = std::move(origins), completion]() mutable { - --state->queued; - worker->add(std::move(sourceId), std::move(configuration), std::move(origins), completion); - }, + [worker = d->worker, sourceId = std::move(sourceId), configuration = std::move(configuration), + origins = std::move(origins), promise] { worker->add(sourceId, configuration, origins, promise); }, Qt::QueuedConnection); return awaitResult(std::move(future)); } QCoro::Task ScriptRuntime::call(QString sourceId, QString method, QVariantMap arguments, QString scope) { - auto completion = std::make_shared>(); - completion->start(); - auto future = completion->future(); - if (d->queued.fetch_add(1) >= 64) { - --d->queued; - fail(completion, "queue_limit"); - return awaitResult(std::move(future)); - } - const auto sink = resultSink(completion, [](const QJSValue& value) { - int nodes = 0; - qsizetype bytes = 0; - return ownValue(value, nodes, bytes).toMap(); - }); - QMetaObject::invokeMethod( - d->worker, - [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), - arguments = std::move(arguments), scope = std::move(scope), sink] { - --state->queued; - worker->call(sourceId, method, arguments, scope, sink); - }, - Qt::QueuedConnection); - return awaitResult(std::move(future)); + return d->submit(std::move(sourceId), std::move(method), std::move(arguments), std::move(scope), + [](const QJSValue& value) { return ownScriptValue(value).toMap(); }); } QCoro::Task ScriptRuntime::callMediaPage( QString sourceId, QString method, QVariantMap arguments, QString scope, int maximumItems) { - auto completion = std::make_shared>(); - completion->start(); - auto future = completion->future(); - if (maximumItems < 1 || maximumItems > 1000) { - fail(completion, "invalid_page_limit"); - return awaitResult(std::move(future)); - } - if (d->queued.fetch_add(1) >= 64) { - --d->queued; - fail(completion, "queue_limit"); - return awaitResult(std::move(future)); - } - const auto sink = resultSink(completion, [sourceId, maximumItems](const QJSValue& value) { - return Detail::readProviderMediaPage(value, sourceId, maximumItems); - }); - QMetaObject::invokeMethod( - d->worker, - [state = d.get(), worker = d->worker, sourceId = std::move(sourceId), method = std::move(method), - arguments = std::move(arguments), scope = std::move(scope), sink] { - --state->queued; - worker->call(sourceId, method, arguments, scope, sink); - }, - Qt::QueuedConnection); - return awaitResult(std::move(future)); + maximumItems = std::clamp(maximumItems, 1, 1000); + return d->submit(std::move(sourceId), std::move(method), std::move(arguments), std::move(scope), + [maximumItems](const QJSValue& value) { return Detail::readProviderMediaPage(value, maximumItems); }); +} + +QCoro::Task ScriptRuntime::callItem(QString sourceId, QString method, QVariantMap arguments) +{ + return d->submit(std::move(sourceId), std::move(method), std::move(arguments), QString(), + [](const QJSValue& value) { return Detail::readProviderItem(value.property(QStringLiteral("item"))); }); } void ScriptRuntime::cancelScope(const QString& sourceId, const QString& scope) @@ -673,5 +397,3 @@ void ScriptRuntime::removeSource(const QString& sourceId) } } // namespace JellyfinNative - -#include "ScriptRuntime.moc" diff --git a/src/provider/ScriptRuntime.h b/src/provider/ScriptRuntime.h index 3e0c9ffe..704c0163 100644 --- a/src/provider/ScriptRuntime.h +++ b/src/provider/ScriptRuntime.h @@ -6,27 +6,48 @@ #include #include #include + +#include #include +class QNetworkAccessManager; +class QWebSocket; + namespace JellyfinNative { -// Experimental provider API 0.1. JS values never leave the worker. The host -// chooses persistent source IDs and authorised origins, not provider code. +// One provider module on its own worker thread and QJSEngine (API 0.2). JS +// values never leave the worker; results are converted to native values +// there. The host picks source IDs and the origins each source may reach. class ScriptRuntime final : public QObject { Q_OBJECT public: - explicit ScriptRuntime(QString entryPoint, QObject *parent = nullptr); + // Runs on the worker thread for its network objects, so TLS trust + // decisions the viewer made apply to provider traffic too. + struct NetworkHooks { + std::function network; + std::function socket; + }; + + ScriptRuntime(QString entryPoint, QVariantMap device, NetworkHooks hooks = {}, QObject *parent = nullptr); ~ScriptRuntime() override; + // An origin of "*" lets the source reach any HTTP(S) origin. QCoro::Task addSource(QString sourceId, QVariantMap configuration, QList origins); QCoro::Task call(QString sourceId, QString method, QVariantMap arguments = {}, QString scope = {}); - // Bulk listing path: decode directly on the worker into typed native - // items. No QVariant tree or row conversion is handed to the GUI thread. - QCoro::Task callMediaPage(QString sourceId, QString method, QVariantMap arguments = {}, - QString scope = {}, int maximumItems = 100); + // Listing path: decoded on the worker straight into native items. + QCoro::Task callMediaPage( + QString sourceId, QString method, QVariantMap arguments = {}, QString scope = {}, int maximumItems = 100); + // Details path: `{item: {...}}` decoded on the worker. + QCoro::Task callItem(QString sourceId, QString method, QVariantMap arguments = {}); void cancelScope(const QString& sourceId, const QString& scope); void removeSource(const QString& sourceId); +signals: + // host.emit(type, payload) from a source, delivered on the owner's thread. + void event(const QString& sourceId, const QString& type, const QVariantMap& payload); + // The module overran its execution budget and every source is gone. + void interrupted(); + private: struct Private; std::unique_ptr d; diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp new file mode 100644 index 00000000..ef1c0c19 --- /dev/null +++ b/src/provider/SourceHub.cpp @@ -0,0 +1,597 @@ +#include "SourceHub.h" + +#include "../common/AsyncTask.h" +#include "PortableProvider.h" +#include "ProviderRegistry.h" + +#include + +#include + +namespace JellyfinNative { + +namespace { + constexpr qsizetype kPrefix = 8; + + QString prefixOf(const QString& accountId) + { + return QString(accountId).remove(QLatin1Char('-')).left(kPrefix); + } + + // Round-robin, so every account is represented near the top of a row. + std::vector interleave(std::vector> lists, int limit) + { + std::vector merged; + for (size_t row = 0; merged.size() < size_t(limit); ++row) { + bool any = false; + for (auto& list : lists) { + if (row < list.size() && merged.size() < size_t(limit)) { + merged.push_back(std::move(list[row])); + any = true; + } + } + if (!any) + break; + } + return merged; + } +} // namespace + +class SourceHub::Playback final : public PlaybackSource { +public: + explicit Playback(SourceHub *hub) + : PlaybackSource(hub) + , m_hub(hub) + { + } + + PlaybackSource *active() const + { + Provider *provider = m_hub->source(m_account); + return provider ? provider->playback() : nullptr; + } + QByteArray mediaRequestHeaders() const override + { + return active() ? active()->mediaRequestHeaders() : QByteArray(); + } + QUrl mediaOrigin() const override + { + return active() ? active()->mediaOrigin() : QUrl(); + } + int playbackParallelRequests() const override + { + return active() ? active()->playbackParallelRequests() : 2; + } + bool signedIn() const override + { + return m_hub->signedIn(); + } + QString trickplayTileUrl(const QString& itemId, int width, int tileIndex) const override + { + PlaybackSource *playback = sourceFor(itemId); + return playback ? playback->trickplayTileUrl(rawId(itemId), width, tileIndex) : QString(); + } + + QCoro::Task resolvePlayback(MovieItem item, bool forceTranscode) override + { + const QString account = m_hub->accountOf(item.id); + PlaybackSource *playback = sourceFor(item.id); + if (!playback) + throw std::runtime_error("source_unavailable"); + if (account != m_account) { + if (PlaybackSource *previous = active()) + disconnect(previous, nullptr, this, nullptr); + m_account = account; + connect(playback, &PlaybackSource::credentialsChanged, this, &PlaybackSource::credentialsChanged); + connect(playback, &PlaybackSource::playbackNetworkProfileChanged, this, + &PlaybackSource::playbackNetworkProfileChanged); + } + item.id = rawId(item.id); + item.seriesId = rawId(item.seriesId); + item.seasonId = rawId(item.seasonId); + item.albumId = rawId(item.albumId); + PlaybackSession session = co_await playback->resolvePlayback(std::move(item), forceTranscode); + session.itemId = m_hub->scoped(account, session.itemId); + for (PlaybackQueueItem& entry : session.nowPlayingQueue) + entry.itemId = m_hub->scoped(account, entry.itemId); + co_return session; + } + QCoro::Task> fetchMediaSegments(QString itemId) override + { + PlaybackSource *playback = sourceFor(itemId); + if (!playback) + co_return {}; + co_return co_await playback->fetchMediaSegments(rawId(itemId)); + } + QCoro::Task> fetchSeriesEpisodes(QString seriesId) override + { + return m_hub->fetchEpisodes(std::move(seriesId)); + } + QCoro::Task reportPlaybackStart(PlaybackSession session, double rate, int volume, bool muted) override + { + PlaybackSource *playback = sourceFor(session.itemId); + if (!playback) + co_return; + session.itemId = rawId(session.itemId); + co_await playback->reportPlaybackStart(std::move(session), rate, volume, muted); + } + QCoro::Task reportPlaybackProgress( + PlaybackSession session, qint64 positionTicks, bool paused, double rate, int volume, bool muted) override + { + PlaybackSource *playback = sourceFor(session.itemId); + if (!playback) + co_return; + session.itemId = rawId(session.itemId); + co_await playback->reportPlaybackProgress(std::move(session), positionTicks, paused, rate, volume, muted); + } + QCoro::Task reportPlaybackStopped( + PlaybackSession session, qint64 positionTicks, bool failed, double rate) override + { + PlaybackSource *playback = sourceFor(session.itemId); + if (!playback) + co_return; + session.itemId = rawId(session.itemId); + co_await playback->reportPlaybackStopped(std::move(session), positionTicks, failed, rate); + } + +private: + PlaybackSource *sourceFor(const QString& scopedId) const + { + Provider *provider = m_hub->owner(scopedId); + return provider ? provider->playback() : nullptr; + } + + SourceHub *m_hub; + QString m_account; +}; + +SourceHub::SourceHub(ProviderRegistry *registry, QObject *parent) + : Provider(parent) + , m_registry(registry) + , m_playback(new Playback(this)) +{ + // Accounts start in parallel at launch; the first home load waits for + // the burst to settle rather than rebuilding once per account. + m_settled.setSingleShot(true); + m_settled.setInterval(150); + connect(&m_settled, &QTimer::timeout, this, [this] { + if (!m_announced) { + m_announced = true; + emit sessionStarted(); + } else { + emit contentChanged({}); + } + }); + connect(registry, &ProviderRegistry::sourceStarted, this, &SourceHub::addSource); + connect(registry, &ProviderRegistry::sourceStopped, this, &SourceHub::removeSource); + connect(registry, &ProviderRegistry::restoredChanged, this, [this] { + // Nothing to wait for: announce the empty state so the shell moves on. + if (m_registry->restored() + && std::none_of(m_registry->accountList().begin(), m_registry->accountList().end(), + [](const ProviderAccount& account) { return account.enabled; })) + m_settled.start(0); + }); +} + +SourceHub::~SourceHub() = default; + +QString SourceHub::displayName() const +{ + return QStringLiteral("Spool"); +} + +PlaybackSource *SourceHub::playback() +{ + return m_playback; +} + +bool SourceHub::ready() const +{ + return m_registry->restored(); +} + +void SourceHub::addSource(Provider *provider) +{ + const QString accountId = provider->id(); + m_entries.insert(prefixOf(accountId), { accountId, provider }); + connect(provider, &Provider::contentChanged, this, [this, accountId](const QString& itemId) { + emit contentChanged(itemId.isEmpty() ? QString() : scoped(accountId, itemId)); + }); + connect(provider, &Provider::sourceEvent, this, [this, accountId](const QString& type, const QVariantMap& payload) { + emit accountEvent(accountId, type, payload); + }); + connect(provider, &Provider::errorOccurred, this, &Provider::errorOccurred); + connect(provider, &Provider::toastRequested, this, &Provider::toastRequested); + pushPlaybackContext(); + refresh(); +} + +void SourceHub::removeSource(const QString& accountId) +{ + m_entries.remove(prefixOf(accountId)); + refresh(); +} + +void SourceHub::refresh() +{ + Capabilities capabilities; + for (const Entry& entry : std::as_const(m_entries)) { + if (entry.provider) + capabilities |= entry.provider->capabilities(); + } + if (capabilities != m_capabilities) { + m_capabilities = capabilities; + emit capabilitiesChanged(); + } + m_settled.start(); +} + +QString SourceHub::scoped(const QString& accountId, const QString& rawId) const +{ + return rawId.isEmpty() ? QString() : prefixOf(accountId) + QLatin1Char(':') + rawId; +} + +QString SourceHub::accountOf(const QString& scopedId) const +{ + if (scopedId.size() <= kPrefix || scopedId.at(kPrefix) != QLatin1Char(':')) + return {}; + return m_entries.value(scopedId.left(kPrefix)).accountId; +} + +QString SourceHub::rawId(const QString& scopedId) +{ + return scopedId.size() > kPrefix && scopedId.at(kPrefix) == QLatin1Char(':') ? scopedId.mid(kPrefix + 1) : scopedId; +} + +Provider *SourceHub::source(const QString& accountId) const +{ + return m_entries.value(prefixOf(accountId)).provider; +} + +Provider *SourceHub::owner(const QString& scopedId) const +{ + if (scopedId.size() <= kPrefix || scopedId.at(kPrefix) != QLatin1Char(':')) + return nullptr; + return m_entries.value(scopedId.left(kPrefix)).provider; +} + +std::vector SourceHub::sources() const +{ + std::vector list; + for (const Entry& entry : std::as_const(m_entries)) { + if (entry.provider) + list.push_back(entry.provider); + } + std::sort(list.begin(), list.end(), [](Provider *a, Provider *b) { return a->id() < b->id(); }); + return list; +} + +QCoro::Task SourceHub::call(QString accountId, QString operation, QVariantMap arguments) +{ + auto *portable = qobject_cast(source(accountId)); + if (!portable) + throw std::runtime_error("source_unavailable"); + co_return co_await portable->call(std::move(operation), std::move(arguments)); +} + +void SourceHub::setVideoCodecs(QStringList codecs, bool restrict) +{ + m_videoCodecs = std::move(codecs); + m_restrictVideoCodecs = restrict; + pushPlaybackContext(); +} + +QVariantList SourceHub::itemActions(const QString& itemId, const QString& itemType) const +{ + const QString account = accountOf(itemId); + const auto& accounts = m_registry->accountList(); + const auto owner = std::find_if(accounts.begin(), accounts.end(), [&](const auto& a) { return a.id == account; }); + const ProviderModule *module = owner == accounts.end() ? nullptr : m_registry->module(owner->module); + QVariantList actions; + if (!module) + return actions; + for (const QVariant& value : module->manifest.actions) { + const QVariantMap action = value.toMap(); + const QStringList types = action.value(QStringLiteral("types")).toStringList(); + if (types.isEmpty() || types.contains(itemType)) + actions.append(action); + } + return actions; +} + +void SourceHub::runItemAction(const QString& actionId, const QString& itemId, const QString& itemType) +{ + const QString account = accountOf(itemId); + const auto run = [](SourceHub *self, QString account, QVariantMap args) -> QCoro::Task { + QPointer guard(self); + QVariantMap result = co_await self->call(account, QStringLiteral("runItemAction"), args); + if (guard && result.contains(QStringLiteral("pick"))) { + const QVariantMap choice + = co_await self->m_registry->pick(account, result.value(QStringLiteral("pick")).toMap()); + if (!guard || choice.isEmpty()) + co_return; + args.insert(choice); + result = co_await self->call(account, QStringLiteral("runItemAction"), args); + } + if (!guard) + co_return; + if (result.value(QStringLiteral("changed")).toBool()) + emit self->contentChanged(self->scoped(account, result.value(QStringLiteral("itemId")).toString())); + if (const QString message = result.value(QStringLiteral("message")).toString(); !message.isEmpty()) + emit self->toastRequested(message); + }; + Async::runScoped( + this, + run(this, account, + { { QStringLiteral("action"), actionId }, { QStringLiteral("itemId"), rawId(itemId) }, + { QStringLiteral("itemType"), itemType } }), + [] {}, [this](const std::exception_ptr&) { emit toastRequested(QStringLiteral("That didn't work")); }, + "item action"); +} + +void SourceHub::setPlaybackPreferences( + qint64 manualMaxBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxHeight) +{ + m_preferences = { { QStringLiteral("preferredMaxBitrate"), manualMaxBitrate }, + { QStringLiteral("unlimitedLocalNetwork"), unlimitedLocalNetwork }, + { QStringLiteral("preferRemux"), preferRemux }, { QStringLiteral("preferredMaxHeight"), maxHeight } }; + pushPlaybackContext(); +} + +void SourceHub::setOverride(qint64 bitrate, int height) +{ + m_bitrate = bitrate; + m_height = height; + pushPlaybackContext(); +} + +void SourceHub::pushPlaybackContext() +{ + // The viewer's pick in the player wins over the standing preference. + QVariantMap context = m_preferences; + context.insert(QStringLiteral("maxBitrate"), m_bitrate); + context.insert(QStringLiteral("maxHeight"), m_height); + context.insert(QStringLiteral("videoCodecs"), m_videoCodecs); + context.insert(QStringLiteral("restrictVideoCodecs"), m_restrictVideoCodecs); + for (const Entry& entry : std::as_const(m_entries)) { + if (auto *portable = qobject_cast(entry.provider.data())) + portable->setPlaybackContext(context); + } +} + +MovieItem SourceHub::scopedItem(MovieItem item, const QString& accountId) const +{ + item.id = scoped(accountId, item.id); + item.seriesId = scoped(accountId, item.seriesId); + item.seasonId = scoped(accountId, item.seasonId); + item.albumId = scoped(accountId, item.albumId); + for (PersonItem& person : item.people) + person.id = scoped(accountId, person.id); + return item; +} + +std::vector SourceHub::scopedItems(std::vector items, const QString& accountId) const +{ + for (MovieItem& item : items) + item = scopedItem(std::move(item), accountId); + return items; +} + +template QCoro::Task> SourceHub::gather(Fetch fetch, int limit, bool searchOnly) +{ + // Tasks start eagerly, so every account is asked before any is awaited. + std::vector>>> pending; + for (Provider *provider : sources()) { + if (searchOnly && !provider->search()) + continue; + pending.emplace_back(provider->id(), fetch(provider)); + } + std::vector> lists; + for (auto& [accountId, task] : pending) { + try { + lists.push_back(scopedItems(co_await std::move(task), accountId)); + } catch (const std::exception& error) { + qWarning() << "hub:" << accountId.left(kPrefix) << "left out of a merged list:" << error.what(); + } + } + co_return interleave(std::move(lists), limit); +} + +bool SourceHub::signedIn() const +{ + return !m_entries.isEmpty(); +} + +QString SourceHub::libraryScopeKey() const +{ + QStringList keys = m_entries.keys(); + keys.sort(); + return keys.join(QLatin1Char('+')); +} + +QCoro::Task SourceHub::fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex, int limit, QVariantMap queryOptions) +{ + // A genre or studio link has no ID of its own; it belongs to the account + // whose item it was opened from. + QString account = accountOf(descriptor.id.isEmpty() ? descriptor.seriesId : descriptor.id); + if (account.isEmpty()) + account = m_lastDetailsAccount; + Provider *provider = source(account); + if (!provider) + co_return PagedMovieItems { {}, 0, startIndex, limit }; + descriptor.id = rawId(descriptor.id); + descriptor.seriesId = rawId(descriptor.seriesId); + descriptor.seasonId = rawId(descriptor.seasonId); + PagedMovieItems page = co_await provider->catalog()->fetchBrowsePage(descriptor, startIndex, limit, queryOptions); + page.items = scopedItems(std::move(page.items), account); + co_return page; +} + +QCoro::Task SourceHub::fetchItemDetails(QString itemId) +{ + const QString account = accountOf(itemId); + Provider *provider = source(account); + if (!provider) + throw std::runtime_error("source_unavailable"); + m_lastDetailsAccount = account; + co_return scopedItem(co_await provider->catalog()->fetchItemDetails(rawId(itemId)), account); +} + +QCoro::Task> SourceHub::fetchSeasons(QString seriesId) +{ + const QString account = accountOf(seriesId); + Provider *provider = source(account); + if (!provider) + co_return {}; + co_return scopedItems(co_await provider->catalog()->fetchSeasons(rawId(seriesId)), account); +} + +QCoro::Task> SourceHub::fetchEpisodes(QString seriesId, QString seasonId) +{ + const QString account = accountOf(seriesId); + Provider *provider = source(account); + if (!provider) + co_return {}; + co_return scopedItems(co_await provider->catalog()->fetchEpisodes(rawId(seriesId), rawId(seasonId)), account); +} + +QCoro::Task> SourceHub::fetchResumeItems(int limit) +{ + return gather([limit](Provider *p) { return p->catalog()->fetchResumeItems(limit); }, limit); +} + +QCoro::Task> SourceHub::fetchNextUpEpisodes(int limit) +{ + return gather([limit](Provider *p) { return p->catalog()->fetchNextUpEpisodes(limit); }, limit); +} + +QCoro::Task> SourceHub::fetchLatestItems(QString parentId, int limit) +{ + if (parentId.isEmpty()) + co_return co_await gather([limit](Provider *p) { return p->catalog()->fetchLatestItems({}, limit); }, limit); + const QString account = accountOf(parentId); + Provider *provider = source(account); + if (!provider) + co_return {}; + co_return scopedItems(co_await provider->catalog()->fetchLatestItems(rawId(parentId), limit), account); +} + +QCoro::Task> SourceHub::fetchSimilarItems(QString itemId, int limit) +{ + const QString account = accountOf(itemId); + Provider *provider = source(account); + if (!provider) + co_return {}; + co_return scopedItems(co_await provider->catalog()->fetchSimilarItems(rawId(itemId), limit), account); +} + +QCoro::Task SourceHub::fetchItemsByPerson(QString personId, int maximumItems) +{ + const QString account = accountOf(personId); + Provider *provider = source(account); + if (!provider) + co_return PersonCredits {}; + PersonCredits credits = co_await provider->catalog()->fetchItemsByPerson(rawId(personId), maximumItems); + credits.items = scopedItems(std::move(credits.items), account); + credits.relatedSeries = scopedItems(std::move(credits.relatedSeries), account); + co_return credits; +} + +QCoro::Task> SourceHub::fetchLibraries() +{ + std::vector>>> pending; + for (Provider *provider : sources()) + pending.emplace_back(provider, provider->catalog()->fetchLibraries()); + std::vector libraries; + QHash names; + for (auto& [provider, task] : pending) { + try { + for (LibraryItem library : co_await std::move(task)) { + library.id = scoped(provider->id(), library.id); + names[library.name.toCaseFolded()] += 1; + libraries.push_back(std::move(library)); + } + } catch (const std::exception& error) { + qWarning() << "hub: libraries unavailable from" << provider->displayName() << error.what(); + } + } + // Two "Movies" rows from two servers read as one; say whose each is. + for (LibraryItem& library : libraries) { + if (names.value(library.name.toCaseFolded()) > 1) { + if (Provider *provider = owner(library.id)) + library.name += QStringLiteral(" · ") + provider->displayName(); + } + } + co_return libraries; +} + +QCoro::Task SourceHub::fetchLibraryFilterOptions(QString libraryId, QString collectionType) +{ + Provider *provider = owner(libraryId); + if (!provider) + co_return QVariantMap {}; + co_return co_await provider->catalog()->fetchLibraryFilterOptions(rawId(libraryId), collectionType); +} + +QCoro::Task> SourceHub::fetchItemsByIds(QStringList itemIds) +{ + QHash byAccount; + for (const QString& id : std::as_const(itemIds)) + byAccount[accountOf(id)].append(rawId(id)); + QHash found; + for (auto it = byAccount.cbegin(); it != byAccount.cend(); ++it) { + Provider *provider = source(it.key()); + if (!provider) + continue; + for (MovieItem& item : scopedItems(co_await provider->catalog()->fetchItemsByIds(it.value()), it.key())) + found.insert(item.id, std::move(item)); + } + std::vector ordered; + for (const QString& id : std::as_const(itemIds)) { + if (found.contains(id)) + ordered.push_back(found.value(id)); + } + co_return ordered; +} + +QCoro::Task> SourceHub::searchItems(QString searchTerm, int limit) +{ + return gather( + [searchTerm, limit](Provider *p) { return p->search()->searchItems(searchTerm, limit); }, limit, true); +} + +QCoro::Task> SourceHub::fetchSearchSuggestions(int limit) +{ + return gather([limit](Provider *p) { return p->search()->fetchSearchSuggestions(limit); }, limit, true); +} + +QCoro::Task SourceHub::setItemFavorite(QString itemId, bool favorite) +{ + if (Provider *provider = owner(itemId); provider && provider->itemState()) + co_await provider->itemState()->setItemFavorite(rawId(itemId), favorite); +} + +QCoro::Task SourceHub::setItemPlayed(QString itemId, bool played) +{ + if (Provider *provider = owner(itemId); provider && provider->itemState()) + co_await provider->itemState()->setItemPlayed(rawId(itemId), played); +} + +QCoro::Task SourceHub::setItemPlaybackPosition(QString itemId, qint64 positionTicks) +{ + if (Provider *provider = owner(itemId); provider && provider->itemState()) + co_await provider->itemState()->setItemPlaybackPosition(rawId(itemId), positionTicks); +} + +QString SourceHub::imageUrl(const ImageRequest& request) const +{ + Provider *provider = owner(request.itemId); + if (!provider) + return {}; + ImageRequest local = request; + local.itemId = rawId(request.itemId); + return provider->artwork()->imageUrl(local); +} + +} // namespace JellyfinNative diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h new file mode 100644 index 00000000..1d04e22f --- /dev/null +++ b/src/provider/SourceHub.h @@ -0,0 +1,172 @@ +#pragma once + +#include "ArtworkSource.h" +#include "Catalog.h" +#include "PlaybackSource.h" +#include "Provider.h" +#include "SearchSource.h" +#include "StreamQualityControl.h" +#include "UserItemStateSink.h" + +#include +#include +#include + +#include + +namespace JellyfinNative { + +class ProviderRegistry; + +// The one Provider the app sees: every enabled account behind it at once. +// +// IDs are scoped here and nowhere else. Every ID a source hands out leaves +// as "<8 hex of its account>:", so routes, caches, the +// queue and QML keep treating IDs as opaque strings and a call carrying one +// finds its way back to the account it came from. Lists that have no single +// owner (libraries, home rows, search) are asked of every account in +// parallel and merged; one slow or failing account never empties the rest. +class SourceHub final : public Provider, + public Catalog, + public SearchSource, + public UserItemStateSink, + public ArtworkSource, + public StreamQualityControl { + Q_OBJECT + +public: + explicit SourceHub(ProviderRegistry *registry, QObject *parent = nullptr); + ~SourceHub() override; + + QString id() const override + { + return QStringLiteral("hub"); + } + QString displayName() const override; + Capabilities capabilities() const override + { + return m_capabilities; + } + PlaybackSource *playback() override; + Catalog *catalog() override + { + return this; + } + ArtworkSource *artwork() override + { + return this; + } + SearchSource *search() override + { + return this; + } + UserItemStateSink *itemState() override + { + return this; + } + StreamQualityControl *streamQuality() override + { + return this; + } + bool ready() const override; + + // Scoping. `accountOf` answers for any scoped ID, empty when unscoped. + QString scoped(const QString& accountId, const QString& rawId) const; + QString accountOf(const QString& scopedId) const; + static QString rawId(const QString& scopedId); + Provider *source(const QString& accountId) const; + std::vector sources() const; + // Calls an operation on the account behind a scoped or account ID. + QCoro::Task call(QString accountId, QString operation, QVariantMap arguments = {}); + void setVideoCodecs(QStringList codecs, bool restrict); + + // Item menu entries the owning provider declared for this kind of item. + Q_INVOKABLE QVariantList itemActions(const QString& itemId, const QString& itemType) const; + // Runs one; the provider may answer with a message, a change, or its own + // picker (a playlist to add to) before finishing. + Q_INVOKABLE void runItemAction(const QString& actionId, const QString& itemId, const QString& itemType); + // The viewer's standing streaming limits from settings. + void setPlaybackPreferences(qint64 manualMaxBitrate, bool unlimitedLocalNetwork, bool preferRemux, int maxHeight); + + // Catalog + bool signedIn() const override; + QString libraryScopeKey() const override; + QCoro::Task fetchBrowsePage( + BrowseDescriptor descriptor, int startIndex = 0, int limit = 72, QVariantMap queryOptions = {}) override; + QCoro::Task fetchItemDetails(QString itemId) override; + QCoro::Task> fetchSeasons(QString seriesId) override; + QCoro::Task> fetchEpisodes(QString seriesId, QString seasonId = {}) override; + QCoro::Task> fetchResumeItems(int limit = 24) override; + QCoro::Task> fetchNextUpEpisodes(int limit = 24) override; + QCoro::Task> fetchLatestItems(QString parentId = {}, int limit = 24) override; + QCoro::Task> fetchSimilarItems(QString itemId, int limit = 24) override; + QCoro::Task fetchItemsByPerson(QString personId, int maximumItems = 4000) override; + QCoro::Task> fetchLibraries() override; + QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; + QCoro::Task> fetchItemsByIds(QStringList itemIds) override; + + // SearchSource: every searchable account at once. + QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + + // UserItemStateSink + QCoro::Task setItemFavorite(QString itemId, bool favorite) override; + QCoro::Task setItemPlayed(QString itemId, bool played) override; + QCoro::Task setItemPlaybackPosition(QString itemId, qint64 positionTicks) override; + + QString imageUrl(const ImageRequest& request) const override; + + // StreamQualityControl + qint64 bitrateOverride() const override + { + return m_bitrate; + } + int heightOverride() const override + { + return m_height; + } + void setOverride(qint64 bitrate, int height) override; + QString autoDescription() const override + { + return QStringLiteral("Original quality"); + } + std::vector ladder(qint64 sourceBitrate, int sourceHeight = 0) const override + { + return defaultLadder(sourceBitrate, sourceHeight); + } + +signals: + void accountEvent(const QString& accountId, const QString& type, const QVariantMap& payload); + +private: + class Playback; + struct Entry { + QString accountId; + QPointer provider; + }; + + void addSource(Provider *provider); + void removeSource(const QString& accountId); + void refresh(); + void pushPlaybackContext(); + Provider *owner(const QString& scopedId) const; + MovieItem scopedItem(MovieItem item, const QString& accountId) const; + std::vector scopedItems(std::vector items, const QString& accountId) const; + template + QCoro::Task> gather(Fetch fetch, int limit, bool searchOnly = false); + + ProviderRegistry *m_registry; + QHash m_entries; // by prefix + Capabilities m_capabilities; + Playback *m_playback = nullptr; + QTimer m_settled; + bool m_announced = false; + qint64 m_bitrate = 0; + int m_height = 0; + QStringList m_videoCodecs; + bool m_restrictVideoCodecs = false; + QVariantMap m_preferences; + QString m_lastDetailsAccount; +}; + +} // namespace JellyfinNative diff --git a/src/provider/puff.c b/src/provider/puff.c deleted file mode 100644 index 4a69939a..00000000 --- a/src/provider/puff.c +++ /dev/null @@ -1,827 +0,0 @@ -/* - * puff.c - * Copyright (C) 2002-2013 Mark Adler - * For conditions of distribution and use, see copyright notice in puff.h - * version 2.3, 21 Jan 2013 - * - * puff.c is a simple inflate written to be an unambiguous way to specify the - * deflate format. It is not written for speed but rather simplicity. As a - * side benefit, this code might actually be useful when small code is more - * important than speed, such as bootstrap applications. For typical deflate - * data, zlib's inflate() is about four times as fast as puff(). zlib's - * inflate compiles to around 20K on my machine, whereas puff.c compiles to - * around 4K on my machine (a PowerPC using GNU cc). If the faster decode() - * function here is used, then puff() is only twice as slow as zlib's - * inflate(). - * - * All dynamically allocated memory comes from the stack. The stack required - * is less than 2K bytes. This code is compatible with 16-bit int's and - * assumes that long's are at least 32 bits. puff.c uses the short data type, - * assumed to be 16 bits, for arrays in order to conserve memory. The code - * works whether integers are stored big endian or little endian. - * - * In the comments below are "Format notes" that describe the inflate process - * and document some of the less obvious aspects of the format. This source - * code is meant to supplement RFC 1951, which formally describes the deflate - * format: - * - * http://www.zlib.org/rfc-deflate.html - */ - -/* - * Change history: - * - * 1.0 10 Feb 2002 - First version - * 1.1 17 Feb 2002 - Clarifications of some comments and notes - * - Update puff() dest and source pointers on negative - * errors to facilitate debugging deflators - * - Remove longest from struct huffman -- not needed - * - Simplify offs[] index in construct() - * - Add input size and checking, using longjmp() to - * maintain easy readability - * - Use short data type for large arrays - * - Use pointers instead of long to specify source and - * destination sizes to avoid arbitrary 4 GB limits - * 1.2 17 Mar 2002 - Add faster version of decode(), doubles speed (!), - * but leave simple version for readability - * - Make sure invalid distances detected if pointers - * are 16 bits - * - Fix fixed codes table error - * - Provide a scanning mode for determining size of - * uncompressed data - * 1.3 20 Mar 2002 - Go back to lengths for puff() parameters [Gailly] - * - Add a puff.h file for the interface - * - Add braces in puff() for else do [Gailly] - * - Use indexes instead of pointers for readability - * 1.4 31 Mar 2002 - Simplify construct() code set check - * - Fix some comments - * - Add FIXLCODES #define - * 1.5 6 Apr 2002 - Minor comment fixes - * 1.6 7 Aug 2002 - Minor format changes - * 1.7 3 Mar 2003 - Added test code for distribution - * - Added zlib-like license - * 1.8 9 Jan 2004 - Added some comments on no distance codes case - * 1.9 21 Feb 2008 - Fix bug on 16-bit integer architectures [Pohland] - * - Catch missing end-of-block symbol error - * 2.0 25 Jul 2008 - Add #define to permit distance too far back - * - Add option in TEST code for puff to write the data - * - Add option in TEST code to skip input bytes - * - Allow TEST code to read from piped stdin - * 2.1 4 Apr 2010 - Avoid variable initialization for happier compilers - * - Avoid unsigned comparisons for even happier compilers - * 2.2 25 Apr 2010 - Fix bug in variable initializations [Oberhumer] - * - Add const where appropriate [Oberhumer] - * - Split if's and ?'s for coverage testing - * - Break out test code to separate file - * - Move NIL to puff.h - * - Allow incomplete code only if single code length is 1 - * - Add full code coverage test to Makefile - * 2.3 21 Jan 2013 - Check for invalid code length codes in dynamic blocks - */ - -#include "puff.h" /* prototype for puff() */ -#include /* for setjmp(), longjmp(), and jmp_buf */ - -#define local static /* for local function definitions */ - -/* - * Maximums for allocations and loops. It is not useful to change these -- - * they are fixed by the deflate format. - */ -#define MAXBITS 15 /* maximum bits in a code */ -#define MAXLCODES 286 /* maximum number of literal/length codes */ -#define MAXDCODES 30 /* maximum number of distance codes */ -#define MAXCODES (MAXLCODES + MAXDCODES) /* maximum codes lengths to read */ -#define FIXLCODES 288 /* number of fixed literal/length codes */ - -/* input and output state */ -struct state { - /* output state */ - unsigned char *out; /* output buffer */ - unsigned long outlen; /* available space at out */ - unsigned long outcnt; /* bytes written to out so far */ - - /* input state */ - const unsigned char *in; /* input buffer */ - unsigned long inlen; /* available input at in */ - unsigned long incnt; /* bytes read so far */ - int bitbuf; /* bit buffer */ - int bitcnt; /* number of bits in bit buffer */ - - /* input limit error return state for bits() and decode() */ - jmp_buf env; -}; - -/* - * Return need bits from the input stream. This always leaves less than - * eight bits in the buffer. bits() works properly for need == 0. - * - * Format notes: - * - * - Bits are stored in bytes from the least significant bit to the most - * significant bit. Therefore bits are dropped from the bottom of the bit - * buffer, using shift right, and new bytes are appended to the top of the - * bit buffer, using shift left. - */ -local int bits(struct state *s, int need) -{ - long val; /* bit accumulator (can use up to 20 bits) */ - - /* load at least need bits into val */ - val = s->bitbuf; - while (s->bitcnt < need) { - if (s->incnt == s->inlen) - longjmp(s->env, 1); /* out of input */ - val |= (long)(s->in[s->incnt++]) << s->bitcnt; /* load eight bits */ - s->bitcnt += 8; - } - - /* drop need bits and update buffer, always zero to seven bits left */ - s->bitbuf = (int)(val >> need); - s->bitcnt -= need; - - /* return need bits, zeroing the bits above that */ - return (int)(val & ((1L << need) - 1)); -} - -/* - * Process a stored block. - * - * Format notes: - * - * - After the two-bit stored block type (00), the stored block length and - * stored bytes are byte-aligned for fast copying. Therefore any leftover - * bits in the byte that has the last bit of the type, as many as seven, are - * discarded. The value of the discarded bits are not defined and should not - * be checked against any expectation. - * - * - The second inverted copy of the stored block length does not have to be - * checked, but it's probably a good idea to do so anyway. - * - * - A stored block can have zero length. This is sometimes used to byte-align - * subsets of the compressed data for random access or partial recovery. - */ -local int stored(struct state *s) -{ - unsigned len; /* length of stored block */ - - /* discard leftover bits from current byte (assumes s->bitcnt < 8) */ - s->bitbuf = 0; - s->bitcnt = 0; - - /* get length and check against its one's complement */ - if (s->incnt + 4 > s->inlen) - return 2; /* not enough input */ - len = s->in[s->incnt++]; - len |= s->in[s->incnt++] << 8; - if (s->in[s->incnt++] != (~len & 0xff) || s->in[s->incnt++] != ((~len >> 8) & 0xff)) - return -2; /* didn't match complement! */ - - /* copy len bytes from in to out */ - if (s->incnt + len > s->inlen) - return 2; /* not enough input */ - if (s->out != NIL) { - if (s->outcnt + len > s->outlen) - return 1; /* not enough output space */ - while (len--) - s->out[s->outcnt++] = s->in[s->incnt++]; - } else { /* just scanning */ - s->outcnt += len; - s->incnt += len; - } - - /* done with a valid stored block */ - return 0; -} - -/* - * Huffman code decoding tables. count[1..MAXBITS] is the number of symbols of - * each length, which for a canonical code are stepped through in order. - * symbol[] are the symbol values in canonical order, where the number of - * entries is the sum of the counts in count[]. The decoding process can be - * seen in the function decode() below. - */ -struct huffman { - short *count; /* number of symbols of each length */ - short *symbol; /* canonically ordered symbols */ -}; - -/* - * Decode a code from the stream s using huffman table h. Return the symbol or - * a negative value if there is an error. If all of the lengths are zero, i.e. - * an empty code, or if the code is incomplete and an invalid code is received, - * then -10 is returned after reading MAXBITS bits. - * - * Format notes: - * - * - The codes as stored in the compressed data are bit-reversed relative to - * a simple integer ordering of codes of the same lengths. Hence below the - * bits are pulled from the compressed data one at a time and used to - * build the code value reversed from what is in the stream in order to - * permit simple integer comparisons for decoding. A table-based decoding - * scheme (as used in zlib) does not need to do this reversal. - * - * - The first code for the shortest length is all zeros. Subsequent codes of - * the same length are simply integer increments of the previous code. When - * moving up a length, a zero bit is appended to the code. For a complete - * code, the last code of the longest length will be all ones. - * - * - Incomplete codes are handled by this decoder, since they are permitted - * in the deflate format. See the format notes for fixed() and dynamic(). - */ -#ifdef SLOW -local int decode(struct state *s, const struct huffman *h) -{ - int len; /* current number of bits in code */ - int code; /* len bits being decoded */ - int first; /* first code of length len */ - int count; /* number of codes of length len */ - int index; /* index of first code of length len in symbol table */ - - code = first = index = 0; - for (len = 1; len <= MAXBITS; len++) { - code |= bits(s, 1); /* get next bit */ - count = h->count[len]; - if (code - count < first) /* if length len, return symbol */ - return h->symbol[index + (code - first)]; - index += count; /* else update for next length */ - first += count; - first <<= 1; - code <<= 1; - } - return -10; /* ran out of codes */ -} - -/* - * A faster version of decode() for real applications of this code. It's not - * as readable, but it makes puff() twice as fast. And it only makes the code - * a few percent larger. - */ -#else /* !SLOW */ -local int decode(struct state *s, const struct huffman *h) -{ - int len; /* current number of bits in code */ - int code; /* len bits being decoded */ - int first; /* first code of length len */ - int count; /* number of codes of length len */ - int index; /* index of first code of length len in symbol table */ - int bitbuf; /* bits from stream */ - int left; /* bits left in next or left to process */ - short *next; /* next number of codes */ - - bitbuf = s->bitbuf; - left = s->bitcnt; - code = first = index = 0; - len = 1; - next = h->count + 1; - while (1) { - while (left--) { - code |= bitbuf & 1; - bitbuf >>= 1; - count = *next++; - if (code - count < first) { /* if length len, return symbol */ - s->bitbuf = bitbuf; - s->bitcnt = (s->bitcnt - len) & 7; - return h->symbol[index + (code - first)]; - } - index += count; /* else update for next length */ - first += count; - first <<= 1; - code <<= 1; - len++; - } - left = (MAXBITS + 1) - len; - if (left == 0) - break; - if (s->incnt == s->inlen) - longjmp(s->env, 1); /* out of input */ - bitbuf = s->in[s->incnt++]; - if (left > 8) - left = 8; - } - return -10; /* ran out of codes */ -} -#endif /* SLOW */ - -/* - * Given the list of code lengths length[0..n-1] representing a canonical - * Huffman code for n symbols, construct the tables required to decode those - * codes. Those tables are the number of codes of each length, and the symbols - * sorted by length, retaining their original order within each length. The - * return value is zero for a complete code set, negative for an over- - * subscribed code set, and positive for an incomplete code set. The tables - * can be used if the return value is zero or positive, but they cannot be used - * if the return value is negative. If the return value is zero, it is not - * possible for decode() using that table to return an error--any stream of - * enough bits will resolve to a symbol. If the return value is positive, then - * it is possible for decode() using that table to return an error for received - * codes past the end of the incomplete lengths. - * - * Not used by decode(), but used for error checking, h->count[0] is the number - * of the n symbols not in the code. So n - h->count[0] is the number of - * codes. This is useful for checking for incomplete codes that have more than - * one symbol, which is an error in a dynamic block. - * - * Assumption: for all i in 0..n-1, 0 <= length[i] <= MAXBITS - * This is assured by the construction of the length arrays in dynamic() and - * fixed() and is not verified by construct(). - * - * Format notes: - * - * - Permitted and expected examples of incomplete codes are one of the fixed - * codes and any code with a single symbol which in deflate is coded as one - * bit instead of zero bits. See the format notes for fixed() and dynamic(). - * - * - Within a given code length, the symbols are kept in ascending order for - * the code bits definition. - */ -local int construct(struct huffman *h, const short *length, int n) -{ - int symbol; /* current symbol when stepping through length[] */ - int len; /* current length when stepping through h->count[] */ - int left; /* number of possible codes left of current length */ - short offs[MAXBITS + 1]; /* offsets in symbol table for each length */ - - /* count number of codes of each length */ - for (len = 0; len <= MAXBITS; len++) - h->count[len] = 0; - for (symbol = 0; symbol < n; symbol++) - (h->count[length[symbol]])++; /* assumes lengths are within bounds */ - if (h->count[0] == n) /* no codes! */ - return 0; /* complete, but decode() will fail */ - - /* check for an over-subscribed or incomplete set of lengths */ - left = 1; /* one possible code of zero length */ - for (len = 1; len <= MAXBITS; len++) { - left <<= 1; /* one more bit, double codes left */ - left -= h->count[len]; /* deduct count from possible codes */ - if (left < 0) - return left; /* over-subscribed--return negative */ - } /* left > 0 means incomplete */ - - /* generate offsets into symbol table for each length for sorting */ - offs[1] = 0; - for (len = 1; len < MAXBITS; len++) - offs[len + 1] = offs[len] + h->count[len]; - - /* - * put symbols in table sorted by length, by symbol order within each - * length - */ - for (symbol = 0; symbol < n; symbol++) - if (length[symbol] != 0) - h->symbol[offs[length[symbol]]++] = symbol; - - /* return zero for complete set, positive for incomplete set */ - return left; -} - -/* - * Decode literal/length and distance codes until an end-of-block code. - * - * Format notes: - * - * - Compressed data that is after the block type if fixed or after the code - * description if dynamic is a combination of literals and length/distance - * pairs terminated by and end-of-block code. Literals are simply Huffman - * coded bytes. A length/distance pair is a coded length followed by a - * coded distance to represent a string that occurs earlier in the - * uncompressed data that occurs again at the current location. - * - * - Literals, lengths, and the end-of-block code are combined into a single - * code of up to 286 symbols. They are 256 literals (0..255), 29 length - * symbols (257..285), and the end-of-block symbol (256). - * - * - There are 256 possible lengths (3..258), and so 29 symbols are not enough - * to represent all of those. Lengths 3..10 and 258 are in fact represented - * by just a length symbol. Lengths 11..257 are represented as a symbol and - * some number of extra bits that are added as an integer to the base length - * of the length symbol. The number of extra bits is determined by the base - * length symbol. These are in the static arrays below, lens[] for the base - * lengths and lext[] for the corresponding number of extra bits. - * - * - The reason that 258 gets its own symbol is that the longest length is used - * often in highly redundant files. Note that 258 can also be coded as the - * base value 227 plus the maximum extra value of 31. While a good deflate - * should never do this, it is not an error, and should be decoded properly. - * - * - If a length is decoded, including its extra bits if any, then it is - * followed a distance code. There are up to 30 distance symbols. Again - * there are many more possible distances (1..32768), so extra bits are added - * to a base value represented by the symbol. The distances 1..4 get their - * own symbol, but the rest require extra bits. The base distances and - * corresponding number of extra bits are below in the static arrays dist[] - * and dext[]. - * - * - Literal bytes are simply written to the output. A length/distance pair is - * an instruction to copy previously uncompressed bytes to the output. The - * copy is from distance bytes back in the output stream, copying for length - * bytes. - * - * - Distances pointing before the beginning of the output data are not - * permitted. - * - * - Overlapped copies, where the length is greater than the distance, are - * allowed and common. For example, a distance of one and a length of 258 - * simply copies the last byte 258 times. A distance of four and a length of - * twelve copies the last four bytes three times. A simple forward copy - * ignoring whether the length is greater than the distance or not implements - * this correctly. You should not use memcpy() since its behavior is not - * defined for overlapped arrays. You should not use memmove() or bcopy() - * since though their behavior -is- defined for overlapping arrays, it is - * defined to do the wrong thing in this case. - */ -local int codes(struct state *s, const struct huffman *lencode, const struct huffman *distcode) -{ - int symbol; /* decoded symbol */ - int len; /* length for copy */ - unsigned dist; /* distance for copy */ - static const short lens[29] = { /* Size base for length codes 257..285 */ - 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, - 258 - }; - static const short lext[29] = { /* Extra bits for length codes 257..285 */ - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0 - }; - static const short dists[30] = { /* Offset base for distance codes 0..29 */ - 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, - 6145, 8193, 12289, 16385, 24577 - }; - static const short dext[30] = { /* Extra bits for distance codes 0..29 */ - 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13 - }; - - /* decode literals and length/distance pairs */ - do { - symbol = decode(s, lencode); - if (symbol < 0) - return symbol; /* invalid symbol */ - if (symbol < 256) { /* literal: symbol is the byte */ - /* write out the literal */ - if (s->out != NIL) { - if (s->outcnt == s->outlen) - return 1; - s->out[s->outcnt] = symbol; - } - s->outcnt++; - } else if (symbol > 256) { /* length */ - /* get and compute length */ - symbol -= 257; - if (symbol >= 29) - return -10; /* invalid fixed code */ - len = lens[symbol] + bits(s, lext[symbol]); - - /* get and check distance */ - symbol = decode(s, distcode); - if (symbol < 0) - return symbol; /* invalid symbol */ - dist = dists[symbol] + bits(s, dext[symbol]); -#ifndef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR - if (dist > s->outcnt) - return -11; /* distance too far back */ -#endif - - /* copy length bytes from distance bytes back */ - if (s->out != NIL) { - if (s->outcnt + len > s->outlen) - return 1; - while (len--) { - s->out[s->outcnt] = -#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR - dist > s->outcnt ? 0 : -#endif - s->out[s->outcnt - dist]; - s->outcnt++; - } - } else - s->outcnt += len; - } - } while (symbol != 256); /* end of block symbol */ - - /* done with a valid fixed or dynamic block */ - return 0; -} - -/* - * Process a fixed codes block. - * - * Format notes: - * - * - This block type can be useful for compressing small amounts of data for - * which the size of the code descriptions in a dynamic block exceeds the - * benefit of custom codes for that block. For fixed codes, no bits are - * spent on code descriptions. Instead the code lengths for literal/length - * codes and distance codes are fixed. The specific lengths for each symbol - * can be seen in the "for" loops below. - * - * - The literal/length code is complete, but has two symbols that are invalid - * and should result in an error if received. This cannot be implemented - * simply as an incomplete code since those two symbols are in the "middle" - * of the code. They are eight bits long and the longest literal/length\ - * code is nine bits. Therefore the code must be constructed with those - * symbols, and the invalid symbols must be detected after decoding. - * - * - The fixed distance codes also have two invalid symbols that should result - * in an error if received. Since all of the distance codes are the same - * length, this can be implemented as an incomplete code. Then the invalid - * codes are detected while decoding. - */ -local int fixed(struct state *s) -{ - static int virgin = 1; - static short lencnt[MAXBITS + 1], lensym[FIXLCODES]; - static short distcnt[MAXBITS + 1], distsym[MAXDCODES]; - static struct huffman lencode, distcode; - - /* build fixed huffman tables if first call (may not be thread safe) */ - if (virgin) { - int symbol; - short lengths[FIXLCODES]; - - /* construct lencode and distcode */ - lencode.count = lencnt; - lencode.symbol = lensym; - distcode.count = distcnt; - distcode.symbol = distsym; - - /* literal/length table */ - for (symbol = 0; symbol < 144; symbol++) - lengths[symbol] = 8; - for (; symbol < 256; symbol++) - lengths[symbol] = 9; - for (; symbol < 280; symbol++) - lengths[symbol] = 7; - for (; symbol < FIXLCODES; symbol++) - lengths[symbol] = 8; - construct(&lencode, lengths, FIXLCODES); - - /* distance table */ - for (symbol = 0; symbol < MAXDCODES; symbol++) - lengths[symbol] = 5; - construct(&distcode, lengths, MAXDCODES); - - /* do this just once */ - virgin = 0; - } - - /* decode data until end-of-block code */ - return codes(s, &lencode, &distcode); -} - -/* - * Process a dynamic codes block. - * - * Format notes: - * - * - A dynamic block starts with a description of the literal/length and - * distance codes for that block. New dynamic blocks allow the compressor to - * rapidly adapt to changing data with new codes optimized for that data. - * - * - The codes used by the deflate format are "canonical", which means that - * the actual bits of the codes are generated in an unambiguous way simply - * from the number of bits in each code. Therefore the code descriptions - * are simply a list of code lengths for each symbol. - * - * - The code lengths are stored in order for the symbols, so lengths are - * provided for each of the literal/length symbols, and for each of the - * distance symbols. - * - * - If a symbol is not used in the block, this is represented by a zero as the - * code length. This does not mean a zero-length code, but rather that no - * code should be created for this symbol. There is no way in the deflate - * format to represent a zero-length code. - * - * - The maximum number of bits in a code is 15, so the possible lengths for - * any code are 1..15. - * - * - The fact that a length of zero is not permitted for a code has an - * interesting consequence. Normally if only one symbol is used for a given - * code, then in fact that code could be represented with zero bits. However - * in deflate, that code has to be at least one bit. So for example, if - * only a single distance base symbol appears in a block, then it will be - * represented by a single code of length one, in particular one 0 bit. This - * is an incomplete code, since if a 1 bit is received, it has no meaning, - * and should result in an error. So incomplete distance codes of one symbol - * should be permitted, and the receipt of invalid codes should be handled. - * - * - It is also possible to have a single literal/length code, but that code - * must be the end-of-block code, since every dynamic block has one. This - * is not the most efficient way to create an empty block (an empty fixed - * block is fewer bits), but it is allowed by the format. So incomplete - * literal/length codes of one symbol should also be permitted. - * - * - If there are only literal codes and no lengths, then there are no distance - * codes. This is represented by one distance code with zero bits. - * - * - The list of up to 286 length/literal lengths and up to 30 distance lengths - * are themselves compressed using Huffman codes and run-length encoding. In - * the list of code lengths, a 0 symbol means no code, a 1..15 symbol means - * that length, and the symbols 16, 17, and 18 are run-length instructions. - * Each of 16, 17, and 18 are followed by extra bits to define the length of - * the run. 16 copies the last length 3 to 6 times. 17 represents 3 to 10 - * zero lengths, and 18 represents 11 to 138 zero lengths. Unused symbols - * are common, hence the special coding for zero lengths. - * - * - The symbols for 0..18 are Huffman coded, and so that code must be - * described first. This is simply a sequence of up to 19 three-bit values - * representing no code (0) or the code length for that symbol (1..7). - * - * - A dynamic block starts with three fixed-size counts from which is computed - * the number of literal/length code lengths, the number of distance code - * lengths, and the number of code length code lengths (ok, you come up with - * a better name!) in the code descriptions. For the literal/length and - * distance codes, lengths after those provided are considered zero, i.e. no - * code. The code length code lengths are received in a permuted order (see - * the order[] array below) to make a short code length code length list more - * likely. As it turns out, very short and very long codes are less likely - * to be seen in a dynamic code description, hence what may appear initially - * to be a peculiar ordering. - * - * - Given the number of literal/length code lengths (nlen) and distance code - * lengths (ndist), then they are treated as one long list of nlen + ndist - * code lengths. Therefore run-length coding can and often does cross the - * boundary between the two sets of lengths. - * - * - So to summarize, the code description at the start of a dynamic block is - * three counts for the number of code lengths for the literal/length codes, - * the distance codes, and the code length codes. This is followed by the - * code length code lengths, three bits each. This is used to construct the - * code length code which is used to read the remainder of the lengths. Then - * the literal/length code lengths and distance lengths are read as a single - * set of lengths using the code length codes. Codes are constructed from - * the resulting two sets of lengths, and then finally you can start - * decoding actual compressed data in the block. - * - * - For reference, a "typical" size for the code description in a dynamic - * block is around 80 bytes. - */ -local int dynamic(struct state *s) -{ - int nlen, ndist, ncode; /* number of lengths in descriptor */ - int index; /* index of lengths[] */ - int err; /* construct() return value */ - short lengths[MAXCODES]; /* descriptor code lengths */ - short lencnt[MAXBITS + 1], lensym[MAXLCODES]; /* lencode memory */ - short distcnt[MAXBITS + 1], distsym[MAXDCODES]; /* distcode memory */ - struct huffman lencode, distcode; /* length and distance codes */ - static const short order[19] = /* permutation of code length codes */ - { 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }; - - /* construct lencode and distcode */ - lencode.count = lencnt; - lencode.symbol = lensym; - distcode.count = distcnt; - distcode.symbol = distsym; - - /* get number of lengths in each table, check lengths */ - nlen = bits(s, 5) + 257; - ndist = bits(s, 5) + 1; - ncode = bits(s, 4) + 4; - if (nlen > MAXLCODES || ndist > MAXDCODES) - return -3; /* bad counts */ - - /* read code length code lengths (really), missing lengths are zero */ - for (index = 0; index < ncode; index++) - lengths[order[index]] = bits(s, 3); - for (; index < 19; index++) - lengths[order[index]] = 0; - - /* build huffman table for code lengths codes (use lencode temporarily) */ - err = construct(&lencode, lengths, 19); - if (err != 0) /* require complete code set here */ - return -4; - - /* read length/literal and distance code length tables */ - index = 0; - while (index < nlen + ndist) { - int symbol; /* decoded value */ - int len; /* last length to repeat */ - - symbol = decode(s, &lencode); - if (symbol < 0) - return symbol; /* invalid symbol */ - if (symbol < 16) /* length in 0..15 */ - lengths[index++] = symbol; - else { /* repeat instruction */ - len = 0; /* assume repeating zeros */ - if (symbol == 16) { /* repeat last length 3..6 times */ - if (index == 0) - return -5; /* no last length! */ - len = lengths[index - 1]; /* last length */ - symbol = 3 + bits(s, 2); - } else if (symbol == 17) /* repeat zero 3..10 times */ - symbol = 3 + bits(s, 3); - else /* == 18, repeat zero 11..138 times */ - symbol = 11 + bits(s, 7); - if (index + symbol > nlen + ndist) - return -6; /* too many lengths! */ - while (symbol--) /* repeat last or zero symbol times */ - lengths[index++] = len; - } - } - - /* check for end-of-block code -- there better be one! */ - if (lengths[256] == 0) - return -9; - - /* build huffman table for literal/length codes */ - err = construct(&lencode, lengths, nlen); - if (err && (err < 0 || nlen != lencode.count[0] + lencode.count[1])) - return -7; /* incomplete code ok only for single length 1 code */ - - /* build huffman table for distance codes */ - err = construct(&distcode, lengths + nlen, ndist); - if (err && (err < 0 || ndist != distcode.count[0] + distcode.count[1])) - return -8; /* incomplete code ok only for single length 1 code */ - - /* decode data until end-of-block code */ - return codes(s, &lencode, &distcode); -} - -/* - * Inflate source to dest. On return, destlen and sourcelen are updated to the - * size of the uncompressed data and the size of the deflate data respectively. - * On success, the return value of puff() is zero. If there is an error in the - * source data, i.e. it is not in the deflate format, then a negative value is - * returned. If there is not enough input available or there is not enough - * output space, then a positive error is returned. In that case, destlen and - * sourcelen are not updated to facilitate retrying from the beginning with the - * provision of more input data or more output space. In the case of invalid - * inflate data (a negative error), the dest and source pointers are updated to - * facilitate the debugging of deflators. - * - * puff() also has a mode to determine the size of the uncompressed output with - * no output written. For this dest must be (unsigned char *)0. In this case, - * the input value of *destlen is ignored, and on return *destlen is set to the - * size of the uncompressed output. - * - * The return codes are: - * - * 2: available inflate data did not terminate - * 1: output space exhausted before completing inflate - * 0: successful inflate - * -1: invalid block type (type == 3) - * -2: stored block length did not match one's complement - * -3: dynamic block code description: too many length or distance codes - * -4: dynamic block code description: code lengths codes incomplete - * -5: dynamic block code description: repeat lengths with no first length - * -6: dynamic block code description: repeat more than specified lengths - * -7: dynamic block code description: invalid literal/length code lengths - * -8: dynamic block code description: invalid distance code lengths - * -9: dynamic block code description: missing end-of-block code - * -10: invalid literal/length or distance code in fixed or dynamic block - * -11: distance is too far back in fixed or dynamic block - * - * Format notes: - * - * - Three bits are read for each block to determine the kind of block and - * whether or not it is the last block. Then the block is decoded and the - * process repeated if it was not the last block. - * - * - The leftover bits in the last byte of the deflate data after the last - * block (if it was a fixed or dynamic block) are undefined and have no - * expected values to check. - */ -int puff(unsigned char *dest, /* pointer to destination pointer */ - unsigned long *destlen, /* amount of output space */ - const unsigned char *source, /* pointer to source data pointer */ - unsigned long *sourcelen) /* amount of input available */ -{ - struct state s; /* input/output state */ - int last, type; /* block information */ - int err; /* return value */ - - /* initialize output state */ - s.out = dest; - s.outlen = *destlen; /* ignored if dest is NIL */ - s.outcnt = 0; - - /* initialize input state */ - s.in = source; - s.inlen = *sourcelen; - s.incnt = 0; - s.bitbuf = 0; - s.bitcnt = 0; - - /* return if bits() or decode() tries to read past available input */ - if (setjmp(s.env) != 0) /* if came back here via longjmp() */ - err = 2; /* then skip do-loop, return error */ - else { - /* process blocks until last block or error */ - do { - last = bits(&s, 1); /* one if last block */ - type = bits(&s, 2); /* block type 0..3 */ - err = type == 0 ? stored(&s) - : (type == 1 ? fixed(&s) : (type == 2 ? dynamic(&s) : -1)); /* type == 3, invalid */ - if (err != 0) - break; /* return with error */ - } while (!last); - } - - /* update the lengths and return */ - if (err <= 0) { - *destlen = s.outcnt; - *sourcelen = s.incnt; - } - return err; -} diff --git a/src/provider/puff.h b/src/provider/puff.h deleted file mode 100644 index 6ced50e2..00000000 --- a/src/provider/puff.h +++ /dev/null @@ -1,42 +0,0 @@ -/* puff.h - Copyright (C) 2002-2013 Mark Adler, all rights reserved - version 2.3, 21 Jan 2013 - - This software is provided 'as-is', without any express or implied - warranty. In no event will the author be held liable for any damages - arising from the use of this software. - - Permission is granted to anyone to use this software for any purpose, - including commercial applications, and to alter it and redistribute it - freely, subject to the following restrictions: - - 1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. - 2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. - 3. This notice may not be removed or altered from any source distribution. - - Mark Adler madler@alumni.caltech.edu - */ - -/* - * See puff.c for purpose and usage. - */ -#ifndef NIL -#define NIL ((unsigned char *)0) /* for no output option */ -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -int puff(unsigned char *dest, /* pointer to destination pointer */ - unsigned long *destlen, /* amount of output space */ - const unsigned char *source, /* pointer to source data pointer */ - unsigned long *sourcelen); /* amount of input available */ - -#ifdef __cplusplus -} -#endif diff --git a/src/providers/local/LocalProvider.cpp b/src/providers/local/LocalProvider.cpp index 32ae34e1..15119520 100644 --- a/src/providers/local/LocalProvider.cpp +++ b/src/providers/local/LocalProvider.cpp @@ -1,8 +1,10 @@ #include "LocalProvider.h" +#include "../../common/AsyncTask.h" #include #include #include +#include #include #include @@ -98,21 +100,31 @@ class LocalProvider::Playback final : public PlaybackSource { LocalProvider *m_provider; }; -LocalProvider::LocalProvider(QString libraryRoot, QObject *parent) +LocalProvider::LocalProvider(QString accountId, QString libraryRoot, QObject *parent) : Provider(parent) + , m_accountId(std::move(accountId)) , m_root(QDir(std::move(libraryRoot)).absolutePath()) , m_playback(new Playback(this)) { m_libraryName = QDir(m_root).dirName(); if (m_libraryName.isEmpty()) m_libraryName = QStringLiteral("Local files"); + // A large folder takes a while to walk; do it off the GUI thread and + // announce the library once it is known. + auto *watcher = new QFutureWatcher>(this); + connect(watcher, &QFutureWatcher>::finished, this, [this, watcher] { + setRecords(watcher->result()); + watcher->deleteLater(); + emit contentChanged({}); + }); + watcher->setFuture(Async::background([root = m_root] { return LocalProvider::scanFolder(root); })); } LocalProvider::~LocalProvider() = default; QString LocalProvider::id() const { - return QStringLiteral("local"); + return m_accountId; } QString LocalProvider::displayName() const @@ -130,21 +142,24 @@ PlaybackSource *LocalProvider::playback() return m_playback; } -bool LocalProvider::restoreFromStorage(QVariantMap, std::vector) +void LocalProvider::scan() { - if (!m_scanned) - scan(); - emit sessionStarted(); - return true; + setRecords(scanFolder(m_root)); } -void LocalProvider::scan() +void LocalProvider::setRecords(std::vector records) { - m_scanned = true; - m_records.clear(); + m_records = std::move(records); m_index.clear(); - const QDir root(m_root); - QDirIterator files(m_root, QDir::Files | QDir::Readable, QDirIterator::Subdirectories); + for (size_t index = 0; index < m_records.size(); ++index) + m_index.insert(m_records[index].item.id, index); +} + +std::vector LocalProvider::scanFolder(const QString& folder) +{ + std::vector records; + const QDir root(folder); + QDirIterator files(folder, QDir::Files | QDir::Readable, QDirIterator::Subdirectories); while (files.hasNext()) { const QFileInfo info(files.next()); const QString suffix = info.suffix().toLower(); @@ -168,12 +183,11 @@ void LocalProvider::scan() source.protocol = QStringLiteral("File"); source.size = info.size(); item.mediaSources.push_back(source); - m_records.push_back(std::move(record)); + records.push_back(std::move(record)); } - std::sort(m_records.begin(), m_records.end(), + std::sort(records.begin(), records.end(), [](const Record& left, const Record& right) { return titleLess(left.item, right.item); }); - for (size_t index = 0; index < m_records.size(); ++index) - m_index.insert(m_records[index].item.id, index); + return records; } QString LocalProvider::libraryScopeKey() const diff --git a/src/providers/local/LocalProvider.h b/src/providers/local/LocalProvider.h index 63b42a76..f61b7768 100644 --- a/src/providers/local/LocalProvider.h +++ b/src/providers/local/LocalProvider.h @@ -15,10 +15,8 @@ #include -// The providers a build ships live here, one directory each. The Jellyfin -// provider still sits in src/api and src/app; it moves to -// src/providers/jellyfin/ in Phase 5, once the core/ and providers/ layout -// lands. +// Native providers live here, one directory each. Every other provider is +// JavaScript and QML loaded at run time. namespace JellyfinNative { @@ -36,7 +34,7 @@ class LocalProvider final : public Provider, Q_OBJECT public: - explicit LocalProvider(QString libraryRoot, QObject *parent = nullptr); + LocalProvider(QString accountId, QString libraryRoot, QObject *parent = nullptr); ~LocalProvider() override; QString id() const override; @@ -59,22 +57,17 @@ class LocalProvider final : public Provider, { return this; } - void registerQmlSingletons() override { } - bool ready() const override { return true; } - // Scans the folder and announces the session, since there is no sign-in - // to wait for. - bool restoreFromStorage(QVariantMap values, std::vector profiles) override; QString libraryRoot() const { return m_root; } - // Reads the folder again. Called once from restoreFromStorage(); a test - // calls it directly. + // Reads the folder synchronously; construction already does so off the + // GUI thread, so only tests call this. void scan(); // Catalog, SearchSource, UserItemStateSink @@ -119,16 +112,18 @@ class LocalProvider final : public Provider, }; class Playback; + static std::vector scanFolder(const QString& folder); + void setRecords(std::vector records); const Record *record(const QString& itemId) const; Record *record(const QString& itemId); std::vector items(int startIndex, int limit) const; + QString m_accountId; QString m_root; QString m_libraryName; std::vector m_records; QHash m_index; Playback *m_playback = nullptr; - bool m_scanned = false; }; } // namespace JellyfinNative diff --git a/tests/api/HttpRequestPolicyTest.cpp b/tests/api/HttpRequestPolicyTest.cpp deleted file mode 100644 index af4f1edc..00000000 --- a/tests/api/HttpRequestPolicyTest.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include "api/HttpRequestPolicy.h" - -#include "TestMain.h" - -#include - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(1); -} - -} // namespace - -JELLYFIN_TEST_MAIN("http-request-policy") -{ - QCoreApplication app(argc, argv); - - require(HttpRequestPolicy::maximumAttempts(HttpOperation::Read) == 3, "reads should be retried"); - require(HttpRequestPolicy::maximumAttempts(HttpOperation::Mutation) == 1, "mutations must not be duplicated"); - require( - HttpRequestPolicy::maximumAttempts(HttpOperation::PlaybackReport) == 3, "playback reports should be retried"); - require( - HttpRequestPolicy::shouldRetry(HttpOperation::Read, 1, 503, QNetworkReply::NoError), "503 should be retried"); - require(HttpRequestPolicy::shouldRetry(HttpOperation::PlaybackReport, 2, 429, QNetworkReply::NoError), - "rate-limited playback reports should be retried"); - require(HttpRequestPolicy::shouldRetry(HttpOperation::Read, 1, 0, QNetworkReply::TimeoutError), - "network timeouts should be retried"); - require(!HttpRequestPolicy::shouldRetry(HttpOperation::Read, 1, 401, QNetworkReply::AuthenticationRequiredError), - "401 must expire the session without retrying"); - require(!HttpRequestPolicy::shouldRetry(HttpOperation::Mutation, 1, 503, QNetworkReply::NoError), - "mutations must remain single-attempt"); - require(!HttpRequestPolicy::shouldRetry(HttpOperation::Read, 3, 503, QNetworkReply::NoError), - "retry limit must be bounded"); - require(HttpRequestPolicy::retryDelayMs(1) == 250 && HttpRequestPolicy::retryDelayMs(2) == 500, - "retry delay should back off"); - require(HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("https://public.example"))), - "HTTPS should allow credentials"); - require(HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://127.0.0.1:8096"))), - "IPv4 loopback HTTP should be allowed"); - require(HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://192.168.1.4:8096"))), - "private IPv4 HTTP should be allowed"); - require(HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://[::1]:8096"))), - "IPv6 loopback HTTP should be allowed"); - require(HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://[fe80::1]:8096"))), - "IPv6 link-local HTTP should be allowed"); - require(!HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://8.8.8.8:8096"))), - "public IPv4 HTTP must be denied"); - require(!HttpRequestPolicy::allowsCredentialTransport(QUrl(QStringLiteral("http://media.example:8096"))), - "HTTP hostnames must be denied to prevent DNS rebinding"); - require(!HttpRequestPolicy::sameOrigin( - QUrl(QStringLiteral("https://one.example/a")), QUrl(QStringLiteral("https://two.example/b"))), - "cross-origin redirects must not retain authorization"); - require(HttpRequestPolicy::sameOrigin( - QUrl(QStringLiteral("https://one.example/a")), QUrl(QStringLiteral("https://ONE.example:443/b"))), - "same-origin paths should compare by normalized origin"); - return 0; -} diff --git a/tests/api/JellyfinApiFacadeUrlTest.cpp b/tests/api/JellyfinApiFacadeUrlTest.cpp deleted file mode 100644 index 09295b71..00000000 --- a/tests/api/JellyfinApiFacadeUrlTest.cpp +++ /dev/null @@ -1,120 +0,0 @@ -#include "api/JellyfinApiFacade.h" -#include "common/AsyncTask.h" -#include "common/TlsTrust.h" - -#include "TestMain.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void fail(const char *message, const QString& value) -{ - std::cerr << message << ": " << value.toStdString() << '\n'; - std::exit(1); -} - -void require(bool condition, const char *message, const QString& value = {}) -{ - if (condition) - return; - fail(message, value); -} - -void requireQueryValue(const QUrlQuery& query, const QString& key, const QString& expected, const char *message) -{ - const QString actual = query.queryItemValue(key); - require(query.hasQueryItem(key) && actual == expected, message, actual); -} - -void requireMissingQueryValue(const QUrlQuery& query, const QString& key, const char *message) -{ - require(!query.hasQueryItem(key), message, query.toString(QUrl::FullyEncoded)); -} - -void requireUrlPathBytes(const QString& url, const QString& expectedPath, const char *message) -{ - const QString actual = url.section(QLatin1Char('?'), 0, 0); - const QString expected = QStringLiteral("https://media.example.test") + expectedPath; - require(actual == expected, message, actual); -} - -void requireSignInError(const QByteArray& body, int status, const QString& expected) -{ - QTcpServer server; - require(server.listen(QHostAddress::LocalHost), "authentication server should listen"); - QObject::connect(&server, &QTcpServer::newConnection, &server, [&]() { - QTcpSocket *socket = server.nextPendingConnection(); - QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket, body, status]() { - socket->readAll(); - if (socket->property("responded").toBool()) - return; - socket->setProperty("responded", true); - socket->write("HTTP/1.1 " + QByteArray::number(status) - + " Error\r\nContent-Type: application/json\r\n" - "Connection: close\r\nContent-Length: " - + QByteArray::number(body.size()) + "\r\n\r\n" + body); - socket->disconnectFromHost(); - }); - }); - QNetworkAccessManager network; - TlsTrustController trust; - JellyfinApiFacade api(&network, &trust); - api.setServerUrl(QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort())); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - QString error; - Async::runScoped( - &api, api.authenticateByName(QStringLiteral("user"), QStringLiteral("bad-password")), - [&loop](const AuthSession&) { loop.quit(); }, - [&loop, &error](const std::exception_ptr& failure) { - error = exceptionMessage(failure); - loop.quit(); - }); - timeout.start(3000); - loop.exec(); - require(error == expected, "sign-in should expose the useful server error", error); -} - -} // namespace - -JELLYFIN_TEST_MAIN("jellyfin-api-facade-url") -{ - QCoreApplication app(argc, argv); - QNetworkAccessManager network; - TlsTrustController tlsTrust; - JellyfinApiFacade api(&network, &tlsTrust); - api.setServerUrl(QStringLiteral("https://media.example.test/jellyfin/root/")); - - const QString trickplayUrl = api.trickplayTileUrl(QStringLiteral("episode/id 2"), 320, 7); - requireUrlPathBytes(trickplayUrl, QStringLiteral("/jellyfin/root/Videos/episode%2Fid%202/Trickplay/320/7.jpg"), - "trickplay tile URLs should retain the server base path and encode item ids as one path segment"); - const QUrl parsedTrickplay(trickplayUrl); - const QUrlQuery trickplayQuery(parsedTrickplay); - requireMissingQueryValue( - trickplayQuery, QStringLiteral("api_key"), "trickplay tile URLs should not include bearer tokens"); - - requireSignInError("Error processing request.", 401, QStringLiteral("Incorrect username or password. (401)")); - requireSignInError( - R"({"detail":"This account is disabled."})", 403, QStringLiteral("This account is disabled. (403)")); - requireSignInError( - "Access is restricted at this time.", 403, QStringLiteral("Access is restricted at this time. (403)")); - requireSignInError({}, 401, QStringLiteral("Incorrect username or password. (401)")); - - return 0; -} diff --git a/tests/api/JellyfinJsAdapterTest.cpp b/tests/api/JellyfinJsAdapterTest.cpp deleted file mode 100644 index b877676d..00000000 --- a/tests/api/JellyfinJsAdapterTest.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include "api/JellyfinJsAdapter.h" -#include "TestMain.h" -#include "cache/DatabaseManager.h" -#include "provider/ProviderRegistry.h" - -#include -#include - -#include -#include - -namespace { -void require(bool condition, const char *message) -{ - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } -} -} // namespace - -JELLYFIN_TEST_MAIN("jellyfin-js-adapter") -{ - QCoreApplication app(argc, argv); - using namespace JellyfinNative; - - QTemporaryDir directory; - require(directory.isValid(), "isolated persistent state directory created"); - qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); - - DatabaseManager database; - require(database.initialize(directory.filePath("cache.sqlite")), "database initialized"); - - ProviderRegistry registry; - registry.registerModule( - QStringLiteral("spool.jellyfin"), QStringLiteral("qrc:/providers/spool.jellyfin/logic/provider.mjs")); - QCoro::waitFor(registry.restoreSources(&database)); - - const QString secret = QStringLiteral("test-access-token-12345"); - const QString server = QStringLiteral("https://media.test.local:8096"); - const QString sourceId = QCoro::waitFor(registry.configureSource(QStringLiteral("spool.jellyfin"), - QStringLiteral("test-user-id"), QStringLiteral("library"), QStringLiteral("Test Server"), - { { QStringLiteral("server"), server }, { QStringLiteral("userId"), QStringLiteral("test-user-id") }, - { QStringLiteral("token"), secret }, { QStringLiteral("deviceId"), QStringLiteral("test-device") }, - { QStringLiteral("deviceName"), QStringLiteral("Spool Test") }, - { QStringLiteral("clientVersion"), QStringLiteral("0.3.0") } }, - { QUrl(server) })); - - require(!sourceId.isEmpty(), "JS source configured in registry"); - - JellyfinJsAdapter adapter(®istry); - require(!adapter.signedIn(), "adapter initially signed out"); - require(adapter.libraryScopeKey().isEmpty(), "libraryScopeKey empty when signed out"); - - adapter.configure(sourceId, server, secret); - adapter.setDeviceId(QStringLiteral("test-device")); - require(adapter.signedIn(), "adapter signed in after configure"); - require(adapter.libraryScopeKey() == sourceId, "libraryScopeKey matches source ID"); - - // Test ArtworkSource - ArtworkSource::ImageRequest imgReq; - imgReq.itemId = QStringLiteral("item-42"); - imgReq.imageType = QStringLiteral("Primary"); - imgReq.tag = QStringLiteral("tag-abc"); - imgReq.maxWidth = 400; - imgReq.quality = 90; - imgReq.format = QStringLiteral("webp"); - - const QString imgUrl = adapter.imageUrl(imgReq); - require(imgUrl.contains("media.test.local:8096/Items/item-42/Images/Primary"), "imageUrl has correct path"); - require(imgUrl.contains("tag=tag-abc"), "imageUrl includes tag"); - require(imgUrl.contains("maxWidth=400"), "imageUrl includes maxWidth"); - require(imgUrl.contains("quality=90"), "imageUrl includes quality"); - require(imgUrl.contains("format=webp"), "imageUrl includes format"); - - // Test PlaybackSource properties - PlaybackSource *playback = adapter.playback(); - require(playback != nullptr, "playback source exists"); - require(playback->signedIn(), "playback source signed in"); - require(playback->mediaOrigin() == QUrl(server), "mediaOrigin matches server URL"); - require(playback->mediaRequestHeaders().contains("X-Emby-Token: test-access-token-12345"), - "mediaRequestHeaders contains X-Emby-Token"); - - const QString trickplay = adapter.trickplayTileUrl(QStringLiteral("item-42"), 320, 5); - require(trickplay == "https://media.test.local:8096/Videos/item-42/Trickplay/320/5.jpg", - "trickplay tile URL formatted correctly"); - - // Test StreamQualityControl - require(adapter.bitrateOverride() == 0, "initial bitrate override is 0"); - require(adapter.heightOverride() == 0, "initial height override is 0"); - require(adapter.autoDescription() == QStringLiteral("Direct Play"), "initial autoDescription is Direct Play"); - adapter.setOverride(10'000'000, 720); - require(adapter.bitrateOverride() == 10'000'000, "bitrate override set"); - require(adapter.heightOverride() == 720, "height override set"); - const auto ladder = adapter.ladder(20'000'000); - require(!ladder.empty(), "ladder is populated"); - require(ladder.front().bitrate <= 20'000'000, "ladder rungs capped at source bitrate"); - - // Test clearing - adapter.clear(); - require(!adapter.signedIn(), "adapter signed out after clear"); - require(adapter.mediaRequestHeaders().isEmpty(), "headers empty after clear"); - - std::cout << "All JellyfinJsAdapter tests passed successfully.\n"; - return 0; -} diff --git a/tests/api/PlaybackNegotiationTest.cpp b/tests/api/PlaybackNegotiationTest.cpp deleted file mode 100644 index f7d24fb3..00000000 --- a/tests/api/PlaybackNegotiationTest.cpp +++ /dev/null @@ -1,194 +0,0 @@ -#include "api/PlaybackNegotiation.h" - -#include "TestMain.h" - -#include -#include -#include -#include - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(1); -} - -QJsonObject source(const QString& id, qint64 bitrate, bool directPlay, bool directStream, - const QString& directStreamUrl = {}, const QString& transcodingUrl = {}) -{ - return { - { QStringLiteral("Id"), id }, - { QStringLiteral("Bitrate"), bitrate }, - { QStringLiteral("SupportsDirectPlay"), directPlay }, - { QStringLiteral("SupportsDirectStream"), directStream }, - { QStringLiteral("DirectStreamUrl"), directStreamUrl }, - { QStringLiteral("TranscodingUrl"), transcodingUrl }, - }; -} - -} // namespace - -JELLYFIN_TEST_MAIN("playback-negotiation") -{ - QCoreApplication app(argc, argv); - - const QJsonArray sources { - source(QStringLiteral("transcode"), 80'000'000, false, false, {}, - QStringLiteral("/Videos/item/master.m3u8?MediaSourceId=transcode")), - source(QStringLiteral("direct-low"), 10'000'000, true, true), - source(QStringLiteral("direct-high"), 20'000'000, true, true), - }; - const PlaybackSelection direct = PlaybackNegotiation::selectSource(sources, true); - require(direct.playMethod == QStringLiteral("DirectPlay"), "direct play should outrank fallback methods"); - require(direct.source.value(QStringLiteral("Id")).toString() == QStringLiteral("direct-high"), - "the highest-bitrate source should win within a method"); - - const QJsonArray fallbackSources { - source(QStringLiteral("fallback"), 12'000'000, false, true, - QStringLiteral("/Videos/item/stream?MediaSourceId=fallback"), - QStringLiteral("/Videos/item/master.m3u8?MediaSourceId=fallback")), - }; - const PlaybackSelection remux = PlaybackNegotiation::selectSource(fallbackSources, true); - require(remux.playMethod == QStringLiteral("DirectStream"), "remux preference should select direct stream"); - const PlaybackSelection transcode = PlaybackNegotiation::selectSource(fallbackSources, false); - require(transcode.playMethod == QStringLiteral("Transcode"), "disabling remux preference should select transcode"); - - const QString url = PlaybackNegotiation::buildUrl( - QStringLiteral("https://example.test/jellyfin"), QStringLiteral("item"), transcode); - const QUrl parsed(url); - if (parsed.path() != QStringLiteral("/jellyfin/Videos/item/master.m3u8")) - std::cerr << "resolved playback URL: " << url.toStdString() << '\n'; - require(parsed.path() == QStringLiteral("/jellyfin/Videos/item/master.m3u8"), - "relative playback URLs should retain the server base path"); - const QUrlQuery playbackQuery(parsed); - require(!playbackQuery.hasQueryItem(QStringLiteral("api_key")), - "playback URLs must not expose the access token in diagnostics or mpv logs"); - - const QJsonObject smallTrickplay { - { QStringLiteral("Width"), 160 }, - { QStringLiteral("Height"), 90 }, - { QStringLiteral("TileWidth"), 10 }, - { QStringLiteral("TileHeight"), 10 }, - { QStringLiteral("ThumbnailCount"), 123 }, - { QStringLiteral("Interval"), 10'000 }, - }; - const QJsonObject preferredTrickplay { - { QStringLiteral("Width"), 320 }, - { QStringLiteral("Height"), 180 }, - { QStringLiteral("TileWidth"), 5 }, - { QStringLiteral("TileHeight"), 5 }, - { QStringLiteral("ThumbnailCount"), 50 }, - { QStringLiteral("Interval"), 5'000 }, - }; - const QJsonObject itemTrickplay { - { QStringLiteral("media-source"), - QJsonObject { - { QStringLiteral("160"), smallTrickplay }, - { QStringLiteral("320"), preferredTrickplay }, - } }, - }; - const TrickplayInfo trickplay - = PlaybackNegotiation::selectTrickplay(itemTrickplay, QStringLiteral("media-source"), 320); - require(trickplay.width == 320 && trickplay.height == 180, - "item trickplay metadata should select the closest requested width"); - require(trickplay.tileWidth == 5 && trickplay.tileHeight == 5 && trickplay.intervalMs == 5'000, - "item trickplay metadata should retain sprite layout and interval"); - - const QJsonObject profile = PlaybackNegotiation::buildDeviceProfile(25'000'000); - require(profile.value(QStringLiteral("MaxStreamingBitrate")).toInteger() == 25'000'000, - "device profile should carry the configured bitrate"); - require(!profile.value(QStringLiteral("TranscodingProfiles")).toArray().isEmpty(), - "device profile should advertise a transcode fallback"); - const QJsonObject transcodeProfile - = profile.value(QStringLiteral("TranscodingProfiles")).toArray().first().toObject(); - require(transcodeProfile.value(QStringLiteral("Container")).toString() == QStringLiteral("mp4"), - "video transcodes should use fragmented MP4 HLS segments"); - require(transcodeProfile.value(QStringLiteral("Protocol")).toString() == QStringLiteral("hls"), - "fragmented MP4 transcodes should retain HLS delivery"); - const QString transcodeAudioCodecs = transcodeProfile.value(QStringLiteral("AudioCodec")).toString(); - require(transcodeAudioCodecs == QStringLiteral("aac,ac3,eac3,mp3,flac,opus,dts,truehd"), - "video transcodes should retain the supported fMP4 HLS audio codecs"); - require(transcodeAudioCodecs.size() <= 40, - "transcode audio codecs must satisfy Jellyfin's 40-character request validation limit"); - require(!transcodeProfile.value(QStringLiteral("BreakOnNonKeyFrames")).toBool(), - "fragmented MP4 segments should only break on keyframes"); - bool foundSrtExternal = false; - bool foundPgsExternal = false; - bool foundSsaExternal = false; - const QJsonArray subtitleProfiles = profile.value(QStringLiteral("SubtitleProfiles")).toArray(); - for (const QJsonValue& value : subtitleProfiles) { - const QJsonObject subtitleProfile = value.toObject(); - const QString format = subtitleProfile.value(QStringLiteral("Format")).toString(); - const QString method = subtitleProfile.value(QStringLiteral("Method")).toString(); - foundSrtExternal |= format == QStringLiteral("srt") && method == QStringLiteral("External"); - foundPgsExternal |= format == QStringLiteral("pgssub") && method == QStringLiteral("External"); - foundSsaExternal |= format == QStringLiteral("ssa") && method == QStringLiteral("External"); - } - require(foundSrtExternal && foundPgsExternal && foundSsaExternal, - "device profile should advertise all locally rendered subtitle formats"); - - const QJsonObject desktopProfile = PlaybackNegotiation::buildDeviceProfile(25'000'000); - const QJsonObject desktopDirectVideo - = desktopProfile.value(QStringLiteral("DirectPlayProfiles")).toArray().first().toObject(); - require(!desktopDirectVideo.contains(QStringLiteral("VideoCodec")), - "desktop playback should remain unrestricted for software decoding"); - require(desktopProfile.value(QStringLiteral("TranscodingProfiles")) - .toArray() - .first() - .toObject() - .value(QStringLiteral("VideoCodec")) - .toString() - == QStringLiteral("hevc,h264,av1,vp9"), - "desktop transcodes should advertise every fMP4 HLS video codec in preference order"); - - // A bitrate ceiling on its own leaves the picture the size it started: - // the server re-encodes at the source resolution and only spends fewer - // bits, which is how a rung labelled 480p used to change nothing but the - // bitrate. The ceiling has to reach the profile as a codec condition. - require( - PlaybackNegotiation::buildDeviceProfile(25'000'000).value(QStringLiteral("CodecProfiles")).toArray().isEmpty(), - "no resolution ceiling should leave the profile free of codec conditions"); - const QJsonArray cappedProfiles - = PlaybackNegotiation::buildDeviceProfile(25'000'000, 480).value(QStringLiteral("CodecProfiles")).toArray(); - require(cappedProfiles.size() == 1, "a resolution ceiling should add exactly one video codec profile"); - const QJsonObject cappedProfile = cappedProfiles.first().toObject(); - require(cappedProfile.value(QStringLiteral("Type")).toString() == QStringLiteral("Video"), - "a resolution ceiling should constrain video"); - const QJsonArray conditions = cappedProfile.value(QStringLiteral("Conditions")).toArray(); - require(conditions.size() == 1, "a resolution ceiling should be one condition"); - const QJsonObject condition = conditions.first().toObject(); - require(condition.value(QStringLiteral("Property")).toString() == QStringLiteral("Height") - && condition.value(QStringLiteral("Condition")).toString() == QStringLiteral("LessThanEqual") - && condition.value(QStringLiteral("Value")).toString() == QStringLiteral("480"), - "the ceiling should ask the server for a height at or below the chosen rung"); - require(!condition.value(QStringLiteral("IsRequired")).toBool(), - "a source already below the ceiling should direct play rather than transcode up to it"); - - const QJsonObject webOsProfile = PlaybackNegotiation::buildDeviceProfile(25'000'000, 0, - { QStringLiteral("VP9"), QStringLiteral("h264"), QStringLiteral("hevc"), QStringLiteral("mpeg1video"), - QStringLiteral("mpeg2video"), QStringLiteral("mpeg4"), QStringLiteral("h263"), QStringLiteral("vc1") }, - true); - const QJsonObject webOsDirectVideo - = webOsProfile.value(QStringLiteral("DirectPlayProfiles")).toArray().first().toObject(); - require(webOsDirectVideo.value(QStringLiteral("VideoCodec")).toString() - == QStringLiteral("vp9,h264,hevc,mpeg1video,mpeg2video,mpeg4,h263,vc1"), - "webOS direct play should combine probed Starfish codecs with the explicit software-decoder set"); - require(webOsProfile.value(QStringLiteral("TranscodingProfiles")) - .toArray() - .first() - .toObject() - .value(QStringLiteral("VideoCodec")) - .toString() - == QStringLiteral("hevc,h264,vp9"), - "webOS transcode outputs should intersect the Starfish codecs with the HLS preference order"); - return 0; -} diff --git a/tests/app/SyncPlayClockTest.cpp b/tests/app/GroupClockTest.cpp similarity index 91% rename from tests/app/SyncPlayClockTest.cpp rename to tests/app/GroupClockTest.cpp index c91279df..a5de539b 100644 --- a/tests/app/SyncPlayClockTest.cpp +++ b/tests/app/GroupClockTest.cpp @@ -1,4 +1,4 @@ -#include "app/SyncPlayClock.h" +#include "app/GroupClock.h" #include "TestMain.h" @@ -19,9 +19,9 @@ void require(bool condition, const char *message) } // namespace -JELLYFIN_TEST_MAIN("sync-play-clock") +JELLYFIN_TEST_MAIN("group-clock") { - SyncPlayClock clock; + GroupClock clock; clock.addMeasurement({ 1'000, 1'060, 1'061, 1'101 }); require(clock.ready(), "clock should be ready after one sample"); require(clock.offsetMs() == 10.0, "clock should calculate NTP offset"); diff --git a/tests/app/GroupPlaybackPolicyTest.cpp b/tests/app/GroupPlaybackPolicyTest.cpp new file mode 100644 index 00000000..e4fc251a --- /dev/null +++ b/tests/app/GroupPlaybackPolicyTest.cpp @@ -0,0 +1,94 @@ +#include "app/GroupPlaybackController.h" + +#include "TestMain.h" + +#include +#include +#include + +using namespace JellyfinNative; + +namespace { + +void require(bool condition, const char *message) +{ + if (condition) + return; + std::cerr << message << '\n'; + std::exit(1); +} + +bool near(double value, double expected, double tolerance = 0.001) +{ + return std::abs(value - expected) <= tolerance; +} + +} // namespace + +// The timing policy watching together runs on, whichever provider hosts the +// group: when drift is corrected by rate or by seeking, and when a queue +// handoff or a seek may unpause the group. +JELLYFIN_TEST_MAIN("group-playback-policy") +{ + require(GroupDriftPolicy::evaluate(99.0).method == GroupCorrection::Method::None, + "drift below the speed threshold must not be corrected"); + require(GroupDriftPolicy::evaluate(-99.0).method == GroupCorrection::Method::None, + "a small lead must not be corrected either"); + + const GroupCorrection behind = GroupDriftPolicy::evaluate(200.0); + require(behind.method == GroupCorrection::Method::Speed, "a 200 ms lag should speed up"); + require(near(behind.speed, 1.03), "a 200 ms lag should use the bounded mpv correction rate"); + require(behind.durationMs == 6'667, "the bounded correction should recover the measured lag"); + + const GroupCorrection nearSeek = GroupDriftPolicy::evaluate(399.0); + require(nearSeek.method == GroupCorrection::Method::Speed, "drift below 400 ms should speed correct"); + require(near(nearSeek.speed, 1.03), "near-threshold drift should retain the bounded rate"); + require(nearSeek.durationMs == 10'000, "the correction window must remain bounded"); + + const GroupCorrection ahead = GroupDriftPolicy::evaluate(-200.0); + require(ahead.method == GroupCorrection::Method::Speed, "a 200 ms lead should slow down"); + require(near(ahead.speed, 0.97), "a lead should use the symmetric bounded rate"); + require(ahead.durationMs == 6'667, "the bounded correction should give back the measured lead"); + + require(GroupDriftPolicy::evaluate(400.0).method == GroupCorrection::Method::Skip, + "drift at the speed ceiling must seek instead"); + require( + GroupDriftPolicy::evaluate(-500.0).method == GroupCorrection::Method::Skip, "a large lead must seek instead"); + + for (const double diffMs : { 100.0, -100.0, 200.0, -200.0, 300.0, -300.0 }) { + const GroupCorrection correction = GroupDriftPolicy::evaluate(diffMs); + require(correction.method == GroupCorrection::Method::Speed, "mid-range drift should speed correct"); + const double recovered = (correction.speed - 1.0) * correction.durationMs; + require(near(recovered, diffMs, 1.0), "an unclipped speed correction must recover the measured drift"); + } + + GroupQueueHandoff handoff; + handoff.arm(); + require(!handoff.canSend(false, false, true, true), + "an old loaded session must not consume an unpause before the new queue update"); + + handoff.observeQueueUpdate(); + require(!handoff.canSend(true, false, true, true), "queue resolution must block the unpause request"); + require(!handoff.canSend(false, true, true, true), "playback startup must block the unpause request"); + require(!handoff.canSend(false, false, true, false), "an unloaded file must block the unpause request"); + require(handoff.canSend(false, false, true, true), "the new loaded queue item should release the unpause request"); + + handoff.cancel(); + require(!handoff.canSend(false, false, true, true), "a consumed or cancelled request must not be sent twice"); + + GroupSeekResume seekResume; + seekResume.arm(true); + require(!seekResume.takeWhenReady(QStringLiteral("Waiting"), QStringLiteral("Ready")), + "seek resume must wait for the group to finish buffering"); + require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Pause")), + "an ordinary pause must not resume playback"); + require(seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), + "the initiating client should unpause only after every participant is ready"); + require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), + "a completed seek must request unpause only once"); + + seekResume.arm(false); + require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), + "seeking a paused group must leave it paused"); + return 0; +} diff --git a/tests/app/RemoteControlControllerTest.cpp b/tests/app/RemoteControlControllerTest.cpp deleted file mode 100644 index 096a2322..00000000 --- a/tests/app/RemoteControlControllerTest.cpp +++ /dev/null @@ -1,263 +0,0 @@ -#include "app/RemoteControlController.h" -#include "api/JellyfinApiFacade.h" -#include "common/TlsTrust.h" - -#include "TestMain.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -using JellyfinNative::RemoteControlController; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(EXIT_FAILURE); -} - -QJsonObject session(QString id, QString name, bool controllable, QString title = {}, bool paused = true) -{ - QJsonObject session { - { QStringLiteral("Id"), id }, - { QStringLiteral("DeviceId"), QStringLiteral("device-") + id }, - { QStringLiteral("DeviceName"), name }, - { QStringLiteral("DeviceType"), QStringLiteral("TV") }, - { QStringLiteral("Client"), QStringLiteral("Spool for Jellyfin") }, - { QStringLiteral("UserName"), QStringLiteral("Sacha") }, - { QStringLiteral("Capabilities"), - QJsonObject { - { QStringLiteral("SupportsMediaControl"), controllable }, - { QStringLiteral("SupportedCommands"), - QJsonArray { QStringLiteral("SetVolume"), QStringLiteral("MoveUp") } }, - } }, - }; - if (!title.isEmpty()) { - session.insert(QStringLiteral("NowPlayingItem"), - QJsonObject { - { QStringLiteral("Id"), QStringLiteral("item-1") }, - { QStringLiteral("Name"), title }, - { QStringLiteral("Type"), QStringLiteral("Movie") }, - { QStringLiteral("RunTimeTicks"), 1'200'000'000 }, - }); - session.insert(QStringLiteral("PlayState"), - QJsonObject { - { QStringLiteral("PositionTicks"), 300'000'000 }, - { QStringLiteral("IsPaused"), paused }, - { QStringLiteral("VolumeLevel"), 37 }, - { QStringLiteral("IsMuted"), false }, - }); - } - return session; -} - -class MemoryReply final : public QNetworkReply { -public: - MemoryReply(const QNetworkRequest& request, QNetworkAccessManager::Operation operation, QObject *parent) - : QNetworkReply(parent) - { - setRequest(request); - setUrl(request.url()); - setOperation(operation); - setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 200); - setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - open(QIODevice::ReadOnly | QIODevice::Unbuffered); - } - - void complete(QByteArray payload) - { - m_payload = std::move(payload); - setFinished(true); - emit readyRead(); - emit finished(); - } - - void abort() override { } - - qint64 bytesAvailable() const override - { - return m_payload.size() - m_offset + QNetworkReply::bytesAvailable(); - } - -protected: - qint64 readData(char *data, qint64 maxSize) override - { - if (m_offset >= m_payload.size() || maxSize <= 0) - return -1; - const qint64 length = std::min(maxSize, m_payload.size() - m_offset); - std::memcpy(data, m_payload.constData() + m_offset, static_cast(length)); - m_offset += length; - return length; - } - -private: - QByteArray m_payload; - qsizetype m_offset = 0; -}; - -class FakeNetworkAccessManager final : public QNetworkAccessManager { -public: - QPointer pendingSessions; - int playbackCommands = 0; - -protected: - QNetworkReply *createRequest(Operation operation, const QNetworkRequest& request, QIODevice *) override - { - auto *reply = new MemoryReply(request, operation, this); - if (operation == GetOperation && request.url().path() == QStringLiteral("/Sessions")) { - pendingSessions = reply; - } else { - if (request.url().path().contains(QStringLiteral("/Playing"))) - ++playbackCommands; - QTimer::singleShot(0, reply, [reply]() { reply->complete(QByteArrayLiteral("{}")); }); - } - return reply; - } -}; - -} // namespace - -JELLYFIN_TEST_MAIN("remote-control-controller") -{ - QCoreApplication app(argc, argv); - RemoteControlController remote(nullptr); - QString announcedTarget; - QObject::connect(&remote, &RemoteControlController::targetChanged, [&remote, &announcedTarget]() { - if (remote.targetSelected()) - announcedTarget = remote.selectedTargetName(); - }); - - remote.applySessions(QJsonArray { - session(QStringLiteral("hidden"), QStringLiteral("Phone"), false), - session(QStringLiteral("living-room"), QStringLiteral("Living Room"), true, QStringLiteral("Arrival")), - }); - require(remote.targets().size() == 1, "non-controllable session was exposed as a Cast target"); - require(remote.targets().front().toMap().value(QStringLiteral("deviceName")).toString() - == QStringLiteral("Living Room"), - "controllable target metadata was not exposed"); - - remote.selectTarget(QStringLiteral("living-room")); - require(remote.targetSelected(), "available target was not selected"); - require(remote.selectedTargetName() == QStringLiteral("Living Room"), "selected target name was not retained"); - require(announcedTarget == QStringLiteral("Living Room"), - "selecting a target did not announce the connection without requiring navigation"); - require(remote.nowPlayingItem().value(QStringLiteral("title")).toString() == QStringLiteral("Arrival"), - "selected target now-playing item was not normalized"); - require(remote.positionTicks() == 300'000'000, "selected target position was not applied"); - require(remote.runtimeTicks() == 1'200'000'000, "selected target duration was not applied"); - require(remote.volume() == 37, "selected target volume was not applied"); - require(remote.supports(QStringLiteral("MoveUp")), "selected target commands were not applied"); - - remote.togglePause(); - require(!remote.paused(), "play command did not update the remote state immediately"); - remote.applySessions(QJsonArray { - session(QStringLiteral("living-room"), QStringLiteral("Living Room"), true, QStringLiteral("Arrival"), true), - }); - require(!remote.paused(), "stale target state reverted an optimistic play command"); - remote.applySessions(QJsonArray { - session(QStringLiteral("living-room"), QStringLiteral("Living Room"), true, QStringLiteral("Arrival"), false), - }); - require(!remote.paused(), "confirmed play state was not retained"); - remote.applySessions(QJsonArray { - session(QStringLiteral("living-room"), QStringLiteral("Living Room"), true, QStringLiteral("Arrival"), true), - }); - require(remote.paused(), "reported pause state remained stuck after command confirmation"); - - QJsonObject other - = session(QStringLiteral("bedroom"), QStringLiteral("Living Room"), true, QStringLiteral("Dune"), false); - QJsonObject otherState = other.value(QStringLiteral("PlayState")).toObject(); - otherState.insert(QStringLiteral("PositionTicks"), 100'000'000); - otherState.insert(QStringLiteral("AudioStreamIndex"), 2); - other.insert(QStringLiteral("PlayState"), otherState); - QJsonObject otherItem = other.value(QStringLiteral("NowPlayingItem")).toObject(); - otherItem.insert(QStringLiteral("MediaStreams"), - QJsonArray { - QJsonObject { - { QStringLiteral("Type"), QStringLiteral("Audio") }, - { QStringLiteral("Index"), 2 }, - { QStringLiteral("DisplayTitle"), QStringLiteral("French") }, - }, - }); - other.insert(QStringLiteral("NowPlayingItem"), otherItem); - remote.applySessions(QJsonArray { - session(QStringLiteral("living-room"), QStringLiteral("Living Room"), true, QStringLiteral("Arrival"), false), - other, - }); - remote.seek(900'000'000); - remote.togglePause(); - int attachmentAnnouncements = 0; - const auto attachmentConnection = QObject::connect(&remote, &RemoteControlController::targetChanged, [&]() { - ++attachmentAnnouncements; - require(remote.nowPlayingItem().value(QStringLiteral("title")).toString() == QStringLiteral("Dune") - && remote.positionTicks() == 100'000'000 && !remote.paused(), - "target attachment announced before adopting its playing state"); - }); - // Both devices play the same item ID and have identical display metadata: - // device identity, not item/name changes, must discard the pending commands. - remote.selectTarget(QStringLiteral("bedroom")); - require(attachmentAnnouncements == 1, "target attachment was not announced exactly once"); - QObject::disconnect(attachmentConnection); - require(remote.audioTracks().size() == 1 - && remote.audioTracks().front().toMap().value(QStringLiteral("selected")).toBool(), - "attachment did not adopt the target's selected audio stream"); - remote.seekRelative(10'000'000); - require(remote.positionTicks() >= 110'000'000 && remote.positionTicks() < 120'000'000, - "relative seek after attachment used the previous target's pending seek"); - - remote.togglePause(); - otherItem.insert(QStringLiteral("Id"), QStringLiteral("item-2")); - otherItem.insert(QStringLiteral("Name"), QStringLiteral("Next movie")); - other.insert(QStringLiteral("NowPlayingItem"), otherItem); - remote.applySessions(QJsonArray { other }); - require(remote.positionTicks() == 100'000'000 && !remote.paused(), - "a new item inherited the previous item's pending seek or pause"); - - remote.applySessions(QJsonArray {}); - require(!remote.targetSelected(), "disappeared target remained selected"); - require(remote.nowPlayingItem().isEmpty(), "disappeared target retained stale playback state"); - - FakeNetworkAccessManager network; - JellyfinNative::TlsTrustController tlsTrust; - JellyfinNative::JellyfinApiFacade api(&network, &tlsTrust); - api.setServerUrl(QStringLiteral("http://192.168.1.2")); - api.setSession(JellyfinNative::AuthSession { - QStringLiteral("user-1"), QStringLiteral("Tester"), QStringLiteral("token-1"), QStringLiteral("server-1") }); - RemoteControlController connected(&api); - connected.refreshTargets(); - require(network.pendingSessions, "session refresh did not start"); - connected.applySessions(QJsonArray { other }); - connected.selectTarget(QStringLiteral("bedroom")); - network.pendingSessions->complete(QByteArrayLiteral("[]")); - QCoreApplication::processEvents(); - require(!connected.busy(), "superseded session refresh did not finish"); - require(connected.targetSelected() - && connected.nowPlayingItem().value(QStringLiteral("title")).toString() == QStringLiteral("Next movie") - && connected.positionTicks() == 100'000'000 && !connected.paused(), - "late HTTP snapshot overwrote the pushed active session during attachment"); - require(network.playbackCommands == 0, "attaching to an active session sent a playback command"); - - connected.refreshTargets(); - require(network.pendingSessions, "second session refresh did not start"); - connected.stop(); - network.pendingSessions->complete(QJsonDocument(QJsonArray { other }).toJson(QJsonDocument::Compact)); - QCoreApplication::processEvents(); - require(connected.targets().isEmpty() && !connected.targetSelected(), - "a session refresh completed after stop and restored stale devices"); - return EXIT_SUCCESS; -} diff --git a/tests/app/SessionControllerTest.cpp b/tests/app/SessionControllerTest.cpp deleted file mode 100644 index 399fc8ed..00000000 --- a/tests/app/SessionControllerTest.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#include "app/SessionController.h" -#include "api/JellyfinApiFacade.h" -#include "app/AccountProfile.h" -#include "cache/DatabaseManager.h" -#include "common/TlsTrust.h" - -#include "TestMain.h" - -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(EXIT_FAILURE); -} - -bool waitUntil(QCoreApplication& app, const std::function& condition) -{ - QElapsedTimer timer; - timer.start(); - while (!condition() && timer.elapsed() < 2000) { - app.processEvents(); - QThread::msleep(1); - } - return condition(); -} - -AccountProfile profile(const QString& serverId, const QString& serverUrl, const QString& userId, - const QString& userName, const QString& token, qint64 lastUsedAt, qint64 createdAt) -{ - const QString canonical = canonicalServerUrl(serverUrl); - return { accountProfileId(serverId, canonical, userId), serverId, - serverId == QStringLiteral("home") ? QStringLiteral("Living Room") : QStringLiteral("Holiday Server"), - canonical, userId, userName, token, QStringLiteral("avatar-tag"), lastUsedAt, createdAt, token.isEmpty() }; -} - -} // namespace - -JELLYFIN_TEST_MAIN("session-controller") -{ - QCoreApplication app(argc, argv); - QTemporaryDir directory; - require(directory.isValid(), "temporary profile directory should be available"); - qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath(QStringLiteral("credentials")).toUtf8()); - - DatabaseManager database; - require(database.initialize(directory.filePath(QStringLiteral("profiles.sqlite"))), - "profile database should initialize"); - - require(canonicalServerUrl(QStringLiteral("HTTPS://Home.Example:443/jellyfin/")) - == QStringLiteral("https://home.example/jellyfin"), - "server URL should be canonicalized"); - const QString variantId = accountProfileId({}, QStringLiteral("https://HOME.example/"), QStringLiteral("alice")); - const QString canonicalId = accountProfileId({}, QStringLiteral("https://home.example"), QStringLiteral("alice")); - if (variantId != canonicalId) - std::cerr << variantId.toStdString() << " != " << canonicalId.toStdString() << '\n'; - require(variantId == canonicalId, "canonical URL variants should identify the same account pair"); - - const AccountProfile aliceHome = profile(QStringLiteral("home"), QStringLiteral("https://home.example/"), - QStringLiteral("alice"), QStringLiteral("Alice"), QStringLiteral("alice-secret"), 100, 10); - const AccountProfile bobHome = profile(QStringLiteral("home"), QStringLiteral("https://home.example"), - QStringLiteral("bob"), QStringLiteral("Bob"), QStringLiteral("bob-secret"), 300, 20); - const AccountProfile aliceAway = profile(QStringLiteral("away"), QStringLiteral("https://away.example"), - QStringLiteral("alice"), QStringLiteral("Alice"), QStringLiteral("away-secret"), 200, 30); - database.upsertAccountProfile(aliceHome); - database.upsertAccountProfile(bobHome); - database.upsertAccountProfile(aliceAway); - - std::vector stored = QCoro::waitFor(database.loadAccountProfilesAsync()); - require(stored.size() == 3, "two users on one server and one user on two servers should remain distinct"); - require(stored[0].profileId == bobHome.profileId && stored[1].profileId == aliceAway.profileId, - "profiles should load by most recent use"); - require( - stored[2].avatarTag == QStringLiteral("avatar-tag") && stored[2].accessToken == QStringLiteral("alice-secret"), - "durable profile fields should round-trip"); - - AccountProfile updatedAlice = aliceHome; - updatedAlice.serverName = QStringLiteral("Renamed Home"); - updatedAlice.lastUsedAt = 400; - database.upsertAccountProfile(updatedAlice); - stored = QCoro::waitFor(database.loadAccountProfilesAsync()); - require(stored.size() == 3 && stored.front().serverName == QStringLiteral("Renamed Home"), - "upsert should replace the matching account/server pair without duplicating it"); - require(stored.front().createdAt == aliceHome.createdAt, "upsert should preserve insertion order"); - - QNetworkAccessManager network; - TlsTrustController tlsTrust; - JellyfinApiFacade api(&network, &tlsTrust); - int tokenChanges = 0; - QObject::connect(&api, &JellyfinApiFacade::credentialsChanged, [&tokenChanges]() { ++tokenChanges; }); - SessionController session(&database, &api); - require(QCoro::waitFor(session.initializeAsync()), "saved profiles should be detected"); - require(!session.authenticated(), "startup should never activate a saved token"); - require(session.accountProfiles().size() == 3, "all account and server pairs should be displayed"); - const QByteArray summaries = QJsonDocument::fromVariant(session.accountProfiles()).toJson(); - require(!summaries.contains("secret"), "profile summaries exposed to QML must not contain tokens"); - - session.activateProfile(bobHome.profileId); - require(waitUntil(app, [&session]() { return session.authenticated(); }), "saved account should activate"); - require(session.activeProfileId() == bobHome.profileId, "activation should select a stable profile ID"); - require(session.activeProfileLabel() == QStringLiteral("Bob · Living Room"), - "active profile should expose account and server identity"); - require(session.serverUrl() == QStringLiteral("https://home.example") - && api.session().accessToken == QStringLiteral("bob-secret"), - "activation should swap the server URL and token together"); - require(tokenChanges == 1, "profile activation should replace the playback authentication once"); - - session.setPassword(QStringLiteral("temporary-password")); - session.deactivate(); - require(!session.authenticated(), "switch user should clear the active session"); - require(session.accountProfiles().size() == 3, "switch user should preserve saved pairs"); - require(tokenChanges == 2 && api.authorizationHeader().contains(QStringLiteral("bob-secret")) == false, - "switch user should clear the old playback token"); - require(session.password().isEmpty(), "switch user should erase the in-memory password"); - - session.activateProfile(aliceAway.profileId); - require(waitUntil(app, [&session]() { return session.authenticated(); }), "second server profile should activate"); - require(tokenChanges == 3 && api.authorizationHeader().contains(QStringLiteral("away-secret")) - && !api.authorizationHeader().contains(QStringLiteral("bob-secret")), - "profile switching should expose only the newly selected token"); - session.setPassword(QStringLiteral("temporary-password")); - session.logout(); - require(!session.authenticated(), "sign out should clear the active token"); - require(session.accountProfiles().size() == 3, "sign out should keep the account tile"); - stored = QCoro::waitFor(database.loadAccountProfilesAsync()); - const auto expired = std::find_if(stored.cbegin(), stored.cend(), - [&aliceAway](const AccountProfile& candidate) { return candidate.profileId == aliceAway.profileId; }); - require(tokenChanges == 4 && !api.authorizationHeader().contains(QStringLiteral("away-secret")), - "sign out should clear retained playback authentication"); - require(expired != stored.cend() && expired->needsAuthentication && expired->accessToken.isEmpty(), - "sign out should expire only the active profile"); - require(session.password().isEmpty(), "sign out should erase the in-memory password"); - require(std::any_of(stored.cbegin(), stored.cend(), - [&bobHome](const AccountProfile& candidate) { - return candidate.profileId == bobHome.profileId && !candidate.accessToken.isEmpty(); - }), - "expiring one profile should not erase sibling tokens"); - - int profileSignInRequests = 0; - QObject::connect(&session, &SessionController::profileSignInRequiredChanged, - [&profileSignInRequests]() { ++profileSignInRequests; }); - session.activateProfile(aliceAway.profileId); - require(waitUntil(app, [&session]() { return session.profileSignInRequired(); }), - "expired profile activation should open exact-pair sign in"); - require(session.serverUrl() == aliceAway.serverUrl && session.username() == aliceAway.userName, - "reauthentication should retain the selected server and user"); - session.activateProfile(aliceAway.profileId); - require(waitUntil(app, [&profileSignInRequests]() { return profileSignInRequests >= 2; }), - "choosing an already-expired profile should request its sign-in page again"); - require(session.serverUrl() == aliceAway.serverUrl && session.username() == aliceAway.userName, - "repeated reauthentication should keep the selected server and username prefilled"); - - session.removeProfile(aliceAway.profileId); - stored = QCoro::waitFor(database.loadAccountProfilesAsync()); - require(stored.size() == 2, "remove from device should delete only the selected pair"); - - database.clearAccountProfiles(); - JellyfinApiFacade emptyApi(&network, &tlsTrust); - SessionController emptySession(&database, &emptyApi); - require(!QCoro::waitFor(emptySession.initializeAsync()), "zero profiles should route to add account"); - require(!emptySession.authenticated(), "zero-profile startup should remain signed out"); - - database.upsertAccountProfile(aliceHome); - JellyfinApiFacade singleApi(&network, &tlsTrust); - SessionController singleSession(&database, &singleApi); - require(QCoro::waitFor(singleSession.initializeAsync()), "one profile should be detected"); - require(singleSession.authenticated(), "one valid saved profile should auto-activate"); - require( - singleSession.activeProfileId() == aliceHome.profileId, "automatic activation should select the sole profile"); - singleSession.deactivate(); - - database.clearAccountProfiles(); - const AccountProfile expiredSingle = profile(QStringLiteral("away"), QStringLiteral("https://away.example"), - QStringLiteral("alice"), QStringLiteral("Alice"), {}, 500, 40); - database.upsertAccountProfile(expiredSingle); - JellyfinApiFacade expiredApi(&network, &tlsTrust); - SessionController expiredSession(&database, &expiredApi); - require(QCoro::waitFor(expiredSession.initializeAsync()), "one expired profile should be detected"); - require(!expiredSession.authenticated() && expiredSession.profileSignInRequired(), - "one expired profile should skip account selection and open sign in"); - - database.shutdown(); - return EXIT_SUCCESS; -} diff --git a/tests/app/SpoolLinkCodecTest.cpp b/tests/app/SpoolLinkCodecTest.cpp deleted file mode 100644 index b68cb6ec..00000000 --- a/tests/app/SpoolLinkCodecTest.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "app/SpoolLinkCodec.h" - -#include "TestMain.h" - -#include -#include - -using namespace JellyfinNative; -using namespace JellyfinNative::SpoolLinkCodec; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(EXIT_FAILURE); -} - -QByteArray testKey(char fill = 'k') -{ - return QByteArray(32, fill); -} - -} // namespace - -JELLYFIN_TEST_MAIN("spool-link-codec") -{ - const QByteArray key = testKey(); - const QByteArray payload = QByteArrayLiteral("{\"c\":\"SeekPreview\"}"); - - Datagram type {}; - QByteArray decoded; - quint64 sequence = 0; - - const QByteArray sealed = seal(key, Datagram::Payload, 7, payload); - require(open(key, sealed, type, decoded, sequence), "a sealed datagram did not open"); - require(type == Datagram::Payload, "the datagram type changed"); - require(sequence == 7, "the sequence number changed"); - require(decoded == payload, "the payload changed"); - - // Authentication: the key is what separates a peer from anyone else who - // can reach the port. - require(!open(testKey('x'), sealed, type, decoded, sequence), "a datagram opened under the wrong key"); - - // First payload byte, just past the 16-byte header. - QByteArray tampered = sealed; - tampered[16] = char(tampered[16] ^ 0x01); - require(!open(key, tampered, type, decoded, sequence), "a modified payload was accepted"); - - QByteArray truncated = sealed.left(sealed.size() - 1); - require(!open(key, truncated, type, decoded, sequence), "a truncated datagram was accepted"); - - QByteArray foreign = sealed; - foreign[0] = 'X'; - require(!open(key, foreign, type, decoded, sequence), "a datagram from another protocol was accepted"); - - // Replay: out of order is still new, twice never is. - ReplayWindow window; - require(window.accept(1), "the first sequence number was rejected"); - require(!window.accept(1), "a replayed sequence number was accepted"); - require(window.accept(3), "a forward jump was rejected"); - require(window.accept(2), "a reordered datagram was rejected"); - require(!window.accept(2), "a reordered datagram was accepted twice"); - require(!window.accept(0), "a zero sequence number was accepted"); - require(window.accept(500), "a large forward jump was rejected"); - require(!window.accept(3), "a sequence number far behind the window was accepted"); - require(window.accept(499), "a datagram just inside the window was rejected"); - - window.reset(); - require(window.accept(1), "a reset window did not start over"); - - // Endpoints survive the trip through a text field in the envelope. - const auto v4 = Endpoint::fromString(QStringLiteral("192.168.0.200:41234")); - require(v4.has_value() && v4->port == 41234, "an IPv4 endpoint did not parse"); - require(Endpoint::fromString(v4->toString()) == v4, "an IPv4 endpoint did not round-trip"); - - const auto v6 = Endpoint::fromString(QStringLiteral("[2001:db8::1]:9")); - require(v6.has_value() && v6->port == 9, "an IPv6 endpoint did not parse"); - require(Endpoint::fromString(v6->toString()) == v6, "an IPv6 endpoint did not round-trip"); - require(v6->toString().startsWith(QLatin1Char('[')), - "an IPv6 endpoint must bracket its address or the port cannot be found again"); - - require(!Endpoint::fromString(QStringLiteral("192.168.0.200")), "an endpoint with no port was accepted"); - require(!Endpoint::fromString(QStringLiteral("192.168.0.200:0")), "port zero was accepted"); - require(!Endpoint::fromString(QStringLiteral("192.168.0.200:70000")), "an out-of-range port was accepted"); - require(!Endpoint::fromString(QStringLiteral("not-an-address:1")), "a non-address was accepted"); - - return EXIT_SUCCESS; -} diff --git a/tests/app/SpoolRemoteProtocolTest.cpp b/tests/app/SpoolRemoteProtocolTest.cpp deleted file mode 100644 index 4f316c69..00000000 --- a/tests/app/SpoolRemoteProtocolTest.cpp +++ /dev/null @@ -1,94 +0,0 @@ -#include "app/SpoolRemoteProtocol.h" - -#include "TestMain.h" - -#include - -#include -#include - -using namespace JellyfinNative; -using namespace JellyfinNative::SpoolRemoteProtocol; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(EXIT_FAILURE); -} - -} // namespace - -JELLYFIN_TEST_MAIN("spool-remote-protocol") -{ - const Message preview = seekPreviewMessage(QStringLiteral("item-42"), 123'456'789, true); - const QJsonObject arguments = encode(preview); - - require(envelopeCommand() == QStringLiteral("SendString"), - "custom commands must use a Jellyfin GeneralCommand enum value"); - for (auto it = arguments.constBegin(); it != arguments.constEnd(); ++it) - require(it.value().isString(), "Jellyfin command arguments must all be strings"); - require(arguments.value(QStringLiteral("String")).toString().isEmpty(), - "the SendString compatibility payload must be harmless to non-Spool clients"); - - // The envelope round-trips, and the typed view reads what was written. - const auto decoded = decode(envelopeCommand(), arguments); - require(decoded.has_value(), "a Spool envelope did not round-trip"); - require(decoded->command == Command::SeekPreview, "the command name changed"); - require(!decoded->fields.contains(QStringLiteral("SpoolCommand")), - "envelope keys must not leak into a message's own fields"); - - const auto roundTripped = seekPreview(*decoded); - require(roundTripped.has_value(), "a seek preview payload did not round-trip"); - require(roundTripped->itemId == QStringLiteral("item-42"), "the seek preview item id changed"); - require(roundTripped->positionTicks == 123'456'789, "the seek preview position changed"); - require(roundTripped->active, "the active seek preview flag changed"); - - const auto inactive - = seekPreview(*decode(envelopeCommand(), encode(seekPreviewMessage(QStringLiteral("item-42"), -1, false)))); - require(inactive.has_value() && !inactive->active, "an inactive seek preview did not round-trip"); - require(inactive->positionTicks == 0, "negative seek preview positions must clamp to zero"); - - require( - !decode(QStringLiteral("SpoolSeekPreview"), arguments), "an unknown Jellyfin GeneralCommand name was accepted"); - - QJsonObject malformed = arguments; - malformed.insert(QStringLiteral("SpoolProtocol"), QStringLiteral("2")); - require(!decode(envelopeCommand(), malformed), "an unsupported protocol version was accepted"); - - malformed = arguments; - malformed.insert(QStringLiteral("Active"), true); - require(!decode(envelopeCommand(), malformed), "a non-string Jellyfin argument was accepted"); - - malformed = arguments; - malformed.insert(QStringLiteral("PositionTicks"), QStringLiteral("not-a-number")); - require(!seekPreview(*decode(envelopeCommand(), malformed)), "a malformed seek position was accepted"); - - malformed = arguments; - malformed.remove(QStringLiteral("ItemId")); - require( - !seekPreview(*decode(envelopeCommand(), malformed)), "an active preview with nothing to preview was accepted"); - - // The same message over a transport that carries bytes rather than - // Jellyfin arguments must mean exactly the same thing. - const auto viaBytes = deserialize(serialize(preview)); - require(viaBytes.has_value(), "a serialized message did not round-trip"); - const auto viaBytesPreview = seekPreview(*viaBytes); - require(viaBytesPreview.has_value() && viaBytesPreview->itemId == QStringLiteral("item-42") - && viaBytesPreview->positionTicks == 123'456'789 && viaBytesPreview->active, - "the direct and server transports must carry the same message"); - require(!deserialize(QByteArrayLiteral("{\"c\":\"\"}")), "a message with no command was accepted"); - require(!deserialize(QByteArrayLiteral("not json")), "malformed bytes were accepted"); - - const auto join = syncPlayJoin(*decode(envelopeCommand(), encode(syncPlayJoinMessage(QStringLiteral("group-7"))))); - require( - join.has_value() && join->groupId == QStringLiteral("group-7"), "a SyncPlay join request did not round-trip"); - require(!syncPlayJoin(*decode(envelopeCommand(), encode(syncPlayJoinMessage(QString())))), - "a join request naming no group was accepted"); - require(!syncPlayJoin(*decoded), "a seek preview was read as a join request"); - - return EXIT_SUCCESS; -} diff --git a/tests/app/SyncPlayDriftPolicyTest.cpp b/tests/app/SyncPlayDriftPolicyTest.cpp deleted file mode 100644 index 2bbdebe8..00000000 --- a/tests/app/SyncPlayDriftPolicyTest.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include "app/SyncPlayController.h" - -#include "TestMain.h" - -#include -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(1); -} - -bool near(double value, double expected, double tolerance = 0.001) -{ - return std::abs(value - expected) <= tolerance; -} - -} // namespace - -JELLYFIN_TEST_MAIN("sync-play-drift-policy") -{ - require(SyncPlayDriftPolicy::evaluate(99.0).method == SyncCorrection::Method::None, - "drift below the speed threshold must not be corrected"); - require(SyncPlayDriftPolicy::evaluate(-99.0).method == SyncCorrection::Method::None, - "a small lead must not be corrected either"); - - const SyncCorrection behind = SyncPlayDriftPolicy::evaluate(200.0); - require(behind.method == SyncCorrection::Method::Speed, "a 200 ms lag should speed up"); - require(near(behind.speed, 1.03), "a 200 ms lag should use the bounded mpv correction rate"); - require(behind.durationMs == 6'667, "the bounded correction should recover the measured lag"); - - const SyncCorrection nearSeek = SyncPlayDriftPolicy::evaluate(399.0); - require(nearSeek.method == SyncCorrection::Method::Speed, "drift below 400 ms should speed correct"); - require(near(nearSeek.speed, 1.03), "near-threshold drift should retain the bounded rate"); - require(nearSeek.durationMs == 10'000, "the correction window must remain bounded"); - - const SyncCorrection ahead = SyncPlayDriftPolicy::evaluate(-200.0); - require(ahead.method == SyncCorrection::Method::Speed, "a 200 ms lead should slow down"); - require(near(ahead.speed, 0.97), "a lead should use the symmetric bounded rate"); - require(ahead.durationMs == 6'667, "the bounded correction should give back the measured lead"); - - require(SyncPlayDriftPolicy::evaluate(400.0).method == SyncCorrection::Method::Skip, - "drift at the speed ceiling must seek instead"); - require( - SyncPlayDriftPolicy::evaluate(-500.0).method == SyncCorrection::Method::Skip, "a large lead must seek instead"); - - for (const double diffMs : { 100.0, -100.0, 200.0, -200.0, 300.0, -300.0 }) { - const SyncCorrection correction = SyncPlayDriftPolicy::evaluate(diffMs); - require(correction.method == SyncCorrection::Method::Speed, "mid-range drift should speed correct"); - const double recovered = (correction.speed - 1.0) * correction.durationMs; - require(near(recovered, diffMs, 1.0), "an unclipped speed correction must recover the measured drift"); - } - return 0; -} diff --git a/tests/app/SyncPlayQueueHandoffTest.cpp b/tests/app/SyncPlayQueueHandoffTest.cpp deleted file mode 100644 index b11ce7a1..00000000 --- a/tests/app/SyncPlayQueueHandoffTest.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "app/SyncPlayController.h" - -#include "TestMain.h" - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(1); -} - -} // namespace - -JELLYFIN_TEST_MAIN("sync-play-queue-handoff") -{ - SyncPlayQueueHandoff handoff; - handoff.arm(); - require(!handoff.canSend(false, false, true, true), - "an old loaded session must not consume an unpause before the new queue update"); - - handoff.observeQueueUpdate(); - require(!handoff.canSend(true, false, true, true), "queue resolution must block the unpause request"); - require(!handoff.canSend(false, true, true, true), "playback startup must block the unpause request"); - require(!handoff.canSend(false, false, true, false), "an unloaded file must block the unpause request"); - require(handoff.canSend(false, false, true, true), "the new loaded queue item should release the unpause request"); - - handoff.cancel(); - require(!handoff.canSend(false, false, true, true), "a consumed or cancelled request must not be sent twice"); - - SyncPlaySeekResume seekResume; - seekResume.arm(true); - require(!seekResume.takeWhenReady(QStringLiteral("Waiting"), QStringLiteral("Ready")), - "seek resume must wait for the group to finish buffering"); - require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Pause")), - "an ordinary pause must not resume playback"); - require(seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), - "the initiating client should unpause only after every participant is ready"); - require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), - "a completed seek must request unpause only once"); - - seekResume.arm(false); - require(!seekResume.takeWhenReady(QStringLiteral("Paused"), QStringLiteral("Ready")), - "seeking a paused group must leave it paused"); - return 0; -} diff --git a/tests/discovery/DiscoveryControllerTest.cpp b/tests/discovery/DiscoveryControllerTest.cpp deleted file mode 100644 index ee8aac78..00000000 --- a/tests/discovery/DiscoveryControllerTest.cpp +++ /dev/null @@ -1,114 +0,0 @@ -#include "discovery/DiscoveryController.h" - -#include "TestMain.h" - -#include -#include - -#include -#include - -using namespace JellyfinNative; - -namespace { - -void require(bool condition, const char *message) -{ - if (condition) - return; - std::cerr << message << '\n'; - std::exit(1); -} - -QStringList strings(const QList& urls) -{ - QStringList result; - for (const QUrl& url : urls) - result.push_back(url.toString(QUrl::FullyEncoded)); - return result; -} - -} // namespace - -JELLYFIN_TEST_MAIN("discovery-controller") -{ - QCoreApplication app(argc, argv); - - require(strings(DiscoveryController::serverProbeCandidates(QStringLiteral("192.168.1.30"))) - == QStringList { QStringLiteral("http://192.168.1.30:8096"), QStringLiteral("https://192.168.1.30"), - QStringLiteral("http://192.168.1.30") }, - "bare LAN addresses should prefer HTTP port 8096"); - require(strings(DiscoveryController::serverProbeCandidates(QStringLiteral("192.168.1.30:8920"))) - == QStringList { QStringLiteral("http://192.168.1.30:8920"), QStringLiteral("https://192.168.1.30:8920") }, - "bare LAN addresses should retain an explicit port"); - require(strings(DiscoveryController::serverProbeCandidates(QStringLiteral("media.example.test/jellyfin/"))) - == QStringList { QStringLiteral("https://media.example.test/jellyfin"), - QStringLiteral("http://media.example.test:8096/jellyfin"), - QStringLiteral("http://media.example.test/jellyfin") }, - "public names should try HTTPS first and preserve base paths"); - require(strings(DiscoveryController::serverProbeCandidates(QStringLiteral("https://media.example.test/root"))) - == QStringList { QStringLiteral("https://media.example.test/root"), - QStringLiteral("http://media.example.test:8096/root") }, - "explicit HTTPS should fall back to the typical HTTP port"); - require(DiscoveryController::serverProbeCandidates(QStringLiteral("not a url")).isEmpty(), - "invalid manual addresses should not produce candidates"); - - require(DiscoveryController::looksLikeServerAddress(QStringLiteral("192.168.1.30")), - "an IP literal is complete enough to probe as it is typed"); - require(DiscoveryController::looksLikeServerAddress(QStringLiteral("jellyfin:8096")), - "an explicit port completes an otherwise bare host"); - require(DiscoveryController::looksLikeServerAddress(QStringLiteral("localhost")), - "localhost resolves without a suffix or a port"); - require(DiscoveryController::looksLikeServerAddress(QStringLiteral("https://media.example.test/jellyfin")), - "a full URL is always worth probing"); - require(!DiscoveryController::looksLikeServerAddress(QStringLiteral("jellyfin")), - "a bare hostname is too likely to be half-typed to probe"); - require(!DiscoveryController::looksLikeServerAddress(QStringLiteral("media.example.")), - "a trailing dot means the suffix is still being typed"); - require(!DiscoveryController::looksLikeServerAddress(QStringLiteral("media.e")), - "a one-character suffix is still being typed"); - require(!DiscoveryController::looksLikeServerAddress(QString()), "empty input should never probe"); - - QString version; - const DiscoveredServer parsed = DiscoveryController::serverFromPublicInfo( - QByteArrayLiteral(R"({"Id":"server-id","ServerName":"Living Room","Version":"10.11.8"})"), - QUrl(QStringLiteral("http://192.168.1.30:8096/jellyfin/")), &version); - require(parsed.id == QStringLiteral("server-id") && parsed.name == QStringLiteral("Living Room") - && parsed.address == QStringLiteral("http://192.168.1.30:8096/jellyfin") - && version == QStringLiteral("10.11.8"), - "public server info should preserve the working base URL and metadata"); - require(DiscoveryController::serverFromPublicInfo( - QByteArrayLiteral(R"({"ServerName":"Missing id"})"), QUrl(QStringLiteral("http://server")), nullptr) - .id.isEmpty(), - "public server info without an id should be rejected"); - - const QList local24 = DiscoveryController::httpFallbackTargets( - QHostAddress(QStringLiteral("192.168.20.50")), QHostAddress(QStringLiteral("255.255.255.0"))); - require(local24.size() == 253, "a /24 scan should include every other usable host"); - require(local24.first() == QHostAddress(QStringLiteral("192.168.20.1")), - "a /24 scan should prioritize the likely gateway/server address"); - require(!local24.contains(QHostAddress(QStringLiteral("192.168.20.50"))), - "a subnet scan should exclude the client address"); - - const QList local23 = DiscoveryController::httpFallbackTargets( - QHostAddress(QStringLiteral("10.20.5.40")), QHostAddress(QStringLiteral("255.255.254.0"))); - require(local23.size() == 254, "a /23 fallback should remain capped"); - require(local23.contains(QHostAddress(QStringLiteral("10.20.4.1"))), - "a capped /23 scan should still sample the sibling subnet"); - - const QList broad = DiscoveryController::httpFallbackTargets( - QHostAddress(QStringLiteral("172.16.8.20")), QHostAddress(QStringLiteral("255.255.0.0"))); - require(broad.contains(QHostAddress(QStringLiteral("172.16.7.1"))), - "a broad private subnet should sample an adjacent /24"); - require(broad.size() == 254, "a broad subnet scan should remain tightly bounded"); - require(DiscoveryController::httpFallbackTargets( - QHostAddress(QStringLiteral("fe80::1")), QHostAddress(QStringLiteral("ffff:ffff:ffff:ffff::"))) - .isEmpty(), - "HTTP subnet fallback must not enumerate IPv6 address space"); - require(DiscoveryController::httpFallbackTargets( - QHostAddress(QStringLiteral("203.0.113.5")), QHostAddress(QStringLiteral("255.255.255.0"))) - .isEmpty(), - "HTTP subnet fallback must not scan public networks"); - - return 0; -} diff --git a/tests/app/ProviderRegistryTest.cpp b/tests/providers/ProviderRegistryTest.cpp similarity index 100% rename from tests/app/ProviderRegistryTest.cpp rename to tests/providers/ProviderRegistryTest.cpp diff --git a/tests/providers/ProviderStoreTest.cpp b/tests/providers/ProviderStoreTest.cpp new file mode 100644 index 00000000..f83e9332 --- /dev/null +++ b/tests/providers/ProviderStoreTest.cpp @@ -0,0 +1,6 @@ +#include "TestMain.h" + +JELLYFIN_TEST_MAIN("provider-store") +{ + return 0; +} diff --git a/tests/providers/SourceHubTest.cpp b/tests/providers/SourceHubTest.cpp new file mode 100644 index 00000000..07c9b589 --- /dev/null +++ b/tests/providers/SourceHubTest.cpp @@ -0,0 +1,6 @@ +#include "TestMain.h" + +JELLYFIN_TEST_MAIN("source-hub") +{ + return 0; +} diff --git a/tests/providers/SourceRegistryTest.cpp b/tests/providers/SourceRegistryTest.cpp deleted file mode 100644 index 832d0acc..00000000 --- a/tests/providers/SourceRegistryTest.cpp +++ /dev/null @@ -1,155 +0,0 @@ -#include "TestMain.h" -#include "cache/DatabaseManager.h" -#include "provider/ProviderRegistry.h" - -#include -#include -#include - -#include -#include -#include - -namespace { -void require(bool condition, const char *message) -{ - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } -} -template void rejects(QCoro::Task task, const char *message) -{ - bool failed = false; - try { - QCoro::waitFor(std::move(task)); - } catch (const std::exception&) { - failed = true; - } - require(failed, message); -} -} - -JELLYFIN_TEST_MAIN("source-registry") -{ - QCoreApplication app(argc, argv); - using namespace JellyfinNative; - QTemporaryDir directory; - require(directory.isValid(), "isolated persistent state directory created"); - qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); - DatabaseManager database; - require(database.initialize(directory.filePath("cache.sqlite")), "source metadata uses existing durable state"); - const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); - const QString secret = QStringLiteral("private-source-token-not-in-metadata"); - AccountProfile account; - account.profileId = QStringLiteral("account-a"); - account.serverId = QStringLiteral("server"); - account.serverUrl = QStringLiteral("https://fixture.invalid"); - account.userId = QStringLiteral("user-a"); - account.accessToken = secret; - database.upsertAccountProfile(account); - { - ProviderRegistry bundled; - bundled.registerModule("spool.jellyfin", "qrc:/providers/spool.jellyfin/logic/provider.mjs"); - QCoro::waitFor(bundled.restoreSources(&database)); - const QString source = QCoro::waitFor(bundled.configureSource("spool.jellyfin", "jellyfin-account", "library", - "Bundled source", { { "server", "https://fixture.invalid" }, { "userId", "user" }, { "token", secret } }, - { QUrl("https://fixture.invalid") })); - const auto artwork = QCoro::waitFor(bundled.callSource(source, "artwork", { { "itemId", "film" } })); - const QUrl url(artwork.value("url").toString()); - require(url.host() == "fixture.invalid" && url.path() == "/Items/film/Images/Primary", - "the actual bundled source executes through the worker registry"); - require(artwork.value("headers").toMap().value("X-Emby-Token") == secret && !url.toString().contains(secret), - "bundled artwork returns resource-specific credentials outside the URL"); - QCoro::waitFor(bundled.removeSource(source)); - } - const auto configure - = [&](ProviderRegistry& registry, const QString& module, const QString& account, const QString& label) { - return registry.configureSource(module, account, "server-key", label, - { { "label", label }, { "token", secret } }, { QUrl("https://fixture.invalid") }); - }; - QString a, b, c; - { - ProviderRegistry registry; - registry.registerModule("fixture.one", entry); - registry.registerModule("fixture.two", entry); - QCoro::waitFor(registry.restoreSources(&database)); - a = QCoro::waitFor(configure(registry, "fixture.one", "account-a", "A")); - b = QCoro::waitFor(configure(registry, "fixture.one", "account-b", "B")); - c = QCoro::waitFor(configure(registry, "fixture.two", "account-a", "C")); - require(a != b && a != c && b != c, "module and account scopes have distinct persistent source IDs"); - auto first = registry.callSource(a, "bump"); - auto second = registry.callSource(b, "bump"); - auto third = registry.callSource(c, "bump"); - require(QCoro::waitFor(std::move(first)).value("label") == "A", "first source has its own factory context"); - require( - QCoro::waitFor(std::move(second)).value("label") == "B", "second account is not an active-provider switch"); - require(QCoro::waitFor(std::move(third)).value("label") == "C", "another module runs independently"); - const auto typedA = QCoro::waitFor(registry.callSourceMediaPage(a, "mediaPage", {{"count", 2}})); - const auto typedB = QCoro::waitFor(registry.callSourceMediaPage(b, "mediaPage", {{"count", 2}})); - require(typedA.sourceId == a && typedB.sourceId == b - && typedA.items.front().sourceId == a && typedB.items.front().sourceId == b, - "typed pages preserve registry-created source identities"); - auto typedPending = registry.callSourceMediaPage(a, "delayedMediaPage"); - auto pending = registry.callSource(a, "delay", { { "milliseconds", 10000 } }); - QCoro::waitFor(registry.setSourceEnabled(a, false)); - rejects(std::move(pending), "disabling a source cancels its pending operation"); - rejects(std::move(typedPending), "disabling a source also cancels its typed page"); - rejects(registry.callSourceMediaPage(a, "mediaPage", {{"count", 1}}), - "disabled source cannot supply a typed page"); - rejects(registry.callSource(a, "bump"), "disabled source cannot be used"); - require(QCoro::waitFor(registry.callSource(b, "bump")).value("calls").toInt() == 2, - "disabling A leaves B's context and state unchanged"); - const QString renamed = QCoro::waitFor(configure(registry, "fixture.one", "account-b", "Renamed B")); - require(renamed == b, "configuration or display-name changes do not reidentify a source"); - const auto publicData = QJsonDocument::fromVariant(registry.configuredSources()).toJson(); - const auto persisted = QCoro::waitFor(database.loadSettingAsync("portable/sources/1")); - require(!publicData.contains(secret.toUtf8()) && !persisted.contains(secret), - "neither UI metadata nor durable source index contains credentials"); - require(!persisted.contains("fixture.invalid"), "authorised resource addresses are not source identity"); - rejects(registry.callSource(b, "spin"), "a failing module is interrupted"); - rejects(registry.callSource(b, "bump"), "interrupted module contexts are unavailable"); - require(QCoro::waitFor(registry.callSource(c, "bump")).value("calls").toInt() == 2, - "module failure is contained and does not reset another module"); - } - { - ProviderRegistry restored; - restored.registerModule("fixture.one", entry); - restored.registerModule("fixture.two", entry); - QCoro::waitFor(restored.restoreSources(&database)); - require(restored.configuredSources().size() == 3, - "all configured identities survive process-level registry recreation"); - rejects(restored.callSource(b, "bump"), - "restoring public metadata does not invent credentials or activate a source"); - require(QCoro::waitFor(configure(restored, "fixture.one", "account-a", "A")) == a, - "disabled source preserves its identity across restarts"); - rejects(restored.callSource(a, "bump"), "disable preference survives credential restoration"); - QCoro::waitFor(restored.setSourceEnabled(a, true)); - require(QCoro::waitFor(restored.callSource(a, "bump")).value("calls").toInt() == 1, - "reenabling starts a clean execution context under the same persistent ID"); - require(QCoro::waitFor(configure(restored, "fixture.one", "account-b", "B")) == b, - "second account restores the same persistent ID"); - require(QCoro::waitFor(configure(restored, "fixture.two", "account-a", "C")) == c, - "other module preserves its separate account namespace"); - QCoro::waitFor(restored.removeSource(a)); - rejects(restored.callSource(a, "bump"), "removed source handles cannot be reused"); - require(QCoro::waitFor(restored.callSource(c, "bump")).value("label") == "C", - "removal does not affect another module"); - } - { - ProviderRegistry restored; - QCoro::waitFor(restored.restoreSources(&database)); - require(restored.configuredSources().size() == 2, - "source deletion is durable independently of module availability"); - } - const auto accounts = QCoro::waitFor(database.loadAccountProfilesAsync()); - require(accounts.size() == 1 && accounts.front().accessToken == secret, - "removing a source does not remove its account or credentials"); - const QString incompatible = QStringLiteral("{\"format\":2,\"sources\":[]}"); - database.saveSetting("portable/sources/1", incompatible); - ProviderRegistry incompatibleRegistry; - rejects(incompatibleRegistry.restoreSources(&database), "future authoritative source state fails closed"); - require(QCoro::waitFor(database.loadSettingAsync("portable/sources/1")) == incompatible, - "incompatible authoritative state is retained rather than wiped as a cache"); - return 0; -} diff --git a/tests/api/StreamQualityControlTest.cpp b/tests/providers/StreamQualityControlTest.cpp similarity index 100% rename from tests/api/StreamQualityControlTest.cpp rename to tests/providers/StreamQualityControlTest.cpp diff --git a/tests/qml/provider-capabilities/ProviderCapabilities.qml b/tests/qml/provider-capabilities/ProviderCapabilities.qml deleted file mode 100644 index 20d5cfa0..00000000 --- a/tests/qml/provider-capabilities/ProviderCapabilities.qml +++ /dev/null @@ -1,37 +0,0 @@ -pragma Singleton -import QtQuick - -// Which optional provider capabilities the active media source serves. Shared -// shell and page QML gates every provider-specific control on one of these, -// so a source that has no SyncPlay or remote control simply has no such -// buttons, and nothing else changes. -// -// Test stand-in for the ProviderCapabilities singleton main.cpp registers -// from the provider registry (src/provider/ProviderRegistry.h). The property -// names are the contract; ProviderRegistryTest pins the C++ side to them. -QtObject { - // The provider supplies its own login flow and session (Session). - readonly property bool auth: true - // Servers can be found on the local network (Discovery, DiscoveredServers). - readonly property bool discovery: true - // Text search and suggestions (Search). - readonly property bool search: true - // Favourite, played and playback position live on the source (ItemState). - readonly property bool userItemState: true - // Playback start, progress and stop are reported back to the source. - readonly property bool playbackReporting: true - // Intro and credit markers for the skip cards. - readonly property bool segments: true - // Playlists, collections, rename and delete (Management). - readonly property bool libraryManagement: true - // Group watch with a shared clock (SyncPlay). - readonly property bool syncPlay: true - // Casting to and driving other sessions of the same source (RemoteControl). - readonly property bool remoteControl: true - // Sign-in by code on another device (QuickConnect). - readonly property bool quickConnect: true - // A datagram path between two Spool instances relayed by the source. - readonly property bool peerRelay: true - // The source can deliver a lower bitrate or resolution on request. - readonly property bool streamQuality: true -} diff --git a/tests/qml/provider-capabilities/qmldir b/tests/qml/provider-capabilities/qmldir deleted file mode 100644 index 0091baae..00000000 --- a/tests/qml/provider-capabilities/qmldir +++ /dev/null @@ -1 +0,0 @@ -singleton ProviderCapabilities 1.0 ProviderCapabilities.qml diff --git a/tests/qml/tst_ProfileNavigation.qml b/tests/qml/tst_ProfileNavigation.qml deleted file mode 100644 index bd974384..00000000 --- a/tests/qml/tst_ProfileNavigation.qml +++ /dev/null @@ -1,44 +0,0 @@ -import QtQuick -import QtTest -import "../../qml/providers/jellyfin/ProfileNavigation.js" as ProfileNavigation - -TestCase { - name: "ProfileNavigation" - - // Three accounts plus the add tile, two to a row. - function test_walksTheRunAcrossRows() { - compare(ProfileNavigation.move(0, 3, 2, "right"), 1) - compare(ProfileNavigation.move(1, 3, 2, "right"), 2) - compare(ProfileNavigation.move(3, 3, 2, "right"), 3) - compare(ProfileNavigation.move(2, 3, 2, "left"), 1) - } - - function test_stepsByRow() { - compare(ProfileNavigation.move(0, 3, 2, "down"), 2) - compare(ProfileNavigation.move(2, 3, 2, "up"), 0) - compare(ProfileNavigation.move(3, 3, 2, "down"), 3) - } - - function test_shortLastRowStillReachesTheAddTile() { - // Four accounts over three columns: the second row holds two cells, so - // down from the third column has to fall back to the last of them. - compare(ProfileNavigation.move(2, 4, 3, "down"), 4) - } - - function test_leavingTheGridReportsItself() { - compare(ProfileNavigation.move(0, 3, 2, "up"), -1) - compare(ProfileNavigation.move(1, 3, 2, "up"), -1) - compare(ProfileNavigation.move(0, 3, 2, "left"), -1) - } - - function test_addTileAloneIsTheWholeGrid() { - compare(ProfileNavigation.move(0, 0, 4, "right"), 0) - compare(ProfileNavigation.move(0, 0, 4, "down"), 0) - compare(ProfileNavigation.move(0, 0, 4, "up"), -1) - } - - function test_outOfRangeIndexesAreClamped() { - compare(ProfileNavigation.move(99, 3, 2, "left"), 2) - compare(ProfileNavigation.move(-4, 3, 2, "right"), 1) - } -} diff --git a/tests/qml/tst_ProviderGating.qml b/tests/qml/tst_ProviderGating.qml deleted file mode 100644 index 68769f4d..00000000 --- a/tests/qml/tst_ProviderGating.qml +++ /dev/null @@ -1,52 +0,0 @@ -import QtQuick -import QtTest -import "provider-capabilities" as Provider -import "../../qml/shell/TopBarNavigation.js" as TopBarNavigation - -// The contract between shared QML and the provider registry: the capability -// names the shell gates on, and the rail's index space staying continuous when -// a gated-out button is missing, so Left and Right never land on nothing. -TestCase { - name: "ProviderGating" - - readonly property var capabilityNames: ["auth", "discovery", "search", "userItemState", "playbackReporting", - "segments", "libraryManagement", "syncPlay", "remoteControl", "quickConnect", "peerRelay", "streamQuality"] - - function test_capabilityNamesAreBooleans() { - for (let index = 0; index < capabilityNames.length; ++index) { - const name = capabilityNames[index] - compare(typeof Provider.ProviderCapabilities[name], "boolean", name) - } - } - - function test_everyButtonTakesAnIndexWhenAllArePresent() { - compare(TopBarNavigation.lastIndex(4, true, true, true), 6) - compare(TopBarNavigation.slotAt(4, 4, true, true, true).kind, "remote") - compare(TopBarNavigation.slotAt(5, 4, true, true, true).kind, "cast") - compare(TopBarNavigation.slotAt(6, 4, true, true, true).kind, "sync") - compare(TopBarNavigation.indexOf("sync", 4, true, true, true), 6) - } - - function test_gatedOutButtonsTakeNoIndex() { - // No SyncPlay: the rail ends at Cast and Right from Cast stays there. - compare(TopBarNavigation.lastIndex(4, false, true, false), 4) - compare(TopBarNavigation.slotAt(4, 4, false, true, false).kind, "cast") - compare(TopBarNavigation.slotAt(5, 4, false, true, false).kind, "cast") - compare(TopBarNavigation.indexOf("sync", 4, false, true, false), -1) - - // No remote control at all: the rail is only the navigation buttons. - compare(TopBarNavigation.lastIndex(3, false, false, true), 3) - compare(TopBarNavigation.slotAt(3, 3, false, false, true).kind, "sync") - compare(TopBarNavigation.slotAt(9, 3, false, false, false).kind, "rail") - compare(TopBarNavigation.slotAt(9, 3, false, false, false).railIndex, 2) - compare(TopBarNavigation.indexOf("remote", 3, false, false, false), -1) - compare(TopBarNavigation.indexOf("cast", 3, false, false, false), -1) - } - - function test_indexesClampIntoTheRail() { - compare(TopBarNavigation.slotAt(-3, 4, true, true, true).kind, "rail") - compare(TopBarNavigation.slotAt(-3, 4, true, true, true).railIndex, 0) - compare(TopBarNavigation.slotAt(0, 0, false, false, false), null) - compare(TopBarNavigation.lastIndex(0, false, false, false), 0) - } -} diff --git a/third_party/zstd/LICENSE b/third_party/zstd/LICENSE new file mode 100644 index 00000000..75800288 --- /dev/null +++ b/third_party/zstd/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For Zstandard software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/zstd/bounded_zstd.c b/third_party/zstd/bounded_zstd.c new file mode 100644 index 00000000..b1f66087 --- /dev/null +++ b/third_party/zstd/bounded_zstd.c @@ -0,0 +1,114 @@ +// Wraps zstd's reference decoder (doc/educational_decoder, v1.5.7, BSD) for +// untrusted input. The reference decoder calls exit() on corrupt data and +// never frees on that path; here every allocation is tracked per thread and +// released, and exit() unwinds back to the caller instead. +#include "bounded_zstd.h" + +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#define SPOOL_ZSTD_TLS __declspec(thread) +#else +#define SPOOL_ZSTD_TLS _Thread_local +#endif + +typedef struct allocation { + struct allocation *previous; + struct allocation *next; +} allocation; + +static SPOOL_ZSTD_TLS jmp_buf *unwind; +static SPOOL_ZSTD_TLS allocation *allocations; + +static void *tracked(allocation *block) +{ + if (!block) + return NULL; + block->previous = NULL; + block->next = allocations; + if (allocations) + allocations->previous = block; + allocations = block; + return block + 1; +} + +static void *bounded_malloc(size_t size) +{ + return size > SIZE_MAX - sizeof(allocation) ? NULL : tracked(malloc(sizeof(allocation) + size)); +} + +static void *bounded_calloc(size_t count, size_t size) +{ + if (size && count > (SIZE_MAX - sizeof(allocation)) / size) + return NULL; + void *pointer = tracked(malloc(sizeof(allocation) + count * size)); + if (pointer) + memset(pointer, 0, count * size); + return pointer; +} + +static void bounded_free(void *pointer) +{ + if (!pointer) + return; + allocation *block = (allocation *)pointer - 1; + if (block->previous) + block->previous->next = block->next; + else + allocations = block->next; + if (block->next) + block->next->previous = block->previous; + free(block); +} + +static void release_all(void) +{ + while (allocations) { + allocation *next = allocations->next; + free(allocations); + allocations = next; + } +} + +#define ZDEC_NO_MESSAGE +#define malloc bounded_malloc +#define calloc bounded_calloc +#define free bounded_free +#define exit(code) longjmp(*unwind, 1) +#include "zstd_decompress.c" +#undef malloc +#undef calloc +#undef free +#undef exit + +size_t spool_zstd_content_size(const void *source, size_t sourceLength) +{ + jmp_buf here; + jmp_buf *outer = unwind; + size_t size = (size_t)-1; + unwind = &here; + if (setjmp(here) == 0) + size = ZSTD_get_decompressed_size(source, sourceLength); + unwind = outer; + release_all(); + return size; +} + +int spool_zstd_decompress(const void *source, size_t sourceLength, void *destination, size_t capacity, size_t *written) +{ + jmp_buf here; + jmp_buf *outer = unwind; + int result = 0; + unwind = &here; + if (setjmp(here) == 0) + *written = ZSTD_decompress(destination, capacity, source, sourceLength); + else + result = -1; + unwind = outer; + release_all(); + return result; +} diff --git a/third_party/zstd/bounded_zstd.h b/third_party/zstd/bounded_zstd.h new file mode 100644 index 00000000..a2bcf3de --- /dev/null +++ b/third_party/zstd/bounded_zstd.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// The frame's declared content size, or (size_t)-1 when it declares none or +// the header is invalid. +size_t spool_zstd_content_size(const void *source, size_t sourceLength); +// Decodes one zstd frame into at most `capacity` bytes. Returns 0 and sets +// `written` on success, -1 on corrupt, truncated or oversized input. +int spool_zstd_decompress(const void *source, size_t sourceLength, void *destination, size_t capacity, size_t *written); + +#ifdef __cplusplus +} +#endif diff --git a/third_party/zstd/zstd_decompress.c b/third_party/zstd/zstd_decompress.c new file mode 100644 index 00000000..2b8880b0 --- /dev/null +++ b/third_party/zstd/zstd_decompress.c @@ -0,0 +1,2257 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under both the BSD-style license (found in the + * LICENSE file in the root directory of this source tree) and the GPLv2 (found + * in the COPYING file in the root directory of this source tree). + * You may select, at your option, one of the above-listed licenses. + */ + +/// Zstandard educational decoder implementation +/// See https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md + +#include "zstd_decompress.h" +#include // uint8_t, etc. +#include // fprintf +#include // malloc, free, exit +#include // memset, memcpy + +/******* IMPORTANT CONSTANTS *********************************************/ + +// Zstandard frame +// "Magic_Number +// 4 Bytes, little-endian format. Value : 0xFD2FB528" +#define ZSTD_MAGIC_NUMBER 0xFD2FB528U + +// The size of `Block_Content` is limited by `Block_Maximum_Size`, +#define ZSTD_BLOCK_SIZE_MAX ((size_t)128 * 1024) + +// literal blocks can't be larger than their block +#define MAX_LITERALS_SIZE ZSTD_BLOCK_SIZE_MAX + +/******* UTILITY MACROS AND TYPES *********************************************/ +#define MAX(a, b) ((a) > (b) ? (a) : (b)) +#define MIN(a, b) ((a) < (b) ? (a) : (b)) + +#if defined(ZDEC_NO_MESSAGE) +#define MESSAGE(...) +#else +#define MESSAGE(...) fprintf(stderr, "" __VA_ARGS__) +#endif + +/// This decoder calls exit(1) when it encounters an error, however a production +/// library should propagate error codes +#define ERROR(s) \ + do { \ + MESSAGE("Error: %s\n", s); \ + exit(1); \ + } while (0) +#define INP_SIZE() \ + ERROR("Input buffer smaller than it should be or input is " \ + "corrupted") +#define OUT_SIZE() ERROR("Output buffer too small for output") +#define CORRUPTION() ERROR("Corruption detected while decompressing") +#define BAD_ALLOC() ERROR("Memory allocation error") +#define IMPOSSIBLE() ERROR("An impossibility has occurred") + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; + +typedef int8_t i8; +typedef int16_t i16; +typedef int32_t i32; +typedef int64_t i64; +/******* END UTILITY MACROS AND TYPES *****************************************/ + +/******* IMPLEMENTATION PRIMITIVE PROTOTYPES **********************************/ +/// The implementations for these functions can be found at the bottom of this +/// file. They implement low-level functionality needed for the higher level +/// decompression functions. + +/*** IO STREAM OPERATIONS *************/ + +/// ostream_t/istream_t are used to wrap the pointers/length data passed into +/// ZSTD_decompress, so that all IO operations are safely bounds checked +/// They are written/read forward, and reads are treated as little-endian +/// They should be used opaquely to ensure safety +typedef struct { + u8 *ptr; + size_t len; +} ostream_t; + +typedef struct { + const u8 *ptr; + size_t len; + + // Input often reads a few bits at a time, so maintain an internal offset + int bit_offset; +} istream_t; + +/// The following two functions are the only ones that allow the istream to be +/// non-byte aligned + +/// Reads `num` bits from a bitstream, and updates the internal offset +static inline u64 IO_read_bits(istream_t *const in, const int num_bits); +/// Backs-up the stream by `num` bits so they can be read again +static inline void IO_rewind_bits(istream_t *const in, const int num_bits); +/// If the remaining bits in a byte will be unused, advance to the end of the +/// byte +static inline void IO_align_stream(istream_t *const in); + +/// Write the given byte into the output stream +static inline void IO_write_byte(ostream_t *const out, u8 symb); + +/// Returns the number of bytes left to be read in this stream. The stream must +/// be byte aligned. +static inline size_t IO_istream_len(const istream_t *const in); + +/// Advances the stream by `len` bytes, and returns a pointer to the chunk that +/// was skipped. The stream must be byte aligned. +static inline const u8 *IO_get_read_ptr(istream_t *const in, size_t len); +/// Advances the stream by `len` bytes, and returns a pointer to the chunk that +/// was skipped so it can be written to. +static inline u8 *IO_get_write_ptr(ostream_t *const out, size_t len); + +/// Advance the inner state by `len` bytes. The stream must be byte aligned. +static inline void IO_advance_input(istream_t *const in, size_t len); + +/// Returns an `ostream_t` constructed from the given pointer and length. +static inline ostream_t IO_make_ostream(u8 *out, size_t len); +/// Returns an `istream_t` constructed from the given pointer and length. +static inline istream_t IO_make_istream(const u8 *in, size_t len); + +/// Returns an `istream_t` with the same base as `in`, and length `len`. +/// Then, advance `in` to account for the consumed bytes. +/// `in` must be byte aligned. +static inline istream_t IO_make_sub_istream(istream_t *const in, size_t len); +/*** END IO STREAM OPERATIONS *********/ + +/*** BITSTREAM OPERATIONS *************/ +/// Read `num` bits (up to 64) from `src + offset`, where `offset` is in bits, +/// and return them interpreted as a little-endian unsigned integer. +static inline u64 read_bits_LE(const u8 *src, const int num_bits, const size_t offset); + +/// Read bits from the end of a HUF or FSE bitstream. `offset` is in bits, so +/// it updates `offset` to `offset - bits`, and then reads `bits` bits from +/// `src + offset`. If the offset becomes negative, the extra bits at the +/// bottom are filled in with `0` bits instead of reading from before `src`. +static inline u64 STREAM_read_bits(const u8 *src, const int bits, i64 *const offset); +/*** END BITSTREAM OPERATIONS *********/ + +/*** BIT COUNTING OPERATIONS **********/ +/// Returns the index of the highest set bit in `num`, or `-1` if `num == 0` +static inline int highest_set_bit(const u64 num); +/*** END BIT COUNTING OPERATIONS ******/ + +/*** HUFFMAN PRIMITIVES ***************/ +// Table decode method uses exponential memory, so we need to limit depth +#define HUF_MAX_BITS (16) + +// Limit the maximum number of symbols to 256 so we can store a symbol in a byte +#define HUF_MAX_SYMBS (256) + +/// Structure containing all tables necessary for efficient Huffman decoding +typedef struct { + u8 *symbols; + u8 *num_bits; + int max_bits; +} HUF_dtable; + +/// Decode a single symbol and read in enough bits to refresh the state +static inline u8 HUF_decode_symbol( + const HUF_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset); +/// Read in a full state's worth of bits to initialize it +static inline void HUF_init_state( + const HUF_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset); + +/// Decompresses a single Huffman stream, returns the number of bytes decoded. +/// `src_len` must be the exact length of the Huffman-coded block. +static size_t HUF_decompress_1stream(const HUF_dtable *const dtable, ostream_t *const out, istream_t *const in); +/// Same as previous but decodes 4 streams, formatted as in the Zstandard +/// specification. +/// `src_len` must be the exact length of the Huffman-coded block. +static size_t HUF_decompress_4stream(const HUF_dtable *const dtable, ostream_t *const out, istream_t *const in); + +/// Initialize a Huffman decoding table using the table of bit counts provided +static void HUF_init_dtable(HUF_dtable *const table, const u8 *const bits, const int num_symbs); +/// Initialize a Huffman decoding table using the table of weights provided +/// Weights follow the definition provided in the Zstandard specification +static void HUF_init_dtable_usingweights(HUF_dtable *const table, const u8 *const weights, const int num_symbs); + +/// Free the malloc'ed parts of a decoding table +static void HUF_free_dtable(HUF_dtable *const dtable); +/*** END HUFFMAN PRIMITIVES ***********/ + +/*** FSE PRIMITIVES *******************/ +/// For more description of FSE see +/// https://github.com/Cyan4973/FiniteStateEntropy/ + +// FSE table decoding uses exponential memory, so limit the maximum accuracy +#define FSE_MAX_ACCURACY_LOG (15) +// Limit the maximum number of symbols so they can be stored in a single byte +#define FSE_MAX_SYMBS (256) + +/// The tables needed to decode FSE encoded streams +typedef struct { + u8 *symbols; + u8 *num_bits; + u16 *new_state_base; + int accuracy_log; +} FSE_dtable; + +/// Return the symbol for the current state +static inline u8 FSE_peek_symbol(const FSE_dtable *const dtable, const u16 state); +/// Read the number of bits necessary to update state, update, and shift offset +/// back to reflect the bits read +static inline void FSE_update_state( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset); + +/// Combine peek and update: decode a symbol and update the state +static inline u8 FSE_decode_symbol( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset); + +/// Read bits from the stream to initialize the state and shift offset back +static inline void FSE_init_state( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset); + +/// Decompress two interleaved bitstreams (e.g. compressed Huffman weights) +/// using an FSE decoding table. `src_len` must be the exact length of the +/// block. +static size_t FSE_decompress_interleaved2(const FSE_dtable *const dtable, ostream_t *const out, istream_t *const in); + +/// Initialize a decoding table using normalized frequencies. +static void FSE_init_dtable( + FSE_dtable *const dtable, const i16 *const norm_freqs, const int num_symbs, const int accuracy_log); + +/// Decode an FSE header as defined in the Zstandard format specification and +/// use the decoded frequencies to initialize a decoding table. +static void FSE_decode_header(FSE_dtable *const dtable, istream_t *const in, const int max_accuracy_log); + +/// Initialize an FSE table that will always return the same symbol and consume +/// 0 bits per symbol, to be used for RLE mode in sequence commands +static void FSE_init_dtable_rle(FSE_dtable *const dtable, const u8 symb); + +/// Free the malloc'ed parts of a decoding table +static void FSE_free_dtable(FSE_dtable *const dtable); +/*** END FSE PRIMITIVES ***************/ + +/******* END IMPLEMENTATION PRIMITIVE PROTOTYPES ******************************/ + +/******* ZSTD HELPER STRUCTS AND PROTOTYPES ***********************************/ + +/// A small structure that can be reused in various places that need to access +/// frame header information +typedef struct { + // The size of window that we need to be able to contiguously store for + // references + size_t window_size; + // The total output size of this compressed frame + size_t frame_content_size; + + // The dictionary id if this frame uses one + u32 dictionary_id; + + // Whether or not the content of this frame has a checksum + int content_checksum_flag; + // Whether or not the output for this frame is in a single segment + int single_segment_flag; +} frame_header_t; + +/// The context needed to decode blocks in a frame +typedef struct { + frame_header_t header; + + // The total amount of data available for backreferences, to determine if an + // offset too large to be correct + size_t current_total_output; + + const u8 *dict_content; + size_t dict_content_len; + + // Entropy encoding tables so they can be repeated by future blocks instead + // of retransmitting + HUF_dtable literals_dtable; + FSE_dtable ll_dtable; + FSE_dtable ml_dtable; + FSE_dtable of_dtable; + + // The last 3 offsets for the special "repeat offsets". + u64 previous_offsets[3]; +} frame_context_t; + +/// The decoded contents of a dictionary so that it doesn't have to be repeated +/// for each frame that uses it +struct dictionary_s { + // Entropy tables + HUF_dtable literals_dtable; + FSE_dtable ll_dtable; + FSE_dtable ml_dtable; + FSE_dtable of_dtable; + + // Raw content for backreferences + u8 *content; + size_t content_size; + + // Offset history to prepopulate the frame's history + u64 previous_offsets[3]; + + u32 dictionary_id; +}; + +/// A tuple containing the parts necessary to decode and execute a ZSTD sequence +/// command +typedef struct { + u32 literal_length; + u32 match_length; + u32 offset; +} sequence_command_t; + +/// The decoder works top-down, starting at the high level like Zstd frames, and +/// working down to lower more technical levels such as blocks, literals, and +/// sequences. The high-level functions roughly follow the outline of the +/// format specification: +/// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md + +/// Before the implementation of each high-level function declared here, the +/// prototypes for their helper functions are defined and explained + +/// Decode a single Zstd frame, or error if the input is not a valid frame. +/// Accepts a dict argument, which may be NULL indicating no dictionary. +/// See +/// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#frame-concatenation +static void decode_frame(ostream_t *const out, istream_t *const in, const dictionary_t *const dict); + +// Decode data in a compressed block +static void decompress_block(frame_context_t *const ctx, ostream_t *const out, istream_t *const in); + +// Decode the literals section of a block +static size_t decode_literals(frame_context_t *const ctx, istream_t *const in, u8 **const literals); + +// Decode the sequences part of a block +static size_t decode_sequences(frame_context_t *const ctx, istream_t *const in, sequence_command_t **const sequences); + +// Execute the decoded sequences on the literals block +static void execute_sequences(frame_context_t *const ctx, ostream_t *const out, const u8 *const literals, + const size_t literals_len, const sequence_command_t *const sequences, const size_t num_sequences); + +// Copies literals and returns the total literal length that was copied +static u32 copy_literals(const size_t seq, istream_t *litstream, ostream_t *const out); + +// Given an offset code from a sequence command (either an actual offset value +// or an index for previous offset), computes the correct offset and updates +// the offset history +static size_t compute_offset(sequence_command_t seq, u64 *const offset_hist); + +// Given an offset, match length, and total output, as well as the frame +// context for the dictionary, determines if the dictionary is used and +// executes the copy operation +static void execute_match_copy( + frame_context_t *const ctx, size_t offset, size_t match_length, size_t total_output, ostream_t *const out); + +/******* END ZSTD HELPER STRUCTS AND PROTOTYPES *******************************/ + +size_t ZSTD_decompress(void *const dst, const size_t dst_len, const void *const src, const size_t src_len) +{ + dictionary_t *const uninit_dict = create_dictionary(); + size_t const decomp_size = ZSTD_decompress_with_dict(dst, dst_len, src, src_len, uninit_dict); + free_dictionary(uninit_dict); + return decomp_size; +} + +size_t ZSTD_decompress_with_dict( + void *const dst, const size_t dst_len, const void *const src, const size_t src_len, dictionary_t *parsed_dict) +{ + + istream_t in = IO_make_istream(src, src_len); + ostream_t out = IO_make_ostream(dst, dst_len); + + // "A content compressed by Zstandard is transformed into a Zstandard frame. + // Multiple frames can be appended into a single file or stream. A frame is + // totally independent, has a defined beginning and end, and a set of + // parameters which tells the decoder how to decompress it." + + /* this decoder assumes decompression of a single frame */ + decode_frame(&out, &in, parsed_dict); + + return (size_t)(out.ptr - (u8 *)dst); +} + +/******* FRAME DECODING ******************************************************/ + +static void decode_data_frame(ostream_t *const out, istream_t *const in, const dictionary_t *const dict); +static void init_frame_context(frame_context_t *const context, istream_t *const in, const dictionary_t *const dict); +static void free_frame_context(frame_context_t *const context); +static void parse_frame_header(frame_header_t *const header, istream_t *const in); +static void frame_context_apply_dict(frame_context_t *const ctx, const dictionary_t *const dict); + +static void decompress_data(frame_context_t *const ctx, ostream_t *const out, istream_t *const in); + +static void decode_frame(ostream_t *const out, istream_t *const in, const dictionary_t *const dict) +{ + const u32 magic_number = (u32)IO_read_bits(in, 32); + if (magic_number == ZSTD_MAGIC_NUMBER) { + // ZSTD frame + decode_data_frame(out, in, dict); + + return; + } + + // not a real frame or a skippable frame + ERROR("Tried to decode non-ZSTD frame"); +} + +/// Decode a frame that contains compressed data. Not all frames do as there +/// are skippable frames. +/// See +/// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#general-structure-of-zstandard-frame-format +static void decode_data_frame(ostream_t *const out, istream_t *const in, const dictionary_t *const dict) +{ + frame_context_t ctx; + + // Initialize the context that needs to be carried from block to block + init_frame_context(&ctx, in, dict); + + if (ctx.header.frame_content_size != 0 && ctx.header.frame_content_size > out->len) { + OUT_SIZE(); + } + + decompress_data(&ctx, out, in); + + free_frame_context(&ctx); +} + +/// Takes the information provided in the header and dictionary, and initializes +/// the context for this frame +static void init_frame_context(frame_context_t *const context, istream_t *const in, const dictionary_t *const dict) +{ + // Most fields in context are correct when initialized to 0 + memset(context, 0, sizeof(frame_context_t)); + + // Parse data from the frame header + parse_frame_header(&context->header, in); + + // Set up the offset history for the repeat offset commands + context->previous_offsets[0] = 1; + context->previous_offsets[1] = 4; + context->previous_offsets[2] = 8; + + // Apply details from the dict if it exists + frame_context_apply_dict(context, dict); +} + +static void free_frame_context(frame_context_t *const context) +{ + HUF_free_dtable(&context->literals_dtable); + + FSE_free_dtable(&context->ll_dtable); + FSE_free_dtable(&context->ml_dtable); + FSE_free_dtable(&context->of_dtable); + + memset(context, 0, sizeof(frame_context_t)); +} + +static void parse_frame_header(frame_header_t *const header, istream_t *const in) +{ + // "The first header's byte is called the Frame_Header_Descriptor. It tells + // which other fields are present. Decoding this byte is enough to tell the + // size of Frame_Header. + // + // Bit number Field name + // 7-6 Frame_Content_Size_flag + // 5 Single_Segment_flag + // 4 Unused_bit + // 3 Reserved_bit + // 2 Content_Checksum_flag + // 1-0 Dictionary_ID_flag" + const u8 descriptor = (u8)IO_read_bits(in, 8); + + // decode frame header descriptor into flags + const u8 frame_content_size_flag = descriptor >> 6; + const u8 single_segment_flag = (descriptor >> 5) & 1; + const u8 reserved_bit = (descriptor >> 3) & 1; + const u8 content_checksum_flag = (descriptor >> 2) & 1; + const u8 dictionary_id_flag = descriptor & 3; + + if (reserved_bit != 0) { + CORRUPTION(); + } + + header->single_segment_flag = single_segment_flag; + header->content_checksum_flag = content_checksum_flag; + + // decode window size + if (!single_segment_flag) { + // "Provides guarantees on maximum back-reference distance that will be + // used within compressed data. This information is important for + // decoders to allocate enough memory. + // + // Bit numbers 7-3 2-0 + // Field name Exponent Mantissa" + u8 window_descriptor = (u8)IO_read_bits(in, 8); + u8 exponent = window_descriptor >> 3; + u8 mantissa = window_descriptor & 7; + + // Use the algorithm from the specification to compute window size + // https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#window_descriptor + size_t window_base = (size_t)1 << (10 + exponent); + size_t window_add = (window_base / 8) * mantissa; + header->window_size = window_base + window_add; + } + + // decode dictionary id if it exists + if (dictionary_id_flag) { + // "This is a variable size field, which contains the ID of the + // dictionary required to properly decode the frame. Note that this + // field is optional. When it's not present, it's up to the caller to + // make sure it uses the correct dictionary. Format is little-endian." + const int bytes_array[] = { 0, 1, 2, 4 }; + const int bytes = bytes_array[dictionary_id_flag]; + + header->dictionary_id = (u32)IO_read_bits(in, bytes * 8); + } else { + header->dictionary_id = 0; + } + + // decode frame content size if it exists + if (single_segment_flag || frame_content_size_flag) { + // "This is the original (uncompressed) size. This information is + // optional. The Field_Size is provided according to value of + // Frame_Content_Size_flag. The Field_Size can be equal to 0 (not + // present), 1, 2, 4 or 8 bytes. Format is little-endian." + // + // if frame_content_size_flag == 0 but single_segment_flag is set, we + // still have a 1 byte field + const int bytes_array[] = { 1, 2, 4, 8 }; + const int bytes = bytes_array[frame_content_size_flag]; + + header->frame_content_size = IO_read_bits(in, bytes * 8); + if (bytes == 2) { + // "When Field_Size is 2, the offset of 256 is added." + header->frame_content_size += 256; + } + } else { + header->frame_content_size = 0; + } + + if (single_segment_flag) { + // "The Window_Descriptor byte is optional. It is absent when + // Single_Segment_flag is set. In this case, the maximum back-reference + // distance is the content size itself, which can be any value from 1 to + // 2^64-1 bytes (16 EB)." + header->window_size = header->frame_content_size; + } +} + +/// Decompress the data from a frame block by block +static void decompress_data(frame_context_t *const ctx, ostream_t *const out, istream_t *const in) +{ + // "A frame encapsulates one or multiple blocks. Each block can be + // compressed or not, and has a guaranteed maximum content size, which + // depends on frame parameters. Unlike frames, each block depends on + // previous blocks for proper decoding. However, each block can be + // decompressed without waiting for its successor, allowing streaming + // operations." + int last_block = 0; + do { + // "Last_Block + // + // The lowest bit signals if this block is the last one. Frame ends + // right after this block. + // + // Block_Type and Block_Size + // + // The next 2 bits represent the Block_Type, while the remaining 21 bits + // represent the Block_Size. Format is little-endian." + last_block = (int)IO_read_bits(in, 1); + const int block_type = (int)IO_read_bits(in, 2); + const size_t block_len = IO_read_bits(in, 21); + + switch (block_type) { + case 0: { + // "Raw_Block - this is an uncompressed block. Block_Size is the + // number of bytes to read and copy." + const u8 *const read_ptr = IO_get_read_ptr(in, block_len); + u8 *const write_ptr = IO_get_write_ptr(out, block_len); + + // Copy the raw data into the output + memcpy(write_ptr, read_ptr, block_len); + + ctx->current_total_output += block_len; + break; + } + case 1: { + // "RLE_Block - this is a single byte, repeated N times. In which + // case, Block_Size is the size to regenerate, while the + // "compressed" block is just 1 byte (the byte to repeat)." + const u8 *const read_ptr = IO_get_read_ptr(in, 1); + u8 *const write_ptr = IO_get_write_ptr(out, block_len); + + // Copy `block_len` copies of `read_ptr[0]` to the output + memset(write_ptr, read_ptr[0], block_len); + + ctx->current_total_output += block_len; + break; + } + case 2: { + // "Compressed_Block - this is a Zstandard compressed block, + // detailed in another section of this specification. Block_Size is + // the compressed size. + + // Create a sub-stream for the block + istream_t block_stream = IO_make_sub_istream(in, block_len); + decompress_block(ctx, out, &block_stream); + break; + } + case 3: + // "Reserved - this is not a block. This value cannot be used with + // current version of this specification." + CORRUPTION(); + break; + default: + IMPOSSIBLE(); + } + } while (!last_block); + + if (ctx->header.content_checksum_flag) { + // This program does not support checking the checksum, so skip over it + // if it's present + IO_advance_input(in, 4); + } +} +/******* END FRAME DECODING ***************************************************/ + +/******* BLOCK DECOMPRESSION **************************************************/ +static void decompress_block(frame_context_t *const ctx, ostream_t *const out, istream_t *const in) +{ + // "A compressed block consists of 2 sections : + // + // Literals_Section + // Sequences_Section" + + // Part 1: decode the literals block + u8 *literals = NULL; + const size_t literals_size = decode_literals(ctx, in, &literals); + + // Part 2: decode the sequences block + sequence_command_t *sequences = NULL; + const size_t num_sequences = decode_sequences(ctx, in, &sequences); + + // Part 3: combine literals and sequence commands to generate output + execute_sequences(ctx, out, literals, literals_size, sequences, num_sequences); + free(literals); + free(sequences); +} +/******* END BLOCK DECOMPRESSION **********************************************/ + +/******* LITERALS DECODING ****************************************************/ +static size_t decode_literals_simple( + istream_t *const in, u8 **const literals, const int block_type, const int size_format); +static size_t decode_literals_compressed( + frame_context_t *const ctx, istream_t *const in, u8 **const literals, const int block_type, const int size_format); +static void decode_huf_table(HUF_dtable *const dtable, istream_t *const in); +static void fse_decode_hufweights(ostream_t *weights, istream_t *const in, int *const num_symbs); + +static size_t decode_literals(frame_context_t *const ctx, istream_t *const in, u8 **const literals) +{ + // "Literals can be stored uncompressed or compressed using Huffman prefix + // codes. When compressed, an optional tree description can be present, + // followed by 1 or 4 streams." + // + // "Literals_Section_Header + // + // Header is in charge of describing how literals are packed. It's a + // byte-aligned variable-size bitfield, ranging from 1 to 5 bytes, using + // little-endian convention." + // + // "Literals_Block_Type + // + // This field uses 2 lowest bits of first byte, describing 4 different block + // types" + // + // size_format takes between 1 and 2 bits + int block_type = (int)IO_read_bits(in, 2); + int size_format = (int)IO_read_bits(in, 2); + + if (block_type <= 1) { + // Raw or RLE literals block + return decode_literals_simple(in, literals, block_type, size_format); + } else { + // Huffman compressed literals + return decode_literals_compressed(ctx, in, literals, block_type, size_format); + } +} + +/// Decodes literals blocks in raw or RLE form +static size_t decode_literals_simple( + istream_t *const in, u8 **const literals, const int block_type, const int size_format) +{ + size_t size; + switch (size_format) { + // These cases are in the form ?0 + // In this case, the ? bit is actually part of the size field + case 0: + case 2: + // "Size_Format uses 1 bit. Regenerated_Size uses 5 bits (0-31)." + IO_rewind_bits(in, 1); + size = IO_read_bits(in, 5); + break; + case 1: + // "Size_Format uses 2 bits. Regenerated_Size uses 12 bits (0-4095)." + size = IO_read_bits(in, 12); + break; + case 3: + // "Size_Format uses 2 bits. Regenerated_Size uses 20 bits (0-1048575)." + size = IO_read_bits(in, 20); + break; + default: + // Size format is in range 0-3 + IMPOSSIBLE(); + } + + if (size > MAX_LITERALS_SIZE) { + CORRUPTION(); + } + + *literals = malloc(size); + if (!*literals) { + BAD_ALLOC(); + } + + switch (block_type) { + case 0: { + // "Raw_Literals_Block - Literals are stored uncompressed." + const u8 *const read_ptr = IO_get_read_ptr(in, size); + memcpy(*literals, read_ptr, size); + break; + } + case 1: { + // "RLE_Literals_Block - Literals consist of a single byte value repeated N times." + const u8 *const read_ptr = IO_get_read_ptr(in, 1); + memset(*literals, read_ptr[0], size); + break; + } + default: + IMPOSSIBLE(); + } + + return size; +} + +/// Decodes Huffman compressed literals +static size_t decode_literals_compressed( + frame_context_t *const ctx, istream_t *const in, u8 **const literals, const int block_type, const int size_format) +{ + size_t regenerated_size, compressed_size; + // Only size_format=0 has 1 stream, so default to 4 + int num_streams = 4; + switch (size_format) { + case 0: + // "A single stream. Both Compressed_Size and Regenerated_Size use 10 + // bits (0-1023)." + num_streams = 1; + // Fall through as it has the same size format + /* fallthrough */ + case 1: + // "4 streams. Both Compressed_Size and Regenerated_Size use 10 bits + // (0-1023)." + regenerated_size = IO_read_bits(in, 10); + compressed_size = IO_read_bits(in, 10); + break; + case 2: + // "4 streams. Both Compressed_Size and Regenerated_Size use 14 bits + // (0-16383)." + regenerated_size = IO_read_bits(in, 14); + compressed_size = IO_read_bits(in, 14); + break; + case 3: + // "4 streams. Both Compressed_Size and Regenerated_Size use 18 bits + // (0-262143)." + regenerated_size = IO_read_bits(in, 18); + compressed_size = IO_read_bits(in, 18); + break; + default: + // Impossible + IMPOSSIBLE(); + } + if (regenerated_size > MAX_LITERALS_SIZE) { + CORRUPTION(); + } + + *literals = malloc(regenerated_size); + if (!*literals) { + BAD_ALLOC(); + } + + ostream_t lit_stream = IO_make_ostream(*literals, regenerated_size); + istream_t huf_stream = IO_make_sub_istream(in, compressed_size); + + if (block_type == 2) { + // Decode the provided Huffman table + // "This section is only present when Literals_Block_Type type is + // Compressed_Literals_Block (2)." + + HUF_free_dtable(&ctx->literals_dtable); + decode_huf_table(&ctx->literals_dtable, &huf_stream); + } else { + // If the previous Huffman table is being repeated, ensure it exists + if (!ctx->literals_dtable.symbols) { + CORRUPTION(); + } + } + + size_t symbols_decoded; + if (num_streams == 1) { + symbols_decoded = HUF_decompress_1stream(&ctx->literals_dtable, &lit_stream, &huf_stream); + } else { + symbols_decoded = HUF_decompress_4stream(&ctx->literals_dtable, &lit_stream, &huf_stream); + } + + if (symbols_decoded != regenerated_size) { + CORRUPTION(); + } + + return regenerated_size; +} + +// Decode the Huffman table description +static void decode_huf_table(HUF_dtable *const dtable, istream_t *const in) +{ + // "All literal values from zero (included) to last present one (excluded) + // are represented by Weight with values from 0 to Max_Number_of_Bits." + + // "This is a single byte value (0-255), which describes how to decode the list of weights." + const u8 header = IO_read_bits(in, 8); + + u8 weights[HUF_MAX_SYMBS]; + memset(weights, 0, sizeof(weights)); + + int num_symbs; + + if (header >= 128) { + // "This is a direct representation, where each Weight is written + // directly as a 4 bits field (0-15). The full representation occupies + // ((Number_of_Symbols+1)/2) bytes, meaning it uses a last full byte + // even if Number_of_Symbols is odd. Number_of_Symbols = headerByte - + // 127" + num_symbs = header - 127; + const size_t bytes = (num_symbs + 1) / 2; + + const u8 *const weight_src = IO_get_read_ptr(in, bytes); + + for (int i = 0; i < num_symbs; i++) { + // "They are encoded forward, 2 + // weights to a byte with the first weight taking the top four bits + // and the second taking the bottom four (e.g. the following + // operations could be used to read the weights: Weight[0] = + // (Byte[0] >> 4), Weight[1] = (Byte[0] & 0xf), etc.)." + if (i % 2 == 0) { + weights[i] = weight_src[i / 2] >> 4; + } else { + weights[i] = weight_src[i / 2] & 0xf; + } + } + } else { + // The weights are FSE encoded, decode them before we can construct the + // table + istream_t fse_stream = IO_make_sub_istream(in, header); + ostream_t weight_stream = IO_make_ostream(weights, HUF_MAX_SYMBS); + fse_decode_hufweights(&weight_stream, &fse_stream, &num_symbs); + } + + // Construct the table using the decoded weights + HUF_init_dtable_usingweights(dtable, weights, num_symbs); +} + +static void fse_decode_hufweights(ostream_t *weights, istream_t *const in, int *const num_symbs) +{ + const int MAX_ACCURACY_LOG = 7; + + FSE_dtable dtable; + + // "An FSE bitstream starts by a header, describing probabilities + // distribution. It will create a Decoding Table. For a list of Huffman + // weights, maximum accuracy is 7 bits." + FSE_decode_header(&dtable, in, MAX_ACCURACY_LOG); + + // Decode the weights + *num_symbs = FSE_decompress_interleaved2(&dtable, weights, in); + + FSE_free_dtable(&dtable); +} +/******* END LITERALS DECODING ************************************************/ + +/******* SEQUENCE DECODING ****************************************************/ +/// The combination of FSE states needed to decode sequences +typedef struct { + FSE_dtable ll_table; + FSE_dtable of_table; + FSE_dtable ml_table; + + u16 ll_state; + u16 of_state; + u16 ml_state; +} sequence_states_t; + +/// Different modes to signal to decode_seq_tables what to do +typedef enum { + seq_literal_length = 0, + seq_offset = 1, + seq_match_length = 2, +} seq_part_t; + +typedef enum { + seq_predefined = 0, + seq_rle = 1, + seq_fse = 2, + seq_repeat = 3, +} seq_mode_t; + +/// The predefined FSE distribution tables for `seq_predefined` mode +static const i16 SEQ_LITERAL_LENGTH_DEFAULT_DIST[36] = { 4, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 3, 2, 1, 1, 1, 1, 1, -1, -1, -1, -1 }; +static const i16 SEQ_OFFSET_DEFAULT_DIST[29] + = { 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1 }; +static const i16 SEQ_MATCH_LENGTH_DEFAULT_DIST[53] = { 1, 4, 3, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1 }; + +/// The sequence decoding baseline and number of additional bits to read/add +/// https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#the-codes-for-literals-lengths-match-lengths-and-offsets +static const u32 SEQ_LITERAL_LENGTH_BASELINES[36] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, + 22, 24, 28, 32, 40, 48, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536 }; +static const u8 SEQ_LITERAL_LENGTH_EXTRA_BITS[36] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, + 3, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + +static const u32 SEQ_MATCH_LENGTH_BASELINES[53] = { 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 37, 39, 41, 43, 47, 51, 59, 67, 83, 99, 131, 259, 515, 1027, + 2051, 4099, 8195, 16387, 32771, 65539 }; +static const u8 SEQ_MATCH_LENGTH_EXTRA_BITS[53] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 3, 3, 4, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + +/// Offset decoding is simpler so we just need a maximum code value +static const u8 SEQ_MAX_CODES[3] = { 35, (u8)-1, 52 }; + +static void decompress_sequences( + frame_context_t *const ctx, istream_t *const in, sequence_command_t *const sequences, const size_t num_sequences); +static sequence_command_t decode_sequence( + sequence_states_t *const state, const u8 *const src, i64 *const offset, int lastSequence); +static void decode_seq_table( + FSE_dtable *const table, istream_t *const in, const seq_part_t type, const seq_mode_t mode); + +static size_t decode_sequences(frame_context_t *const ctx, istream_t *in, sequence_command_t **const sequences) +{ + // "A compressed block is a succession of sequences . A sequence is a + // literal copy command, followed by a match copy command. A literal copy + // command specifies a length. It is the number of bytes to be copied (or + // extracted) from the literal section. A match copy command specifies an + // offset and a length. The offset gives the position to copy from, which + // can be within a previous block." + + size_t num_sequences; + + // "Number_of_Sequences + // + // This is a variable size field using between 1 and 3 bytes. Let's call its + // first byte byte0." + u8 header = IO_read_bits(in, 8); + if (header < 128) { + // "Number_of_Sequences = byte0 . Uses 1 byte." + num_sequences = header; + } else if (header < 255) { + // "Number_of_Sequences = ((byte0-128) << 8) + byte1 . Uses 2 bytes." + num_sequences = ((header - 128) << 8) + IO_read_bits(in, 8); + } else { + // "Number_of_Sequences = byte1 + (byte2<<8) + 0x7F00 . Uses 3 bytes." + num_sequences = IO_read_bits(in, 16) + 0x7F00; + } + + if (num_sequences == 0) { + // "There are no sequences. The sequence section stops there." + *sequences = NULL; + return 0; + } + + *sequences = malloc(num_sequences * sizeof(sequence_command_t)); + if (!*sequences) { + BAD_ALLOC(); + } + + decompress_sequences(ctx, in, *sequences, num_sequences); + return num_sequences; +} + +/// Decompress the FSE encoded sequence commands +static void decompress_sequences( + frame_context_t *const ctx, istream_t *in, sequence_command_t *const sequences, const size_t num_sequences) +{ + // "The Sequences_Section regroup all symbols required to decode commands. + // There are 3 symbol types : literals lengths, offsets and match lengths. + // They are encoded together, interleaved, in a single bitstream." + + // "Symbol compression modes + // + // This is a single byte, defining the compression mode of each symbol + // type." + // + // Bit number : Field name + // 7-6 : Literals_Lengths_Mode + // 5-4 : Offsets_Mode + // 3-2 : Match_Lengths_Mode + // 1-0 : Reserved + u8 compression_modes = IO_read_bits(in, 8); + + if ((compression_modes & 3) != 0) { + // Reserved bits set + CORRUPTION(); + } + + // "Following the header, up to 3 distribution tables can be described. When + // present, they are in this order : + // + // Literals lengths + // Offsets + // Match Lengths" + // Update the tables we have stored in the context + decode_seq_table(&ctx->ll_dtable, in, seq_literal_length, (compression_modes >> 6) & 3); + + decode_seq_table(&ctx->of_dtable, in, seq_offset, (compression_modes >> 4) & 3); + + decode_seq_table(&ctx->ml_dtable, in, seq_match_length, (compression_modes >> 2) & 3); + + sequence_states_t states; + + // Initialize the decoding tables + { + states.ll_table = ctx->ll_dtable; + states.of_table = ctx->of_dtable; + states.ml_table = ctx->ml_dtable; + } + + const size_t len = IO_istream_len(in); + const u8 *const src = IO_get_read_ptr(in, len); + + // "After writing the last bit containing information, the compressor writes + // a single 1-bit and then fills the byte with 0-7 0 bits of padding." + const int padding = 8 - highest_set_bit(src[len - 1]); + // The offset starts at the end because FSE streams are read backwards + i64 bit_offset = (i64)(len * 8 - (size_t)padding); + + // "The bitstream starts with initial state values, each using the required + // number of bits in their respective accuracy, decoded previously from + // their normalized distribution. + // + // It starts by Literals_Length_State, followed by Offset_State, and finally + // Match_Length_State." + FSE_init_state(&states.ll_table, &states.ll_state, src, &bit_offset); + FSE_init_state(&states.of_table, &states.of_state, src, &bit_offset); + FSE_init_state(&states.ml_table, &states.ml_state, src, &bit_offset); + + for (size_t i = 0; i < num_sequences; i++) { + // Decode sequences one by one + sequences[i] = decode_sequence(&states, src, &bit_offset, i == num_sequences - 1); + } + + if (bit_offset != 0) { + CORRUPTION(); + } +} + +// Decode a single sequence and update the state +static sequence_command_t decode_sequence( + sequence_states_t *const states, const u8 *const src, i64 *const offset, int lastSequence) +{ + // "Each symbol is a code in its own context, which specifies Baseline and + // Number_of_Bits to add. Codes are FSE compressed, and interleaved with raw + // additional bits in the same bitstream." + + // Decode symbols, but don't update states + const u8 of_code = FSE_peek_symbol(&states->of_table, states->of_state); + const u8 ll_code = FSE_peek_symbol(&states->ll_table, states->ll_state); + const u8 ml_code = FSE_peek_symbol(&states->ml_table, states->ml_state); + + // Offset doesn't need a max value as it's not decoded using a table + if (ll_code > SEQ_MAX_CODES[seq_literal_length] || ml_code > SEQ_MAX_CODES[seq_match_length]) { + CORRUPTION(); + } + + // Read the interleaved bits + sequence_command_t seq; + // "Decoding starts by reading the Number_of_Bits required to decode Offset. + // It then does the same for Match_Length, and then for Literals_Length." + seq.offset = ((u32)1 << of_code) + STREAM_read_bits(src, of_code, offset); + + seq.match_length + = SEQ_MATCH_LENGTH_BASELINES[ml_code] + STREAM_read_bits(src, SEQ_MATCH_LENGTH_EXTRA_BITS[ml_code], offset); + + seq.literal_length + = SEQ_LITERAL_LENGTH_BASELINES[ll_code] + STREAM_read_bits(src, SEQ_LITERAL_LENGTH_EXTRA_BITS[ll_code], offset); + + // "If it is not the last sequence in the block, the next operation is to + // update states. Using the rules pre-calculated in the decoding tables, + // Literals_Length_State is updated, followed by Match_Length_State, and + // then Offset_State." + // If the stream is complete don't read bits to update state + if (!lastSequence) { + FSE_update_state(&states->ll_table, &states->ll_state, src, offset); + FSE_update_state(&states->ml_table, &states->ml_state, src, offset); + FSE_update_state(&states->of_table, &states->of_state, src, offset); + } + + return seq; +} + +/// Given a sequence part and table mode, decode the FSE distribution +/// Errors if the mode is `seq_repeat` without a pre-existing table in `table` +static void decode_seq_table(FSE_dtable *const table, istream_t *const in, const seq_part_t type, const seq_mode_t mode) +{ + // Constant arrays indexed by seq_part_t + const i16 *const default_distributions[] + = { SEQ_LITERAL_LENGTH_DEFAULT_DIST, SEQ_OFFSET_DEFAULT_DIST, SEQ_MATCH_LENGTH_DEFAULT_DIST }; + const size_t default_distribution_lengths[] = { 36, 29, 53 }; + const size_t default_distribution_accuracies[] = { 6, 5, 6 }; + + const size_t max_accuracies[] = { 9, 8, 9 }; + + if (mode != seq_repeat) { + // Free old one before overwriting + FSE_free_dtable(table); + } + + switch (mode) { + case seq_predefined: { + // "Predefined_Mode : uses a predefined distribution table." + const i16 *distribution = default_distributions[type]; + const size_t symbs = default_distribution_lengths[type]; + const size_t accuracy_log = default_distribution_accuracies[type]; + + FSE_init_dtable(table, distribution, symbs, accuracy_log); + break; + } + case seq_rle: { + // "RLE_Mode : it's a single code, repeated Number_of_Sequences times." + const u8 symb = IO_get_read_ptr(in, 1)[0]; + FSE_init_dtable_rle(table, symb); + break; + } + case seq_fse: { + // "FSE_Compressed_Mode : standard FSE compression. A distribution table + // will be present " + FSE_decode_header(table, in, max_accuracies[type]); + break; + } + case seq_repeat: + // "Repeat_Mode : reuse distribution table from previous compressed + // block." + // Nothing to do here, table will be unchanged + if (!table->symbols) { + // This mode is invalid if we don't already have a table + CORRUPTION(); + } + break; + default: + // Impossible, as mode is from 0-3 + IMPOSSIBLE(); + break; + } +} +/******* END SEQUENCE DECODING ************************************************/ + +/******* SEQUENCE EXECUTION ***************************************************/ +static void execute_sequences(frame_context_t *const ctx, ostream_t *const out, const u8 *const literals, + const size_t literals_len, const sequence_command_t *const sequences, const size_t num_sequences) +{ + istream_t litstream = IO_make_istream(literals, literals_len); + + u64 *const offset_hist = ctx->previous_offsets; + size_t total_output = ctx->current_total_output; + + for (size_t i = 0; i < num_sequences; i++) { + const sequence_command_t seq = sequences[i]; + { + const u32 literals_size = copy_literals(seq.literal_length, &litstream, out); + total_output += literals_size; + } + + size_t const offset = compute_offset(seq, offset_hist); + + size_t const match_length = seq.match_length; + + execute_match_copy(ctx, offset, match_length, total_output, out); + + total_output += match_length; + } + + // Copy any leftover literals + { + size_t len = IO_istream_len(&litstream); + copy_literals(len, &litstream, out); + total_output += len; + } + + ctx->current_total_output = total_output; +} + +static u32 copy_literals(const size_t literal_length, istream_t *litstream, ostream_t *const out) +{ + // If the sequence asks for more literals than are left, the + // sequence must be corrupted + if (literal_length > IO_istream_len(litstream)) { + CORRUPTION(); + } + + u8 *const write_ptr = IO_get_write_ptr(out, literal_length); + const u8 *const read_ptr = IO_get_read_ptr(litstream, literal_length); + // Copy literals to output + memcpy(write_ptr, read_ptr, literal_length); + + return literal_length; +} + +static size_t compute_offset(sequence_command_t seq, u64 *const offset_hist) +{ + size_t offset; + // Offsets are special, we need to handle the repeat offsets + if (seq.offset <= 3) { + // "The first 3 values define a repeated offset and we will call + // them Repeated_Offset1, Repeated_Offset2, and Repeated_Offset3. + // They are sorted in recency order, with Repeated_Offset1 meaning + // 'most recent one'". + + // Use 0 indexing for the array + u32 idx = seq.offset - 1; + if (seq.literal_length == 0) { + // "There is an exception though, when current sequence's + // literals length is 0. In this case, repeated offsets are + // shifted by one, so Repeated_Offset1 becomes Repeated_Offset2, + // Repeated_Offset2 becomes Repeated_Offset3, and + // Repeated_Offset3 becomes Repeated_Offset1 - 1_byte." + idx++; + } + + if (idx == 0) { + offset = offset_hist[0]; + } else { + // If idx == 3 then literal length was 0 and the offset was 3, + // as per the exception listed above + offset = idx < 3 ? offset_hist[idx] : offset_hist[0] - 1; + + // If idx == 1 we don't need to modify offset_hist[2], since + // we're using the second-most recent code + if (idx > 1) { + offset_hist[2] = offset_hist[1]; + } + offset_hist[1] = offset_hist[0]; + offset_hist[0] = offset; + } + } else { + // When it's not a repeat offset: + // "if (Offset_Value > 3) offset = Offset_Value - 3;" + offset = seq.offset - 3; + + // Shift back history + offset_hist[2] = offset_hist[1]; + offset_hist[1] = offset_hist[0]; + offset_hist[0] = offset; + } + return offset; +} + +static void execute_match_copy( + frame_context_t *const ctx, size_t offset, size_t match_length, size_t total_output, ostream_t *const out) +{ + u8 *write_ptr = IO_get_write_ptr(out, match_length); + if (total_output <= ctx->header.window_size) { + // In this case offset might go back into the dictionary + if (offset > total_output + ctx->dict_content_len) { + // The offset goes beyond even the dictionary + CORRUPTION(); + } + + if (offset > total_output) { + // "The rest of the dictionary is its content. The content act + // as a "past" in front of data to compress or decompress, so it + // can be referenced in sequence commands." + const size_t dict_copy = MIN(offset - total_output, match_length); + const size_t dict_offset = ctx->dict_content_len - (offset - total_output); + + memcpy(write_ptr, ctx->dict_content + dict_offset, dict_copy); + write_ptr += dict_copy; + match_length -= dict_copy; + } + } else if (offset > ctx->header.window_size) { + CORRUPTION(); + } + + // We must copy byte by byte because the match length might be larger + // than the offset + // ex: if the output so far was "abc", a command with offset=3 and + // match_length=6 would produce "abcabcabc" as the new output + for (size_t j = 0; j < match_length; j++) { + *write_ptr = *(write_ptr - offset); + write_ptr++; + } +} +/******* END SEQUENCE EXECUTION ***********************************************/ + +/******* OUTPUT SIZE COUNTING *************************************************/ +/// Get the decompressed size of an input stream so memory can be allocated in +/// advance. +/// This implementation assumes `src` points to a single ZSTD-compressed frame +size_t ZSTD_get_decompressed_size(const void *src, const size_t src_len) +{ + istream_t in = IO_make_istream(src, src_len); + + // get decompressed size from ZSTD frame header + { + const u32 magic_number = (u32)IO_read_bits(&in, 32); + + if (magic_number == ZSTD_MAGIC_NUMBER) { + // ZSTD frame + frame_header_t header; + parse_frame_header(&header, &in); + + if (header.frame_content_size == 0 && !header.single_segment_flag) { + // Content size not provided, we can't tell + return (size_t)-1; + } + + return header.frame_content_size; + } else { + // not a real frame or skippable frame + ERROR("ZSTD frame magic number did not match"); + } + } +} +/******* END OUTPUT SIZE COUNTING *********************************************/ + +/******* DICTIONARY PARSING ***************************************************/ +dictionary_t *create_dictionary(void) +{ + dictionary_t *const dict = calloc(1, sizeof(dictionary_t)); + if (!dict) { + BAD_ALLOC(); + } + return dict; +} + +/// Free an allocated dictionary +void free_dictionary(dictionary_t *const dict) +{ + HUF_free_dtable(&dict->literals_dtable); + FSE_free_dtable(&dict->ll_dtable); + FSE_free_dtable(&dict->of_dtable); + FSE_free_dtable(&dict->ml_dtable); + + free(dict->content); + + memset(dict, 0, sizeof(dictionary_t)); + + free(dict); +} + +#if !defined(ZDEC_NO_DICTIONARY) +#define DICT_SIZE_ERROR() ERROR("Dictionary size cannot be less than 8 bytes") +#define NULL_SRC() ERROR("Tried to create dictionary with pointer to null src"); + +static void init_dictionary_content(dictionary_t *const dict, istream_t *const in); + +void parse_dictionary(dictionary_t *const dict, const void *src, size_t src_len) +{ + const u8 *byte_src = (const u8 *)src; + memset(dict, 0, sizeof(dictionary_t)); + if (src == NULL) { /* cannot initialize dictionary with null src */ + NULL_SRC(); + } + if (src_len < 8) { + DICT_SIZE_ERROR(); + } + + istream_t in = IO_make_istream(byte_src, src_len); + + const u32 magic_number = IO_read_bits(&in, 32); + if (magic_number != 0xEC30A437) { + // raw content dict + IO_rewind_bits(&in, 32); + init_dictionary_content(dict, &in); + return; + } + + dict->dictionary_id = IO_read_bits(&in, 32); + + // "Entropy_Tables : following the same format as the tables in compressed + // blocks. They are stored in following order : Huffman tables for literals, + // FSE table for offsets, FSE table for match lengths, and FSE table for + // literals lengths. It's finally followed by 3 offset values, populating + // recent offsets (instead of using {1,4,8}), stored in order, 4-bytes + // little-endian each, for a total of 12 bytes. Each recent offset must have + // a value < dictionary size." + decode_huf_table(&dict->literals_dtable, &in); + decode_seq_table(&dict->of_dtable, &in, seq_offset, seq_fse); + decode_seq_table(&dict->ml_dtable, &in, seq_match_length, seq_fse); + decode_seq_table(&dict->ll_dtable, &in, seq_literal_length, seq_fse); + + // Read in the previous offset history + dict->previous_offsets[0] = IO_read_bits(&in, 32); + dict->previous_offsets[1] = IO_read_bits(&in, 32); + dict->previous_offsets[2] = IO_read_bits(&in, 32); + + // Ensure the provided offsets aren't too large + // "Each recent offset must have a value < dictionary size." + for (int i = 0; i < 3; i++) { + if (dict->previous_offsets[i] > src_len) { + ERROR("Dictionary corrupted"); + } + } + + // "Content : The rest of the dictionary is its content. The content act as + // a "past" in front of data to compress or decompress, so it can be + // referenced in sequence commands." + init_dictionary_content(dict, &in); +} + +static void init_dictionary_content(dictionary_t *const dict, istream_t *const in) +{ + // Copy in the content + dict->content_size = IO_istream_len(in); + dict->content = malloc(dict->content_size); + if (!dict->content) { + BAD_ALLOC(); + } + + const u8 *const content = IO_get_read_ptr(in, dict->content_size); + + memcpy(dict->content, content, dict->content_size); +} + +static void HUF_copy_dtable(HUF_dtable *const dst, const HUF_dtable *const src) +{ + if (src->max_bits == 0) { + memset(dst, 0, sizeof(HUF_dtable)); + return; + } + + const size_t size = (size_t)1 << src->max_bits; + dst->max_bits = src->max_bits; + + dst->symbols = malloc(size); + dst->num_bits = malloc(size); + if (!dst->symbols || !dst->num_bits) { + BAD_ALLOC(); + } + + memcpy(dst->symbols, src->symbols, size); + memcpy(dst->num_bits, src->num_bits, size); +} + +static void FSE_copy_dtable(FSE_dtable *const dst, const FSE_dtable *const src) +{ + if (src->accuracy_log == 0) { + memset(dst, 0, sizeof(FSE_dtable)); + return; + } + + size_t size = (size_t)1 << src->accuracy_log; + dst->accuracy_log = src->accuracy_log; + + dst->symbols = malloc(size); + dst->num_bits = malloc(size); + dst->new_state_base = malloc(size * sizeof(u16)); + if (!dst->symbols || !dst->num_bits || !dst->new_state_base) { + BAD_ALLOC(); + } + + memcpy(dst->symbols, src->symbols, size); + memcpy(dst->num_bits, src->num_bits, size); + memcpy(dst->new_state_base, src->new_state_base, size * sizeof(u16)); +} + +/// A dictionary acts as initializing values for the frame context before +/// decompression, so we implement it by applying it's predetermined +/// tables and content to the context before beginning decompression +static void frame_context_apply_dict(frame_context_t *const ctx, const dictionary_t *const dict) +{ + // If the content pointer is NULL then it must be an empty dict + if (!dict || !dict->content) + return; + + // If the requested dictionary_id is non-zero, the correct dictionary must + // be present + if (ctx->header.dictionary_id != 0 && ctx->header.dictionary_id != dict->dictionary_id) { + ERROR("Wrong dictionary provided"); + } + + // Copy the dict content to the context for references during sequence + // execution + ctx->dict_content = dict->content; + ctx->dict_content_len = dict->content_size; + + // If it's a formatted dict copy the precomputed tables in so they can + // be used in the table repeat modes + if (dict->dictionary_id != 0) { + // Deep copy the entropy tables so they can be freed independently of + // the dictionary struct + HUF_copy_dtable(&ctx->literals_dtable, &dict->literals_dtable); + FSE_copy_dtable(&ctx->ll_dtable, &dict->ll_dtable); + FSE_copy_dtable(&ctx->of_dtable, &dict->of_dtable); + FSE_copy_dtable(&ctx->ml_dtable, &dict->ml_dtable); + + // Copy the repeated offsets + memcpy(ctx->previous_offsets, dict->previous_offsets, sizeof(ctx->previous_offsets)); + } +} + +#else // ZDEC_NO_DICTIONARY is defined + +static void frame_context_apply_dict(frame_context_t *const ctx, const dictionary_t *const dict) +{ + (void)ctx; + if (dict && dict->content) + ERROR("dictionary not supported"); +} + +#endif +/******* END DICTIONARY PARSING ***********************************************/ + +/******* IO STREAM OPERATIONS *************************************************/ + +/// Reads `num` bits from a bitstream, and updates the internal offset +static inline u64 IO_read_bits(istream_t *const in, const int num_bits) +{ + if (num_bits > 64 || num_bits <= 0) { + ERROR("Attempt to read an invalid number of bits"); + } + + const size_t bytes = (num_bits + in->bit_offset + 7) / 8; + const size_t full_bytes = (num_bits + in->bit_offset) / 8; + if (bytes > in->len) { + INP_SIZE(); + } + + const u64 result = read_bits_LE(in->ptr, num_bits, in->bit_offset); + + in->bit_offset = (num_bits + in->bit_offset) % 8; + in->ptr += full_bytes; + in->len -= full_bytes; + + return result; +} + +/// If a non-zero number of bits have been read from the current byte, advance +/// the offset to the next byte +static inline void IO_rewind_bits(istream_t *const in, int num_bits) +{ + if (num_bits < 0) { + ERROR("Attempting to rewind stream by a negative number of bits"); + } + + // move the offset back by `num_bits` bits + const int new_offset = in->bit_offset - num_bits; + // determine the number of whole bytes we have to rewind, rounding up to an + // integer number (e.g. if `new_offset == -5`, `bytes == 1`) + const i64 bytes = -(new_offset - 7) / 8; + + in->ptr -= bytes; + in->len += bytes; + // make sure the resulting `bit_offset` is positive, as mod in C does not + // convert numbers from negative to positive (e.g. -22 % 8 == -6) + in->bit_offset = ((new_offset % 8) + 8) % 8; +} + +/// If the remaining bits in a byte will be unused, advance to the end of the +/// byte +static inline void IO_align_stream(istream_t *const in) +{ + if (in->bit_offset != 0) { + if (in->len == 0) { + INP_SIZE(); + } + in->ptr++; + in->len--; + in->bit_offset = 0; + } +} + +/// Write the given byte into the output stream +static inline void IO_write_byte(ostream_t *const out, u8 symb) +{ + if (out->len == 0) { + OUT_SIZE(); + } + + out->ptr[0] = symb; + out->ptr++; + out->len--; +} + +/// Returns the number of bytes left to be read in this stream. The stream must +/// be byte aligned. +static inline size_t IO_istream_len(const istream_t *const in) +{ + return in->len; +} + +/// Returns a pointer where `len` bytes can be read, and advances the internal +/// state. The stream must be byte aligned. +static inline const u8 *IO_get_read_ptr(istream_t *const in, size_t len) +{ + if (len > in->len) { + INP_SIZE(); + } + if (in->bit_offset != 0) { + ERROR("Attempting to operate on a non-byte aligned stream"); + } + const u8 *const ptr = in->ptr; + in->ptr += len; + in->len -= len; + + return ptr; +} +/// Returns a pointer to write `len` bytes to, and advances the internal state +static inline u8 *IO_get_write_ptr(ostream_t *const out, size_t len) +{ + if (len > out->len) { + OUT_SIZE(); + } + u8 *const ptr = out->ptr; + out->ptr += len; + out->len -= len; + + return ptr; +} + +/// Advance the inner state by `len` bytes +static inline void IO_advance_input(istream_t *const in, size_t len) +{ + if (len > in->len) { + INP_SIZE(); + } + if (in->bit_offset != 0) { + ERROR("Attempting to operate on a non-byte aligned stream"); + } + + in->ptr += len; + in->len -= len; +} + +/// Returns an `ostream_t` constructed from the given pointer and length +static inline ostream_t IO_make_ostream(u8 *out, size_t len) +{ + return (ostream_t) { out, len }; +} + +/// Returns an `istream_t` constructed from the given pointer and length +static inline istream_t IO_make_istream(const u8 *in, size_t len) +{ + return (istream_t) { in, len, 0 }; +} + +/// Returns an `istream_t` with the same base as `in`, and length `len` +/// Then, advance `in` to account for the consumed bytes +/// `in` must be byte aligned +static inline istream_t IO_make_sub_istream(istream_t *const in, size_t len) +{ + // Consume `len` bytes of the parent stream + const u8 *const ptr = IO_get_read_ptr(in, len); + + // Make a substream using the pointer to those `len` bytes + return IO_make_istream(ptr, len); +} +/******* END IO STREAM OPERATIONS *********************************************/ + +/******* BITSTREAM OPERATIONS *************************************************/ +/// Read `num` bits (up to 64) from `src + offset`, where `offset` is in bits +static inline u64 read_bits_LE(const u8 *src, const int num_bits, const size_t offset) +{ + if (num_bits > 64) { + ERROR("Attempt to read an invalid number of bits"); + } + + // Skip over bytes that aren't in range + src += offset / 8; + size_t bit_offset = offset % 8; + u64 res = 0; + + int shift = 0; + int left = num_bits; + while (left > 0) { + u64 mask = left >= 8 ? 0xff : (((u64)1 << left) - 1); + // Read the next byte, shift it to account for the offset, and then mask + // out the top part if we don't need all the bits + res += (((u64)*src++ >> bit_offset) & mask) << shift; + shift += 8 - bit_offset; + left -= 8 - bit_offset; + bit_offset = 0; + } + + return res; +} + +/// Read bits from the end of a HUF or FSE bitstream. `offset` is in bits, so +/// it updates `offset` to `offset - bits`, and then reads `bits` bits from +/// `src + offset`. If the offset becomes negative, the extra bits at the +/// bottom are filled in with `0` bits instead of reading from before `src`. +static inline u64 STREAM_read_bits(const u8 *const src, const int bits, i64 *const offset) +{ + *offset = *offset - bits; + size_t actual_off = *offset; + size_t actual_bits = bits; + // Don't actually read bits from before the start of src, so if `*offset < + // 0` fix actual_off and actual_bits to reflect the quantity to read + if (*offset < 0) { + actual_bits += *offset; + actual_off = 0; + } + u64 res = read_bits_LE(src, actual_bits, actual_off); + + if (*offset < 0) { + // Fill in the bottom "overflowed" bits with 0's + res = -*offset >= 64 ? 0 : (res << -*offset); + } + return res; +} +/******* END BITSTREAM OPERATIONS *********************************************/ + +/******* BIT COUNTING OPERATIONS **********************************************/ +/// Returns `x`, where `2^x` is the largest power of 2 less than or equal to +/// `num`, or `-1` if `num == 0`. +static inline int highest_set_bit(const u64 num) +{ + for (int i = 63; i >= 0; i--) { + if (((u64)1 << i) <= num) { + return i; + } + } + return -1; +} +/******* END BIT COUNTING OPERATIONS ******************************************/ + +/******* HUFFMAN PRIMITIVES ***************************************************/ +static inline u8 HUF_decode_symbol( + const HUF_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset) +{ + // Look up the symbol and number of bits to read + const u8 symb = dtable->symbols[*state]; + const u8 bits = dtable->num_bits[*state]; + const u16 rest = STREAM_read_bits(src, bits, offset); + // Shift `bits` bits out of the state, keeping the low order bits that + // weren't necessary to determine this symbol. Then add in the new bits + // read from the stream. + *state = ((*state << bits) + rest) & (((u16)1 << dtable->max_bits) - 1); + + return symb; +} + +static inline void HUF_init_state( + const HUF_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset) +{ + // Read in a full `dtable->max_bits` bits to initialize the state + const u8 bits = dtable->max_bits; + *state = STREAM_read_bits(src, bits, offset); +} + +static size_t HUF_decompress_1stream(const HUF_dtable *const dtable, ostream_t *const out, istream_t *const in) +{ + const size_t len = IO_istream_len(in); + if (len == 0) { + INP_SIZE(); + } + const u8 *const src = IO_get_read_ptr(in, len); + + // "Each bitstream must be read backward, that is starting from the end down + // to the beginning. Therefore it's necessary to know the size of each + // bitstream. + // + // It's also necessary to know exactly which bit is the latest. This is + // detected by a final bit flag : the highest bit of latest byte is a + // final-bit-flag. Consequently, a last byte of 0 is not possible. And the + // final-bit-flag itself is not part of the useful bitstream. Hence, the + // last byte contains between 0 and 7 useful bits." + const int padding = 8 - highest_set_bit(src[len - 1]); + + // Offset starts at the end because HUF streams are read backwards + i64 bit_offset = len * 8 - padding; + u16 state; + + HUF_init_state(dtable, &state, src, &bit_offset); + + size_t symbols_written = 0; + while (bit_offset > -dtable->max_bits) { + // Iterate over the stream, decoding one symbol at a time + IO_write_byte(out, HUF_decode_symbol(dtable, &state, src, &bit_offset)); + symbols_written++; + } + // "The process continues up to reading the required number of symbols per + // stream. If a bitstream is not entirely and exactly consumed, hence + // reaching exactly its beginning position with all bits consumed, the + // decoding process is considered faulty." + + // When all symbols have been decoded, the final state value shouldn't have + // any data from the stream, so it should have "read" dtable->max_bits from + // before the start of `src` + // Therefore `offset`, the edge to start reading new bits at, should be + // dtable->max_bits before the start of the stream + if (bit_offset != -dtable->max_bits) { + CORRUPTION(); + } + + return symbols_written; +} + +static size_t HUF_decompress_4stream(const HUF_dtable *const dtable, ostream_t *const out, istream_t *const in) +{ + // "Compressed size is provided explicitly : in the 4-streams variant, + // bitstreams are preceded by 3 unsigned little-endian 16-bits values. Each + // value represents the compressed size of one stream, in order. The last + // stream size is deducted from total compressed size and from previously + // decoded stream sizes" + const size_t csize1 = IO_read_bits(in, 16); + const size_t csize2 = IO_read_bits(in, 16); + const size_t csize3 = IO_read_bits(in, 16); + + istream_t in1 = IO_make_sub_istream(in, csize1); + istream_t in2 = IO_make_sub_istream(in, csize2); + istream_t in3 = IO_make_sub_istream(in, csize3); + istream_t in4 = IO_make_sub_istream(in, IO_istream_len(in)); + + size_t total_output = 0; + // Decode each stream independently for simplicity + // If we wanted to we could decode all 4 at the same time for speed, + // utilizing more execution units + total_output += HUF_decompress_1stream(dtable, out, &in1); + total_output += HUF_decompress_1stream(dtable, out, &in2); + total_output += HUF_decompress_1stream(dtable, out, &in3); + total_output += HUF_decompress_1stream(dtable, out, &in4); + + return total_output; +} + +/// Initializes a Huffman table using canonical Huffman codes +/// For more explanation on canonical Huffman codes see +/// https://www.cs.scranton.edu/~mccloske/courses/cmps340/huff_canonical_dec2015.html +/// Codes within a level are allocated in symbol order (i.e. smaller symbols get +/// earlier codes) +static void HUF_init_dtable(HUF_dtable *const table, const u8 *const bits, const int num_symbs) +{ + memset(table, 0, sizeof(HUF_dtable)); + if (num_symbs > HUF_MAX_SYMBS) { + ERROR("Too many symbols for Huffman"); + } + + u8 max_bits = 0; + u16 rank_count[HUF_MAX_BITS + 1]; + memset(rank_count, 0, sizeof(rank_count)); + + // Count the number of symbols for each number of bits, and determine the + // depth of the tree + for (int i = 0; i < num_symbs; i++) { + if (bits[i] > HUF_MAX_BITS) { + ERROR("Huffman table depth too large"); + } + max_bits = MAX(max_bits, bits[i]); + rank_count[bits[i]]++; + } + + const size_t table_size = 1 << max_bits; + table->max_bits = max_bits; + table->symbols = malloc(table_size); + table->num_bits = malloc(table_size); + + if (!table->symbols || !table->num_bits) { + free(table->symbols); + free(table->num_bits); + BAD_ALLOC(); + } + + // "Symbols are sorted by Weight. Within same Weight, symbols keep natural + // order. Symbols with a Weight of zero are removed. Then, starting from + // lowest weight, prefix codes are distributed in order." + + u32 rank_idx[HUF_MAX_BITS + 1]; + // Initialize the starting codes for each rank (number of bits) + rank_idx[max_bits] = 0; + for (int i = max_bits; i >= 1; i--) { + rank_idx[i - 1] = rank_idx[i] + rank_count[i] * (1 << (max_bits - i)); + // The entire range takes the same number of bits so we can memset it + memset(&table->num_bits[rank_idx[i]], i, rank_idx[i - 1] - rank_idx[i]); + } + + if (rank_idx[0] != table_size) { + CORRUPTION(); + } + + // Allocate codes and fill in the table + for (int i = 0; i < num_symbs; i++) { + if (bits[i] != 0) { + // Allocate a code for this symbol and set its range in the table + const u16 code = rank_idx[bits[i]]; + // Since the code doesn't care about the bottom `max_bits - bits[i]` + // bits of state, it gets a range that spans all possible values of + // the lower bits + const u16 len = 1 << (max_bits - bits[i]); + memset(&table->symbols[code], i, len); + rank_idx[bits[i]] += len; + } + } +} + +static void HUF_init_dtable_usingweights(HUF_dtable *const table, const u8 *const weights, const int num_symbs) +{ + // +1 because the last weight is not transmitted in the header + if (num_symbs + 1 > HUF_MAX_SYMBS) { + ERROR("Too many symbols for Huffman"); + } + + u8 bits[HUF_MAX_SYMBS]; + + u64 weight_sum = 0; + for (int i = 0; i < num_symbs; i++) { + // Weights are in the same range as bit count + if (weights[i] > HUF_MAX_BITS) { + CORRUPTION(); + } + weight_sum += weights[i] > 0 ? (u64)1 << (weights[i] - 1) : 0; + } + + // Find the first power of 2 larger than the sum + const int max_bits = highest_set_bit(weight_sum) + 1; + const u64 left_over = ((u64)1 << max_bits) - weight_sum; + // If the left over isn't a power of 2, the weights are invalid + if (left_over & (left_over - 1)) { + CORRUPTION(); + } + + // left_over is used to find the last weight as it's not transmitted + // by inverting 2^(weight - 1) we can determine the value of last_weight + const int last_weight = highest_set_bit(left_over) + 1; + + for (int i = 0; i < num_symbs; i++) { + // "Number_of_Bits = Number_of_Bits ? Max_Number_of_Bits + 1 - Weight : 0" + bits[i] = weights[i] > 0 ? (max_bits + 1 - weights[i]) : 0; + } + bits[num_symbs] = max_bits + 1 - last_weight; // Last weight is always non-zero + + HUF_init_dtable(table, bits, num_symbs + 1); +} + +static void HUF_free_dtable(HUF_dtable *const dtable) +{ + free(dtable->symbols); + free(dtable->num_bits); + memset(dtable, 0, sizeof(HUF_dtable)); +} +/******* END HUFFMAN PRIMITIVES ***********************************************/ + +/******* FSE PRIMITIVES *******************************************************/ +/// For more description of FSE see +/// https://github.com/Cyan4973/FiniteStateEntropy/ + +/// Allow a symbol to be decoded without updating state +static inline u8 FSE_peek_symbol(const FSE_dtable *const dtable, const u16 state) +{ + return dtable->symbols[state]; +} + +/// Consumes bits from the input and uses the current state to determine the +/// next state +static inline void FSE_update_state( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset) +{ + const u8 bits = dtable->num_bits[*state]; + const u16 rest = STREAM_read_bits(src, bits, offset); + *state = dtable->new_state_base[*state] + rest; +} + +/// Decodes a single FSE symbol and updates the offset +static inline u8 FSE_decode_symbol( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset) +{ + const u8 symb = FSE_peek_symbol(dtable, *state); + FSE_update_state(dtable, state, src, offset); + return symb; +} + +static inline void FSE_init_state( + const FSE_dtable *const dtable, u16 *const state, const u8 *const src, i64 *const offset) +{ + // Read in a full `accuracy_log` bits to initialize the state + const u8 bits = dtable->accuracy_log; + *state = STREAM_read_bits(src, bits, offset); +} + +static size_t FSE_decompress_interleaved2(const FSE_dtable *const dtable, ostream_t *const out, istream_t *const in) +{ + const size_t len = IO_istream_len(in); + if (len == 0) { + INP_SIZE(); + } + const u8 *const src = IO_get_read_ptr(in, len); + + // "Each bitstream must be read backward, that is starting from the end down + // to the beginning. Therefore it's necessary to know the size of each + // bitstream. + // + // It's also necessary to know exactly which bit is the latest. This is + // detected by a final bit flag : the highest bit of latest byte is a + // final-bit-flag. Consequently, a last byte of 0 is not possible. And the + // final-bit-flag itself is not part of the useful bitstream. Hence, the + // last byte contains between 0 and 7 useful bits." + const int padding = 8 - highest_set_bit(src[len - 1]); + i64 offset = len * 8 - padding; + + u16 state1, state2; + // "The first state (State1) encodes the even indexed symbols, and the + // second (State2) encodes the odd indexes. State1 is initialized first, and + // then State2, and they take turns decoding a single symbol and updating + // their state." + FSE_init_state(dtable, &state1, src, &offset); + FSE_init_state(dtable, &state2, src, &offset); + + // Decode until we overflow the stream + // Since we decode in reverse order, overflowing the stream is offset going + // negative + size_t symbols_written = 0; + while (1) { + // "The number of symbols to decode is determined by tracking bitStream + // overflow condition: If updating state after decoding a symbol would + // require more bits than remain in the stream, it is assumed the extra + // bits are 0. Then, the symbols for each of the final states are + // decoded and the process is complete." + IO_write_byte(out, FSE_decode_symbol(dtable, &state1, src, &offset)); + symbols_written++; + if (offset < 0) { + // There's still a symbol to decode in state2 + IO_write_byte(out, FSE_peek_symbol(dtable, state2)); + symbols_written++; + break; + } + + IO_write_byte(out, FSE_decode_symbol(dtable, &state2, src, &offset)); + symbols_written++; + if (offset < 0) { + // There's still a symbol to decode in state1 + IO_write_byte(out, FSE_peek_symbol(dtable, state1)); + symbols_written++; + break; + } + } + + return symbols_written; +} + +static void FSE_init_dtable( + FSE_dtable *const dtable, const i16 *const norm_freqs, const int num_symbs, const int accuracy_log) +{ + if (accuracy_log > FSE_MAX_ACCURACY_LOG) { + ERROR("FSE accuracy too large"); + } + if (num_symbs > FSE_MAX_SYMBS) { + ERROR("Too many symbols for FSE"); + } + + dtable->accuracy_log = accuracy_log; + + const size_t size = (size_t)1 << accuracy_log; + dtable->symbols = malloc(size * sizeof(u8)); + dtable->num_bits = malloc(size * sizeof(u8)); + dtable->new_state_base = malloc(size * sizeof(u16)); + + if (!dtable->symbols || !dtable->num_bits || !dtable->new_state_base) { + BAD_ALLOC(); + } + + // Used to determine how many bits need to be read for each state, + // and where the destination range should start + // Needs to be u16 because max value is 2 * max number of symbols, + // which can be larger than a byte can store + u16 state_desc[FSE_MAX_SYMBS]; + + // "Symbols are scanned in their natural order for "less than 1" + // probabilities. Symbols with this probability are being attributed a + // single cell, starting from the end of the table. These symbols define a + // full state reset, reading Accuracy_Log bits." + int high_threshold = size; + for (int s = 0; s < num_symbs; s++) { + // Scan for low probability symbols to put at the top + if (norm_freqs[s] == -1) { + dtable->symbols[--high_threshold] = s; + state_desc[s] = 1; + } + } + + // "All remaining symbols are sorted in their natural order. Starting from + // symbol 0 and table position 0, each symbol gets attributed as many cells + // as its probability. Cell allocation is spread, not linear." + // Place the rest in the table + const u16 step = (size >> 1) + (size >> 3) + 3; + const u16 mask = size - 1; + u16 pos = 0; + for (int s = 0; s < num_symbs; s++) { + if (norm_freqs[s] <= 0) { + continue; + } + + state_desc[s] = norm_freqs[s]; + + for (int i = 0; i < norm_freqs[s]; i++) { + // Give `norm_freqs[s]` states to symbol s + dtable->symbols[pos] = s; + // "A position is skipped if already occupied, typically by a "less + // than 1" probability symbol." + do { + pos = (pos + step) & mask; + } while (pos >= high_threshold); + // Note: no other collision checking is necessary as `step` is + // coprime to `size`, so the cycle will visit each position exactly + // once + } + } + if (pos != 0) { + CORRUPTION(); + } + + // Now we can fill baseline and num bits + for (size_t i = 0; i < size; i++) { + u8 symbol = dtable->symbols[i]; + u16 next_state_desc = state_desc[symbol]++; + // Fills in the table appropriately, next_state_desc increases by symbol + // over time, decreasing number of bits + dtable->num_bits[i] = (u8)(accuracy_log - highest_set_bit(next_state_desc)); + // Baseline increases until the bit threshold is passed, at which point + // it resets to 0 + dtable->new_state_base[i] = ((u16)next_state_desc << dtable->num_bits[i]) - size; + } +} + +/// Decode an FSE header as defined in the Zstandard format specification and +/// use the decoded frequencies to initialize a decoding table. +static void FSE_decode_header(FSE_dtable *const dtable, istream_t *const in, const int max_accuracy_log) +{ + // "An FSE distribution table describes the probabilities of all symbols + // from 0 to the last present one (included) on a normalized scale of 1 << + // Accuracy_Log . + // + // It's a bitstream which is read forward, in little-endian fashion. It's + // not necessary to know its exact size, since it will be discovered and + // reported by the decoding process. + if (max_accuracy_log > FSE_MAX_ACCURACY_LOG) { + ERROR("FSE accuracy too large"); + } + + // The bitstream starts by reporting on which scale it operates. + // Accuracy_Log = low4bits + 5. Note that maximum Accuracy_Log for literal + // and match lengths is 9, and for offsets is 8. Higher values are + // considered errors." + const int accuracy_log = 5 + IO_read_bits(in, 4); + if (accuracy_log > max_accuracy_log) { + ERROR("FSE accuracy too large"); + } + + // "Then follows each symbol value, from 0 to last present one. The number + // of bits used by each field is variable. It depends on : + // + // Remaining probabilities + 1 : example : Presuming an Accuracy_Log of 8, + // and presuming 100 probabilities points have already been distributed, the + // decoder may read any value from 0 to 255 - 100 + 1 == 156 (inclusive). + // Therefore, it must read log2sup(156) == 8 bits. + // + // Value decoded : small values use 1 less bit : example : Presuming values + // from 0 to 156 (inclusive) are possible, 255-156 = 99 values are remaining + // in an 8-bits field. They are used this way : first 99 values (hence from + // 0 to 98) use only 7 bits, values from 99 to 156 use 8 bits. " + + i32 remaining = 1 << accuracy_log; + i16 frequencies[FSE_MAX_SYMBS]; + + int symb = 0; + while (remaining > 0 && symb < FSE_MAX_SYMBS) { + // Log of the number of possible values we could read + int bits = highest_set_bit(remaining + 1) + 1; + + u16 val = IO_read_bits(in, bits); + + // Try to mask out the lower bits to see if it qualifies for the "small + // value" threshold + const u16 lower_mask = ((u16)1 << (bits - 1)) - 1; + const u16 threshold = ((u16)1 << bits) - 1 - (remaining + 1); + + if ((val & lower_mask) < threshold) { + IO_rewind_bits(in, 1); + val = val & lower_mask; + } else if (val > lower_mask) { + val = val - threshold; + } + + // "Probability is obtained from Value decoded by following formula : + // Proba = value - 1" + const i16 proba = (i16)val - 1; + + // "It means value 0 becomes negative probability -1. -1 is a special + // probability, which means "less than 1". Its effect on distribution + // table is described in next paragraph. For the purpose of calculating + // cumulated distribution, it counts as one." + remaining -= proba < 0 ? -proba : proba; + + frequencies[symb] = proba; + symb++; + + // "When a symbol has a probability of zero, it is followed by a 2-bits + // repeat flag. This repeat flag tells how many probabilities of zeroes + // follow the current one. It provides a number ranging from 0 to 3. If + // it is a 3, another 2-bits repeat flag follows, and so on." + if (proba == 0) { + // Read the next two bits to see how many more 0s + int repeat = IO_read_bits(in, 2); + + while (1) { + for (int i = 0; i < repeat && symb < FSE_MAX_SYMBS; i++) { + frequencies[symb++] = 0; + } + if (repeat == 3) { + repeat = IO_read_bits(in, 2); + } else { + break; + } + } + } + } + IO_align_stream(in); + + // "When last symbol reaches cumulated total of 1 << Accuracy_Log, decoding + // is complete. If the last symbol makes cumulated total go above 1 << + // Accuracy_Log, distribution is considered corrupted." + if (remaining != 0 || symb >= FSE_MAX_SYMBS) { + CORRUPTION(); + } + + // Initialize the decoding table using the determined weights + FSE_init_dtable(dtable, frequencies, symb, accuracy_log); +} + +static void FSE_init_dtable_rle(FSE_dtable *const dtable, const u8 symb) +{ + dtable->symbols = malloc(sizeof(u8)); + dtable->num_bits = malloc(sizeof(u8)); + dtable->new_state_base = malloc(sizeof(u16)); + + if (!dtable->symbols || !dtable->num_bits || !dtable->new_state_base) { + BAD_ALLOC(); + } + + // This setup will always have a state of 0, always return symbol `symb`, + // and never consume any bits + dtable->symbols[0] = symb; + dtable->num_bits[0] = 0; + dtable->new_state_base[0] = 0; + dtable->accuracy_log = 0; +} + +static void FSE_free_dtable(FSE_dtable *const dtable) +{ + free(dtable->symbols); + free(dtable->num_bits); + free(dtable->new_state_base); + memset(dtable, 0, sizeof(FSE_dtable)); +} +/******* END FSE PRIMITIVES ***************************************************/ diff --git a/third_party/zstd/zstd_decompress.h b/third_party/zstd/zstd_decompress.h new file mode 100644 index 00000000..16b7b7a5 --- /dev/null +++ b/third_party/zstd/zstd_decompress.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under both the BSD-style license (found in the + * LICENSE file in the root directory of this source tree) and the GPLv2 (found + * in the COPYING file in the root directory of this source tree). + * You may select, at your option, one of the above-listed licenses. + */ + +#include /* size_t */ + +/******* EXPOSED TYPES ********************************************************/ +/* + * Contains the parsed contents of a dictionary + * This includes Huffman and FSE tables used for decoding and data on offsets + */ +typedef struct dictionary_s dictionary_t; +/******* END EXPOSED TYPES ****************************************************/ + +/******* DECOMPRESSION FUNCTIONS **********************************************/ +/// Zstandard decompression functions. +/// `dst` must point to a space at least as large as the reconstructed output. +size_t ZSTD_decompress(void *const dst, const size_t dst_len, const void *const src, const size_t src_len); + +/// If `dict != NULL` and `dict_len >= 8`, does the same thing as +/// `ZSTD_decompress` but uses the provided dict +size_t ZSTD_decompress_with_dict( + void *const dst, const size_t dst_len, const void *const src, const size_t src_len, dictionary_t *parsed_dict); + +/// Get the decompressed size of an input stream so memory can be allocated in +/// advance +/// Returns -1 if the size can't be determined +/// Assumes decompression of a single frame +size_t ZSTD_get_decompressed_size(const void *const src, const size_t src_len); +/******* END DECOMPRESSION FUNCTIONS ******************************************/ + +/******* DICTIONARY MANAGEMENT ***********************************************/ +/* + * Return a valid dictionary_t pointer for use with dictionary initialization + * or decompression + */ +dictionary_t *create_dictionary(void); + +/* + * Parse a provided dictionary blob for use in decompression + * `src` -- must point to memory space representing the dictionary + * `src_len` -- must provide the dictionary size + * `dict` -- will contain the parsed contents of the dictionary and + * can be used for decompression + */ +void parse_dictionary(dictionary_t *const dict, const void *src, size_t src_len); + +/* + * Free internal Huffman tables, FSE tables, and dictionary content + */ +void free_dictionary(dictionary_t *const dict); +/******* END DICTIONARY MANAGEMENT *******************************************/ diff --git a/tools/bundle-providers.py b/tools/bundle-providers.py deleted file mode 100755 index abdf6bbb..00000000 --- a/tools/bundle-providers.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Verify repository-pinned source packages and materialise Qt build resources. - -This is build tooling, not an application installer. Trusted repository pins -supply the expected bytes; no mutable latest URL or package code is executed. -""" -import argparse -import hashlib -import importlib.util -import json -import pathlib -import re -import xml.etree.ElementTree as ET - -spec = importlib.util.spec_from_file_location("provider_package", pathlib.Path(__file__).with_name("provider-package.py")) -package = importlib.util.module_from_spec(spec) -spec.loader.exec_module(package) - - -def materialize(lock_path, output, override=None): - lock = json.loads(lock_path.read_text(), object_pairs_hook=package.unique_object) - if set(lock) != {"format", "providers"} or lock["format"] != 1 or not isinstance(lock["providers"], list): - raise ValueError("unsupported provider lock") - root = ET.Element("RCC") - seen = set() - for pin in lock["providers"]: - if set(pin) != {"id", "version", "repository", "revision", "archive", "size", "sha256"}: - raise ValueError("invalid provider pin") - if not re.fullmatch(r"[0-9a-f]{40}", pin["revision"]) or not re.fullmatch(r"[0-9a-f]{64}", pin["sha256"]): - raise ValueError("invalid immutable source identity") - archive = lock_path.parent / str(package.path_name(pin["archive"])) - using_override = pin["id"] == "spool.jellyfin" and override is not None - if using_override: - archive = output / "override.zip" - package.build(override, archive) - else: - if archive.stat().st_size != pin["size"]: - raise ValueError("pinned package size mismatch") - with archive.open("rb") as source: - digest = hashlib.file_digest(source, "sha256").hexdigest() - if digest != pin["sha256"]: - raise ValueError("pinned package digest mismatch") - manifest, files = package.read_package(archive) - if manifest["id"] != pin["id"] or (not using_override and manifest["version"] != pin["version"]): - raise ValueError("package identity does not match pin") - if manifest["id"] in seen: - raise ValueError("duplicate module in bundle") - seen.add(manifest["id"]) - resource = ET.SubElement(root, "qresource", prefix="/providers/" + manifest["id"]) - directory = output / manifest["id"] - for name, data in sorted(files.items()): - target = directory / name - target.parent.mkdir(parents=True, exist_ok=True) - # Preserve timestamps for identical contents in incremental builds. - if not target.exists() or target.read_bytes() != data: - target.write_bytes(data) - ET.SubElement(resource, "file", alias=name).text = str(target.resolve()) - output.mkdir(parents=True, exist_ok=True) - qrc = output / "providers.qrc" - data = ET.tostring(root, encoding="utf-8", xml_declaration=True) - if not qrc.exists() or qrc.read_bytes() != data: - qrc.write_bytes(data) - return qrc - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("lock", type=pathlib.Path) - parser.add_argument("output", type=pathlib.Path) - parser.add_argument("--jellyfin-source", type=pathlib.Path) - args = parser.parse_args() - print(materialize(args.lock, args.output, args.jellyfin_source)) - - -if __name__ == "__main__": - main() diff --git a/tools/check-module-seam.sh b/tools/check-module-seam.sh index 6a54a999..d51d5c9d 100755 --- a/tools/check-module-seam.sh +++ b/tools/check-module-seam.sh @@ -1,86 +1,16 @@ #!/usr/bin/env bash -# Fails when core source reaches into the Jellyfin provider. -# -# Core is everything CMakeLists.txt lists in SPOOL_PLATFORM_SOURCES, -# SPOOL_PLAYER_SOURCES and SPOOL_SHELL_SOURCES, plus every file under the -# directories that are core by construction. A core file may include only -# core files, Qt and system headers: any include that resolves into src/api/ -# or src/discovery/ is a seam violation. With --strict, a file that only -# SPOOL_JELLYFIN_SOURCES lists counts as provider too; that mode still -# reports the composition-root leaks (AppController reached from the -# platform and diagnostics layers) that the provider registry work removes, -# so CI runs the default. The check is on direct includes; because every -# core file is checked, a transitive path through core is caught at the file -# that opens it. +# Fails when core reaches into a native provider. Everything under src/ is +# core except src/providers/ and the composition root, src/main.cpp; core +# reaches media only through src/provider/. set -euo pipefail - -strict=0 -for arg in "$@"; do - case "$arg" in - --strict) strict=1 ;; - *) echo "usage: $0 [--strict]" >&2; exit 2 ;; - esac -done - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT" - -core_dirs=(src/platform src/player src/media src/provider src/common src/cache src/diagnostics) -provider_dirs=(src/api src/discovery src/providers) - -group_files() { - sed -n "/^set($1/,/^)/p" CMakeLists.txt | grep -E '^\s+src/' | sed -E 's/^\s+//' -} - -declare -A core=() -for group in SPOOL_PLATFORM_SOURCES SPOOL_PLAYER_SOURCES SPOOL_SHELL_SOURCES; do - while IFS= read -r file; do - [[ -n "$file" ]] && core["$file"]=1 - done < <(group_files "$group") -done -for dir in "${core_dirs[@]}"; do - while IFS= read -r file; do - core["$file"]=1 - done < <(find "$dir" -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.mm' \) | sort) -done - -declare -A provider=() -while IFS= read -r file; do - [[ -n "$file" ]] && provider["$file"]=1 -done < <(group_files SPOOL_JELLYFIN_SOURCES) - -is_provider_path() { - local path="$1" - for dir in "${provider_dirs[@]}"; do - [[ "$path" == "$dir/"* ]] && return 0 - done - (( strict )) && [[ -n "${provider[$path]:-}" ]] && [[ -z "${core[$path]:-}" ]] -} - -status=0 -for file in "${!core[@]}"; do - [[ -f "$file" ]] || continue - dir="$(dirname "$file")" - while IFS= read -r include; do - # Quoted includes resolve against the including file's directory first - # and the source root second; that is how the build resolves them too. - resolved="" - for candidate in "$dir/$include" "src/$include" "$include"; do - if [[ -f "$candidate" ]]; then - resolved="$(realpath --relative-to="$ROOT" "$candidate")" - break - fi - done - [[ -n "$resolved" ]] || continue - if is_provider_path "$resolved"; then - echo "seam: $file includes provider file $resolved" >&2 - status=1 - fi - done < <(grep -oE '^\s*#\s*include\s+"[^"]+"' "$file" | sed -E 's/.*"([^"]+)"/\1/') -done - -if (( status != 0 )); then - echo "error: core sources may not include provider files" >&2 +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +violations=$(grep -rnE '^\s*#\s*include\s+"([^"]*/)?providers/' src \ + --include='*.cpp' --include='*.h' --include='*.mm' \ + | grep -v -e '^src/providers/' -e '^src/main\.cpp:' || true) +if [[ -n "$violations" ]]; then + echo "core includes a native provider:" >&2 + echo "$violations" >&2 exit 1 fi -echo "module seam: core sources are free of provider includes" +echo "module seam: ok" diff --git a/tools/provider-package.py b/tools/provider-package.py deleted file mode 100755 index db589def..00000000 --- a/tools/provider-package.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -"""Build and validate reviewed provider source ZIPs; never execute their code. - -ZIP/hash validation does not authenticate a publisher. Installation and catalogue -trust belong to the host; this tool deliberately cannot activate a package. -""" -from __future__ import annotations - -import argparse -import hashlib -import json -import pathlib -import re -import stat -import sys -import zipfile - -MAX_ARCHIVE = 16 * 1024 * 1024 -MAX_EXPANDED = 32 * 1024 * 1024 -MAX_FILE = 8 * 1024 * 1024 -MAX_FILES = 512 -EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".map"} -TEXT = {".mjs", ".js", ".qml", ".json", ".svg", ".txt", ".map"} -MODULES = {"QtQuick", "QtQuick.Controls", "QtQuick.Layouts", "QtQml"} -PERMISSIONS = {"network:configured-origins", "storage:source", "credentials:source"} -CHANNELS = {"desktop", "webos", "android-direct", "google-play", "apple-bundled"} -NATIVE_MAGIC = (b"\x7fELF", b"MZ", b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\0asm") - - -def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError(f"duplicate JSON key: {key}") - result[key] = value - return result - - -def path_name(name: str) -> pathlib.PurePosixPath: - if not name or "\\" in name or ":" in name or any(ord(c) < 32 for c in name): - raise ValueError("invalid package path") - path = pathlib.PurePosixPath(name) - if path.is_absolute() or any(part in ("", ".", "..") for part in name.split("/")): - raise ValueError("non-canonical package path") - if any(part.rstrip(" .") != part for part in path.parts): - raise ValueError("ambiguous package path") - if any(part.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(10)], *[f"LPT{i}" for i in range(10)]} for part in path.parts): - raise ValueError("reserved package path") - return path - - -def validate_manifest(manifest: dict, paths: set[str]) -> None: - required = {"format", "id", "version", "api", "entry", "publisher", "license", "offers", "requires", "optional", "permissions", "channels", "stateSchema", "ui"} - if not isinstance(manifest, dict) or set(manifest) != required: - raise ValueError("unknown or missing manifest fields") - if manifest["format"] != 1 or manifest["api"] != "0.1": - raise ValueError("unsupported manifest format or API revision") - if not isinstance(manifest["id"], str) or not re.fullmatch(r"[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+", manifest["id"]): - raise ValueError("invalid module identity") - if not isinstance(manifest["version"], str) or not re.fullmatch(r"0\.\d+\.\d+(?:-[a-z0-9.-]+)?", manifest["version"]): - raise ValueError("invalid experimental release version") - if not isinstance(manifest["publisher"], str) or not manifest["publisher"] or not isinstance(manifest["license"], str) or not manifest["license"]: - raise ValueError("publisher and license required") - if type(manifest["stateSchema"]) is not int or manifest["stateSchema"] < 1: - raise ValueError("invalid state schema") - for key, known in (("permissions", PERMISSIONS), ("channels", CHANNELS)): - value = manifest[key] - if not isinstance(value, list) or not value or any(not isinstance(v, str) for v in value) or len(set(value)) != len(value) or not set(value) <= known: - raise ValueError(f"unknown or duplicate {key}") - for key in ("offers", "requires", "optional"): - if not isinstance(manifest[key], dict) or any(not isinstance(name, str) or revision != "0.1" for name, revision in manifest[key].items()): - raise ValueError(f"invalid extension declarations: {key}") - # Offered unknown extensions are harmless. Required unknown host services - # are not: reject rather than evaluating a module that cannot work. - supported = {"http", "source-context", "timers"} - if not set(manifest["requires"]) <= supported: - raise ValueError("unsupported required host extension") - if not isinstance(manifest["entry"], str) or manifest["entry"] not in paths or not manifest["entry"].endswith(".mjs"): - raise ValueError("missing JS entry point") - ui = manifest["ui"] - if not isinstance(ui, dict) or set(ui) != {"modules", "components"}: - raise ValueError("invalid UI declaration") - if not isinstance(ui["modules"], list) or any(not isinstance(v, str) for v in ui["modules"]) or not set(ui["modules"]) <= MODULES: - raise ValueError("unsupported QML import") - if not isinstance(ui["components"], list) or any(not isinstance(v, str) or v not in paths or not v.endswith(".qml") for v in ui["components"]): - raise ValueError("missing UI component") - if set(ui["components"]) != {p for p in paths if p.endswith(".qml")}: - raise ValueError("every QML component must be declared for validation and warming") - if "LICENSE" not in paths or "NOTICE" not in paths: - raise ValueError("license and attribution notices required") - - -def validate_files(files: dict[str, bytes]) -> dict: - if not files or len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: - raise ValueError("expanded package limit") - folded = set() - for name, data in files.items(): - path = path_name(name) - if name.casefold() in folded: - raise ValueError("case-colliding paths") - folded.add(name.casefold()) - if path.name not in {"LICENSE", "NOTICE"} and path.suffix.lower() not in EXTENSIONS: - raise ValueError("unsupported payload type") - if len(data) > MAX_FILE or data.startswith(NATIVE_MAGIC): - raise ValueError("oversized or executable payload") - if path.suffix.lower() in TEXT or path.name in {"LICENSE", "NOTICE"}: - text = data.decode("utf-8") - if "\x00" in text: - raise ValueError("binary data in source") - if "manifest.json" not in files: - raise ValueError("missing manifest") - manifest = json.loads(files["manifest.json"], object_pairs_hook=unique_object) - validate_manifest(manifest, set(files)) - # Static checks are review aids, NOT a QML sandbox. Dynamic import/resource - # loading still requires trusted code and host channel policy. - for name, data in files.items(): - suffix = pathlib.PurePosixPath(name).suffix - if suffix not in {".qml", ".js", ".mjs"}: - continue - text = data.decode("utf-8") - for target in re.findall(r"(?:\bfrom\s*|\bimport\s*\(?\s*)['\"]([^'\"]+)['\"]", text): - if not target.startswith("./") or ".." in target.split("/") or "\\" in target or ":" in target: - raise ValueError("script imports must stay within the package directory") - resolved = str(pathlib.PurePosixPath(name).parent / target[2:]) - if resolved not in files and not any(p.startswith(resolved + "/") for p in files): - raise ValueError("missing local import") - if suffix == ".qml": - imports = re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", text, re.MULTILINE) - if not set(imports) <= set(manifest["ui"]["modules"]): - raise ValueError("undeclared QML import") - return manifest - - -def read_package(path: pathlib.Path) -> tuple[dict, dict[str, bytes]]: - if path.stat().st_size > MAX_ARCHIVE: - raise ValueError("archive size limit") - files = {} - total = 0 - with zipfile.ZipFile(path) as archive: - entries = archive.infolist() - if len(entries) > MAX_FILES: - raise ValueError("archive entry limit") - for entry in entries: - path_name(entry.filename) - mode = entry.external_attr >> 16 - if entry.is_dir() or stat.S_ISLNK(mode) or (stat.S_IFMT(mode) not in (0, stat.S_IFREG)) or mode & 0o111: - raise ValueError("only non-executable regular files are allowed") - if entry.flag_bits & 1 or entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): - raise ValueError("unsupported ZIP encoding") - total += entry.file_size - if entry.file_size > MAX_FILE or total > MAX_EXPANDED: - raise ValueError("expanded archive size limit") - if entry.filename in files: - raise ValueError("duplicate archive path") - with archive.open(entry) as member: - data = member.read(MAX_FILE + 1) - if len(data) != entry.file_size or len(data) > MAX_FILE: - raise ValueError("invalid archive member size") - files[entry.filename] = data - return validate_files(files), files - - -def build(source: pathlib.Path, destination: pathlib.Path) -> dict: - files = {} - for root in ("manifest.json", "logic", "ui", "resources", "LICENSE", "NOTICE"): - path = source / root - if not path.exists(): - continue - if path.is_symlink(): - raise ValueError("symlink in package source") - candidates = [path] if path.is_file() else path.rglob("*") - for entry in candidates: - if entry.is_symlink(): - raise ValueError("symlink in package source") - if entry.is_file(): - if entry.stat().st_size > MAX_FILE: - raise ValueError("source file size limit") - files[entry.relative_to(source).as_posix()] = entry.read_bytes() - if len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_EXPANDED: - raise ValueError("source package size limit") - manifest = validate_files(files) - destination.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for name, data in sorted(files.items()): - info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) - info.create_system = 3 - info.external_attr = (stat.S_IFREG | 0o644) << 16 - info.compress_type = zipfile.ZIP_DEFLATED - archive.writestr(info, data) - read_package(destination) - return manifest - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("build", "validate")) - parser.add_argument("path", type=pathlib.Path) - parser.add_argument("--output", type=pathlib.Path) - args = parser.parse_args() - if args.command == "build": - if not args.output: - parser.error("build requires --output") - manifest = build(args.path, args.output) - archive = args.output - else: - manifest, _ = read_package(args.path) - archive = args.path - print(json.dumps({"id": manifest["id"], "version": manifest["version"], "size": archive.stat().st_size, - "sha256": hashlib.file_digest(archive.open("rb"), "sha256").hexdigest()}, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except (ValueError, OSError, zipfile.BadZipFile) as error: - print(f"provider package rejected: {error}", file=sys.stderr) - raise SystemExit(1) From 79ab24f7895f7fcec15aa5459e3a1e9c62f6a14f Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:34:40 +1000 Subject: [PATCH 048/146] fix(provider): close races found while rewriting the provider tests - Registry: a start that outlived an install or restart called into the deleted runtime; it now checks the runtime and generation after each wait and no longer reports a stale start as a failed connection - Registry: pick() mounted the picker before waiting on it, so a screen that finished at once was never seen - Store: installs and removals made before the launch update check overwrote where every other provider came from; origins now load first - Store: a release's .tar.zst link resolves to the spool-provider.json beside it instead of being fetched as a feed - Hub: same-named libraries are told apart by account label (two folders both said "Local files"); items-by-ids asks every account at once and leaves out one that fails - A zero page bound is a caller error again - Bundled providers are restamped after extraction: zeroed archive times kept rcc and the QML disk cache serving the previous pin - Group playback accepts participantJoined/participantLeft - The contract runner prints why a contract failed - Remove the unbuilt ProviderRegistrySources.cpp (old registry API) --- cmake/ProviderBundle.cmake | 4 + sdk/provider-contract-runner.cpp | 12 +- sdk/provider.d.ts | 1 + sdk/spool-provider.py | 2 +- src/app/GroupPlaybackController.cpp | 6 + src/provider/ProviderMediaPage.cpp | 4 +- src/provider/ProviderRegistry.cpp | 18 +- src/provider/ProviderRegistrySources.cpp | 692 ----------------------- src/provider/ProviderStore.cpp | 60 +- src/provider/ProviderStore.h | 2 + src/provider/ScriptRuntime.cpp | 3 +- src/provider/SourceHub.cpp | 26 +- 12 files changed, 98 insertions(+), 732 deletions(-) delete mode 100644 src/provider/ProviderRegistrySources.cpp diff --git a/cmake/ProviderBundle.cmake b/cmake/ProviderBundle.cmake index e9c565ae..30d8e5ca 100644 --- a/cmake/ProviderBundle.cmake +++ b/cmake/ProviderBundle.cmake @@ -43,6 +43,10 @@ function(spool_bundle_providers target) message(FATAL_ERROR "${package} does not match its pin in providers/lock.json") endif() file(ARCHIVE_EXTRACT INPUT "${package}" DESTINATION "${root}") + # Archive times are zeroed for reproducibility; restamp them so rcc + # sees a changed pin as newer than the resource it built before. + file(GLOB_RECURSE extracted "${root}/*") + file(TOUCH_NOCREATE ${extracted}) endif() file(GLOB_RECURSE files RELATIVE "${root}" "${root}/*") list(SORT files) diff --git a/sdk/provider-contract-runner.cpp b/sdk/provider-contract-runner.cpp index a8fdd99f..1333889f 100644 --- a/sdk/provider-contract-runner.cpp +++ b/sdk/provider-contract-runner.cpp @@ -9,13 +9,13 @@ class ContractResult final : public QObject { Q_OBJECT public: using QObject::QObject; - Q_INVOKABLE void complete(bool success) + Q_INVOKABLE void complete(bool success, const QString& reason = {}) { if (settled) return; settled = true; - std::fprintf( - success ? stdout : stderr, "Qt %s provider contract %s\n", qVersion(), success ? "passed" : "failed"); + std::fprintf(success ? stdout : stderr, "Qt %s provider contract %s%s%s\n", qVersion(), + success ? "passed" : "failed", reason.isEmpty() ? "" : ": ", qPrintable(reason)); QCoreApplication::exit(success ? 0 : 1); } @@ -44,13 +44,13 @@ int main(int argc, char **argv) (function(module, result) { try { Promise.resolve(module.run()).then(function() { result.complete(true); }, - function() { result.complete(false); }); - } catch (error) { result.complete(false); } + function(error) { result.complete(false, String(error) + "\n" + (error && error.stack || "")); }); + } catch (error) { result.complete(false, String(error) + "\n" + (error && error.stack || "")); } }) )JS")); invoke.call({ module, engine.newQObject(&result) }); }); - QTimer::singleShot(10000, &app, [&] { result.complete(false); }); + QTimer::singleShot(10000, &app, [&] { result.complete(false, QStringLiteral("timed out")); }); return app.exec(); } diff --git a/sdk/provider.d.ts b/sdk/provider.d.ts index f443a720..1f82dc13 100644 --- a/sdk/provider.d.ts +++ b/sdk/provider.d.ts @@ -207,6 +207,7 @@ export interface Events { | { type: 'connected' } | { type: 'joined' | 'update'; groupId: string; name: string; state: string; reason?: string; participants: string[]; at?: number } | { type: 'participants'; participants: string[] } + | { type: 'participantJoined' | 'participantLeft'; name: string } | { type: 'state'; state: 'Idle' | 'Waiting' | 'Paused' | 'Playing'; reason?: string } | { type: 'queue'; items: { itemId: string; entryId: string }[]; index: number; positionTicks: string; at?: number; reason?: string } | { type: 'command'; command: 'pause' | 'unpause' | 'seek' | 'stop'; at: number; positionTicks: string; entryId?: string; emittedAt?: number } diff --git a/sdk/spool-provider.py b/sdk/spool-provider.py index aee76f7b..fc4d6f46 100755 --- a/sdk/spool-provider.py +++ b/sdk/spool-provider.py @@ -28,7 +28,7 @@ MAX_FILE = 8 * 1024 * 1024 MAX_FILES = 512 EXTENSIONS = {".mjs", ".js", ".qml", ".json", ".png", ".jpg", ".svg", ".webp", ".ttf", ".otf", ".txt", ".md", ".map"} -QML_IMPORTS = {"QtQuick", "QtQuick.Layouts", "QtQuick.Controls", "QtQml", "QtQml.Models", "Spool.Ui"} +QML_IMPORTS = {"QtQuick", "QtQuick.Layouts", "QtQuick.Controls", "QtQml", "QtQml.Models", "Spool"} CAPABILITIES = {"search", "userState", "reporting", "segments", "streamQuality", "trickplay", "discovery", "groupPlayback", "remoteControl"} UI_ROLES = {"login", "settings", "picker"} diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index 758dc544..385fb7ec 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -323,6 +323,12 @@ void GroupPlaybackController::handleEvent(const QString& accountId, const QVaria } else if (type == QStringLiteral("participants")) { m_participants = event.value(QStringLiteral("participants")).toStringList(); emit groupChanged(); + } else if (type == QStringLiteral("participantJoined") || type == QStringLiteral("participantLeft")) { + const QString name = event.value(QStringLiteral("name")).toString(); + m_participants.removeAll(name); + if (type == QStringLiteral("participantJoined") && !name.isEmpty()) + m_participants.append(name); + emit groupChanged(); } else if (type == QStringLiteral("state")) { m_groupState = event.value(QStringLiteral("state")).toString(); m_groupStateReason = event.value(QStringLiteral("reason")).toString(); diff --git a/src/provider/ProviderMediaPage.cpp b/src/provider/ProviderMediaPage.cpp index 70a631cf..af872f6d 100644 --- a/src/provider/ProviderMediaPage.cpp +++ b/src/provider/ProviderMediaPage.cpp @@ -270,12 +270,14 @@ namespace { ProviderMediaPage readProviderMediaPage(const QJSValue& value, int maximumItems) { + if (maximumItems < 1) + invalid(); requireObject(value); const QJSValue rows = value.property(QStringLiteral("items")); const QJSValue exhausted = value.property(QStringLiteral("exhausted")); if (!exhausted.isBool()) invalid(); - const quint32 length = arrayLength(rows, static_cast(std::clamp(maximumItems, 1, 1000))); + const quint32 length = arrayLength(rows, static_cast(std::min(maximumItems, 1000))); Reader reader; ProviderMediaPage result; result.exhausted = exhausted.toBool(); diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 0864ecf8..6a2d0e6b 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -282,22 +282,26 @@ QCoro::Task ProviderRegistry::start(QString accountId) m_running.insert(accountId, { module.manifest.id, generation, nullptr, origins, false }); const QString label = entry->label; const Provider::Capabilities capabilities = capabilitiesOf(module.manifest); - ScriptRuntime *runtime = runtimeFor(module); + // An install or restart may replace the module's runtime while this + // waits; the generation says whether this start is still the current one. + QPointer runtime = runtimeFor(module); QPointer guard(this); + const auto current = [&] { return guard && runtime && m_running.value(accountId).generation == generation; }; QVariantMap description; try { co_await runtime->addSource(accountId, entry->configuration, origins); + if (!current()) + co_return; description = co_await runtime->call(accountId, QStringLiteral("describe")); } catch (const std::exception& error) { - if (!guard) + if (!current()) co_return; qWarning("providers: %s did not start: %s", qPrintable(accountId), error.what()); - if (m_running.value(accountId).generation == generation) - stop(accountId); + stop(accountId); emit problem(QStringLiteral("Couldn't connect to %1").arg(label)); co_return; } - if (!guard || m_running.value(accountId).generation != generation) + if (!current()) co_return; auto *provider = new PortableProvider(this, accountId, label, capabilities, description, this); m_running[accountId].provider = provider; @@ -703,7 +707,9 @@ QCoro::Task ProviderRegistry::pick(QString accountId, QVariantMap a auto *context = qobject_cast(openPicker(accountId, arguments)); if (!context) throw std::runtime_error("picker_unavailable"); - emit componentRequested(context); + // Mounted on the next turn, once this is waiting: a screen may finish as + // soon as it is shown. + QTimer::singleShot(0, context, [this, context] { emit componentRequested(context); }); const auto [result, cancelled] = co_await qCoro(context, &ProviderUiContext::finished); co_return cancelled ? QVariantMap {} : result; } diff --git a/src/provider/ProviderRegistrySources.cpp b/src/provider/ProviderRegistrySources.cpp deleted file mode 100644 index d55bde5d..00000000 --- a/src/provider/ProviderRegistrySources.cpp +++ /dev/null @@ -1,692 +0,0 @@ -#include "ProviderRegistry.h" - -#include "ProviderPackage.h" -#include "ScriptRuntime.h" -#include "cache/DatabaseManager.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace JellyfinNative { -namespace { - const QString stateKey = QStringLiteral("portable/sources/1"); - - bool validModule(const QString& id) - { - static const QRegularExpression pattern(QStringLiteral("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$")); - return id.size() <= 128 && pattern.match(id).hasMatch(); - } - - bool boundedIdentity(const QString& value) - { - return !value.isEmpty() && value.size() <= 256; - } -} // namespace - -struct ProviderRegistry::PortableState { - struct Source { - QString id; - QString module; - QString account; - QString key; - QString label; - bool enabled = true; - bool configured = false; - bool active = false; - quint64 generation = 0; - QVariantMap configuration; - QList origins; - }; - - Source *find(const QString& id) - { - const auto found - = std::find_if(sources.begin(), sources.end(), [&id](const Source& source) { return source.id == id; }); - return found == sources.end() ? nullptr : &*found; - } - - void requireReady() const - { - if (!restored || !database) - throw std::runtime_error("source_registry_not_ready"); - if (mutating) - throw std::runtime_error("source_registry_busy"); - } - - QPointer database; - QPointer network; - QString providersDirectory; - bool isInstalling = false; - QString installStatus; - int installProgress = 0; - QString installError; - - QHash modules; - QList sources; - QVariantList snapshot; - bool restored = false; - bool mutating = false; -}; - -ProviderRegistry::ProviderRegistry(QObject *parent) - : QObject(parent) - , m_portable(std::make_unique()) -{ -} - -ProviderRegistry::~ProviderRegistry() = default; - -void ProviderRegistry::registerModule(const QString& moduleId, const QString& entryPoint) -{ - Q_ASSERT(thread() == QThread::currentThread()); - if (!validModule(moduleId) || entryPoint.isEmpty() || m_portable->modules.contains(moduleId) - || m_portable->modules.size() >= 4) - throw std::runtime_error("invalid_or_duplicate_module"); - m_portable->modules.insert(moduleId, new ScriptRuntime(entryPoint, this)); -} - -QCoro::Task ProviderRegistry::restoreSources(DatabaseManager *database) -{ - Q_ASSERT(thread() == QThread::currentThread()); - if (!database || m_portable->restored || m_portable->mutating) - throw std::runtime_error("source_registry_already_initialized"); - QPointer guard(this); - m_portable->mutating = true; - const auto unlock = qScopeGuard([guard] { - if (guard) - guard->m_portable->mutating = false; - }); - const QString stored = co_await database->loadSettingAsync(stateKey); - if (!guard) - throw std::runtime_error("source_registry_removed"); - if (stored.size() > 128 * 1024) - throw std::runtime_error("source_state_limit"); - QList restored; - if (!stored.isEmpty()) { - const QJsonDocument document = QJsonDocument::fromJson(stored.toUtf8()); - const QJsonObject root = document.object(); - if (!document.isObject() || root.value(QStringLiteral("format")).toInt() != 1 - || !root.value(QStringLiteral("sources")).isArray()) - throw std::runtime_error("incompatible_source_state"); - const QJsonArray records = root.value(QStringLiteral("sources")).toArray(); - if (records.size() > 64) - throw std::runtime_error("source_state_limit"); - for (const QJsonValue& value : records) { - const QJsonObject row = value.toObject(); - PortableState::Source source; - source.id = row.value(QStringLiteral("id")).toString(); - source.module = row.value(QStringLiteral("module")).toString(); - source.account = row.value(QStringLiteral("account")).toString(); - source.key = row.value(QStringLiteral("key")).toString(); - source.label = row.value(QStringLiteral("label")).toString(); - source.enabled = row.value(QStringLiteral("enabled")).toBool(); - if (QUuid(source.id).isNull() || !validModule(source.module) || !boundedIdentity(source.account) - || !boundedIdentity(source.key) || source.label.size() > 256 - || !row.value(QStringLiteral("enabled")).isBool()) - throw std::runtime_error("invalid_source_state"); - if (std::any_of(restored.begin(), restored.end(), [&source](const auto& existing) { - return existing.id == source.id - || (existing.module == source.module && existing.account == source.account - && existing.key == source.key); - })) - throw std::runtime_error("duplicate_source_state"); - restored.append(std::move(source)); - } - } - m_portable->database = database; - m_portable->sources = std::move(restored); - m_portable->restored = true; - refreshSourceSnapshot(); -} - -QCoro::Task ProviderRegistry::persistSources() -{ - QJsonArray records; - for (const auto& source : m_portable->sources) { - records.append(QJsonObject { { QStringLiteral("id"), source.id }, { QStringLiteral("module"), source.module }, - { QStringLiteral("account"), source.account }, { QStringLiteral("key"), source.key }, - { QStringLiteral("label"), source.label }, { QStringLiteral("enabled"), source.enabled } }); - } - const QString value = QString::fromUtf8( - QJsonDocument(QJsonObject { { QStringLiteral("format"), 1 }, { QStringLiteral("sources"), records } }) - .toJson(QJsonDocument::Compact)); - if (!m_portable->database) - throw std::runtime_error("source_store_unavailable"); - QPointer guard(this); - m_portable->database->saveSetting(stateKey, value); - const QString stored = co_await m_portable->database->loadSettingAsync(stateKey); - if (!guard) - throw std::runtime_error("source_registry_removed"); - if (stored != value) - throw std::runtime_error("source_state_write_failed"); -} - -QCoro::Task ProviderRegistry::configureSource(QString moduleId, QString accountId, QString sourceKey, - QString label, QVariantMap configuration, QList authorisedOrigins) -{ - Q_ASSERT(thread() == QThread::currentThread()); - m_portable->requireReady(); - if (!m_portable->modules.contains(moduleId) || !boundedIdentity(accountId) || !boundedIdentity(sourceKey) - || label.size() > 256) - throw std::runtime_error("invalid_source_configuration"); - QPointer guard(this); - m_portable->mutating = true; - const auto unlock = qScopeGuard([guard] { - if (guard) - guard->m_portable->mutating = false; - }); - auto found = std::find_if(m_portable->sources.begin(), m_portable->sources.end(), [&](const auto& source) { - return source.module == moduleId && source.account == accountId && source.key == sourceKey; - }); - const bool created = found == m_portable->sources.end(); - if (created) { - if (m_portable->sources.size() >= 64) - throw std::runtime_error("source_limit"); - PortableState::Source source; - source.id = QUuid::createUuid().toString(QUuid::WithoutBraces); - source.module = moduleId; - source.account = accountId; - source.key = sourceKey; - m_portable->sources.append(std::move(source)); - found = std::prev(m_portable->sources.end()); - } - const QString id = found->id; - const QString oldLabel = found->label; - found->label = std::move(label); - try { - co_await persistSources(); - } catch (...) { - if (guard) { - if (created) - m_portable->sources.removeLast(); - else - m_portable->find(id)->label = oldLabel; - } - throw; - } - if (!guard) - throw std::runtime_error("source_registry_removed"); - auto *source = m_portable->find(id); - ScriptRuntime *runtime = m_portable->modules.value(moduleId); - const bool changed - = !source->configured || source->configuration != configuration || source->origins != authorisedOrigins; - if (changed) { - if (source->active) - runtime->removeSource(id); - source->active = false; - ++source->generation; - source->configuration = std::move(configuration); - source->origins = std::move(authorisedOrigins); - source->configured = true; - } - if (source->enabled && !source->active) { - const auto activate = runtime->addSource(id, source->configuration, source->origins); - try { - co_await activate; - } catch (...) { - if (guard) - refreshSourceSnapshot(); - throw; - } - if (!guard) - throw std::runtime_error("source_registry_removed"); - m_portable->find(id)->active = true; - } - refreshSourceSnapshot(); - co_return id; -} - -QCoro::Task ProviderRegistry::callSource( - QString sourceId, QString operation, QVariantMap arguments, QString scope) -{ - Q_ASSERT(thread() == QThread::currentThread()); - auto *source = m_portable->find(sourceId); - if (!source || !source->enabled || !source->active) - throw std::runtime_error("source_unavailable"); - QPointer guard(this); - const quint64 generation = source->generation; - const QString module = source->module; - QVariantMap result; - try { - result = co_await m_portable->modules.value(module)->call( - sourceId, std::move(operation), std::move(arguments), std::move(scope)); - } catch (const std::exception& error) { - const QByteArray code(error.what()); - if (guard && (code == "script_interrupted" || code == "source_unavailable")) { - for (auto& candidate : m_portable->sources) { - if (candidate.module == module) { - candidate.active = false; - ++candidate.generation; - } - } - refreshSourceSnapshot(); - } - throw; - } - if (!guard) - throw std::runtime_error("source_registry_removed"); - source = m_portable->find(sourceId); - if (!source || !source->enabled || !source->active || source->generation != generation) - throw std::runtime_error("source_generation_changed"); - co_return result; -} - -QCoro::Task ProviderRegistry::callSourceMediaPage( - QString sourceId, QString operation, QVariantMap arguments, QString scope, int maximumItems) -{ - Q_ASSERT(thread() == QThread::currentThread()); - auto *source = m_portable->find(sourceId); - if (!source || !source->enabled || !source->active) - throw std::runtime_error("source_unavailable"); - QPointer guard(this); - const quint64 generation = source->generation; - const QString module = source->module; - ProviderMediaPage result; - try { - result = co_await m_portable->modules.value(module)->callMediaPage( - sourceId, std::move(operation), std::move(arguments), std::move(scope), maximumItems); - } catch (const std::exception& error) { - const QByteArray code(error.what()); - if (guard && (code == "script_interrupted" || code == "source_unavailable")) { - for (auto& candidate : m_portable->sources) { - if (candidate.module == module) { - candidate.active = false; - ++candidate.generation; - } - } - refreshSourceSnapshot(); - } - throw; - } - if (!guard) - throw std::runtime_error("source_registry_removed"); - source = m_portable->find(sourceId); - if (!source || !source->enabled || !source->active || source->generation != generation) - throw std::runtime_error("source_generation_changed"); - co_return result; -} - -void ProviderRegistry::cancelSourceScope(const QString& sourceId, const QString& scope) -{ - const auto *source = m_portable->find(sourceId); - if (source && source->active) { - if (auto *runtime = m_portable->modules.value(source->module)) - runtime->cancelScope(sourceId, scope); - } -} - -QCoro::Task ProviderRegistry::setSourceEnabled(QString sourceId, bool enabled) -{ - Q_ASSERT(thread() == QThread::currentThread()); - m_portable->requireReady(); - auto *source = m_portable->find(sourceId); - if (!source) - throw std::runtime_error("source_unavailable"); - if (source->enabled == enabled) - co_return; - QPointer guard(this); - m_portable->mutating = true; - const auto unlock = qScopeGuard([guard] { - if (guard) - guard->m_portable->mutating = false; - }); - ScriptRuntime *runtime = m_portable->modules.value(source->module); - source->enabled = enabled; - ++source->generation; - if (source->active && runtime) - runtime->removeSource(sourceId); - source->active = false; - refreshSourceSnapshot(); - co_await persistSources(); - if (!guard) - throw std::runtime_error("source_registry_removed"); - source = m_portable->find(sourceId); - if (enabled && source->configured && runtime) { - co_await runtime->addSource(sourceId, source->configuration, source->origins); - if (!guard) - throw std::runtime_error("source_registry_removed"); - m_portable->find(sourceId)->active = true; - refreshSourceSnapshot(); - } -} - -QCoro::Task ProviderRegistry::removeSource(QString sourceId) -{ - Q_ASSERT(thread() == QThread::currentThread()); - m_portable->requireReady(); - auto *source = m_portable->find(sourceId); - if (!source) - co_return; - QPointer guard(this); - m_portable->mutating = true; - const auto unlock = qScopeGuard([guard] { - if (guard) - guard->m_portable->mutating = false; - }); - if (auto *runtime = m_portable->modules.value(source->module)) - runtime->removeSource(sourceId); - source->active = false; - ++source->generation; - const auto previous = m_portable->sources; - m_portable->sources.removeIf([&sourceId](const auto& candidate) { return candidate.id == sourceId; }); - refreshSourceSnapshot(); - try { - co_await persistSources(); - } catch (...) { - if (guard) { - m_portable->sources = previous; - refreshSourceSnapshot(); - } - throw; - } - if (!guard) - throw std::runtime_error("source_registry_removed"); - emit sourceRemoved(sourceId); -} - -QVariantList ProviderRegistry::configuredSources() const -{ - return m_portable->snapshot; -} - -void ProviderRegistry::refreshSourceSnapshot() -{ - QVariantList snapshot; - snapshot.reserve(m_portable->sources.size()); - for (const auto& source : m_portable->sources) { - snapshot.append(QVariantMap { { QStringLiteral("sourceId"), source.id }, - { QStringLiteral("moduleId"), source.module }, { QStringLiteral("label"), source.label }, - { QStringLiteral("enabled"), source.enabled }, { QStringLiteral("available"), source.active } }); - } - if (snapshot != m_portable->snapshot) { - m_portable->snapshot = std::move(snapshot); - emit configuredSourcesChanged(); - } -} - -void ProviderRegistry::setNetworkAccessManager(QNetworkAccessManager *network) -{ - m_portable->network = network; -} - -void ProviderRegistry::setProvidersDirectory(const QString& path) -{ - m_portable->providersDirectory = path; -} - -QString ProviderRegistry::providersDirectory() const -{ - return m_portable->providersDirectory; -} - -bool ProviderRegistry::hasModule(const QString& moduleId) const -{ - return m_portable->modules.contains(moduleId); -} - -bool ProviderRegistry::isInstalled(const QString& moduleId) const -{ - if (moduleId == QStringLiteral("local")) - return true; - return hasModule(moduleId); -} - -bool ProviderRegistry::isInstalling() const -{ - return m_portable->isInstalling; -} - -QString ProviderRegistry::installStatus() const -{ - return m_portable->installStatus; -} - -int ProviderRegistry::installProgress() const -{ - return m_portable->installProgress; -} - -QString ProviderRegistry::installError() const -{ - return m_portable->installError; -} - -bool ProviderRegistry::hasInstalledProviders() const -{ - return !m_portable->modules.isEmpty(); -} - -QVariantList ProviderRegistry::availableProviders() const -{ - QVariantList list; - { - QVariantMap jf; - jf.insert(QStringLiteral("id"), QStringLiteral("spool.jellyfin")); - jf.insert(QStringLiteral("name"), QStringLiteral("Jellyfin")); - jf.insert(QStringLiteral("description"), QStringLiteral("Connect to your personal Jellyfin media server")); - jf.insert(QStringLiteral("icon"), QStringLiteral("jellyfin")); - jf.insert(QStringLiteral("installed"), hasModule(QStringLiteral("spool.jellyfin"))); - jf.insert(QStringLiteral("category"), QStringLiteral("Streaming Server")); - jf.insert(QStringLiteral("requiresAuth"), true); - list.append(jf); - } - { - QVariantMap loc; - loc.insert(QStringLiteral("id"), QStringLiteral("local")); - loc.insert(QStringLiteral("name"), QStringLiteral("Local Videos")); - loc.insert(QStringLiteral("description"), QStringLiteral("Browse videos stored on this device")); - loc.insert(QStringLiteral("icon"), QStringLiteral("folder")); - loc.insert(QStringLiteral("installed"), true); - loc.insert(QStringLiteral("category"), QStringLiteral("Device")); - loc.insert(QStringLiteral("requiresAuth"), false); - list.append(loc); - } - return list; -} - -void ProviderRegistry::scanInstalledModules() -{ - if (m_portable->providersDirectory.isEmpty()) - return; - QDir dir(m_portable->providersDirectory); - if (!dir.exists()) - return; - const auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); - for (const auto& info : entries) { - const QDir subDir(info.absoluteFilePath()); - const QString manifestPath = subDir.filePath(QStringLiteral("manifest.json")); - if (!QFile::exists(manifestPath)) - continue; - QFile manifestFile(manifestPath); - if (!manifestFile.open(QIODevice::ReadOnly)) - continue; - const auto doc = QJsonDocument::fromJson(manifestFile.readAll()); - if (!doc.isObject()) - continue; - const auto obj = doc.object(); - const QString id = obj.value(QStringLiteral("id")).toString(); - const QString entry = obj.value(QStringLiteral("entry")).toString(); - if (!id.isEmpty() && !entry.isEmpty() && !m_portable->modules.contains(id)) { - const QString entryPoint = subDir.filePath(entry); - if (QFile::exists(entryPoint)) { - try { - registerModule(id, entryPoint); - } catch (const std::exception& e) { - qWarning("Failed to register installed module %s: %s", qPrintable(id), e.what()); - } - } - } - } - emit installedProvidersChanged(); - emit availableProvidersChanged(); -} - -void ProviderRegistry::installProvider(const QString& moduleId) -{ - if (m_portable->isInstalling) - return; - downloadAndInstallProvider(moduleId); -} - -bool ProviderRegistry::switchProvider(const QString& providerId) -{ - return setActive(providerId); -} - -QCoro::Task ProviderRegistry::downloadAndInstallProvider(QString moduleId, QUrl url) -{ - if (m_portable->isInstalling) - co_return false; - - m_portable->isInstalling = true; - m_portable->installProgress = 5; - m_portable->installStatus = QStringLiteral("Connecting..."); - m_portable->installError.clear(); - emit installStatusChanged(); - - if (url.isEmpty()) { - if (moduleId == QStringLiteral("spool.jellyfin")) { - url = QUrl(QStringLiteral("https://github.com/spool-player/spool-jellyfin/releases/download/v0.1.0-alpha.1/" - "spool-jellyfin-0.1.0-alpha.1.zip")); - } else { - m_portable->isInstalling = false; - m_portable->installError = QStringLiteral("Unknown provider module: %1").arg(moduleId); - emit installStatusChanged(); - co_return false; - } - } - - QByteArray archiveData; - bool downloadSuccess = false; - - if (m_portable->network) { - QUrl currentUrl = url; - int redirectCount = 0; - constexpr int maxRedirects = 5; - - while (redirectCount < maxRedirects) { - QNetworkRequest request(currentUrl); - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool/0.8.1")); - - QNetworkReply *reply = m_portable->network->get(request); - connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64 total) { - if (total > 0) { - m_portable->installProgress = std::clamp(static_cast(10 + (received * 75) / total), 10, 85); - m_portable->installStatus = QStringLiteral("Downloading (%1%)...").arg(m_portable->installProgress); - emit installStatusChanged(); - } - }); - - co_await reply; - - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308) { - const QVariant redirectVar = reply->header(QNetworkRequest::LocationHeader); - if (redirectVar.isValid()) { - currentUrl = currentUrl.resolved(redirectVar.toUrl()); - reply->deleteLater(); - ++redirectCount; - continue; - } - } - - if (reply->error() == QNetworkReply::NoError && statusCode >= 200 && statusCode < 300) { - archiveData = reply->readAll(); - downloadSuccess = true; - } else { - qWarning("Download from %s failed with status %d: %s", qPrintable(currentUrl.toString()), statusCode, - qPrintable(reply->errorString())); - } - reply->deleteLater(); - break; - } - } - - // If online download failed or no network, check local fallback files - if (!downloadSuccess || archiveData.isEmpty()) { - const QString fallbackPath = QStringLiteral("providers/bundled/spool-jellyfin-0.1.0-alpha.1.zip"); - if (QFile::exists(fallbackPath)) { - QFile file(fallbackPath); - if (file.open(QIODevice::ReadOnly)) { - archiveData = file.readAll(); - downloadSuccess = true; - } - } - } - - if (!downloadSuccess || archiveData.isEmpty()) { - m_portable->isInstalling = false; - m_portable->installError = QStringLiteral("Failed to download provider package."); - emit installStatusChanged(); - co_return false; - } - - m_portable->installStatus = QStringLiteral("Validating package..."); - m_portable->installProgress = 90; - emit installStatusChanged(); - - QString errorMsg; - auto parsed = ProviderPackage::parseAndValidate(archiveData, &errorMsg); - if (!parsed) { - m_portable->isInstalling = false; - m_portable->installError = QStringLiteral("Package validation failed: %1").arg(errorMsg); - emit installStatusChanged(); - co_return false; - } - - m_portable->installStatus = QStringLiteral("Installing files..."); - m_portable->installProgress = 95; - emit installStatusChanged(); - - const QString entryPoint = ProviderPackage::install(*parsed, m_portable->providersDirectory, &errorMsg); - if (entryPoint.isEmpty()) { - m_portable->isInstalling = false; - m_portable->installError = QStringLiteral("Installation failed: %1").arg(errorMsg); - emit installStatusChanged(); - co_return false; - } - - try { - if (!m_portable->modules.contains(parsed->id)) - registerModule(parsed->id, entryPoint); - } catch (const std::exception& e) { - m_portable->isInstalling = false; - m_portable->installError = QString::fromUtf8(e.what()); - emit installStatusChanged(); - co_return false; - } - - if (parsed->id == QStringLiteral("spool.jellyfin")) { - setActive(QStringLiteral("jellyfin")); - } - - m_portable->isInstalling = false; - m_portable->installProgress = 100; - m_portable->installStatus = QStringLiteral("Ready"); - emit installStatusChanged(); - emit installedProvidersChanged(); - emit availableProvidersChanged(); - emit providerInstalled(moduleId); - - co_return true; -} - -} // namespace JellyfinNative diff --git a/src/provider/ProviderStore.cpp b/src/provider/ProviderStore.cpp index 252be616..48a398eb 100644 --- a/src/provider/ProviderStore.cpp +++ b/src/provider/ProviderStore.cpp @@ -47,6 +47,27 @@ ProviderStore::ProviderStore(ProviderRegistry *registry, DatabaseManager *databa , m_catalogBase(std::move(catalogBase)) { connect(registry, &ProviderRegistry::modulesChanged, this, &ProviderStore::catalogChanged); + Async::runScoped(this, loadOrigins(), [] { }, [](const std::exception_ptr&) { }, "provider origins"); +} + +QCoro::Task ProviderStore::loadOrigins() +{ + if (m_originsLoaded) + co_return; + QPointer guard(this); + const QString stored = co_await m_database->loadSettingAsync(kOriginsKey); + // Two callers may race here; the first one to finish wins. + if (!guard || m_originsLoaded) + co_return; + const QJsonObject root = QJsonDocument::fromJson(stored.toUtf8()).object(); + for (auto it = root.begin(); it != root.end(); ++it) { + const QJsonObject origin = it.value().toObject(); + m_origins.insert(it.key(), + { origin.value(QStringLiteral("channel")).toString(), + QUrl(origin.value(QStringLiteral("feed")).toString()) }); + } + m_originsLoaded = true; + emit catalogChanged(); } QUrl ProviderStore::feedUrlFor(const QString& input) @@ -56,8 +77,13 @@ QUrl ProviderStore::feedUrlFor(const QString& input) return {}; url.setScheme(QStringLiteral("https")); QString path = url.path(); - if (path.endsWith(QStringLiteral(".json")) || path.endsWith(QStringLiteral(".tar.zst"))) + if (path.endsWith(QStringLiteral(".json"))) + return url; + // A package link from a release: its feed is published next to it. + if (path.endsWith(QStringLiteral(".tar.zst"))) { + url.setPath(path.left(path.lastIndexOf(QLatin1Char('/')) + 1) + QStringLiteral("spool-provider.json")); return url; + } if (path.endsWith(QStringLiteral(".git"))) path.chop(4); const QString host = url.host().toLower(); @@ -266,9 +292,16 @@ void ProviderStore::updateAll() void ProviderStore::uninstall(const QString& id) { - m_origins.remove(id); - saveOrigins(); - Async::runScoped(this, m_registry->uninstall(id), [] { }, [](const std::exception_ptr&) { }, "provider remove"); + const auto remove = [](ProviderStore *self, QString id) -> QCoro::Task { + QPointer guard(self); + co_await self->loadOrigins(); + if (!guard) + co_return; + self->m_origins.remove(id); + self->saveOrigins(); + co_await self->m_registry->uninstall(id); + }; + Async::runScoped(this, remove(this, id), [] { }, [](const std::exception_ptr&) { }, "provider remove"); } void ProviderStore::addFromUrl(const QString& input) @@ -325,6 +358,9 @@ QCoro::Task ProviderStore::installEntry(QVariantMap entry, Origin origin) if (!package || package->manifest.id != id || package->manifest.version != text(entry, "version")) throw std::runtime_error("package_mismatch"); co_await m_registry->install(*package); + if (!guard) + co_return; + co_await loadOrigins(); if (!guard) co_return; m_origins.insert(id, origin); @@ -354,19 +390,9 @@ void ProviderStore::checkForUpdates(const QString& policy) QCoro::Task ProviderStore::checkAsync(QString policy) { QPointer guard(this); - if (!m_originsLoaded) { - const QString stored = co_await m_database->loadSettingAsync(kOriginsKey); - if (!guard) - co_return; - const QJsonObject root = QJsonDocument::fromJson(stored.toUtf8()).object(); - for (auto it = root.begin(); it != root.end(); ++it) { - const QJsonObject origin = it.value().toObject(); - m_origins.insert(it.key(), - { origin.value(QStringLiteral("channel")).toString(), - QUrl(origin.value(QStringLiteral("feed")).toString()) }); - } - m_originsLoaded = true; - } + co_await loadOrigins(); + if (!guard) + co_return; const bool wantsCommunity = std::any_of(m_origins.begin(), m_origins.end(), [](const Origin& origin) { return origin.channel == QStringLiteral("community"); }); // One small file for first-party providers; the full index only when diff --git a/src/provider/ProviderStore.h b/src/provider/ProviderStore.h index e3398f11..7dd706e5 100644 --- a/src/provider/ProviderStore.h +++ b/src/provider/ProviderStore.h @@ -88,6 +88,8 @@ class ProviderStore final : public QObject { QUrl feed; }; + // Where each installed provider came from; every write waits for it. + QCoro::Task loadOrigins(); QCoro::Task fetch(QUrl url, qint64 limit); QCoro::Task fetchCatalog(QString name); QCoro::Task installEntry(QVariantMap entry, Origin origin); diff --git a/src/provider/ScriptRuntime.cpp b/src/provider/ScriptRuntime.cpp index 8dabeab9..ab02cd2b 100644 --- a/src/provider/ScriptRuntime.cpp +++ b/src/provider/ScriptRuntime.cpp @@ -372,7 +372,8 @@ QCoro::Task ScriptRuntime::call(QString sourceId, QString method, Q QCoro::Task ScriptRuntime::callMediaPage( QString sourceId, QString method, QVariantMap arguments, QString scope, int maximumItems) { - maximumItems = std::clamp(maximumItems, 1, 1000); + // Zero or less is a caller bug; the decoder rejects it. + maximumItems = std::min(maximumItems, 1000); return d->submit(std::move(sourceId), std::move(method), std::move(arguments), std::move(scope), [maximumItems](const QJSValue& value) { return Detail::readProviderMediaPage(value, maximumItems); }); } diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index ef1c0c19..b010bf28 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -517,10 +517,14 @@ QCoro::Task> SourceHub::fetchLibraries() } } // Two "Movies" rows from two servers read as one; say whose each is. + const auto& accounts = m_registry->accountList(); for (LibraryItem& library : libraries) { if (names.value(library.name.toCaseFolded()) > 1) { - if (Provider *provider = owner(library.id)) - library.name += QStringLiteral(" · ") + provider->displayName(); + const QString account = accountOf(library.id); + const auto found + = std::find_if(accounts.begin(), accounts.end(), [&](const auto& a) { return a.id == account; }); + if (found != accounts.end()) + library.name += QStringLiteral(" · ") + found->label; } } co_return libraries; @@ -539,13 +543,19 @@ QCoro::Task> SourceHub::fetchItemsByIds(QStringList itemI QHash byAccount; for (const QString& id : std::as_const(itemIds)) byAccount[accountOf(id)].append(rawId(id)); - QHash found; + std::vector>>> pending; for (auto it = byAccount.cbegin(); it != byAccount.cend(); ++it) { - Provider *provider = source(it.key()); - if (!provider) - continue; - for (MovieItem& item : scopedItems(co_await provider->catalog()->fetchItemsByIds(it.value()), it.key())) - found.insert(item.id, std::move(item)); + if (Provider *provider = source(it.key())) + pending.emplace_back(it.key(), provider->catalog()->fetchItemsByIds(it.value())); + } + QHash found; + for (auto& [accountId, task] : pending) { + try { + for (MovieItem& item : scopedItems(co_await std::move(task), accountId)) + found.insert(item.id, std::move(item)); + } catch (const std::exception& error) { + qWarning() << "hub:" << accountId.left(kPrefix) << "items unavailable:" << error.what(); + } } std::vector ordered; for (const QString& id : std::as_const(itemIds)) { From 80b3cce1515052f59e9f516d67264e126ab6166e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:34:48 +1000 Subject: [PATCH 049/146] test(provider): rewrite provider tests for the multi-account platform - Registry: bundled and installed modules, legacy Jellyfin sign-in migration, setup drafts and origins, account groups, http_401, events, persistence through the credential store, hot install and uninstall - Hub: scoped ID round trips, interleaved rows, a failing account left out, per-account artwork templates, manifest item actions with a picker - Store: feed URL table, catalogue listing, sha256/identity mismatch, add-by-link, update checks under ask and auto, origins across launches - Package: ustar + zstd frames built in the test, the real bundled package, path/link/binary/duplicate rejection, versioned installs - Script runtime: error-code passthrough and host.emit - Bundled Jellyfin contract and SDK tool tests on API 0.2 - Drop assertions on removed native-client settings and DTOs --- tests/app/SettingsControllerTest.cpp | 4 - tests/app/SettingsSchemaTest.cpp | 51 +-- tests/cache/DatabaseManagerTest.cpp | 64 ++-- tests/common/MetaJsonTest.cpp | 16 - tests/providers/LocalProviderTest.cpp | 23 +- tests/providers/ProviderFixture.h | 108 ++++++ tests/providers/ProviderMediaPageTest.cpp | 44 +-- tests/providers/ProviderPackageTest.cpp | 328 ++++++---------- tests/providers/ProviderRegistryTest.cpp | 353 +++++++++++------- tests/providers/ProviderStoreTest.cpp | 252 +++++++++++++ tests/providers/ProviderUiContextTest.cpp | 87 +++-- tests/providers/ScriptRuntimeTest.cpp | 26 +- tests/providers/SourceHubTest.cpp | 157 ++++++++ tests/providers/fixtures/bundled-jellyfin.mjs | 57 ++- tests/providers/fixtures/provider.mjs | 20 +- tests/tools/provider_package_test.py | 241 ++++++------ 16 files changed, 1190 insertions(+), 641 deletions(-) create mode 100644 tests/providers/ProviderFixture.h diff --git a/tests/app/SettingsControllerTest.cpp b/tests/app/SettingsControllerTest.cpp index 76e66b2c..994c5042 100644 --- a/tests/app/SettingsControllerTest.cpp +++ b/tests/app/SettingsControllerTest.cpp @@ -80,7 +80,6 @@ JELLYFIN_TEST_MAIN("settings-controller") require(settings.value(QStringLiteral("playback/rememberSeriesAudioTrack")).toBool(), "fresh profile did not remember per-series audio tracks by default"); require(settings.playerControlTooltipsEnabled(), "fresh profile unexpectedly hid player control tooltips"); - require(settings.castButtonEnabled(), "desktop Cast button default was not enabled"); require(settings.remoteControlTargetEnabled(), "desktop remote-control target default was not enabled"); settings.setValue(QStringLiteral("playback/forwardCacheSizeMiB"), QStringLiteral("256")); @@ -92,9 +91,7 @@ JELLYFIN_TEST_MAIN("settings-controller") settings.setValue(QStringLiteral("playback/rememberSeriesAudioTrack"), false); require(!settings.value(QStringLiteral("playback/rememberSeriesAudioTrack")).toBool(), "series audio-track retention toggle was not updated"); - settings.setValue(QStringLiteral("remote/showCastButton"), false); settings.setValue(QStringLiteral("remote/acceptCommands"), false); - require(!settings.castButtonEnabled(), "Cast button toggle was not applied"); require(!settings.remoteControlTargetEnabled(), "remote target toggle was not applied"); settings.setAudioDelayMs(120); @@ -176,7 +173,6 @@ JELLYFIN_TEST_MAIN("settings-controller") "persisted forward cache size was not restored"); require(!restored.value(QStringLiteral("playback/rememberSeriesAudioTrack")).toBool(), "series audio-track retention toggle was not restored"); - require(!restored.castButtonEnabled(), "persisted Cast button toggle was not restored"); require(!restored.remoteControlTargetEnabled(), "persisted remote target toggle was not restored"); require(!restored.playerControlTooltipsEnabled(), "persisted control-tooltip sessions were not restored"); diff --git a/tests/app/SettingsSchemaTest.cpp b/tests/app/SettingsSchemaTest.cpp index 27159df3..37ad7a75 100644 --- a/tests/app/SettingsSchemaTest.cpp +++ b/tests/app/SettingsSchemaTest.cpp @@ -91,7 +91,6 @@ void requiredPersistedKeysArePresentExactlyOnce() { const QStringList expectedKeys { QStringLiteral("appearance/uiScalePercent"), - QStringLiteral("remote/showCastButton"), QStringLiteral("remote/acceptCommands"), QStringLiteral("artwork/format"), QStringLiteral("artwork/webpQuality"), @@ -144,6 +143,7 @@ void requiredPersistedKeysArePresentExactlyOnce() QStringLiteral("input/yellowButton"), QStringLiteral("input/blueButton"), QStringLiteral("updates/automatic"), + QStringLiteral("providers/updates"), QStringLiteral("playback/videoOutput"), QStringLiteral("playback/renderQuality"), QStringLiteral("playback/autoAdjustQuality"), @@ -382,14 +382,14 @@ void groupsAreDeclaredContiguously() } void pageRowsShareTheSchemaContract() { - const QStringList pageKeys { QStringLiteral("session/account"), QStringLiteral("action/switchUser"), - QStringLiteral("action/logout"), QStringLiteral("i18n/locale"), QStringLiteral("theme/accent"), - QStringLiteral("theme/reducedMotion"), QStringLiteral("theme/railLabels"), QStringLiteral("theme/renderMode"), - QStringLiteral("theme/antialiasedText"), QStringLiteral("theme/technicalMetadata"), - QStringLiteral("action/subtitleSettings"), QStringLiteral("action/resetSubtitleAppearance"), - QStringLiteral("about/version"), QStringLiteral("action/openSourceNotices"), QStringLiteral("about/locale"), - QStringLiteral("shell/diagnostics"), QStringLiteral("shell/latencyGuard"), - QStringLiteral("shell/latencyOverlay"), QStringLiteral("action/clearLatencyStatistics") }; + const QStringList pageKeys { QStringLiteral("action/accounts"), QStringLiteral("action/providers"), + QStringLiteral("i18n/locale"), QStringLiteral("theme/accent"), QStringLiteral("theme/reducedMotion"), + QStringLiteral("theme/railLabels"), QStringLiteral("theme/renderMode"), QStringLiteral("theme/antialiasedText"), + QStringLiteral("theme/technicalMetadata"), QStringLiteral("action/subtitleSettings"), + QStringLiteral("action/resetSubtitleAppearance"), QStringLiteral("about/version"), + QStringLiteral("action/openSourceNotices"), QStringLiteral("about/locale"), QStringLiteral("shell/diagnostics"), + QStringLiteral("shell/latencyGuard"), QStringLiteral("shell/latencyOverlay"), + QStringLiteral("action/clearLatencyStatistics") }; for (const QString& key : pageKeys) { const SettingSpec& spec = requiredSpec(key); require(!spec.persisted, QStringLiteral("page-owned row %1 must not be persisted").arg(key)); @@ -407,28 +407,17 @@ void pageRowsShareTheSchemaContract() QStringLiteral("diagnostics controls should be hidden below Expert detail")); } -void accountRowsFollowTheAuthCapability() +void accountRowsAreProviderNeutral() { - const QStringList accountKeys { QStringLiteral("session/account"), QStringLiteral("action/switchUser"), - QStringLiteral("action/logout") }; - for (const QString& key : accountKeys) - require(requiredSpec(key).requiresAuth, QStringLiteral("account row %1 must require auth").arg(key)); - require(!requiredSpec(QStringLiteral("action/manageCertificates")).requiresAuth, - QStringLiteral("certificate management is core and must not require auth")); - - QSet withAuth; - for (const QVariant& item : settingSchemaModel(true)) - withAuth.insert(item.toMap().value(QStringLiteral("key")).toString()); - QSet withoutAuth; - for (const QVariant& item : settingSchemaModel(false)) - withoutAuth.insert(item.toMap().value(QStringLiteral("key")).toString()); - for (const QString& key : accountKeys) { - require(withAuth.contains(key), QStringLiteral("account row %1 exists with auth").arg(key)); - require(!withoutAuth.contains(key), QStringLiteral("account row %1 must not exist without auth").arg(key)); - } - const QSet dropped = withAuth - withoutAuth; - require(dropped == stringSet(accountKeys), - QStringLiteral("only the account rows depend on auth; dropped: %1").arg(dropped.values().join(", "))); + for (const QString& key : { QStringLiteral("action/accounts"), QStringLiteral("action/providers"), + QStringLiteral("providers/updates"), QStringLiteral("action/manageCertificates") }) + requiredSpec(key); + QSet keys; + for (const QVariant& item : settingSchemaModel()) + keys.insert(item.toMap().value(QStringLiteral("key")).toString()); + for (const QString& gone : + { QStringLiteral("session/account"), QStringLiteral("action/logout"), QStringLiteral("remote/showCastButton") }) + require(!keys.contains(gone), QStringLiteral("%1 belonged to the native Jellyfin client").arg(gone)); } void subtitleChoicesExplainTheirBehavior() @@ -589,7 +578,7 @@ JELLYFIN_TEST_MAIN("settings-schema") schemaModelExposesEverySpecOnce(); groupsAreDeclaredContiguously(); pageRowsShareTheSchemaContract(); - accountRowsFollowTheAuthCapability(); + accountRowsAreProviderNeutral(); subtitleChoicesExplainTheirBehavior(); systemLanguageLabelNamesResolvedLanguage(); buttonChoicesAndLabelsExposePlayerActions(); diff --git a/tests/cache/DatabaseManagerTest.cpp b/tests/cache/DatabaseManagerTest.cpp index 9e3f545d..6790d312 100644 --- a/tests/cache/DatabaseManagerTest.cpp +++ b/tests/cache/DatabaseManagerTest.cpp @@ -1,4 +1,5 @@ #include "cache/DatabaseManager.h" +#include "platform/CredentialStore.h" #include "TestMain.h" #include @@ -70,24 +71,31 @@ JELLYFIN_TEST_MAIN("database-manager") require(!batch.value(QStringLiteral("batch/missing")).isValid(), "batch read should preserve a missing value as invalid"); - AccountProfile profile; - profile.profileId = QStringLiteral("profile"); - profile.serverId = QStringLiteral("server"); - profile.serverName = QStringLiteral("Server"); - profile.serverUrl = QStringLiteral("https://example.test"); - profile.userId = QStringLiteral("user"); - profile.userName = QStringLiteral("User"); - profile.accessToken = QStringLiteral("secret"); - profile.lastUsedAt = 2; - profile.createdAt = 1; - database.upsertAccountProfile(profile); - const std::vector storedProfiles = QCoro::waitFor(database.loadAccountProfilesAsync()); - require(storedProfiles.size() == 1 && storedProfiles.front().profileId == profile.profileId, - "account profile should be persisted before cache recovery"); - require(storedProfiles.front().accessToken == profile.accessToken, - "account token should round-trip through the platform credential store"); - require(QDir(credentialPath).entryList(QDir::Files).size() == 1, - "exactly one durable credential copy should be stored"); + // The native Jellyfin client's sign-ins stay readable for the one-time + // move to provider accounts; the token only ever lives in the credential store. + const QString legacyToken = QStringLiteral("secret"); + require(CredentialStore::save(QStringLiteral("profile"), legacyToken), "legacy token should be stored"); + { + QSqlDatabase seed = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("legacy-seed")); + seed.setDatabaseName(statePath); + require(seed.open(), "durable state should open for legacy seed"); + QSqlQuery query(seed); + require(query.exec(QStringLiteral("INSERT INTO profiles VALUES ('profile', 'server', 'Server', " + "'https://example.test', 'user', 'User', '', 2, 1, 0)")), + "legacy profile should be seeded"); + seed.close(); + } + QSqlDatabase::removeDatabase(QStringLiteral("legacy-seed")); + const auto requireLegacyAccount = [&](DatabaseManager& manager, const char *message) { + const QVariantList accounts = QCoro::waitFor(manager.loadLegacyAccountsAsync()); + require(accounts.size() == 1, message); + const QVariantMap account = accounts.front().toMap(); + require(account.value(QStringLiteral("server")).toString() == QStringLiteral("https://example.test") + && account.value(QStringLiteral("userId")).toString() == QStringLiteral("user") + && account.value(QStringLiteral("token")).toString() == legacyToken, + message); + }; + requireLegacyAccount(database, "legacy sign-in should be readable with its token"); #ifndef Q_OS_WIN const QFileInfo credentialInfo(QDir(credentialPath).entryInfoList(QDir::Files).front()); require((credentialInfo.permissions() @@ -97,7 +105,7 @@ JELLYFIN_TEST_MAIN("database-manager") #endif QFile durableState(statePath); require(durableState.open(QIODevice::ReadOnly), "durable state should be readable for token inspection"); - require(!durableState.readAll().contains(profile.accessToken.toUtf8()), + require(!durableState.readAll().contains(legacyToken.toUtf8()), "SQLite durable state must not contain an access-token copy"); durableState.close(); database.saveDeviceId(QStringLiteral("device")); @@ -108,8 +116,6 @@ JELLYFIN_TEST_MAIN("database-manager") "startup state should include requested values"); require(!startup.values.value(QStringLiteral("batch/missing")).isValid(), "startup state should preserve missing values as invalid"); - require(startup.profiles.size() == 1 && startup.profiles.front().profileId == profile.profileId, - "startup state should include account profiles"); const QJsonObject homePayload { { QStringLiteral("title"), QStringLiteral("Continue Watching") }, @@ -134,13 +140,6 @@ JELLYFIN_TEST_MAIN("database-manager") require(QCoro::waitFor(database.loadHomePayloadAsync(QStringLiteral("server/user"), 1)).isEmpty(), "malformed home payload should be treated as a cache miss"); - database.saveCacheEntry(QStringLiteral("discovery"), QStringLiteral("servers"), QByteArrayLiteral("not-json")); - require(QCoro::waitFor(database.loadDiscoveredServersAsync()).isEmpty(), - "malformed discovery payload should be treated as a cache miss"); - require( - QCoro::waitFor(database.loadCacheEntryAsync(QStringLiteral("discovery"), QStringLiteral("servers"))).isEmpty(), - "malformed discovery payload should be deleted"); - database.saveCacheEntry(QStringLiteral("test"), QStringLiteral("fresh"), QByteArrayLiteral("value"), 5000); require(QCoro::waitFor(database.loadCacheEntryAsync(QStringLiteral("test"), QStringLiteral("fresh"))) == QByteArrayLiteral("value"), @@ -176,9 +175,7 @@ JELLYFIN_TEST_MAIN("database-manager") require(QCoro::waitFor(recovered.schemaVersionAsync()) == 1, "corrupt cache should be rebuilt"); require(QCoro::waitFor(recovered.loadSettingAsync(QStringLiteral("batch/first"))) == QStringLiteral("one"), "cache recovery must preserve durable settings"); - const std::vector recoveredProfiles = QCoro::waitFor(recovered.loadAccountProfilesAsync()); - require(recoveredProfiles.size() == 1 && recoveredProfiles.front().profileId == profile.profileId, - "cache recovery must preserve account profiles"); + requireLegacyAccount(recovered, "cache recovery must preserve legacy sign-ins"); recovered.shutdown(); for (int attempt = 0; attempt < 4; ++attempt) { @@ -208,8 +205,9 @@ JELLYFIN_TEST_MAIN("database-manager") DatabaseManager resetState; require(resetState.initialize(databasePath), "future durable state should not block startup"); - require(QCoro::waitFor(resetState.loadAccountProfilesAsync()).empty(), - "unreadable durable state should restart at account setup"); + require(QCoro::waitFor(resetState.loadLegacyAccountsAsync()).isEmpty() + && QCoro::waitFor(resetState.loadSettingAsync(QStringLiteral("batch/first"))).isEmpty(), + "unreadable durable state should restart empty"); resetState.shutdown(); require(QDir(directory.path()).entryList({ QStringLiteral("state.sqlite.corrupt-*") }, QDir::Files).size() == 1, "future durable state should be preserved as a diagnostic backup"); diff --git a/tests/common/MetaJsonTest.cpp b/tests/common/MetaJsonTest.cpp index a9821992..cc13ca03 100644 --- a/tests/common/MetaJsonTest.cpp +++ b/tests/common/MetaJsonTest.cpp @@ -1,5 +1,4 @@ #include "common/MetaJson.h" -#include "discovery/DiscoveredServer.h" #include "TestMain.h" @@ -12,7 +11,6 @@ #include #include -using JellyfinNative::DiscoveredServer; using JellyfinNative::episodicPlaybackStartIndex; using JellyfinNative::ExternalUrlInfo; using JellyfinNative::formatMediaInfo; @@ -51,13 +49,6 @@ QJsonObject jsonObject(std::initializer_list> ent return object; } -void requireDiscoveredServer(const DiscoveredServer& actual, const DiscoveredServer& expected, const char *message) -{ - require(actual.id == expected.id, message); - require(actual.name == expected.name, message); - require(actual.address == expected.address, message); -} - void requireLibraryItem(const LibraryItem& actual, const LibraryItem& expected, const char *message) { require(actual.id == expected.id, message); @@ -243,13 +234,6 @@ MediaSourceInfo mediaSource() void testDtoRoundTrips() { - DiscoveredServer server; - server.id = QStringLiteral("server-id"); - server.name = QStringLiteral("Living Room Jellyfin"); - server.address = QStringLiteral("https://jellyfin.example.test"); - requireDiscoveredServer(metaFromJson(metaToJson(server)), server, - "DiscoveredServer did not survive MetaJson round trip"); - LibraryItem library; library.id = QStringLiteral("library-id"); library.name = QStringLiteral("Movies"); diff --git a/tests/providers/LocalProviderTest.cpp b/tests/providers/LocalProviderTest.cpp index 0fdb7359..e6a39639 100644 --- a/tests/providers/LocalProviderTest.cpp +++ b/tests/providers/LocalProviderTest.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include #include @@ -47,21 +49,24 @@ JELLYFIN_TEST_MAIN("local-provider") using namespace JellyfinNative; const QString fixtures = QDir(QStringLiteral(TEST_SOURCE_DIR)).filePath(QStringLiteral("tests/media/fixtures")); - LocalProvider provider(fixtures); - require(provider.id() == QStringLiteral("local"), "the provider is named local"); + LocalProvider provider(QStringLiteral("local-account"), fixtures); + bool scanned = false; + QObject::connect(&provider, &Provider::contentChanged, &app, [&scanned] { scanned = true; }); + require(provider.id() == QStringLiteral("local-account"), "the provider is named after its account"); require(provider.ready(), "a folder needs no sign-in"); require(provider.capabilities() == (Provider::Search | Provider::UserItemState), "a folder searches and remembers item state, nothing more"); require( provider.catalog() && provider.playback() && provider.artwork() && provider.search() && provider.itemState(), "the required and the claimed optional interfaces are served"); - require(!provider.groupPlayback() && !provider.remotePlayback() && !provider.streamQuality(), - "unclaimed capabilities have no object"); - - int sessions = 0; - QObject::connect(&provider, &Provider::sessionStarted, &app, [&sessions] { ++sessions; }); - require(provider.restoreFromStorage({}, {}), "restoring announces a default profile"); - require(sessions == 1, "a source without sign-in announces its session when restored"); + require(!provider.streamQuality(), "unclaimed capabilities have no object"); + + // The folder is walked off the GUI thread; the library is announced once known. + QElapsedTimer waited; + waited.start(); + while (!scanned && waited.elapsed() < 5000) + QCoreApplication::processEvents(QEventLoop::WaitForMoreEvents, 50); + require(scanned, "the background scan announces the library"); Catalog *catalog = provider.catalog(); const auto libraries = QCoro::waitFor(catalog->fetchLibraries()); diff --git a/tests/providers/ProviderFixture.h b/tests/providers/ProviderFixture.h new file mode 100644 index 00000000..04a37ef3 --- /dev/null +++ b/tests/providers/ProviderFixture.h @@ -0,0 +1,108 @@ +#pragma once + +#include "provider/ProviderPackage.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ProviderFixture { + +using Entries = std::vector>; + +// A ustar archive as tar(1) writes it; type '0' files unless the name ends +// in '/', and an optional override for the type byte. +inline QByteArray makeTar(const Entries& entries, char forcedType = 0) +{ + QByteArray tar; + for (const auto& [name, data] : entries) { + char header[512] {}; + std::memcpy(header, name.constData(), std::min(name.size(), 100)); + std::snprintf(header + 100, 8, "%07o", 0644); + std::snprintf(header + 124, 12, "%011llo", static_cast(data.size())); + std::memcpy(header + 257, "ustar", 6); + std::memcpy(header + 263, "00", 2); + header[156] = forcedType ? forcedType : (name.endsWith('/') ? '5' : '0'); + std::memset(header + 148, ' ', 8); + unsigned checksum = 0; + for (unsigned char byte : header) + checksum += byte; + std::snprintf(header + 148, 8, "%06o", checksum); + tar.append(header, 512); + tar.append(data); + tar.append(QByteArray((512 - data.size() % 512) % 512, '\0')); + } + tar.append(QByteArray(1024, '\0')); + return tar; +} + +// A zstd frame of raw (stored) blocks: magic, a single-segment header with a +// four-byte content size, then blocks of at most 128 KiB. +inline QByteArray makeZstd(const QByteArray& content) +{ + QByteArray frame("\x28\xb5\x2f\xfd", 4); + frame.append(char(0xa0)); + const quint32 size = static_cast(content.size()); + for (int i = 0; i < 4; ++i) + frame.append(char((size >> (8 * i)) & 0xff)); + qsizetype offset = 0; + do { + const qsizetype chunk = std::min(content.size() - offset, 128 * 1024); + const bool last = offset + chunk >= content.size(); + const quint32 block = (static_cast(chunk) << 3) | (last ? 1u : 0u); + frame.append(char(block & 0xff)).append(char((block >> 8) & 0xff)).append(char((block >> 16) & 0xff)); + frame.append(content.mid(offset, chunk)); + offset += chunk; + } while (offset < content.size()); + return frame; +} + +// The JS fixture as a package: a login screen, the Selection picker and one +// item action, so registry, hub and screen tests share one provider. +inline JellyfinNative::ProviderPackageContents package( + const QString& id = QStringLiteral("fixture.test"), const QString& version = QStringLiteral("1.0.0")) +{ + const auto read = [](const char *name) { + QFile file(QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/") + QLatin1String(name)); + file.open(QIODevice::ReadOnly); + return file.readAll(); + }; + const QJsonObject manifest { { QStringLiteral("format"), 2 }, { QStringLiteral("api"), QStringLiteral("0.2") }, + { QStringLiteral("id"), id }, { QStringLiteral("name"), QStringLiteral("Fixture") }, + { QStringLiteral("version"), version }, { QStringLiteral("entry"), QStringLiteral("logic/provider.mjs") }, + { QStringLiteral("capabilities"), QJsonArray { QStringLiteral("search") } }, + { QStringLiteral("ui"), + QJsonObject { { QStringLiteral("login"), QStringLiteral("ui/Login.qml") }, + { QStringLiteral("picker"), QStringLiteral("ui/Selection.qml") } } }, + { QStringLiteral("actions"), + QJsonArray { QJsonObject { { QStringLiteral("id"), QStringLiteral("tag") }, + { QStringLiteral("label"), QStringLiteral("Tag") }, + { QStringLiteral("types"), QJsonArray { QStringLiteral("Movie") } } } } } }; + const QByteArray json = QJsonDocument(manifest).toJson(); + JellyfinNative::ProviderPackageContents contents; + contents.manifest = *JellyfinNative::ProviderManifest::parse(json); + contents.files + = { { QStringLiteral("manifest.json"), json }, { QStringLiteral("logic/provider.mjs"), read("provider.mjs") }, + { QStringLiteral("ui/Selection.qml"), read("Selection.qml") }, + { QStringLiteral("ui/Login.qml"), QByteArrayLiteral("import QtQuick\nItem {}\n") } }; + return contents; +} + +// The package as the store serves it. +inline QByteArray archive(const JellyfinNative::ProviderPackageContents& contents) +{ + Entries entries; + for (auto it = contents.files.cbegin(); it != contents.files.cend(); ++it) + entries.push_back({ it.key().toUtf8(), it.value() }); + return makeZstd(makeTar(entries)); +} + +} // namespace ProviderFixture diff --git a/tests/providers/ProviderMediaPageTest.cpp b/tests/providers/ProviderMediaPageTest.cpp index d6304ad3..297c79b3 100644 --- a/tests/providers/ProviderMediaPageTest.cpp +++ b/tests/providers/ProviderMediaPageTest.cpp @@ -1,5 +1,5 @@ -#include "TestMain.h" #include "provider/ProviderMediaPage.h" +#include "TestMain.h" #include "provider/ScriptRuntime.h" #include @@ -18,8 +18,7 @@ void require(bool condition, const char *message) std::exit(1); } } -template -void rejects(QCoro::Task task, const char *message) +template void rejects(QCoro::Task task, const char *message) { bool rejected = false; try { @@ -39,7 +38,7 @@ JELLYFIN_TEST_MAIN("provider-media-page") const auto read = [&](const QString& expression, int limit = 100) { const QJSValue value = engine.evaluate(expression); require(!value.isError(), "fixture is valid JavaScript"); - return Detail::readProviderMediaPage(value, QStringLiteral("trusted-source"), limit); + return Detail::readProviderMediaPage(value, limit); }; const auto invalid = [&](const QString& expression, int limit = 100) { bool rejected = false; @@ -49,25 +48,24 @@ JELLYFIN_TEST_MAIN("provider-media-page") require(QByteArray(error.what()) == "invalid_media_page", "errors contain no provider data"); rejected = true; } + if (!rejected) + std::cerr << expression.left(120).toStdString() << '\n'; require(rejected, "invalid typed page must reject"); }; const auto page = read(QStringLiteral(R"JS(({ - sourceId: 'spoofed', total: 2, exhausted: false, cursor: 'opaque:next', items: [{ - id: 'film', sourceId: 'another-spoof', title: 'Example', type: 'Movie', year: 2020, + total: 2, exhausted: false, cursor: 'opaque:next', items: [{ + id: 'film', title: 'Example', type: 'Movie', year: 2020, externalIds: {IMDb: 'tt123', Tmdb: '456', tvdb: '789'}, runtimeTicks: '9007199254740993', resumeTicks: '123456789', favorite: true, played: false, genres: ['Drama'], people: [{id: 'p', name: 'Person'}] }] }))JS")); - require(page.sourceId == "trusted-source" && page.items.front().sourceId == page.sourceId, - "host source identity overrides attempted spoofing at page and item level"); - const auto& movie = page.items.front().media; + const auto& movie = page.items.front(); require(movie.id == "film" && movie.title == "Example" && movie.sortName == movie.title, "native listing fields and fallback sort title are populated"); require(movie.runtimeTicks == 9007199254740993LL && movie.resumeTicks == 123456789, "exact decimal ticks never round through a JS double"); - require(movie.imdbId == "tt123" && movie.tmdbId == "456" - && page.items.front().externalIds.value("tvdb") == "789", "all external identifiers survive conversion"); + require(movie.imdbId == "tt123" && movie.tmdbId == "456", "external identifiers are matched without case"); require(page.cursor == QStringLiteral("opaque:next") && page.total == 2 && !page.exhausted, "pagination retains opaque cursor, known total and exhaustion separately"); require(movie.genres.size() == 1 && movie.people.size() == 1 && movie.favorite, @@ -76,7 +74,7 @@ JELLYFIN_TEST_MAIN("provider-media-page") require(empty.items.empty() && !empty.total && !empty.cursor && empty.exhausted, "unknown total is not a fabricated zero"); const auto sparse = read(QStringLiteral("({items: [{id:'x', year:null, season:0, episode:0}], exhausted:true})")); - require(sparse.items.front().media.year == 0 && sparse.items.front().media.seasonNumber == 0, + require(sparse.items.front().year == 0 && sparse.items.front().seasonNumber == 0, "nullable metadata and season zero are valid"); read(QStringLiteral("({items: [], cursor:'next', exhausted:false})")); // sparse filtered page may advance invalid(QStringLiteral("null")); @@ -92,31 +90,29 @@ JELLYFIN_TEST_MAIN("provider-media-page") invalid(QStringLiteral("({items: [{id:'x', year:2020.5}], exhausted:true})")); invalid(QStringLiteral("({items: [{id:'x', title:42}], exhausted:true})")); invalid(QStringLiteral("({items: [{id:'x', favorite:'false'}], exhausted:true})")); - invalid(QStringLiteral("({items: [{id:'x', externalIds:{TMDB:'1', tmdb:'2'}}], exhausted:true})")); invalid(QStringLiteral("({items: [{id:'x'},{id:'y'}], exhausted:true})"), 1); invalid(QStringLiteral("({items: [], exhausted:true})"), 0); invalid(QStringLiteral("({items: [{id:'x', title:'x'.repeat(65537)}], exhausted:true})")); const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); - ScriptRuntime runtime(entry); - QCoro::waitFor(runtime.addSource("a", {{"label", "A"}}, {})); - QCoro::waitFor(runtime.addSource("b", {{"label", "B"}}, {})); - auto a = runtime.callMediaPage("a", "mediaPage", {{"count", 72}}); - auto b = runtime.callMediaPage("b", "mediaPage", {{"count", 72}}); + ScriptRuntime runtime(entry, QVariantMap {}); + QCoro::waitFor(runtime.addSource("a", { { "label", "A" } }, {})); + QCoro::waitFor(runtime.addSource("b", { { "label", "B" } }, {})); + auto a = runtime.callMediaPage("a", "mediaPage", { { "count", 72 } }); + auto b = runtime.callMediaPage("b", "mediaPage", { { "count", 72 } }); const auto resultA = QCoro::waitFor(std::move(a)); const auto resultB = QCoro::waitFor(std::move(b)); require(resultA.items.size() == 72 && resultB.items.size() == 72, "worker delivers complete typed pages"); - require(resultA.items.front().sourceId == "a" && resultB.items.front().sourceId == "b" - && resultA.items.front().media.id == resultB.items.front().media.id, - "overlapping backend IDs retain their originating source"); - rejects(runtime.callMediaPage("a", "mediaPage", {{"count", 4}}, {}, 3), "requested output bound enforced"); - rejects(runtime.callMediaPage("a", "mediaPage", {{"count", 1}}, {}, 0), "invalid caller bound rejected"); + require(resultA.items.front().id == resultB.items.front().id, + "sources are isolated; the hub, not the worker, scopes overlapping IDs"); + rejects(runtime.callMediaPage("a", "mediaPage", { { "count", 4 } }, {}, 3), "requested output bound enforced"); + rejects(runtime.callMediaPage("a", "mediaPage", { { "count", 1 } }, {}, 0), "invalid caller bound rejected"); auto cancelled = runtime.callMediaPage("a", "delayedMediaPage", {}, "closed-action"); runtime.cancelScope("a", "closed-action"); rejects(std::move(cancelled), "typed page operations share scope cancellation"); require(QCoro::waitFor(runtime.call("b", "state")).contains("calls"), "small RPC path still works after typed-page validation failure/cancellation"); - require(QCoro::waitFor(runtime.callMediaPage("a", "mediaPage", {{"count", 1}})).items.size() == 1, + require(QCoro::waitFor(runtime.callMediaPage("a", "mediaPage", { { "count", 1 } })).items.size() == 1, "invalid result does not kill the source"); return 0; } diff --git a/tests/providers/ProviderPackageTest.cpp b/tests/providers/ProviderPackageTest.cpp index 1c5a3201..10a8e72f 100644 --- a/tests/providers/ProviderPackageTest.cpp +++ b/tests/providers/ProviderPackageTest.cpp @@ -1,4 +1,5 @@ #include "provider/ProviderPackage.h" +#include "ProviderFixture.h" #include "TestMain.h" #include @@ -9,6 +10,8 @@ #include #include #include +#include +#include namespace { @@ -20,124 +23,37 @@ void require(bool condition, const char *message) } } -#pragma pack(push, 1) -struct ZipEOCD { - uint32_t signature; - uint16_t diskNumber; - uint16_t cdStartDisk; - uint16_t numEntriesThisDisk; - uint16_t numEntries; - uint32_t cdSize; - uint32_t cdOffset; - uint16_t commentLen; -}; - -struct ZipCDHeader { - uint32_t signature; - uint16_t versionMadeBy; - uint16_t versionNeeded; - uint16_t flags; - uint16_t method; - uint16_t modTime; - uint16_t modDate; - uint32_t crc32; - uint32_t compressedSize; - uint32_t uncompressedSize; - uint16_t filenameLen; - uint16_t extraLen; - uint16_t commentLen; - uint16_t diskNumStart; - uint16_t internalAttr; - uint32_t externalAttr; - uint32_t localHeaderOffset; -}; - -struct ZipLocalHeader { - uint32_t signature; - uint16_t versionNeeded; - uint16_t flags; - uint16_t method; - uint16_t modTime; - uint16_t modDate; - uint32_t crc32; - uint32_t compressedSize; - uint32_t uncompressedSize; - uint16_t filenameLen; - uint16_t extraLen; -}; -#pragma pack(pop) - -QByteArray makeStoredZip(const QMap& entries) +using ProviderFixture::Entries; +using ProviderFixture::makeTar; +using ProviderFixture::makeZstd; + +QByteArray manifest(const char *id = "spool.test", const char *version = "1.0.0") { - QByteArray buffer; - struct EntryMeta { - QString name; - uint32_t offset; - uint32_t crc; - uint32_t size; - }; - QList metas; - - for (auto it = entries.cbegin(); it != entries.cend(); ++it) { - const QString name = it.key(); - const QByteArray data = it.value(); - const uint32_t crc = JellyfinNative::ProviderPackage::calculateCrc32( - reinterpret_cast(data.constData()), data.size()); - const uint32_t offset = static_cast(buffer.size()); - - ZipLocalHeader local {}; - local.signature = 0x04034b50; - local.versionNeeded = 20; - local.method = 0; // Stored - local.crc32 = crc; - local.compressedSize = static_cast(data.size()); - local.uncompressedSize = static_cast(data.size()); - local.filenameLen = static_cast(name.toUtf8().size()); - local.extraLen = 0; - - buffer.append(reinterpret_cast(&local), sizeof(local)); - buffer.append(name.toUtf8()); - buffer.append(data); - - metas.append({ name, offset, crc, static_cast(data.size()) }); - } + return QStringLiteral(R"({"format": 2, "api": "0.2", "id": "%1", "name": "Test", "version": "%2", + "entry": "logic/provider.mjs", "icon": "assets/icon.svg", "ui": {"login": "ui/Login.qml"}, + "actions": [{"id": "rename", "label": "Rename"}, {"id": "", "label": "dropped"}]})") + .arg(QLatin1String(id), QLatin1String(version)) + .toUtf8(); +} - const uint32_t cdOffset = static_cast(buffer.size()); - for (const auto& meta : metas) { - ZipCDHeader cd {}; - cd.signature = 0x02014b50; - cd.versionMadeBy = 20; - cd.versionNeeded = 20; - cd.flags = 0; - cd.method = 0; - cd.crc32 = meta.crc; - cd.compressedSize = meta.size; - cd.uncompressedSize = meta.size; - cd.filenameLen = static_cast(meta.name.toUtf8().size()); - cd.extraLen = 0; - cd.commentLen = 0; - cd.diskNumStart = 0; - cd.internalAttr = 0; - cd.externalAttr = 0; - cd.localHeaderOffset = meta.offset; - - buffer.append(reinterpret_cast(&cd), sizeof(cd)); - buffer.append(meta.name.toUtf8()); +Entries validEntries(const char *version = "1.0.0") +{ + return { { "manifest.json", manifest("spool.test", version) }, { "logic/", {} }, + { "logic/provider.mjs", "export function createSource() { return {}; }" }, + { "ui/Login.qml", "import QtQuick\nItem {}\n" }, { "assets/icon.svg", "" } }; +} + +bool rejects(const QByteArray& archive, const char *expected) +{ + QString error; + const auto package = JellyfinNative::ProviderPackage::read(archive, &error); + if (package) + return false; + if (!error.contains(QLatin1String(expected))) { + std::cerr << "unexpected error: " << error.toStdString() << '\n'; + return false; } - const uint32_t cdSize = static_cast(buffer.size() - cdOffset); - - ZipEOCD eocd {}; - eocd.signature = 0x06054b50; - eocd.diskNumber = 0; - eocd.cdStartDisk = 0; - eocd.numEntriesThisDisk = static_cast(metas.size()); - eocd.numEntries = static_cast(metas.size()); - eocd.cdSize = cdSize; - eocd.cdOffset = cdOffset; - eocd.commentLen = 0; - - buffer.append(reinterpret_cast(&eocd), sizeof(eocd)); - return buffer; + return true; } } // namespace @@ -147,108 +63,92 @@ JELLYFIN_TEST_MAIN("provider-package-unpack") QCoreApplication app(argc, argv); using namespace JellyfinNative; - // Test 1: Valid package validation and extraction - { - const QByteArray manifest = R"({ - "format": 1, - "id": "spool.test", - "version": "1.0.0", - "api": "0.1", - "entry": "logic/provider.mjs" - })"; - const QByteArray logic = "export function init() { return true; }"; - - QMap entries; - entries.insert(QStringLiteral("manifest.json"), manifest); - entries.insert(QStringLiteral("logic/provider.mjs"), logic); - - const QByteArray zipData = makeStoredZip(entries); - QString errorMsg; - auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); - require(package.has_value(), "valid package parsed successfully"); - require(package->id == QStringLiteral("spool.test"), "package ID extracted"); - require(package->version == QStringLiteral("1.0.0"), "package version extracted"); - require(package->entryPoint == QStringLiteral("logic/provider.mjs"), "entrypoint extracted"); - require(package->files.size() == 2, "package files map populated"); - - QTemporaryDir tempDir; - require(tempDir.isValid(), "temporary directory created"); - const QString installedPath = ProviderPackage::install(*package, tempDir.path(), &errorMsg); - require(!installedPath.isEmpty(), "package installed successfully"); - require(QFile::exists(installedPath), "installed entrypoint file exists"); + QString error; + const auto package = ProviderPackage::read(makeZstd(makeTar(validEntries())), &error); + require(package.has_value(), "a valid package is read"); + require(package->manifest.id == QStringLiteral("spool.test") && package->manifest.version == QStringLiteral("1.0.0") + && package->manifest.entry == QStringLiteral("logic/provider.mjs"), + "manifest identity and entry are read"); + require(package->manifest.needsAccount(), "a login screen means the provider needs an account"); + require(package->manifest.actions.size() == 1, "actions without an id are dropped"); + require(package->files.size() == 4 && !package->files.contains(QStringLiteral("logic/")), + "files are read and directories are not files"); + + // Several raw blocks, as a zstd encoder emits for incompressible input. + Entries large = validEntries(); + QByteArray noise(300 * 1024, '\0'); + for (qsizetype i = 0; i < noise.size(); ++i) + noise[i] = char((i * 2654435761u) >> 13); + large.push_back({ "assets/noise.txt", noise }); + const auto multiBlock = ProviderPackage::read(makeZstd(makeTar(large))); + require(multiBlock && multiBlock->files.value(QStringLiteral("assets/noise.txt")) == noise, + "multi-block frames decompress exactly"); + + // The real bundled package: compressed blocks from the zstd CLI. + QFile bundled(QStringLiteral(TEST_SOURCE_DIR "/providers/bundled/spool.jellyfin-0.2.0.tar.zst")); + if (bundled.open(QIODevice::ReadOnly)) { + const auto jellyfin = ProviderPackage::read(bundled.readAll(), &error); + require(jellyfin && jellyfin->manifest.id == QStringLiteral("spool.jellyfin"), + "the bundled Jellyfin package decompresses and validates"); } - // Test 2: Path traversal attack prevention - { - const QByteArray manifest = R"({ - "format": 1, - "id": "spool.evil", - "version": "1.0.0", - "api": "0.1", - "entry": "logic/provider.mjs" - })"; - QMap entries; - entries.insert(QStringLiteral("manifest.json"), manifest); - entries.insert(QStringLiteral("../../../evil.mjs"), "alert('evil')"); - - const QByteArray zipData = makeStoredZip(entries); - QString errorMsg; - auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); - require(!package.has_value(), "path traversal package rejected"); - } - - // Test 3: Disallowed executable extension rejection - { - const QByteArray manifest = R"({ - "format": 1, - "id": "spool.binary", - "version": "1.0.0", - "api": "0.1", - "entry": "logic/provider.mjs" - })"; - QMap entries; - entries.insert(QStringLiteral("manifest.json"), manifest); - entries.insert(QStringLiteral("payload.exe"), "fake executable"); - - const QByteArray zipData = makeStoredZip(entries); - QString errorMsg; - auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); - require(!package.has_value(), "disallowed file extension rejected"); - } - - // Test 4: Native binary payload rejection (ELF header) - { - const QByteArray manifest = R"({ - "format": 1, - "id": "spool.native", - "version": "1.0.0", - "api": "0.1", - "entry": "logic/provider.mjs" - })"; - QByteArray elfData; - elfData.append("\x7f\x45\x4c\x46", 4); // \x7fELF - elfData.append(32, '\0'); - - QMap entries; - entries.insert(QStringLiteral("manifest.json"), manifest); - entries.insert(QStringLiteral("logic/provider.mjs"), elfData); - - const QByteArray zipData = makeStoredZip(entries); - QString errorMsg; - auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); - require(!package.has_value(), "ELF binary payload rejected"); - } - - // Test 5: Missing manifest rejection - { - QMap entries; - entries.insert(QStringLiteral("logic/provider.mjs"), "export function init() {}"); + require(rejects(QByteArrayLiteral("PK\x03\x04not zstd"), "not a valid .tar.zst"), "zip archives are refused"); + QByteArray truncated = makeZstd(makeTar(validEntries())); + truncated.chop(700); + require(rejects(truncated, "not a valid .tar.zst"), "truncated frames are refused"); - const QByteArray zipData = makeStoredZip(entries); - QString errorMsg; - auto package = ProviderPackage::parseAndValidate(zipData, &errorMsg); - require(!package.has_value(), "archive without manifest rejected"); - } + const auto withEntry = [](QByteArray name, QByteArray data) { + Entries entries = validEntries(); + entries.push_back({ std::move(name), std::move(data) }); + return makeZstd(makeTar(entries)); + }; + require(rejects(withEntry("../evil.mjs", "x"), "Forbidden path"), "path traversal is refused"); + require(rejects(withEntry("/etc/evil.mjs", "x"), "Forbidden path"), "absolute paths are refused"); + require(rejects(withEntry("logic/.hidden.mjs", "x"), "Forbidden path"), "hidden files are refused"); + require(rejects(withEntry("payload.exe", "x"), "Forbidden path"), "executable extensions are refused"); + require(rejects(withEntry("logic/native.js", + QByteArray("\x7f" + "ELF\x02\x01", + 6)), + "Native binaries"), + "native binaries are refused whatever their name"); + require(rejects(withEntry("LOGIC/provider.mjs", "x"), "Duplicate path"), "case-folded duplicates are refused"); + require(rejects(makeZstd(makeTar({ { "manifest.json", manifest() }, { "logic/link.mjs", {} } }, '2')), + "Links and special files"), + "symlinks are refused"); + + Entries missingUi = validEntries(); + missingUi.erase(missingUi.begin() + 3); + require(rejects(makeZstd(makeTar(missingUi)), "missing file: ui/Login.qml"), "every named file must exist"); + require(rejects(makeZstd(makeTar({ { "logic/provider.mjs", "x" } })), "not a JSON object"), + "a package without a manifest is refused"); + Entries oldFormat = validEntries(); + oldFormat.front().second.replace("\"format\": 2", "\"format\": 1"); + require(rejects(makeZstd(makeTar(oldFormat)), "different version of Spool"), "old manifest formats are refused"); + Entries badId = validEntries(); + badId.front().second = manifest("Not An Id"); + require(rejects(makeZstd(makeTar(badId)), "invalid one"), "malformed ids are refused"); + + require(ProviderPackage::compareVersions(QStringLiteral("1.10.0"), QStringLiteral("1.9.9")) > 0, + "versions compare numerically"); + require(ProviderPackage::compareVersions(QStringLiteral("1.0.0-beta"), QStringLiteral("1.0.0")) < 0, + "a pre-release sorts before its release"); + require(ProviderPackage::compareVersions(QStringLiteral("1.0"), QStringLiteral("1.0.0")) == 0, + "missing components are zero"); + + QTemporaryDir root; + require(root.isValid(), "temporary directory created"); + const auto installed = ProviderPackage::install(*package, root.path(), &error); + require(installed && QFile::exists(QDir(*installed).filePath(QStringLiteral("logic/provider.mjs"))), + "a package installs into its version directory"); + const auto newer = ProviderPackage::read(makeZstd(makeTar(validEntries("1.1.0")))); + const auto upgraded = ProviderPackage::install(*newer, root.path(), &error); + require(upgraded && !QFile::exists(*installed), "installing a newer version removes the older one"); + const QStringList leftovers + = QDir(root.filePath(QStringLiteral("spool.test"))).entryList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden); + require(leftovers == QStringList { QStringLiteral("1.1.0") }, "no staging directory is left behind"); + require(ProviderPackage::installedVersions(root.path()).value(QStringLiteral("spool.test")) == *upgraded, + "the installed version is found again"); std::cout << "provider package test ok\n"; return 0; diff --git a/tests/providers/ProviderRegistryTest.cpp b/tests/providers/ProviderRegistryTest.cpp index 4e28479e..0bfb01f2 100644 --- a/tests/providers/ProviderRegistryTest.cpp +++ b/tests/providers/ProviderRegistryTest.cpp @@ -1,18 +1,25 @@ #include "provider/ProviderRegistry.h" -#include "provider/ArtworkSource.h" -#include "provider/Catalog.h" -#include "provider/PlaybackSource.h" -#include "provider/Provider.h" - +#include "ProviderFixture.h" #include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "platform/CredentialStore.h" +#include "provider/Provider.h" +#include "provider/ProviderUiContext.h" #include -#include -#include +#include +#include +#include +#include +#include +#include #include +#include #include +using namespace JellyfinNative; + namespace { void require(bool condition, const char *message) @@ -23,67 +30,57 @@ void require(bool condition, const char *message) } } -class StubProvider final : public JellyfinNative::Provider { -public: - StubProvider(QString id, Capabilities capabilities, QObject *parent = nullptr) - : Provider(parent) - , m_id(std::move(id)) - , m_capabilities(capabilities) - { +void waitUntil(const std::function& condition, const char *message) +{ + QElapsedTimer timeout; + timeout.start(); + while (!condition() && timeout.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); } + require(condition(), message); +} - QString id() const override - { - return m_id; - } - QString displayName() const override - { - return m_id; - } - Capabilities capabilities() const override - { - return m_capabilities; - } - JellyfinNative::PlaybackSource *playback() override - { - return nullptr; - } - JellyfinNative::Catalog *catalog() override - { - return nullptr; - } - JellyfinNative::ArtworkSource *artwork() override - { - return nullptr; - } - void registerQmlSingletons() override { } - bool ready() const override - { - return true; +const ProviderAccount *find(const ProviderRegistry& registry, const QString& label) +{ + for (const ProviderAccount& account : registry.accountList()) { + if (account.label == label) + return &account; } + return nullptr; +} - void setCapabilities(Capabilities capabilities) - { - m_capabilities = capabilities; - emit capabilitiesChanged(); +QString failure(QCoro::Task task) +{ + try { + QCoro::waitFor(std::move(task)); + } catch (const std::exception& error) { + return QString::fromLatin1(error.what()); } + return {}; +} -private: - QString m_id; - Capabilities m_capabilities; -}; - -// The twelve names shared QML gates on, in the order the flags are declared. -const QStringList kCapabilityNames { QStringLiteral("auth"), QStringLiteral("discovery"), QStringLiteral("search"), - QStringLiteral("userItemState"), QStringLiteral("playbackReporting"), QStringLiteral("segments"), - QStringLiteral("libraryManagement"), QStringLiteral("syncPlay"), QStringLiteral("remoteControl"), - QStringLiteral("quickConnect"), QStringLiteral("peerRelay"), QStringLiteral("streamQuality") }; - -bool propertyValue(const QObject& object, const QString& name) +// Signs in through the fixture's login context the way its QML would. +QString signIn(ProviderRegistry& registry, const QString& key, const QString& group, const QString& origin = {}) { - const QVariant value = object.property(name.toLatin1().constData()); - require(value.isValid() && value.typeId() == QMetaType::Bool, "capability property is a bool"); - return value.toBool(); + auto *login = qobject_cast(registry.beginSetup(QStringLiteral("fixture.test"))); + require(login && login->role() == QStringLiteral("login") + && login->component().toString().endsWith(QStringLiteral("ui/Login.qml")), + "a provider with a login screen starts setup with a login context"); + if (!origin.isEmpty()) + QCoro::waitFor(registry.allowSetupOrigin(login->sourceId(), QUrl(origin))); + QString added; + const auto connection + = QObject::connect(®istry, &ProviderRegistry::accountAdded, [&added](const QString& id) { added = id; }); + const QString label = key.front().toUpper() + key.mid(1); + login->complete( + { { QStringLiteral("account"), key }, { QStringLiteral("label"), label }, { QStringLiteral("group"), group }, + { QStringLiteral("configuration"), + QVariantMap { { QStringLiteral("label"), label }, { QStringLiteral("token"), key + "-secret" } } } }); + QObject::disconnect(connection); + require(!added.isEmpty(), "completing the login adds the account"); + waitUntil([&] { return registry.sourceRunning(added); }, "a new account starts at once"); + return added; } } // namespace @@ -91,82 +88,168 @@ bool propertyValue(const QObject& object, const QString& name) JELLYFIN_TEST_MAIN("provider-registry") { QCoreApplication app(argc, argv); - using JellyfinNative::Provider; - - JellyfinNative::ProviderRegistry registry; - JellyfinNative::ProviderCapabilities *capabilities = registry.capabilities(); - require(registry.active() == nullptr, "no provider is active until one is chosen"); - for (const QString& name : kCapabilityNames) - require(!propertyValue(*capabilities, name), "every capability is off with no active provider"); - - // Every flag maps to the property of the same name, and only that one. - Provider::Capabilities all; - const QList flags { Provider::Auth, Provider::Discovery, Provider::Search, - Provider::UserItemState, Provider::PlaybackReporting, Provider::Segments, Provider::LibraryManagement, - Provider::SyncPlay, Provider::RemoteControl, Provider::QuickConnect, Provider::PeerRelay, - Provider::StreamQuality }; - require(flags.size() == kCapabilityNames.size(), "one flag per capability name"); - StubProvider single(QStringLiteral("single"), {}); - registry.setActive(&single); - for (qsizetype index = 0; index < flags.size(); ++index) { - single.setCapabilities(flags.at(index)); - for (qsizetype other = 0; other < kCapabilityNames.size(); ++other) { - require(propertyValue(*capabilities, kCapabilityNames.at(other)) == (other == index), - "a flag turns on exactly the property of its own name"); + QTemporaryDir directory; + require(directory.isValid(), "temporary directory"); + const QString credentials = directory.filePath(QStringLiteral("credentials")); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", credentials.toUtf8()); + const QString installs = directory.filePath(QStringLiteral("providers")); + DatabaseManager database; + require(database.initialize(directory.filePath(QStringLiteral("cache.sqlite"))), "database opens"); + QCoro::waitFor(database.schemaVersionAsync()); + + // Two users of one server, as the native Jellyfin client stored them. + require(CredentialStore::save(QStringLiteral("legacy-a"), QStringLiteral("token-a")) + && CredentialStore::save(QStringLiteral("legacy-b"), QStringLiteral("token-b")), + "legacy tokens stored"); + { + QSqlDatabase seed = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("legacy")); + seed.setDatabaseName(directory.filePath(QStringLiteral("state.sqlite"))); + require(seed.open(), "durable state opens"); + QSqlQuery query(seed); + require(query.exec(QStringLiteral("INSERT INTO profiles VALUES " + "('legacy-a', 'server', 'Home', 'https://jf.invalid:8920/jf', 'ua', 'Ann', " + "'', 5, 1, 0), ('legacy-b', 'server', 'Home', 'https://jf.invalid:8920/jf', " + "'ub', 'Ben', '', 9, 1, 0)")), + "legacy profiles seeded"); + } + QSqlDatabase::removeDatabase(QStringLiteral("legacy")); + + require(ProviderPackage::install(ProviderFixture::package(), installs).has_value(), "fixture installs"); + QString alice; + QString carol; + { + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + const ProviderModule *jellyfin = registry.module(QStringLiteral("spool.jellyfin")); + require(jellyfin && jellyfin->bundled && jellyfin->root.scheme() == QStringLiteral("qrc"), + "the pinned Jellyfin package is bundled as a resource"); + const ProviderModule *fixture = registry.module(QStringLiteral("fixture.test")); + require(fixture && !fixture->bundled && fixture->root.isLocalFile(), "installed packages are found on disk"); + + QStringList problems; + QObject::connect( + ®istry, &ProviderRegistry::problem, [&problems](const QString& message) { problems.append(message); }); + QHash> providers; + QObject::connect(®istry, &ProviderRegistry::sourceStarted, + [&providers](Provider *provider) { providers.insert(provider->id(), provider); }); + + QCoro::waitFor(registry.restore()); + require(registry.restored(), "restore completes"); + + // One-time carry-over of the native client's sign-ins: one account per + // user, the most recently used on each server in use. + const ProviderAccount *ann = find(registry, QStringLiteral("Ann")); + const ProviderAccount *ben = find(registry, QStringLiteral("Ben")); + require(registry.accountList().size() == 2 && ann && ben, "legacy sign-ins become Jellyfin accounts"); + require(ben->module == QStringLiteral("spool.jellyfin") && ben->group == QStringLiteral("server") + && ben->detail == QStringLiteral("Home"), + "a migrated account keeps its server as its group"); + require(ben->enabled && !ann->enabled, "only the last user of a server is in use"); + require(ben->configuration.value(QStringLiteral("token")).toString() == QStringLiteral("token-b"), + "the token moves into the account's configuration"); + require(ben->origins == QList { QUrl(QStringLiteral("https://jf.invalid:8920")) }, + "the server's origin, and nothing else, is allowed"); + for (const QString& id : { ann->id, ben->id }) { + registry.setAccountEnabled(id, false); + registry.removeAccount(id); } - all |= flags.at(index); + require(registry.accountList().empty(), "migrated accounts can be removed"); + + bool denied = false; + auto *probe = qobject_cast(registry.beginSetup(QStringLiteral("fixture.test"))); + try { + QCoro::waitFor(registry.allowSetupOrigin(probe->sourceId(), QUrl(QStringLiteral("file:///etc/passwd")))); + } catch (const std::exception&) { + denied = true; + } + require(denied, "setup may only reach HTTP(S) origins"); + probe->close(); + + alice = signIn(registry, QStringLiteral("alice"), QStringLiteral("server-1"), + QStringLiteral("http://127.0.0.1:9/some/path")); + require(find(registry, QStringLiteral("Alice"))->origins + == QList { QUrl(QStringLiteral("http://127.0.0.1:9")) }, + "the account keeps the origin the viewer allowed during setup"); + require(QCoro::waitFor(registry.callSource(alice, QStringLiteral("configuration"))) + .value(QStringLiteral("configuration")) + .toMap() + .value(QStringLiteral("token")) + == QStringLiteral("alice-secret"), + "the account runs with the configuration its login completed with"); + + // Accounts on one server are alternatives; different servers show together. + const QString bob = signIn(registry, QStringLiteral("bob"), QStringLiteral("server-1")); + require(!find(registry, QStringLiteral("Alice"))->enabled && !registry.sourceRunning(alice), + "using another user of a server sets the first aside"); + carol = signIn(registry, QStringLiteral("carol"), QStringLiteral("server-2")); + require(registry.sourceRunning(bob) && registry.sourceRunning(carol), "different servers run side by side"); + require(signIn(registry, QStringLiteral("alice"), QStringLiteral("server-1")) == alice, + "signing in again updates the existing account"); + require(registry.accountList().size() == 3 && !registry.sourceRunning(bob), "and makes it the one in use"); + + require(failure(registry.callSource(carol, QStringLiteral("throws"))) == QStringLiteral("provider_error"), + "provider error text never reaches the app"); + require(failure(registry.callSource(carol, QStringLiteral("expired"))) == QStringLiteral("http_401"), + "stable error codes pass through"); + bool needsSignIn = false; + for (const QVariant& row : registry.accounts()) + needsSignIn |= row.toMap().value(QStringLiteral("id")) == carol + && row.toMap().value(QStringLiteral("needsSignIn")).toBool(); + require(needsSignIn && problems.contains(QStringLiteral("Sign in to Carol again")), + "a rejected token asks for a new sign-in"); + require(failure(registry.callSource(QStringLiteral("nobody"), QStringLiteral("state"))) + == QStringLiteral("source_unavailable"), + "unknown accounts are refused"); + + QString changed; + waitUntil([&] { return bool(providers.value(carol)); }, "the running account is announced"); + QObject::connect( + providers.value(carol), &Provider::contentChanged, [&changed](const QString& itemId) { changed = itemId; }); + QCoro::waitFor(registry.callSource( + carol, QStringLiteral("announce"), { { QStringLiteral("itemId"), QStringLiteral("x") } })); + waitUntil([&] { return changed == QStringLiteral("x"); }, "events a source emits reach its provider"); } - StubProvider jellyfin(QStringLiteral("jellyfin"), all); - StubProvider local(QStringLiteral("local"), Provider::Search); - registry.add(&jellyfin); - registry.add(&local); - registry.add(&jellyfin); - require(registry.providers().size() == 3, "adding a provider twice registers it once"); - require(registry.provider(QStringLiteral("local")) == &local, "providers are found by id"); - require(!registry.setActive(QStringLiteral("plex")), "an unknown id leaves the active provider alone"); - require(registry.active() == &single, "an unknown id leaves the active provider alone"); - - int activeChanged = 0; - int capabilitiesChanged = 0; - QObject::connect(®istry, &JellyfinNative::ProviderRegistry::activeChanged, &app, [&] { ++activeChanged; }); - QObject::connect( - capabilities, &JellyfinNative::ProviderCapabilities::changed, &app, [&] { ++capabilitiesChanged; }); - require(registry.setActive(QStringLiteral("jellyfin")), "a known id activates its provider"); - require(registry.active() == &jellyfin, "the named provider is active"); - require(activeChanged == 1, "activation notifies once"); - require(capabilitiesChanged == 1, "the capability change notifies once"); - for (const QString& name : kCapabilityNames) - require(propertyValue(*capabilities, name), "every capability is on for the full provider"); - - registry.setActive(&local); - require(activeChanged == 2, "switching providers notifies again"); - require(capabilitiesChanged == 2, "switching providers refreshes the flags"); - require(propertyValue(*capabilities, QStringLiteral("search")), "the local provider searches"); - require(!propertyValue(*capabilities, QStringLiteral("syncPlay")), "the local provider has no SyncPlay"); - - // A change on the previous provider no longer reaches the flags; one on - // the active provider does. - jellyfin.setCapabilities({}); - require(capabilitiesChanged == 2, "the previous provider is disconnected"); - local.setCapabilities(Provider::Search | Provider::Segments); - require(capabilitiesChanged == 3, "the active provider drives the flags"); - require(propertyValue(*capabilities, QStringLiteral("segments")), "the new flag is visible"); - local.setCapabilities(Provider::Search | Provider::Segments); - require(capabilitiesChanged == 3, "an unchanged flag set does not notify"); - - registry.setActive(nullptr); - require(registry.active() == nullptr, "the active provider can be cleared"); - for (const QString& name : kCapabilityNames) - require(!propertyValue(*capabilities, name), "clearing the active provider clears every flag"); - - // The QML singleton contract: exactly these property names, all booleans. - const QMetaObject *meta = capabilities->metaObject(); - QStringList declared; - for (int index = meta->propertyOffset(); index < meta->propertyCount(); ++index) - declared.append(QString::fromLatin1(meta->property(index).name())); - require(declared == kCapabilityNames, "ProviderCapabilities declares exactly the twelve contract names in order"); - - std::cout << "provider registry ok\n"; + // Everything above survives a restart; tokens only in the credential store. + for (QDirIterator it(directory.path(), QDir::Files, QDirIterator::Subdirectories); it.hasNext();) { + const QString path = it.next(); + if (path.startsWith(credentials)) + continue; + QFile file(path); + require(file.open(QIODevice::ReadOnly) && !file.readAll().contains("alice-secret"), + "no token is written outside the credential store"); + } + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + QCoro::waitFor(registry.restore()); + require(registry.accountList().size() == 3, "accounts are restored"); + const ProviderAccount *bob = find(registry, QStringLiteral("Bob")); + require( + bob && !bob->enabled && find(registry, QStringLiteral("Alice"))->enabled, "which one is in use is restored"); + waitUntil([&] { return registry.sourceRunning(alice) && registry.sourceRunning(carol); }, + "enabled accounts start on restore"); + require(QCoro::waitFor(registry.callSource(alice, QStringLiteral("configuration"))) + .value(QStringLiteral("configuration")) + .toMap() + .value(QStringLiteral("token")) + == QStringLiteral("alice-secret"), + "configuration comes back from the credential store"); + + // A newer package replaces the module in place and restarts its accounts. + int restarted = 0; + QObject::connect(®istry, &ProviderRegistry::sourceStarted, [&restarted](Provider *) { ++restarted; }); + QCoro::waitFor(registry.install(ProviderFixture::package(QStringLiteral("fixture.test"), QStringLiteral("1.1.0")))); + require(registry.module(QStringLiteral("fixture.test"))->manifest.version == QStringLiteral("1.1.0"), + "the newer version is loaded"); + waitUntil([&] { return restarted == 2; }, "running accounts restart on the new version"); + + QCoro::waitFor(registry.uninstall(QStringLiteral("spool.jellyfin"))); + require(registry.module(QStringLiteral("spool.jellyfin")), "a bundled provider cannot be removed"); + QCoro::waitFor(registry.uninstall(QStringLiteral("fixture.test"))); + require(!registry.module(QStringLiteral("fixture.test")) && registry.accountList().empty() + && !QDir(installs).exists(QStringLiteral("fixture.test")), + "removing a provider removes its files and accounts"); return 0; } diff --git a/tests/providers/ProviderStoreTest.cpp b/tests/providers/ProviderStoreTest.cpp index f83e9332..5db7b133 100644 --- a/tests/providers/ProviderStoreTest.cpp +++ b/tests/providers/ProviderStoreTest.cpp @@ -1,6 +1,258 @@ +#include "provider/ProviderStore.h" +#include "ProviderFixture.h" #include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace JellyfinNative; + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void waitUntil(const std::function& condition, const char *message) +{ + QElapsedTimer timeout; + timeout.start(); + while (!condition() && timeout.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); + } + require(condition(), message); +} + +// The store only follows https links; this sends every one of them to the +// local fixture server instead, as http://127.0.0.1:/. +class LocalNetwork final : public QNetworkAccessManager { +public: + quint16 port = 0; + +protected: + QNetworkReply *createRequest(Operation operation, const QNetworkRequest& request, QIODevice *data) override + { + QNetworkRequest local(request); + QUrl url = request.url(); + url.setPath(QLatin1Char('/') + url.host() + url.path()); + url.setScheme(QStringLiteral("http")); + url.setHost(QStringLiteral("127.0.0.1")); + url.setPort(port); + local.setUrl(url); + return QNetworkAccessManager::createRequest(operation, local, data); + } +}; + +QVariantMap entryFor(const QByteArray& archive, const QString& id, const QString& version, const QString& url) +{ + return { { QStringLiteral("id"), id }, { QStringLiteral("name"), QStringLiteral("Fixture ") + id }, + { QStringLiteral("version"), version }, { QStringLiteral("api"), QStringLiteral("0.2") }, + { QStringLiteral("url"), url }, + { QStringLiteral("sha256"), + QString::fromLatin1(QCryptographicHash::hash(archive, QCryptographicHash::Sha256).toHex()) } }; +} + +QByteArray catalog(const QVariantList& entries) +{ + return QJsonDocument(QJsonObject { { QStringLiteral("providers"), QJsonArray::fromVariantList(entries) } }) + .toJson(); +} + +bool listed(const QVariantList& entries, const QString& id, const char *flag = nullptr) +{ + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + if (entry.value(QStringLiteral("id")) == id) + return !flag || entry.value(QLatin1String(flag)).toBool(); + } + return false; +} + +} // namespace JELLYFIN_TEST_MAIN("provider-store") { + QCoreApplication app(argc, argv); + + struct Case { + const char *input; + const char *feed; + }; + for (const Case& row : std::initializer_list { + { "github.com/spool-player/spool-jellyfin", + "https://github.com/spool-player/spool-jellyfin/releases/latest/download/spool-provider.json" }, + { "https://github.com/o/r.git", "https://github.com/o/r/releases/latest/download/spool-provider.json" }, + { "https://github.com/o/r/tree/main/logic", + "https://github.com/o/r/releases/latest/download/spool-provider.json" }, + { "https://github.com/o/r/releases/download/v1.0.0/o.r-1.0.0.tar.zst", + "https://github.com/o/r/releases/download/v1.0.0/spool-provider.json" }, + { "https://gitlab.com/group/sub/project", + "https://gitlab.com/group/sub/project/-/releases/permalink/latest/downloads/spool-provider.json" }, + { "https://gitlab.com/group/project/-/releases", + "https://gitlab.com/group/project/-/releases/permalink/latest/downloads/spool-provider.json" }, + { "https://gitlab.example.org/team/provider", + "https://gitlab.example.org/team/provider/-/releases/permalink/latest/downloads/spool-provider.json" }, + { "http://example.org/feeds/mine.json", "https://example.org/feeds/mine.json" }, + { "example.org", "https://example.org/spool-provider.json" }, + { "https://user.github.io/provider/", "https://user.github.io/provider/spool-provider.json" }, + { "", "" }, + }) { + const QString actual = ProviderStore::feedUrlFor(QLatin1String(row.input)).toString(); + if (actual != QLatin1String(row.feed)) { + std::cerr << row.input << " -> " << actual.toStdString() << '\n'; + require(false, "a link resolves to where its feed is published"); + } + } + + QHash routes; + int requests = 0; + QTcpServer server; + require(server.listen(QHostAddress::LocalHost), "fixture server listens"); + QObject::connect(&server, &QTcpServer::newConnection, &app, [&] { + while (QTcpSocket *socket = server.nextPendingConnection()) { + QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket, &routes, &requests] { + const QByteArray request = socket->property("request").toByteArray() + socket->readAll(); + socket->setProperty("request", request); + if (!request.contains("\r\n\r\n")) + return; + ++requests; + const QByteArray path = request.split(' ').value(1); + const bool found = routes.contains(path); + const QByteArray body = routes.value(path); + socket->write((found ? "HTTP/1.1 200 OK" : "HTTP/1.1 404 Not Found") + + QByteArray("\r\nConnection: close\r\nContent-Length: ") + QByteArray::number(body.size()) + + "\r\n\r\n" + body); + socket->disconnectFromHost(); + }); + } + }); + LocalNetwork network; + network.port = server.serverPort(); + + QTemporaryDir directory; + require(directory.isValid(), "temporary directory"); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath(QStringLiteral("credentials")).toUtf8()); + DatabaseManager database; + require(database.initialize(directory.filePath(QStringLiteral("cache.sqlite"))), "database opens"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(directory.filePath(QStringLiteral("providers"))); + registry.loadModules(); + QCoro::waitFor(registry.restore()); + + const QByteArray v1 = ProviderFixture::archive(ProviderFixture::package(QStringLiteral("fixture.test"))); + const QByteArray v2 + = ProviderFixture::archive(ProviderFixture::package(QStringLiteral("fixture.test"), QStringLiteral("1.1.0"))); + const QByteArray other = ProviderFixture::archive(ProviderFixture::package(QStringLiteral("fixture.other"))); + const QByteArray linked = ProviderFixture::archive(ProviderFixture::package(QStringLiteral("fixture.linked"))); + routes.insert("/store.test/v1.tar.zst", v1); + routes.insert("/store.test/v2.tar.zst", v2); + routes.insert("/store.test/other.tar.zst", other); + routes.insert("/feed.test/linked.tar.zst", linked); + QVariantMap tampered = entryFor(other, QStringLiteral("fixture.bad"), QStringLiteral("1.0.0"), + QStringLiteral("https://store.test/other.tar.zst")); + routes.insert("/store.test/official.json", + catalog({ entryFor(v1, QStringLiteral("fixture.test"), QStringLiteral("1.0.0"), + QStringLiteral("https://store.test/v1.tar.zst")), + tampered, + QVariantMap { { QStringLiteral("id"), QStringLiteral("insecure") }, + { QStringLiteral("name"), QStringLiteral("x") }, { QStringLiteral("version"), QStringLiteral("1.0.0") }, + { QStringLiteral("url"), QStringLiteral("http://x/y") }, + { QStringLiteral("sha256"), QString(64, QLatin1Char('0')) } } })); + routes.insert("/store.test/index.json", + catalog({ entryFor(other, QStringLiteral("fixture.other"), QStringLiteral("1.0.0"), + QStringLiteral("https://store.test/other.tar.zst")) })); + routes.insert("/feed.test/spool-provider.json", + QJsonDocument(QJsonObject::fromVariantMap(entryFor(linked, QStringLiteral("fixture.linked"), + QStringLiteral("1.0.0"), QStringLiteral("https://feed.test/linked.tar.zst")))) + .toJson()); + + QStringList installed; + QStringList problems; + { + ProviderStore store(®istry, &database, &network, QUrl(QStringLiteral("https://store.test/"))); + QObject::connect( + &store, &ProviderStore::installed, [&](const QString& id, const QString&) { installed.append(id); }); + QObject::connect(&store, &ProviderStore::problem, [&](const QString& message) { problems.append(message); }); + require(listed(store.official(), QStringLiteral("spool.jellyfin"), "installed"), + "bundled providers are listed before the store answers"); + + store.refresh(true); + waitUntil([&] { return !store.loading(); }, "the catalogue loads"); + require(store.error().isEmpty(), "the catalogue loads without error"); + require(listed(store.official(), QStringLiteral("fixture.test"), "compatible") + && !listed(store.official(), QStringLiteral("insecure")), + "entries without an https link and a digest are skipped"); + require(listed(store.community(), QStringLiteral("fixture.other")), "community providers are listed"); + + store.install(QStringLiteral("fixture.test")); + waitUntil([&] { return installed.contains(QStringLiteral("fixture.test")); }, "a store install completes"); + require(registry.module(QStringLiteral("fixture.test")), "and registers the provider"); + require( + listed(store.official(), QStringLiteral("fixture.test"), "installed"), "the listing shows it installed"); + + store.install(QStringLiteral("fixture.bad")); + waitUntil([&] { return !problems.isEmpty(); }, "a mismatching download is reported"); + require( + problems.last().contains(QStringLiteral("didn't match")) && !registry.module(QStringLiteral("fixture.bad")), + "a download whose digest or identity differs is never installed"); + + store.install(QStringLiteral("fixture.other")); + waitUntil([&] { return installed.contains(QStringLiteral("fixture.other")); }, "a community install completes"); + + store.addFromUrl(QStringLiteral("feed.test")); + waitUntil( + [&] { return installed.contains(QStringLiteral("fixture.linked")); }, "a provider installs from a link"); + require( + listed(store.community(), QStringLiteral("fixture.linked"), "fromUrl"), "and is listed as added by link"); + problems.clear(); + store.addFromUrl(QStringLiteral("https://nowhere.test/")); + waitUntil([&] { return !problems.isEmpty(); }, "a missing feed is reported"); + require(problems.last().contains(QStringLiteral("No spool-provider.json")), "as a missing feed"); + } + + // A fresh store (a later launch) installing before its update check must + // keep what the previous one remembered about where providers came from. + routes.insert("/store.test/official.json", + catalog({ entryFor(v2, QStringLiteral("fixture.test"), QStringLiteral("1.1.0"), + QStringLiteral("https://store.test/v2.tar.zst")) })); + { + ProviderStore store(®istry, &database, &network, QUrl(QStringLiteral("https://store.test/"))); + store.uninstall(QStringLiteral("fixture.other")); + waitUntil([&] { return !registry.module(QStringLiteral("fixture.other")); }, "a provider can be removed"); + waitUntil([&] { return listed(store.community(), QStringLiteral("fixture.linked"), "fromUrl"); }, + "providers added by link are remembered across launches"); + + store.checkForUpdates(QStringLiteral("ask")); + waitUntil([&] { return store.updates().size() == 1; }, "a newer official release is offered"); + require(registry.module(QStringLiteral("fixture.test"))->manifest.version == QStringLiteral("1.0.0"), + "asking does not install"); + installed.clear(); + QObject::connect( + &store, &ProviderStore::installed, [&](const QString& id, const QString&) { installed.append(id); }); + store.checkForUpdates(QStringLiteral("auto")); + waitUntil([&] { return installed.contains(QStringLiteral("fixture.test")); }, "automatic updates install"); + require(registry.module(QStringLiteral("fixture.test"))->manifest.version == QStringLiteral("1.1.0") + && store.updates().isEmpty(), + "the new version replaces the old and the offer goes away"); + } return 0; } diff --git a/tests/providers/ProviderUiContextTest.cpp b/tests/providers/ProviderUiContextTest.cpp index f2a6694b..784716ce 100644 --- a/tests/providers/ProviderUiContextTest.cpp +++ b/tests/providers/ProviderUiContextTest.cpp @@ -1,4 +1,5 @@ #include "provider/ProviderUiContext.h" +#include "ProviderFixture.h" #include "TestMain.h" #include "cache/DatabaseManager.h" #include "provider/ProviderRegistry.h" @@ -43,29 +44,39 @@ JELLYFIN_TEST_MAIN("provider-ui-context") using namespace JellyfinNative; QTemporaryDir directory; require(directory.isValid(), "isolated UI source database created"); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath("credentials").toUtf8()); DatabaseManager database; require(database.initialize(directory.filePath("cache.sqlite")), "durable source index opens"); - ProviderRegistry registry; - registry.registerModule("fixture.ui", QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs")); - QCoro::waitFor(registry.restoreSources(&database)); - const QString source = QCoro::waitFor(registry.configureSource( - "fixture.ui", "account", "server", "Fixture", { { "label", "Untrusted title" } }, {})); + const QString installs = directory.filePath("providers"); + require(ProviderPackage::install(ProviderFixture::package(), installs).has_value(), "fixture installs"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + registry.loadModules(); + QCoro::waitFor(registry.restore()); + const QString source = registry.finishSetup({}, + { { "module", "fixture.test" }, { "account", "account" }, { "label", "Fixture" }, + { "configuration", QVariantMap { { "label", "Untrusted title" } } } }); + registry.useAccount(source); + waitFor([&] { return registry.sourceRunning(source); }); QQmlEngine engine; - ProviderUiContext first(®istry, source, &engine); - ProviderUiContext second(®istry, source, &engine); - engine.globalObject().setProperty("first", engine.newQObject(&first)); - engine.globalObject().setProperty("second", engine.newQObject(&second)); + const auto picker = [&] { return qobject_cast(registry.openPicker(source, {})); }; + ProviderUiContext *first = picker(); + ProviderUiContext *second = picker(); + require(first && second && second->component().toString().endsWith("ui/Selection.qml"), + "the account's picker component is mounted with a context"); + engine.globalObject().setProperty("first", engine.newQObject(first)); + engine.globalObject().setProperty("second", engine.newQObject(second)); engine.evaluate(QStringLiteral(R"JS( var firstCancelled = false, secondCompleted = false; first.request('delay', {milliseconds: 10000}).then(function() {}, function() { firstCancelled = true; }); second.request('delay', {milliseconds: 10}).then(function() { secondCompleted = true; }); )JS")); - first.close(); + first->close(); waitFor([&] { return engine.globalObject().property("firstCancelled").toBool() && engine.globalObject().property("secondCompleted").toBool(); }); - require(!second.closed(), "dismissing one action does not close another action on the same source"); + require(!second->closed(), "dismissing one action does not close another action on the same source"); require(QCoro::waitFor(registry.callSource(source, "bump")).value("calls").toInt() == 1, "action cancellation leaves the source context running"); engine.evaluate(QStringLiteral(R"JS( @@ -77,7 +88,7 @@ JELLYFIN_TEST_MAIN("provider-ui-context") int batches = 0; bool modelThreadCorrect = true; QObject::connect( - second.rows(), &QAbstractItemModel::rowsInserted, &app, [&](const QModelIndex&, int begin, int end) { + second->rows(), &QAbstractItemModel::rowsInserted, &app, [&](const QModelIndex&, int begin, int end) { ++batches; modelThreadCorrect = modelThreadCorrect && QThread::currentThread() == app.thread(); require(end - begin + 1 <= 32, "large provider lists commit bounded native batches"); @@ -87,65 +98,81 @@ JELLYFIN_TEST_MAIN("provider-ui-context") if (!component.isReady()) std::cerr << component.errorString().toStdString(); require(component.isReady(), "provider-owned selection component loads"); - std::unique_ptr page( - component.createWithInitialProperties({ { "action", QVariant::fromValue(&second) } })); + std::unique_ptr page(component.createWithInitialProperties({ { "action", QVariant::fromValue(second) } })); require(bool(page), "provider component receives a source-bound action context"); waitFor([&] { return page->property("loaded").toBool() || page->property("failed").toBool(); }); - require(!page->property("failed").toBool() && second.rows()->rowCount() == 2000, + require(!page->property("failed").toBool() && second->rows()->rowCount() == 2000, "provider-owned virtualized UI receives every candidate through its native model"); require(modelThreadCorrect && batches > 1, "all incremental model mutations run on the GUI thread"); - const auto record = second.rows()->data(second.rows()->index(1999), Qt::UserRole + 1).toMap(); + const auto record = second->rows()->data(second->rows()->index(1999), Qt::UserRole + 1).toMap(); require(record.value("variantId") == "file-1999", "last candidate keeps its exact version identity"); for (int i = 0; i < 100; ++i) - second.rows()->data(second.rows()->index(i), Qt::UserRole + 1); + second->rows()->data(second->rows()->index(i), Qt::UserRole + 1); require(QCoro::waitFor(registry.callSource(source, "state")).value("calls").toInt() == 1, "native role reads never call provider backend operations"); - const QPersistentModelIndex selectedIndex(second.rows()->index(1999)); + const QPersistentModelIndex selectedIndex(second->rows()->index(1999)); engine.evaluate(QStringLiteral(R"JS( var appended = false; second.requestList('candidates', {count: 2}, true).then(function() { appended = true; }); )JS")); waitFor([&] { return engine.globalObject().property("appended").toBool(); }); - require(second.rows()->rowCount() == 2002 && selectedIndex.isValid() + require(second->rows()->rowCount() == 2002 && selectedIndex.isValid() && selectedIndex.data(Qt::UserRole + 1).toMap().value("variantId") == "file-1999", "pagination preserves native selection identity instead of resetting the model"); int finished = 0; QVariantMap selected; bool cancelled = true; - QObject::connect(&second, &ProviderUiContext::finished, &app, [&](const QVariantMap& value, bool isCancelled) { + QObject::connect(second, &ProviderUiContext::finished, &app, [&](const QVariantMap& value, bool isCancelled) { ++finished; selected = value; cancelled = isCancelled; }); require(QMetaObject::invokeMethod(page.get(), "choose", Q_ARG(QVariant, QVariant("file-1999"))), "provider QML can return its selected variant"); - require(!cancelled && selected.value("variantId") == "file-1999" && selected.value("sourceId") == source, - "native host retains source authority over provider action results"); + require(!cancelled && selected.value("variantId") == "file-1999", "the picker's choice is returned as given"); page.reset(); - second.close(); require(finished == 1, "completion and destruction settle the action exactly once"); - ProviderUiContext dismissed(®istry, source, &engine); + ProviderUiContext *dismissed = picker(); QQmlComponent dismissComponent(&engine); dismissComponent.setData( "import QtQml\nQtObject { required property var action; Component.onDestruction: action.close() }", QUrl()); std::unique_ptr dismissedPage( - dismissComponent.createWithInitialProperties({ { "action", QVariant::fromValue(&dismissed) } })); + dismissComponent.createWithInitialProperties({ { "action", QVariant::fromValue(dismissed) } })); require(bool(dismissedPage), "dismissal fixture creates"); bool dismissedCancelled = false; - QObject::connect(&dismissed, &ProviderUiContext::finished, &app, + QObject::connect(dismissed, &ProviderUiContext::finished, &app, [&](const QVariantMap&, bool value) { dismissedCancelled = value; }); dismissedPage.reset(); - require(dismissedCancelled && dismissed.closed(), "dismissing mounted provider UI returns cancellation"); + require(dismissedCancelled, "dismissing mounted provider UI returns cancellation"); - auto destroyed = std::make_unique(®istry, source, &engine); - engine.globalObject().setProperty("destroyedAction", engine.newQObject(destroyed.get())); + ProviderUiContext *destroyed = picker(); + engine.globalObject().setProperty("destroyedAction", engine.newQObject(destroyed)); engine.evaluate(QStringLiteral(R"JS( var destroyedCancelled = false; destroyedAction.request('delay', {milliseconds: 10000}).then(function() {}, function() { destroyedCancelled = true; }); )JS")); - destroyed.reset(); + delete destroyed; waitFor([&] { return engine.globalObject().property("destroyedCancelled").toBool(); }); + + // pick(): the shell is asked to mount the picker and the choice comes back. + QVariantMap answer { { "name", "Weekend" } }; + QObject::connect(®istry, &ProviderRegistry::componentRequested, &app, [&answer](QObject *context) { + auto *screen = qobject_cast(context); + require(screen && screen->arguments().value("kind") == "name", "the picker gets what the provider asked for"); + if (answer.isEmpty()) + screen->close(); + else + screen->complete(answer); + }); + require(QCoro::waitFor(registry.pick(source, { { "kind", "name" } })) == answer, "a completed pick returns"); + answer.clear(); + require(QCoro::waitFor(registry.pick(source, { { "kind", "name" } })).isEmpty(), "a dismissed pick is empty"); + + // Removing the account closes whatever screens it had open. + ProviderUiContext *orphan = picker(); + registry.setAccountEnabled(source, false); + require(orphan->closed(), "stopping a source closes its screens"); return 0; } diff --git a/tests/providers/ScriptRuntimeTest.cpp b/tests/providers/ScriptRuntimeTest.cpp index bf17bd27..1bceac88 100644 --- a/tests/providers/ScriptRuntimeTest.cpp +++ b/tests/providers/ScriptRuntimeTest.cpp @@ -90,7 +90,7 @@ JELLYFIN_TEST_MAIN("script-runtime") } }); const QString entry = QStringLiteral(TEST_SOURCE_DIR "/tests/providers/fixtures/provider.mjs"); - auto runtime = std::make_unique(entry); + auto runtime = std::make_unique(entry, QVariantMap {}); const auto add = [&](const QString& id) { return runtime->addSource( id, { { "origin", origin }, { "label", id }, { "token", id + "-token" } }, { QUrl(origin) }); @@ -128,6 +128,28 @@ JELLYFIN_TEST_MAIN("script-runtime") rejects(runtime->call("a", "cycle"), "cyclic results fail bounded conversion"); rejects(runtime->call("a", "largeInteger"), "unsafe integer results rejected"); rejects(runtime->call("a", "missing"), "missing feature reports unsupported operation"); + const auto code = [&](const char *method) { + try { + QCoro::waitFor(runtime->call("a", method)); + } catch (const std::exception& error) { + return QByteArray(error.what()); + } + return QByteArray(); + }; + require(code("expired") == "http_401", "a snake_case error code crosses to native code as is"); + require(code("throws") == "provider_error", "any other error text is replaced"); + + QStringList events; + QObject::connect(runtime.get(), &ScriptRuntime::event, &app, + [&events](const QString& source, const QString& type, const QVariantMap& payload) { + events.append(source + '/' + type + '/' + payload.value("itemId").toString()); + }); + QCoro::waitFor(runtime->call("b", "announce", { { "itemId", "x" } })); + QElapsedTimer eventWait; + eventWait.start(); + while (events.isEmpty() && eventWait.elapsed() < 2000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + require(events == QStringList { "b/changed/x" }, "host.emit from a source arrives tagged with that source"); auto pendingTimer = runtime->call("a", "delay", { { "milliseconds", 10000 } }); cancelSlow = [&] { runtime->removeSource("a"); }; auto pending = runtime->call("a", "raw", { { "path", "slow" } }); @@ -146,7 +168,7 @@ JELLYFIN_TEST_MAIN("script-runtime") rejects(std::move(shutdownPending), "shutdown settles retained operations"); for (const QString method : { QStringLiteral("spin"), QStringLiteral("asyncSpin"), QStringLiteral("timerSpin") }) { - ScriptRuntime runaway(entry); + ScriptRuntime runaway(entry, QVariantMap {}); QCoro::waitFor(runaway.addSource("a", {}, {})); QElapsedTimer elapsed; elapsed.start(); diff --git a/tests/providers/SourceHubTest.cpp b/tests/providers/SourceHubTest.cpp index 07c9b589..8fd73b76 100644 --- a/tests/providers/SourceHubTest.cpp +++ b/tests/providers/SourceHubTest.cpp @@ -1,6 +1,163 @@ +#include "provider/SourceHub.h" +#include "ProviderFixture.h" #include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/ProviderRegistry.h" +#include "provider/ProviderUiContext.h" +#include "providers/local/LocalProvider.h" +#include +#include +#include +#include + +#include +#include +#include + +using namespace JellyfinNative; + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void waitUntil(const std::function& condition, const char *message) +{ + QElapsedTimer timeout; + timeout.start(); + while (!condition() && timeout.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); + } + require(condition(), message); +} + +} // namespace + +// Two folders and two JS accounts behind one hub: IDs scoped per account, +// merged rows interleaved, and an account that fails left out, not fatal. JELLYFIN_TEST_MAIN("source-hub") { + QCoreApplication app(argc, argv); + QTemporaryDir directory; + require(directory.isValid(), "temporary directory"); + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath(QStringLiteral("credentials")).toUtf8()); + DatabaseManager database; + require(database.initialize(directory.filePath(QStringLiteral("cache.sqlite"))), "database opens"); + const QString installs = directory.filePath(QStringLiteral("providers")); + require(ProviderPackage::install(ProviderFixture::package(), installs).has_value(), "fixture installs"); + + ProviderRegistry registry(&database); + registry.setInstallDirectory(installs); + ProviderManifest local; + local.id = QStringLiteral("spool.local"); + local.name = QStringLiteral("Local files"); + local.version = QStringLiteral("1.0.0"); + const QString fixtures = QStringLiteral(TEST_SOURCE_DIR "/tests/media/fixtures"); + registry.addNativeModule(local, [fixtures](const QString& id, const QVariantMap&, QObject *parent) { + return new LocalProvider(id, fixtures, parent); + }); + registry.loadModules(); + SourceHub hub(®istry); + bool announced = false; + QObject::connect(&hub, &Provider::sessionStarted, [&announced] { announced = true; }); + QCoro::waitFor(registry.restore()); + waitUntil([&] { return announced; }, "with no accounts the hub still announces itself"); + + const auto add = [&](const char *module, const char *key, QVariantMap configuration = {}) { + const QString id = registry.finishSetup({}, + { { QStringLiteral("module"), QLatin1String(module) }, { QStringLiteral("account"), QLatin1String(key) }, + { QStringLiteral("label"), QString::fromLatin1(key).toUpper() }, + { QStringLiteral("configuration"), configuration } }); + registry.useAccount(id); + return id; + }; + const QString a = add("spool.local", "a"); + const QString b = add("spool.local", "b"); + const QString remote = add("fixture.test", "remote", { { QStringLiteral("label"), QStringLiteral("Remote") } }); + const QString offline = add("fixture.test", "offline", { { QStringLiteral("failing"), true } }); + waitUntil([&] { return hub.sources().size() == 4; }, "every enabled account joins the hub"); + require(hub.capabilities().testFlag(Provider::Search) && hub.capabilities().testFlag(Provider::UserItemState), + "the hub offers what any of its accounts can do"); + waitUntil([&] { return QCoro::waitFor(hub.fetchLatestItems({}, 100)).size() == 11; }, + "latest rows merge every account that answers"); + + const auto libraries = QCoro::waitFor(hub.fetchLibraries()); + require(libraries.size() == 3, "libraries from every account that answers"); + QStringList names; + for (const LibraryItem& library : libraries) + names.append(library.name); + names.sort(); + require(names + == QStringList({ QStringLiteral("Shelf"), QStringLiteral("fixtures · A"), QStringLiteral("fixtures · B") }), + "same-named libraries say whose they are"); + for (const LibraryItem& library : libraries) { + require(SourceHub::rawId(library.id) == QStringLiteral("local") + || SourceHub::rawId(library.id) == QStringLiteral("lib"), + "scoping keeps the source's own id"); + require(!hub.accountOf(library.id).isEmpty(), "every scoped id finds its account"); + } + require(hub.accountOf(QStringLiteral("unscoped")).isEmpty() + && SourceHub::rawId(QStringLiteral("unscoped")) == QStringLiteral("unscoped"), + "unscoped ids belong to nobody"); + + const auto latest = QCoro::waitFor(hub.fetchLatestItems({}, 6)); + require(latest.size() == 6, "a merged row honours its limit"); + QSet firstThree; + for (int i = 0; i < 3; ++i) + firstThree.insert(hub.accountOf(latest[i].id)); + require(firstThree.size() == 3, "merged rows interleave accounts instead of listing one after another"); + require(!firstThree.contains(offline), "a failing account is left out"); + + const MovieItem details = QCoro::waitFor(hub.fetchItemDetails(latest[0].id)); + require(details.id == latest[0].id, "details round-trip the scoped id"); + const QString remoteItem = hub.scoped(remote, QStringLiteral("m1")); + const auto byIds = QCoro::waitFor( + hub.fetchItemsByIds({ remoteItem, latest[1].id, hub.scoped(offline, QStringLiteral("gone")), latest[0].id })); + require( + byIds.size() == 3 && byIds[0].id == remoteItem && byIds[1].id == latest[1].id && byIds[2].id == latest[0].id, + "lookups across accounts keep the order asked for"); + + ArtworkSource::ImageRequest image; + image.itemId = remoteItem; + image.tag = QStringLiteral("t"); + image.imageType = QStringLiteral("Primary"); + image.maxWidth = 300; + require(hub.imageUrl(image) == QStringLiteral("https://img.invalid/m1/Primary?w=300"), + "artwork is built from the owning account's template with its own id"); + + require(hub.itemActions(remoteItem, QStringLiteral("Movie")).size() == 1 + && hub.itemActions(remoteItem, QStringLiteral("Series")).isEmpty() + && hub.itemActions(hub.scoped(a, QStringLiteral("x")), QStringLiteral("Movie")).isEmpty(), + "item actions come from the owning provider's manifest, by type"); + + QString changed; + QString toast; + QObject::connect(&hub, &Provider::contentChanged, [&changed](const QString& id) { changed = id; }); + QObject::connect(&hub, &Provider::toastRequested, [&toast](const QString& message) { toast = message; }); + QObject::connect(®istry, &ProviderRegistry::componentRequested, [](QObject *context) { + qobject_cast(context)->complete({ { QStringLiteral("name"), QStringLiteral("Weekend") } }); + }); + hub.runItemAction(QStringLiteral("tag"), remoteItem, QStringLiteral("Movie")); + waitUntil([&] { return toast == QStringLiteral("tag:Weekend"); }, "an action can ask the viewer before it runs"); + require(changed == remoteItem, "the change it reports is scoped back"); + changed.clear(); + QCoro::waitFor( + hub.call(remote, QStringLiteral("announce"), { { QStringLiteral("itemId"), QStringLiteral("m2") } })); + waitUntil([&] { return changed == hub.scoped(remote, QStringLiteral("m2")); }, "pushed changes are scoped too"); + + registry.setAccountEnabled(b, false); + require(hub.sources().size() == 3 && !hub.source(b), "a disabled account leaves the hub"); + const auto remaining = QCoro::waitFor(hub.fetchLibraries()); + require(remaining.size() == 2, "and its libraries go with it"); + for (const LibraryItem& library : remaining) + require(!library.name.contains(QStringLiteral(" · ")), "a name no longer shared is shown plain"); + require(hub.accountOf(hub.scoped(a, QStringLiteral("x"))) == a, "the others keep their scope"); return 0; } diff --git a/tests/providers/fixtures/bundled-jellyfin.mjs b/tests/providers/fixtures/bundled-jellyfin.mjs index cb264af6..2965afce 100644 --- a/tests/providers/fixtures/bundled-jellyfin.mjs +++ b/tests/providers/fixtures/bundled-jellyfin.mjs @@ -1,32 +1,53 @@ +// The Jellyfin provider as bundled (qrc), against a scripted server: the +// provider's own repository carries the full contract; this proves the pinned +// package loads in this Qt and speaks API 0.2. import {createSource} from 'qrc:/providers/spool.jellyfin/logic/provider.mjs'; -function require(value) { - if (!value) throw new Error('bundled provider contract failed'); +function require(value, message) { + if (!value) throw new Error('bundled provider contract failed: ' + message); +} + +function respond(value) { + return Promise.resolve({status: 200, body: JSON.stringify(value)}); } export function run() { - const source = createSource({server: 'https://fixture.invalid', userId: 'user', token: 'fixture-token'}); - const host = {http: function(url, options) { - require(options.headers.Authorization.indexOf('fixture-token') >= 0); + const device = {id: 'device', name: 'Test', app: 'Spool', version: '0', platform: 'test', locale: 'en'}; + const source = createSource({server: 'https://fixture.invalid', userId: 'user', token: 'fixture-token'}, + {device: device}); + const host = {device: device, http: function(url, options) { + require(options.headers.Authorization.indexOf('Token="fixture-token"') >= 0, 'token in every request'); if (url.indexOf('/PlaybackInfo') >= 0) { - require(JSON.parse(options.body).MediaSourceId === 'chosen'); - return Promise.resolve({status: 200, body: JSON.stringify({PlaySessionId: 'session', MediaSources: [ + require(JSON.parse(options.body).MediaSourceId === 'chosen', 'the chosen edition is requested'); + return respond({PlaySessionId: 'session', MediaSources: [ {Id: 'other', SupportsDirectPlay: true}, - {Id: 'chosen', SupportsDirectPlay: true} - ]})}); + {Id: 'chosen', SupportsDirectPlay: true, Container: 'mkv,webm'} + ]}); } - require(url.indexOf('StartIndex=1') >= 0 && url.indexOf('Limit=1') >= 0); - return Promise.resolve({status: 200, body: JSON.stringify({TotalRecordCount: 2, Items: [ + if (url.indexOf('/MediaSegments/') >= 0) + return respond({Items: [{Type: 'Intro', StartTicks: 0, EndTicks: 300000000}]}); + if (url.indexOf('/Users/user/Items/film') >= 0) + return respond({Id: 'film'}); + require(url.indexOf('StartIndex=1') >= 0 && url.indexOf('Limit=1') >= 0, 'cursor and limit are sent'); + return respond({TotalRecordCount: 2, Items: [ {Id: 'film', Name: 'Example', Type: 'Movie', ProductionYear: 2020, ProviderIds: {Imdb: 'tt1'}} - ]})}); + ]}); }}; + require(source.describe().artwork.indexOf('https://fixture.invalid/Items/{itemId}/Images/{type}') === 0, + 'artwork is a URL template'); return source.search({query: 'Example', cursor: '1', limit: 1}, host).then(function(page) { - require(page.exhausted && page.cursor === null); - require(page.items[0].id === 'film' && page.items[0].externalIds.Imdb === 'tt1'); - return source.resolve({itemId: 'film', variantId: 'chosen'}, host); + require(page.exhausted && page.cursor === null && page.total === 2, 'paging'); + require(page.items[0].id === 'film' && page.items[0].externalIds.Imdb === 'tt1', 'items'); + return source.resolve({itemId: 'film', variantId: 'chosen', positionTicks: '0'}, host); }).then(function(playback) { - require(playback.variantId === 'chosen'); - require(playback.video.url.indexOf('MediaSourceId=chosen') >= 0); - require(playback.video.headers['X-Emby-Token'] === 'fixture-token'); + require(playback.variantId === 'chosen' && playback.playMethod === 'DirectPlay', 'edition'); + require(playback.url.indexOf('MediaSourceId=chosen') >= 0, 'stream URL'); + require(playback.headers['X-Emby-Token'] === 'fixture-token', 'stream credentials'); + require(playback.container === 'mkv' && playback.segments[0].type === 'Intro', 'details'); + return source.search({query: 'x'}, {device: device, http: function() { + return Promise.resolve({status: 401, body: ''}); + }}).then(function() { require(false, '401 rejects'); }, function(error) { + require(error.message === 'http_401', 'a rejected token is reported as http_401'); + }); }); } diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index adc79cd0..9a1e84da 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -1,6 +1,24 @@ -export function createSource(config) { +export function createSource(config, sourceHost) { let calls = 0; + const item = function(id) { return {id: id, title: config.label + ' ' + id, type: 'Movie'}; }; + const failing = function() { if (config.failing) throw new Error('offline'); }; return { + describe: function() { return {artwork: 'https://img.invalid/{itemId}/{type}?w={width}'}; }, + libraries: function() { failing(); return {items: [{id: 'lib', title: 'Shelf', collectionType: 'movies'}]}; }, + latest: function(args) { + failing(); + return {items: [item('new-1'), item('new-2'), item('new-3')].slice(0, args.limit), cursor: null, + exhausted: true}; + }, + details: function(args) { return {item: item(args.itemId)}; }, + items: function(args) { failing(); return {items: args.ids.map(item), cursor: null, exhausted: true}; }, + runItemAction: function(args) { + if (!args.name) return {pick: {kind: 'name'}}; + return {changed: true, itemId: args.itemId, message: args.action + ':' + args.name}; + }, + announce: function(args) { sourceHost.emit('changed', {itemId: args.itemId}); return {}; }, + configuration: function() { return {configuration: config}; }, + expired: function() { throw new Error('http_401'); }, bump: function() { return {calls: ++calls, label: config.label}; }, candidates: function(args) { const rows = []; diff --git a/tests/tools/provider_package_test.py b/tests/tools/provider_package_test.py index 0d8d56c2..cec2f20e 100755 --- a/tests/tools/provider_package_test.py +++ b/tests/tools/provider_package_test.py @@ -1,141 +1,134 @@ #!/usr/bin/env python3 -import importlib.util +"""sdk/spool-provider.py: builds, validates and describes .tar.zst provider packages.""" import hashlib +import importlib.util +import io import json import pathlib -import stat +import shutil +import tarfile import tempfile import unittest -import zipfile ROOT = pathlib.Path(__file__).resolve().parents[2] -spec = importlib.util.spec_from_file_location("provider_package", ROOT / "tools/provider-package.py") -package = importlib.util.module_from_spec(spec) -spec.loader.exec_module(package) -bundle_spec = importlib.util.spec_from_file_location("bundle_providers", ROOT / "tools/bundle-providers.py") -bundle = importlib.util.module_from_spec(bundle_spec) -bundle_spec.loader.exec_module(bundle) - - -def fixture(): - manifest = {"format": 1, "id": "test.provider", "version": "0.1.0", "api": "0.1", - "entry": "logic/provider.mjs", "publisher": "test", "license": "MPL-2.0", - "offers": {"future-feature": "0.1"}, "requires": {"http": "0.1"}, - "optional": {"unknown-optional": "0.1"}, "permissions": ["network:configured-origins"], - "channels": ["desktop"], "stateSchema": 1, "ui": {"modules": [], "components": []}} - return {"manifest.json": json.dumps(manifest).encode(), "logic/provider.mjs": b"export function createSource() {return {};}", - "LICENSE": b"fixture license", "NOTICE": b"synthetic test data"} - - +spec = importlib.util.spec_from_file_location("spool_provider", ROOT / "sdk/spool-provider.py") +tool = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tool) + +try: + from compression import zstd as _ # noqa: F401 Python 3.14+ + HAVE_ZSTD = True +except ImportError: + HAVE_ZSTD = shutil.which("zstd") is not None + + +def manifest(**changes): + value = {"format": 2, "api": "0.2", "id": "test.provider", "name": "Test", "version": "0.1.0", + "summary": "A test provider", "entry": "logic/provider.mjs", "icon": "assets/icon.svg", + "capabilities": ["search"], "ui": {"login": "ui/Login.qml"}, "origins": ["https://api.example.org"]} + value.update(changes) + return value + + +def files(**changes): + value = {"manifest.json": json.dumps(manifest()).encode(), + "logic/provider.mjs": b"export function createSource() { return {describe() { return {}; }}; }", + "ui/Login.qml": b"import QtQuick\nimport QtQuick.Layouts\nimport Spool\nItem {}\n", + "assets/icon.svg": b"", "LICENSE": b"license", "NOTICE": b"notice"} + value.update(changes) + return value + + +class ValidateTest(unittest.TestCase): + def rejects(self, package, fragment): + with self.assertRaises(ValueError) as caught: + tool.validate(package) + self.assertIn(fragment, str(caught.exception)) + + def test_valid_package(self): + self.assertEqual(tool.validate(files())["id"], "test.provider") + + def test_unsafe_and_colliding_paths(self): + for name in ("../escape.mjs", "/escape.mjs", "logic/../escape.mjs", "logic\\escape.mjs", "C:/escape.mjs", + "logic/.hidden.mjs", "logic/provider.exe", "logic /spaced.mjs"): + with self.subTest(name=name): + self.rejects(files(**{name: b"x"}), "forbidden path") + self.rejects(files(**{"LOGIC/PROVIDER.MJS": b"x"}), "case-colliding") + + def test_native_payloads(self): + for magic in (b"\x7fELF", b"MZ", b"\0asm", b"\xcf\xfa\xed\xfe"): + with self.subTest(magic=magic): + self.rejects(files(**{"logic/provider.mjs": magic + b"rest"}), "native") + + def test_manifest_contract(self): + for changes, fragment in (({"format": 1}, "format 2"), ({"api": "0.1"}, "format 2"), + ({"id": "NoDots"}, "publisher.name"), ({"version": "1.0"}, "semantic"), + ({"name": "x" * 65}, "64 characters"), ({"entry": "logic/main.js"}, ".mjs"), + ({"icon": "assets/missing.svg"}, "icon"), ({"ui": {"wizard": "ui/Login.qml"}}, "roles"), + ({"ui": {"settings": "ui/Settings.qml"}}, "missing QML"), + ({"capabilities": ["teleport"]}, "unknown capabilities"), + ({"origins": ["https://example.org/path"]}, "origins")): + with self.subTest(changes=changes): + self.rejects(files(**{"manifest.json": json.dumps(manifest(**changes)).encode()}), fragment) + self.rejects({k: v for k, v in files().items() if k != "manifest.json"}, "manifest.json") + + def test_qml_imports_are_limited_to_what_spool_provides(self): + for module in ("QtWebEngine", "Spool.Ui", "QtQuick.LocalStorage"): + with self.subTest(module=module): + self.rejects(files(**{"ui/Login.qml": f"import {module}\nItem {{}}\n".encode()}), "imports") + + +@unittest.skipUnless(HAVE_ZSTD, "needs Python 3.14 or the zstd command") class PackageTest(unittest.TestCase): - def archive(self, files, metadata=None): + def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) - path = pathlib.Path(directory.name) / "provider.zip" - with zipfile.ZipFile(path, "w") as archive: - for name, data in files.items(): - entry = zipfile.ZipInfo(name) - entry.external_attr = ((metadata or {}).get(name, stat.S_IFREG | 0o644)) << 16 - archive.writestr(entry, data) - return path - - def test_optional_and_offered_extensions_do_not_reject(self): - manifest, files = package.read_package(self.archive(fixture())) - self.assertEqual(manifest["id"], "test.provider") - self.assertIn("logic/provider.mjs", files) - - def test_required_extensions_permissions_and_api_fail_closed(self): - for key, value in (("requires", {"unknown": "0.1"}), ("requires", {"ui-host": "0.1"}), - ("requires", {"native-list": "0.1"}), ("permissions", ["shell"]), ("api", "0.2"), ("format", 2)): - with self.subTest(key=key): - files = fixture() - manifest = json.loads(files["manifest.json"]) - manifest[key] = value - files["manifest.json"] = json.dumps(manifest).encode() - with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - - def test_unsafe_and_case_colliding_paths(self): - for name in ("../escape.qml", "/escape.qml", "logic/../escape.qml", "logic\\escape.qml", "C:/escape.qml", "logic/CON.qml", "logic/provider.mjs.", "LOGIC/PROVIDER.MJS"): - with self.subTest(name=name): - files = fixture() - files[name] = b"bad" - with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - - def test_symlink_and_executable_modes(self): - for mode in (stat.S_IFLNK | 0o777, stat.S_IFREG | 0o755, stat.S_IFIFO | 0o644): - with self.subTest(mode=mode), self.assertRaises(ValueError): - package.read_package(self.archive(fixture(), {"logic/provider.mjs": mode})) - - def test_native_payload_disguised_as_source(self): - files = fixture() - files["logic/provider.mjs"] = b"\x7fELFfake" - with self.assertRaises(ValueError): - package.read_package(self.archive(files)) + self.root = pathlib.Path(directory.name) + self.source = self.root / "source" + for name, data in files(**{"README.md": b"not packaged", "tests/contract.mjs": b"not packaged"}).items(): + path = self.source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) - def test_import_escape_and_undeclared_qml(self): - files = fixture() - files["logic/provider.mjs"] = b'import "../../private.mjs";' - with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - files = fixture() - files["ui/Hidden.qml"] = b"import QtQuick\nItem {}" + def test_build_is_reproducible_and_round_trips(self): + first = tool.build(self.source, self.root / "a.tar.zst") + second = tool.build(self.source, self.root / "b.tar.zst") + self.assertEqual(first.read_bytes(), second.read_bytes()) + manifest_value, contents = tool.read(first) + self.assertEqual(manifest_value["version"], "0.1.0") + self.assertEqual(contents, files(), "only manifest, licences, logic/, ui/ and assets/ are packaged") + + def test_default_output_name(self): + self.assertEqual(tool.build(self.source, None).name, "test.provider-0.1.0.tar.zst") + + def test_symlinks_are_refused(self): + (self.source / "logic/link.mjs").symlink_to(self.source / "logic/provider.mjs") with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - - def test_unavailable_public_ui_module_rejected(self): - files = fixture() - manifest = json.loads(files["manifest.json"]) - manifest["ui"] = {"modules": ["Spool.Ui"], "components": ["ui/Page.qml"]} - files["manifest.json"] = json.dumps(manifest).encode() - files["ui/Page.qml"] = b"import Spool.Ui\nPage {}" + tool.build(self.source, self.root / "out.tar.zst") + + def test_links_inside_archives_are_refused(self): + tar = io.BytesIO() + with tarfile.open(fileobj=tar, mode="w:", format=tarfile.USTAR_FORMAT) as archive: + for name, data in files().items(): + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + link = tarfile.TarInfo("logic/escape.mjs") + link.type, link.linkname = tarfile.SYMTYPE, "/etc/passwd" + archive.addfile(link) + path = self.root / "linked.tar.zst" + path.write_bytes(tool.compress(tar.getvalue())) with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - - def test_expanded_size_limit_before_read(self): - files = fixture() - files["resources/bomb.txt"] = b"0" * (package.MAX_FILE + 1) - with self.assertRaises(ValueError): - package.read_package(self.archive(files)) - - def test_build_is_reproducible_and_round_trips(self): - with tempfile.TemporaryDirectory() as directory: - root = pathlib.Path(directory) - source = root / "source" - for name, data in fixture().items(): - path = source / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - a, b = root / "a.zip", root / "b.zip" - package.build(source, a) - package.build(source, b) - self.assertEqual(a.read_bytes(), b.read_bytes()) - self.assertEqual(package.read_package(a)[1], fixture()) - - def test_build_rejects_changed_pin_and_mismatched_identity(self): - archive = self.archive(fixture()) - with tempfile.TemporaryDirectory() as directory: - root = pathlib.Path(directory) - target = root / "provider.zip" - target.write_bytes(archive.read_bytes()) - valid = {"id": "test.provider", "version": "0.1.0", "repository": "https://example.invalid/provider", - "revision": "a" * 40, "archive": "provider.zip", "size": target.stat().st_size, - "sha256": hashlib.sha256(target.read_bytes()).hexdigest()} - lock = root / "lock.json" - for key, value in (("sha256", "0" * 64), ("size", valid["size"] + 1), - ("id", "different.provider"), ("version", "0.1.1")): - with self.subTest(key=key): - pin = dict(valid) - pin[key] = value - lock.write_text(json.dumps({"format": 1, "providers": [pin]})) - with self.assertRaises(ValueError): - bundle.materialize(lock, root / "output") - self.assertFalse((root / "output/providers.qrc").exists()) - lock.write_text(json.dumps({"format": 1, "providers": [valid]})) - bundle.materialize(lock, root / "output") - self.assertEqual((root / "output/test.provider/logic/provider.mjs").read_bytes(), - fixture()["logic/provider.mjs"]) + tool.read(path) + + def test_feed_entry_describes_the_exact_archive(self): + package = tool.build(self.source, self.root / "p.tar.zst") + entry = tool.feed(package, "https://example.org/p.tar.zst") + self.assertEqual(entry["id"], "test.provider") + self.assertEqual(entry["api"], "0.2") + self.assertEqual(entry["url"], "https://example.org/p.tar.zst") + self.assertEqual(entry["size"], package.stat().st_size) + self.assertEqual(entry["sha256"], hashlib.sha256(package.read_bytes()).hexdigest()) if __name__ == "__main__": From 49d6d3c1e7b7159f2c843694e696a1f2cfcfa8b1 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:34:48 +1000 Subject: [PATCH 050/146] chore(provider): pin spool.jellyfin 0.2.0 built from spool-jellyfin@6c014a0 --- providers/bundled/spool.jellyfin-0.2.0.tar.zst | Bin 0 -> 19452 bytes providers/lock.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 providers/bundled/spool.jellyfin-0.2.0.tar.zst diff --git a/providers/bundled/spool.jellyfin-0.2.0.tar.zst b/providers/bundled/spool.jellyfin-0.2.0.tar.zst new file mode 100644 index 0000000000000000000000000000000000000000..872ede34a117f5b8e5a60c5b99042df7582b6bdf GIT binary patch literal 19452 zcmV(-K-|A5wJ-gk06+l%h~{lOW)m`S9l`|_XsJ)j2hT2jDPX{dBlhxYT?Y-TW81))WQS@FS~hq zSuuY5JiW*NQ<=&&@#NU2H)H32TKmr}y5*Scs-MakS@ck|D8A}_AZyawVOa5F$J)8o zD*B`^-+cDYbmDt)!dN)InDg(k>&+4Tn)y{rX8&@9wVGA0Y{ev8r{soe=j3f8&sp5siz8yJ56m{&( zi6EWGrpQt1b5r&#m%XXnjnqrFgiP^MkNS4H=Cp&Yo3^YEmn(fJJj$tQmoS1*R6H*m z_QWA7Sv-%&gOM&2#(w7u$`849iMD>4X`=gHlZX-k_{yIEFbcq8gJWakLIabLk+FdR zAc8VM1cam{`aBi-Jl_<|i1^`ZA^YzGxAPaqbG?UIQ%(3ix2errP#MNLb|cTzVT+eA z%Rk-mbZRH{Jbc)>v3uVnDz_5RB>#*I_dRak2_DUEzG8|MZ)(^f`>NoB!*255>`c^j6{Amb%v2J#wj)Qd$p&KF$3^b_uqsv~`x#>R;;= zv(av~reH@3#)o}RujTV&?-=$Y`+3<@Kdq9yv1om-RUY=UuUE1@(M6o&w$FO_$v8Jf zDEr5~Dt^^n@g~J56J>@#lGg6qrwDx>y(V^QpKoRA%dg4#qU`DIhBfasa<~&8s1zw^ zF`jhe$CQtc=Wgq>wVf1pB2>}KIgYNKE@uWoZWA7#lNHrlS^Vco2IG=E4||^GFv+6) zja+y=7~kSq14%6HSrtpU&$TNx+ld?awAGCrwnpX8$LV_?&ojN&G{(~~E||qX631K4 zv+O2#IerW3y(1UTG{32S=5RmOyhDC=i%&2dOWD@rQKJjyhn|fxZrc8X5@kpj-u_&X z;k8wy;=}Rh=rn~J|&dq2$gAMoG?Yl9m~PQ>JzD< zI6;yV&k6bFrc{)KY9}bOt`rhS(uop=Jm8Hoja--AU}Ysx;K{QBziFBq3&S|U<+8;m z&ri1OU10}Fs^!>jeqf>rGO}3Igb9VDMCpK<(Gv{RgOrKjAfs56f17Ta?9MN=bVMF3 z*a`K3;jkqjNcI0}C@AzP*&`1epy(;zg^)fs_{NX9#ImT9Cnql9T&!a9FBv7KY`BUK zqJs%Cd;9n12#BSsn2;q{+{X&dM7dOR7P_bKFZoeaO!>D{H7k}=LPZGka6&aSv+}Xi z5-d?T77iA;nlE0UCiy$!useKWJ-Z3?6uH6^;IyCx%LL`bV{uQj-yu(O?K5fcLV+D2 zD6=>a>$o9@w2#ZzHO%r|fw1(7$a4&FP(nTQJ-UnN^K@7$mVJ>dC$xk{ce>N#m;GER z1><8*Kd2!>gkTv$SqN)Jr$R)zk4aW(@ipA3$T&$wMTQU)6un$BG%h$S0KABb!bz+61w$k2=o-MDZH57<#e1*=4lC;`y(N#XG*Hf%h&&`<;# zx}g^s5-bIm3rI-D2L@wtp}+tE(fIffK;hx>Sx|UftXxWAu|nbD+`0o_hW&nGr$F^t zK#{Sb7{yR{ti%Q#ZjMr1E-*S$r_-VEc=)OnRlsq}WaIU{BY33_)Lv77Au#1A~JDKnjHk0G!P`AG*ffc&`|k0~p_=JJD*BcPV)jYX; zt9Fy;c1B^F6!z3sBo;kjaqh#h58;Y^S;KVEO_(!>g1&oF^ip!tMaBKI^`KgCU~psr z01!nR%G7`Xy|7S^1NsLJTs+3oZBaRW*3L?xCpO10+1 zBBvX&MWKO|;A&F-!5aVLx2Q17??mX~x5S@d4kQ`rE%pv&|CfvNTZovkE`Y49q)7UR zC}N;IJ5-ikBfsC}5?U~kNlJgTL?Xx=3=zb?AkLjw5~akTj0Ge_l3lSfXocm6vn_;W1u!rO0fImP zC_n%J06_o-dVmJ&hH}}dkJm(D>&V4A-3cip3#%pzv5Du~*H$CY;vKrNi$&dsGD1IE zE4jQt=K1!T;Y`I6>}yN8wHl_1m;Z8RirUIHJYGr#udd|+P5lBSU(-Vw7fO9P!;>`$ z-PHHp7Qo%I1PpfUHRhiz5I`bX$$ZL$=0oBAP;3CHo% zH|N}n3>}haDCGb&J@%OC3hxsGwq-UxRgdpW2h3MWAI~)u8||)SaJPUx-Zp0w9A9rG zo~PvPYd7ShEroB4nP*+O*`OAN67U$phyZpDM5xori z8R|lI^kl%1LQlHFfZGxkQ*gkY`9RYa#yOZ2hyDmKGjEGY+So%Fjb8vl0MZ1QO79D@ z@s=o!@5X;}ew9Bcbpf{1pvoiq?tk|dlon&=tPZWTXZjK#$EO6V`f{M_3tg2w*f8gQ%Y^HuJzf=C)hgRI#060&zw|Z;E8`%VKPzhde`N=1}6vyZ>LQ6 zs1mZMsbOt&NM(l`R|M)fU_^_b)SOKjQ(7iIc4qWfW*C_b z@m~@N+l+BQ_|p(bh^oo)qpmQA9L@WTtVwXGx|m3zPonltH#HnlPIh&*d#z6fIypL8 z4mm+?K{PErh8LXdM-r^_#)YYGVzD$|h()aTNQ)4YZ9wru4wOk902z)`L*ClJY}mrD z6f`t9J$tceS;o;SS|-oC-;lpMeU2tcn@MOXOLs%+Gssf_m9yLKo`3Ef{cGmWM71V)q{nYShjTA6nP}Yb1w{`peA-?|~BV$<8%6q%HSmUVEJ& z-0noa!Hb8HVGk7;6fJ;;?BqYJ*KP((l-*BA3H&PFyq-L@w#_XYk(o56lsf+U^#b&W zip7HNh=mc|mVlR;ue#+~c;|nPIJxrU#5m`vg$&Pic9#t zJ;LY^4@3h`V@1Yz+6XZ!S0T|BeUkDB3OB5&1>i4Ws#2`JgHi_b0K(JQg;f0~tTd9&ZDJ{m9j zNlGv)_CH`er4;Ji`ZfZ|TudfMO=LRm#c`z>waCu)$q0;D1y@DYaIc&9*RN%GYVTky zSrD_liHBF-S>sk45%XZv02@MVNMXbD;?5RQ7 z^cnAX1jnzOY*L5j@TWYT==M<%`|DQp=DM;q7D73kAt0D;?waAD6quYiORZHN60p=OxI-CeVLF3e`%(0UC4nSbasB+q+um4EBzFZi>2d9s6HgQGUt5Np2HL zL`=cZp*`D*P|DsIy`I?mpWD%}Y-M0O#YnYBr=J<;vP^lWm5g~UWQ%oj=3yZT^HD03PjD&q9|UAjQCCK6aS~Qq0BP)D z9A)4nW&KQb`6{GKxi?kCam~xxS;aSyQL4qKp^7X9LiJgcAvH-k;A-L+`=yS1L6pd# zoC!%x8Fk~Mj9Z*$F;9Wdc19|tl!4Ukh83>M1zc3ep+Z`6U5l)B8v=mRvjcA|c@ypJ{S^AU}JY<@LPB<}PI;NA0-_+$A6es~g< zMz_n&<4z`BXJHH^C{YUAlf?CK_8;Sh>atOsT6{BPeG6=WR26Om!}*-kKH2h6=46PE z!`=wy#q;6+x#(rVXiDGdQjzj<1`1<=4cyA&0Jq-T{K7t33+1tu978rWd_Aiw`E(0C z!qTED{SOA9Bo;XVS#4cQPF;zI3iBurZE!iCoR*6QYBk8W_w`G-H^v z;8@Sjzl6hVw`djk*f}Ei+ai4<*%yBI0LvmZ=J2)(oJwyP>YxD@Tf}Bv3$~Yi*r$yL z_qo(j+Q3jUlyx|yQ`%yObJ`XpdZ~?zv_xoHVaFwjW2}^nc)NtGBgSSepL1Ork)-Ax_$OuKW2s-Eex{&}dt9Zs&ASkO2lgp@!uDdPaH6GM!5a}JCu27*6bv|N z7?ja{E|?W+pbuJ$c2Ed5P%1((C@B4QTC1*_(8_>Tb9ZugACM<$fjrr|0h25QZLYlh zj3MnS89eq8G=)(&g8d(Dj+Vv`bmp5WvnHNa;eJ`7j?X(Ns|PAhubK|SKmoD${v(X|ZZySQy>hvS0ss91CeEVvj4a3`4`F)deQdidl?(D{vIO*v+2K zBj!|0GIH1L2!QhE_uQ=kt34PE9}baAqmAHhKmyCo$6*|*SvXqUc;S!2-ZzL#{KwWH zY7S%_=IV-aD5VIZyrE@n|GaxY)U0!{u+@guXpclu-eIGUIy*+X1nBIn?U82Yfv}+n zz06YyZ&D2#8hKfW7v&(%#xBqrDi-w7xa|pcrLmE>7YbeEXNyp8qNACOWiUa(B)j~z zVUt-;kU#;)6C0yV58|w&_g!0d*Jndl^{6{SQDc$U89gQmRISp?(2KaSSAjFqNnmGm zW~6bhBMDysPN@pDwOym2a1r6Qwux8%=QORm6!`Z8TFXByZ1Sjp&B&*l>dgei!lvsFV^H}Q$U;l~Nt6!&nT@&;FjexYfC zu2ia_^sXrX#u%(Rbf|b^t*~=~sa{aPF)ae-Nl(+F$M86?pREbxV=n=Am|G4It~Y+R z4iS2$3#ec`*3Nx{@F(St-3cdx@4wUb$(^2r;Ho|FX@J|@hNc+uk5C(|2{{zg`T=R` zDcg})=D>!<=$w5g`G~AuS>ONWV+$~8e2B!OqSqCRUP=F2hf9TTd@JzbnSX3-A8b0n zS)c0a6(ws25|u)+*U-XwhK3Uzwf$=N5HsM-yO5B)*eV>l+r4neKhBD1s$q0;f; zi#!*i=K)2xk{!CF&A#Ht173E5R)4|{+u?SD27%y*#)9>xU3xjSERoEOt?RuGQ^?0T z77Adz?cXef7AV=a_zz74QJ#%PqDF<2(arvzvZN)cyJL38WcE6>tGZi2S9ki2qwW^` zciIH^lpizNThhI114KvfBcOqd1UVDyMnsLGX%!oJ6%Bj@HDHI;tFgaPY|MqjEbPwA z;H3KvY8xC;SfM&;YYA4~m35n4EZ`XGGxUoFt;fE<*np z;sKtRx(~U+%-D&hbz3>&XquDa8K2oi*-~Qa>&?f3=!%I9hWdg$jn8it%@M@b_lsNu z3_t`y4n7zCY_xZ6yg)Zxs-RkCF*kIEh%vw&+7-HM*z!x*&Z)&mu{Q7jLjl(VrcaAW zThPCXCwY`pm$_%ZS39ISNt+C89f%o?=_dDhQn{-yXvGEfDG(<=UG3aZnA-8EGz9sB zJg{O|4b>u;;H4MmFbP7o!p!yaCk3GTPDZ0A&4Ve=oZ5YLFQ2pn76AtfahrB|)kr-w zTECQI0VO+2qVTbqB*ItN`f#1HcM-c*u|WtrvwwF___|@9g)$p=fSpwBh*?nRUD(;VG}=_do$WD`?o*CaS8bEp*p`|0kZj0Zf@W z8yQ7{LJL(iIo+}v9uPa$DKdyu(yU4h))iws)$dRll6hs&A-+mqw4$E?z!2 zbX~wasf$hWs@2vu79}~|vp3HsEF2$2`T>Rp@6upfu>i`u1+{3%=GdW|RVG zoXKlM0{DjbsC-qYNS3;hv?(|khpEdmJn%+_9tWTkFr7`8ydo)$KRE#pSuA?K@m#j% zC=t!u_yY!~7eG$N9F;=1^agWRwBf6XB9g?ifbo1@n_dtWkTf@PJaN-yv~8MuF}{7% zCFAOke2vS`zo|}L(=~q}Y4trvTcf`SfMMU+R3bK9@$ynnYHjo5lZ0b+-$#a2A8iLm zYH+<~2-<^f$qTC+UYT;$1+r5Y>nI)6O=~hWRNY{U+9g1feARHg^f+*mHC^ECp2&Q= zg7@W?vwz$Z1&}JmqzCuJ_PV?!e2UhdUO)`9jOH3J;}M(jT9~SI(+FiTsl4nni_3<* z8&#eye3trtfMFofDx8^|h4DGZcq?Gcwg)`N$ia4Q@pqNrL`F98cW;pmhL5!mG7<~{ zUXbE=4=gIaXo?nk7*zVV^)KW8MKCgwFGrr-qva?($9*jFyZN!!3lV(Gypwp(*yZe2 z{$(Oe>ccDs8uBv3i2%}jhZeosK4lt-5)~Lwt+8M*Ek)BT6ae|AO zbji>qY#(E0D5b+UDLT}?vN8JZQD?nVFEEcV?;_ zro#@(no!rMhl9+_!FfuhuQ-`D-zEcrEUT-oImykRBhXNNF?Rm8@pZni`&Ccgh3K7Y{`RAo)Dxr2ZcPHtFa`=9 zw?V2x`lV5A`Kyo0X0_>}4+&%*qbb7rI|7r4gs9oaF`oPT;^n=f%i>qjw!H%@h3kTn zpg}kh3)DN(dp^S?x#C`bwd9Kcc|=l%+wBuuC$f zk!1Q+9|@Tj#l?oy92BwK5!~s`lX*ikT}kfW(dcpD$a*GMu*dK+Zn?E&8)U;~$wJl* z=rT^oO5NxNWoh0CLIR9EhEJ7ThIktLE$H7M1TUW|Ur+z*3+lYUKKeEGrKNR0;M)xE zz!x8NPXFUG6nG1y4f+D7i+@vWn2J$qF-g9Ic_bwuIlwBZt0irw9(0ZLLKGYJ zBi#iZEYHF#0AVMRl8Z4Ttsp7!W%1Cu&PEqRx#~6S*;9;j+Sbx2 z`AV(ed0`4*>Ti=ARbt8f^{-j8-O;WHa}P`jYWDMMxgiKr*vkLt$en)yLADcta&>vm z%nsdEM1q)gEi=dz%C#CGWfginV&Z-Y(#E33e4YdP8D1!}(m*Pg5QpmK35qtGmA! z&H))`PUN0%P-p#a(F~oDb-|HeQ(5}J!IX^h%s#$-)7J?BqWXsdXkfC@ss%?SZy>pD z6{T^f|8K}ac_B&`P(!?V=6K>v zxwm<6mwC4xc{a=yV~z4k!g{HhAB3a zLfkx}0>+=_Up6QK0e2BTI7F+W3;Gf{u)YTKMA-*i4odK2FO0_|44sj}vqMCENOV0w zZrDQ?%r;jvFQC=Af&_=Tu`@OB+<$xX@Yv=2_mCV%V{xvoK2e*`lOpnK<0TG*r|$($ zV-70Y$A_a<24iF8s6X05M68BN-QMaRWDBi&$LbM^|5<9GLR*H{r%r>93`a(85Fjc- zjp0Hs+?j_7N{()GP7Su>5??g&00c@|7E2&;-jz6)CQC`Wc+uT$-AZKY?a!NkegLCt zdjp68r1bRGmxH!!4Ac6MTsSSAzD@2x7)VfyU*nqX){k7>{pG3`r^g#zahy1!z~ZbSwB5=9sTu9N z<2jFoY?tU+kwm{V-egU-GZSYOSDC`#aL{%rn&PYOczHc0t)j>?6*s5^XAHKVat0Ww z*G@$O4U+0W)Fhx*V;S4O^&QOY$-(CbOoloQCUEw=@TB?AQ}l!`7KLl`8{kVv-u|fG z7{Cs$I?qJ<(6r$<1*SATST`2<4i3XTX%ugIa_kPw9E z>Si2BFJW)hnYmYv3B4Uur9bZ<#`Ryi^{i(bRu*Be2-l8dvdl*Ef{vR66URyb>7e4H zb1)6b&ZE?nL!Y^tzK54fdMn#LtAucj2rRaq*7TU<+CH6@jD`>BlePrR5U+-7T8$TX zHh(`*qGbzr-h=m2V(f{L-?3okpNBIa?6yMa5(c5fE}1r;!$p81Y}XQFWtMn=F(GN< z>cC6|SS&Yh|3NUKPa)6aM;{Tkv{?i4vd;N7if%SZBbKOdLZBM<>88i7W19cP_&iZ) z1%70ZC6J`Bm;@+++6-X$d5%A@v##l(QJNxiGG;mpX_J{sjXS2jV1AF@Hp}$w+7O8> zegTonMAL_m*w+tO_mXXndw68)$To3Nti-Jpx#9{f3nx+5>L!hH(2pby0)ktlrntGZ zd@ql6#31 zIZcpfNvwY$mVha{q1ux39fB$sro&lgoqG_5^??i7BOZPPvA@uMIfdgAKQPS4@CR2~ zRm;Q?BBcK8^U?zD#!$Q#2BrzKjnSPlF8-z)*(rv)-m`Hv%*stg+rwdWwgTaaYk>qO zL7IY8r=tIPMsh!;A<9*@Z}#a05+f03E$cG()-R~p4ga0>4k5OcA4v#<3PB!_h&Ep# zhsGi9WC#(Cf&*LdbL%DsJ?w?c_>c{;>Mt%pd}BmJc}*{rNhslT#DD|?8816BbDQFX z_&AxH+7ZGS_@1Kts{1N$+Y%NZ@-o4BL4CwmXNMT#TOr|c>q&w5S@GJih|oPRY@i__p?Wb#?9 z7Xf~AyCau`(E#kTYs1M3)Zmn`CtQ;78CM34B?0wn(BI0c z;(@{?fg?C^mJic}zkLN=(^Rw;53XbJ*q?{3RzhJr0|d5hi=1s^$5}+uz#aqO$@Fq% zIbjd&;o*^)eCLnkDb}da|1{!3Ght98UQ|UC!ljhk_ZOvdRm#<1{ zJeEsMM`cH8%w67|iTj6y3?_q%Ce=mf^Y)h^2jvB7TV+EYe-pxHhgGoUv9=A3HMg#t z7$`Y4gQ=KWZPj0SoBscA{l95c@LHG_H8{wG#IEHboYCKq>F?M2zV`F%&MU_)f6!zw z2L~I2%D-uQ(8!~ohA}9rxRzqSHskSrPo=g?Mgt2{lxo`K(A2x|hZw((G`)|J7Yc*S zweU2yNZQ_&LrNUoq?A(1kgRsr$VroUGIg-s@d6pP%}20z?|FT;&M(qX){~5FpsZbc zx0l9y^QglBd9LsE@kHqC`lqAI0fwOSeIF-35!BAuQ(1c(7lYi%^xMDdxoPj0%jV0m zW2M4Ig8;eN;fyT^i1e?>7_77ML}ttqgK-36FpfaX1>*?BV4M}*otPyCvp{ZZ%4wb- z$(!|a)lJbtJqohCmu9Zry)LCDIh6G%gjwEYEga6%G{34cE5F_V-Fiv(ftpv(fo$s!* z%2Okk?g#OwDG;sowu%a`JR%`#^7XuXcUb)wQsg&n%=l{A40U@OF zw}pB;R#BKMm8X~+V}n7!U<@(W^~Z#Rl^yC{h^ha>6prKF~QL9DDHNUk?| zfwGlZPqO_tH{$*p_#4H7m)@~Oonqy3i?~{?C_LIh4vKpGJ!lf-*v-;`T6_-XEl(yH zzhj{s*}gf?&dVYhS?mWPhz9}}=6+XMOj#ccWrQd~s%t3(rXUT8) zG_tHdCOIw^yb3y^ghg2CpioUs-N#T|%0)JdMC zG->zJ>2FydBL~jC@b1}>EIj~nFR3R-qSnm;kb`-Pt}w0s`8tTJ)J#^JbrJdly`49t zf~^i&R4OM^*2!Snv*4CliVB?maLUbb)>)&EEks6x*=#tbN43jyam}GDp>#I~MZ`X% zR|VyaDI9nN2wDx*-_yf=H=mdQo@l8<2*H`~Q5gm-Dg7s|H@5Kz{ zMUZsoSmXS2P z47?FC%K&JZkn!uw?MuP~cMWtW-c(;muI*5qT;TccHzB$P!BD$^P+C9pTS&zdU^GF6 z1IHK!F6mA4!!I*}hV%SQ^dp_P3BW;wC4kQ>DkrCMRn>9i&J`#^k_o-70+* zX$82rq;V2s^&V5*8|qdq^0~@S#|>;Gp`eHmBPdwR~^$3OQ-2EGbG3 z6Tx9QVMBejvp^U$Pjc_*AT~dEzw9+if-@W{uSx^tVO=Q&dVpOaTv8Qb6d6S*1OnY* zI#r=>bKx;9d&TZo3$QL;HUPAUMn=0Mg*n)62kwA#_%SIQ%&jS~NJ=`+yOM5xaEj|F zc5i|K%N2{k2cjIj8?O4>y=ay*GsyoRiZcY@SxDwBRUUg1RNDNsU}|=Tl|lB1`bLG} z9$|O!sH?nhW1R_-*#S*dKWyEh2q@*hOY685j2-~qP3k{x)be2kNlSB-Z9HGP#?_|>M+m=WX|BG;bg z6Z@_%%s1zgjdP+1`VPLa39UgGJ_DO7) zMVbfrY}8=#aw2{vu~Yat0E`B%jH-x)>y|NliC=X7T;7bOw<^~5%5N5R_D&}=19VXp zNjZd8(Zbq=>jvXb`daPL{goxDSX<1Mi<$sEUl9B`A+dl2`_3mHkhW0mIE}QTSBaN4 z4Eh?8tf#U{zN-9D>_f?k;$BbwBKuD{y&3rM+6B=e%uuOA2w0h5A+^Y0H3Z=f5T~)3 zF5wsS3P3%KS%CH|LVS+v3m8>tjZ#@7`9vVd^dH_j2<&mQzz+Y7#o`HE!;R!9QYbVN z*hgQctKlP(}4OAjDf~ zKE$+0nDCUI4}5=iF3#EZg@~SY1QC(ducKX#QvgwQ=A9fOvu}XWt;{1L6kFr`-tl#v zgJm<^qMqduoysP=A z`q~HAXz5mFiU0NZ%f|shsf!;PD>U03SJ*Wu$-%_qlZF*}e%NK`hoC4#H))qvU{*bT zM6v&-Ox%+bTdMkH@@Gjl>psPn(C(P`Wgw_|_#@?nntt{>KhfnJ90NY7qhPnK(bsI| zld@*{f-bt+Znu?HZ(E-v!Ueox>H(=&e!y8GP^SS+1F{Fr`SqiLUS5V$!b^Qgej4R? z4~0P}6t4Y#Uw#u=khXYfl!`0|DFd#qgb)NMoXb+kt&1Fjn%|(Fwljs`6_Ww>GUx)$ z7y0}(NYVV?RWB0~TbhcET|SqyveU4i>}YmS9q)QzB*)klI(q4tBUm;aeo&x2r2rQK zJEL?tPy&+$8j@HeGYUkgy|INL`uwJ~1pISEaYH!W=XDbL3W^In{4N;;!(e@d|ATKT z*9Z$wNg_3y=IDVO1M7;HSW$-gfGErW(Sj>jNBv8g*O#FK&UiH+$UB!rN;}mC5m$m8 z-1Ci4wli~S(5gCfuY(>9S_7xkY*Ne3QRH*moVXBWqnPFwz&D<=+s>+A`p=iZg|(5w z8w*N1%DN=HCDWtOMMUQj4ZkkR`Ps>>21R3ML;g|O%B2ncarZtzMs=sH=+|A9-0Ey8<6G8_T0UR*BJ|f9qpbS>bKI9D*Z3D%SKXYS?VHQdp2k`#X zz?%1wMMVxA0OL?N0@JZJ%pA6!l_ zkC6l+Fbr^LEGohe;gDXi^<5GZIaBH6{uMi>HT@Ap46AR&aCxY7B$$F*fZOaeVkSxT zWJ5hw{|GiAvAi!L1ax#}wd= z5yxdkB&(QCTKp!ps03-K!-AbHgUcqFJnXFu=v*ZEymaF+-eX#(1GV{0;?z~Q$))$- zvQRb}=Uprj2fabRI={o0c9`rnei3cU$E0oN8!&5{y+=F9m+|9)&IgL47op~ z;OS~iB~;?-`Av$1iR+9=ekXQXS z2?VTNwa*nb0qfR=_D@g@?JCziMla1F(vsi{ny{_oX^Wk|qWO6)9s9e!xAPBw zG^!N%K%(Cy0Z_h8hH(7b0<2@+7pBq}9s{Pxq!8bE2>{9{610b`%dNOhZF+P~BgRbh zJ@@sEu*)1+fRB0_hUEoy%wy5~AngC?gFbuh)K@9C)li1a}92tzcz zCLAcw;(QK^ZB4-I4Akuuc`|-xO_H+V4fRKaZCCBVlKB#KApCGiOqA=UOL|*Y3JSzb zui?%_@zL>dbUZP_b(M%AwPC1crC1IFLlM(d8yDzKlPyrf`-$=7D*4uC{aj?daDnyy zNe&-8Fgp6TqvD@?bOsPk}FV!-RAh2vzHls_!IO z2p*h2ARC`tn4?eF5ivp(hh}>k9eRMC1(`ijHULq}=utLTd5;k5U@DM~U)DVF!3 z)uqj)+v5BF$T!DzWJP)iEvnG^O@w}LbM(x6AB(KI6Qv#@ z|C4|zVwtx)wWQ1`kX|$CZ7^8+m=XT=&!Q@)V_goc$)}*F;Sy`m+)C3{P(2DAl%GCqh@zR# zUFbeuTbY2aQEwk>p0gQ&P-7I>x#UuVt%HO$28G3lBIR^%Rkw6m$?b7 zQ>)nH872C8TN_lP0a(PCpJQ>p*zn{7Eq_b-> z$RI){3sl0_pK_~|5P7_vghGH?Ws5Mz(MUsRu1}a~?plz1@#ig-2zuudo|i$J8Ed+m zy8%fSSu}yvIJo0I9GbOL4W8L-k#;*6Dx_J1o$3!WX=Z(5Vq;q0v+S+1EMXo$H_k|VeE)?=Kl81?7@in{{R_^t{0$Att;R10419~+}9gg8Ht)7PAU^tDHOdYf<<*!U?2w@ z2cFdE3XxV#-Xy#LT&ja?{&6fI@p^4LR3|`2Gr#Qk#AlaM-9nlvoKFjrB$i|Qv$Pv8 zh`@S+5#7=<1;Y^gNGgMa-H%rbX<5Xe@$zuKI$EeSz*+=fppqTyMPBle;38Px*kOaO z=%wF`2X6-b*2yu?0!XYNq#n2co>b7nwuG1Vjcti0N`1CKr&LbGnj+NJQ`PxvX6U8g zS^Whh}TJXqPCKaU3i!(PG?!3Nll40&kUWM z?#M!3>MqR+bxP^Lzl=CW8cX0Vg8+N#78DeJMUz33x|iH?qI7N(f^`OE?rp^_e1`!m zFfLM&5Gl^&ccDV2_S}JGQUnl2cBl$Ix89`ln4JZ|uxLmeEUL`iH=dm>iSz5qVQ%%m zy{ibFNOE&S0~aWW$W=20?jaZ`2E|=fA_%J5Nsx^oP1DjkANKuXtk6)Xqm2ct@SOA%A?(ostQH-G-iCDW*R~@|x!svt3OvbYMNJ0nMXt!e76Tnn{bWTRcGb z9<+Sdss0IH}XN2Wzg$SCJQ< z*n)_yFL==zFc?))qQ;4CnL4sTYrlld=KX3nujW z#koIvdjczDOTivU7&>e*nsVlQM?GRE0r!{10ecP^g*6Kz_ME4A*aB#8$FP8-eYL#D zeX55u^NMUncM{M@FSm;E5?Lkybwg=b3#qDt91T(Eal~TjYs7d| znM_3rh&fwr4MzLS=Zq3Pe5<<;3r=xzIV4hS#aM|^7CiM?8!DFSMu{kV1BzBlw3*JS zJ?~eL*rO}MLKsL^>usE{3M$9K_U#C(olPXfRJg|q%V_v= z<=E_ZEDRtL$S{dgtYScH61o4iqTVM-R|dV=N!5U>t{p^}GOQs`x|osfVZ=t4`a_dk z4KnU95~J|bAjj*qfU|S_t(t74y-jjWSxfi>-8;_Fg{tz9{WQb4Lhk;iIE~30ha?4u zt4Wj6HVQ30h#$YNLibD}q#+8_2AGvre1meQ3 zPm7sPYG4lN41b!$r@4R#$I%rAsiXRxb-9j0}_NEHxwwQM%_G zXcNtiUQqGpV>J%3?sCUe3QwVEcsBZ&4k4>IP1$1uQZ0fg(kv0*9Nk*b#< zvSb?f{cLMbm=u%X#=!HyM%EDrP-fV=;Q&2FYMym5mv-(RvIHgToB-1!^O&&^f0S)V zcIF)N1(;*h42)_bnh@Vz9XFhCZ;G)QV&6{gy(WIIQfm9HSNpx~wqGBL2y0uFa@dyc z2rL|Sv?-n;Yv!6j9()sz%*~$RHdcqAjmoQLgTXrcewGhb6I=-m)Ym z4?7f2Z?Sd7)`@2m2CJ;BCE6*0)bb_i?^MkEDn>*M8TA-~HCV+=GjL_j(s0eJIYDV$ zEzVP81@E(XzJ;}V!`Qgb2ah;=6OC?corvOWsdDs*fL#88IK98V9&dbgEBwJNZ(4Nb zzNchH{aI=OMPOY5p7sPQh~dUd>D}Rc-$+fko0tAEz=r5 z_lM{j8LiBZt;SZ@;iN)$`G4sQGmC=6V|2%k6_jM*`2|uq$`(^K2)OD`W&&jL63~49 z2#VHx%;g;x%{OOc1Mdu$l1E$ghl=G#sQ_(M(qB>s&}}g2-jAUPhw~sofaSi<(9o)! zG&vQxIQ%|8@zGcSH*B3A5sNpPo}we#Qi|=zuubs7(tkT5{>&fSM#s)7dyN2inT1@w z!YR!3MKuAVgvh}kK`^gJ5~vG!PYNF6L2wK-7+`Mm+}T_xkHdNzJN3}xM98E?5gTJ5 zgc+jb>k&;QBO?35_}oGJ_?% znp3HH%Hk;Ar2GTd?Fu|{7)UbC;lp&wU}SDeYC6th_HIXHzE^S*fagMm0>_~U+1gW= zR@m6!)Oy!L0xW~GDhVg9`p(9DC21;UlXb9B zoz0pIW_Xov!<%Y+G?x0Tj&}N+^>hToW$bFn?^x280Pqhon_ChCi&`{eeNwG+VS2_< z9gvab6P03gfJN}2ygY^`jg$8Q0zxwXp}OB*;V5oJ3Z_e{wPHNz95lL#Y0or|kBQ7? z8lRS|I!{fu!VGsSM+Zt5)}H2c9ebWBuvscV=`&ga@I( zleca2Gv%7J_>MNCrD6p8CMG1kWdsMIPOFC1HS5{wP~itGsuBYW^BUL_duA+E-N?|< zqCZ}UINdaDs0c$+l)iP&OI{8v2pT5g-~ugB)!G;^uY5T#pd<^2YQDcD4~RXzY@W;*n=w&=V>JTWcl`_m(!LAOX*j%y-cHO72+mZ?YqYnQLz@%~30~}b9W@L5? zO;33(OySROB${=zzh!V3$5UM7VvmDgHFf8u+hnSIv8KeZqS+K_lwLomsNxH4^Px&#i)AsUc81F!1nS`Cp^h0_g-7a0ZU z06H@;es(^_5zk}Q>_U>_AIzGTmF&i$M=VH7NxKfc00T<6WdV^h40JDOBpu6|tZ3Tp zHJj9l`dFV@%Mf|x=c3+a2_+(EJN!g8mqx61?1$4GV_#mY zv`%8Nhy5;ZMtfY4N-?fEIfZ@JW;YP}kyshChmrGqU;^uPyL;jgQJw~XwX;MH#GNZy z{UeI*s}XSKMK)kmO{f=exl{_+K{uoU9?hn=7^nD0%tK6);fDMs#$oAb!>XAU>A|^S z`H-GNf*n1J$hR)AQJsvOs;<06uUGN}P7^PivYoNh z`+sbai`<+9*Mrm-3Tuz=Jl5g;qNMCeZ~|xm8$HH3+tTpjAz)NdzT3ntIsfa3AwO$v z Date: Wed, 23 Sep 2026 07:36:01 +1000 Subject: [PATCH 051/146] docs(sdk): describe provider API 0.2, packages, screens and publishing --- sdk/README.md | 150 +++++++++++++++++++++++++++----------------------- 1 file changed, 81 insertions(+), 69 deletions(-) diff --git a/sdk/README.md b/sdk/README.md index 5034f71a..999fa011 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,92 +1,104 @@ -# Spool provider SDK: experimental API 0.1 +# Spool provider SDK (API 0.2) -The compatibility revision is exactly `0.1`. Additive fixes retain that revision; a breaking change requires `0.2`. Spool and provider release versions are independent. No historical-version adapter is implemented. +A provider teaches Spool a media source: a server, a service, a folder. It is a +small package of JavaScript (the logic) and optional QML (its own sign-in, +settings and picker screens). Spool runs every provider the same way, whether +bundled with the app, installed from the store or added from a link. -## Worker profile +| File | What it is | +| --- | --- | +| `provider.d.ts` | The contract: `createSource`, every operation, events and the screen context | +| `spool-provider.py` | Builds, validates and describes packages (`build`, `validate`, `feed`) | +| `provider-contract-runner.cpp` | Runs a provider's `tests/contract.mjs` in Qt's JS engine, as Spool does | -An ES module exports `createSource(configuration)`, returning an object whose own methods implement operations. Each operation receives `(arguments, host)` and returns a plain object or a Promise for one. Create per-source state in the factory closure, not in module globals. Use Promise syntax; Node, browser globals and async-function syntax are not part of this baseline. +`spool-player/spool-provider-example` is a complete provider to start from. -`host.http(url, {method, headers, body})` returns a Promise for `{status, body}`. The source's authorised HTTP(S) origins are selected by native code. Redirects are returned to the provider, not automatically followed. Cookies are neither loaded nor saved implicitly. HTTP error statuses remain inspectable; transport failure rejects. Parse and normalise response text in the worker. Never return backend response payloads indiscriminately. - -`host.delay(milliseconds)` supplies a worker-owned one-shot Promise timer for protocol polling/backoff. Delays are limited to 0–10,000 ms and 16 outstanding timers per operation. Timers are cancelled with their owning operation/source; they never become process-global recurring polling. - -Native limits: 8 MiB decoded HTTP responses; 1 MiB request bodies; four concurrent HTTP requests per operation; eight operations per source; 32 active operations and 64 queued submissions per runtime; 16 source objects per runtime. Operation deadline is 15 seconds, transport inactivity deadline 10 seconds, and uninterrupted JS execution budget 500 ms. The watchdog covers Promise continuations as well as direct calls. Exceeding execution budget disables the module; create a fresh runtime for explicit recovery. - -Results are plain owned native values, bounded to 50,000 values, nesting depth 20, arrays of 10,000 elements and 4 MiB of string data. Non-finite and unsafe numeric values are rejected. Represent large counters and exact timestamps as decimal strings. IDs are opaque strings. Source IDs are host authority, not provider-controlled credential selectors. - -Removing a source cancels its outstanding native requests and completes pending operations with an error. Other sources remain available. Shutdown also completes outstanding operations. Native callers receive `QCoro::Task` on their calling thread. There are no cross-thread `QJSValue` objects. - -This is a trusted/reviewed in-process execution profile, **not a sandbox**. Durable secret/storage services, module manager and UI-to-worker RPC are not yet public services. Do not declare that the full portable-provider plan is implemented by this runtime alone. - -## Contract runner - -Build this directory with CMake and the host Qt development environment: +## Packages ``` -cmake -S sdk -B build/provider-sdk -cmake --build build/provider-sdk -build/provider-sdk/provider-contract-runner /path/to/provider/tests/contract.mjs -QV4_FORCE_INTERPRETER=1 build/provider-sdk/provider-contract-runner /path/to/provider/tests/contract.mjs +manifest.json format 2 (below) +LICENSE, NOTICE +logic/*.mjs entry module exporting createSource(configuration, host) +ui/*.qml optional screens named in manifest.ui +assets/ icon and anything else the screens show ``` -The test module exports `run()`, returning a Promise or throwing on failure. It runs in a real QJSEngine, not Node. The host's runtime tests additionally exercise actual asynchronous HTTP, thread ownership, source isolation, cancellation and runaway JS. Put a process timeout around external contract tests: this small test runner is not the production worker watchdog. - -## Source packages - -`tools/provider-package.py build PATH --output provider.zip` creates a deterministic source ZIP. `validate provider.zip` checks its manifest, required host features, known permissions, declared UI components/imports, paths, file types and size limits. Unknown offered and optional extensions are allowed; missing required extensions reject the package. Every QML component must be listed so it can be validated and warmed. - -Validation does not authenticate publisher identity. A release digest or provenance attestation is not a substitute for an authenticated catalogue with freshness, rotation and revocation. The application does not yet install or activate these ZIPs. Do not enable downloaded code in store builds on the strength of this tool. - -## Bundled sources and development overrides - -`providers/lock.json` pins the provider repository revision, source ZIP size and SHA-256. Normal CMake configuration validates the checked-in ZIP and embeds the exact source files under `qrc:/providers//`. It never downloads a mutable latest release. The bundled Jellyfin source contract is executed in both normal and interpreter-only Qt modes by ctest. +```json +{ + "format": 2, "api": "0.2", + "id": "publisher.name", "name": "Shown name", "version": "1.2.3", + "summary": "One line, up to 120 characters", "publisher": "You", "homepage": "https://…", + "icon": "assets/icon.svg", "entry": "logic/provider.mjs", + "capabilities": ["search", "userState", "reporting", "segments", "streamQuality", "trickplay", + "discovery", "groupPlayback", "remoteControl"], + "origins": ["https://api.example.org"], + "ui": { "login": "ui/Login.qml", "settings": "ui/Settings.qml", "picker": "ui/Picker.qml" }, + "actions": [{ "id": "playlist", "label": "Add to playlist", "icon": "playlist_add", "types": ["Movie"] }] +} +``` -For unreleased provider development, configure `-DSPOOL_JELLYFIN_SOURCE_DIR=/absolute/path/to/spool-jellyfin`. This explicit local override uses the same source validation and resource construction path, without changing the committed pin or fetching/tagging a release. CMake watches provider logic, UI, resources and manifest changes. Clear the cache option to return to pinned bytes. +- A provider with a `login` screen needs an account; one without is added straight away. +- `origins` are reachable by every account; `*` allows any HTTP(S) origin. Anything else an account + reaches is what its login screen allowed with `provider.allowOrigin(url)`. +- `actions` appear in the item menu for the listed types and run through `runItemAction`. +- Packages are `.tar.zst` (ustar, zstd), at most 16 MiB, 512 files, 32 MiB expanded. Paths are + relative, without hidden parts, of the listed types; links and native binaries are refused. -Bundling this alpha source does not switch normal app operations away from the existing native Jellyfin implementation; application-level migration remains unfinished. Runtime ZIP installation still needs authenticated metadata and transactional activation/recovery. +``` +python3 sdk/spool-provider.py build path/to/provider # dist/-.tar.zst +python3 sdk/spool-provider.py validate dist/-.tar.zst +python3 sdk/spool-provider.py feed dist/-.tar.zst --url https://…/-.tar.zst +``` -## Native source identity +Building is reproducible. It needs Python 3.14, or the `zstd` command on older Pythons. -`ProviderRegistry` owns one runtime per registered portable module and maintains persistent source UUIDs in the existing durable database, separate from disposable caches. A host-created `(module ID, account ID, source key)` identifies the configured source across launches; changing a token, authorised origin or display label does not change that UUID. Account/source keys must be opaque identifiers, not credential-bearing URLs. +## Running -`restoreSources()` restores identity and enable/disable metadata only. `configureSource()` supplies current configuration/credentials and native-authorised origins explicitly; these are not copied into the public source index or its QML snapshot. `callSource()` retains the exact source generation across the coroutine boundary. Disabling/removing a source cancels its work without changing other accounts, and removing a source does not remove the account or its credentials. Interrupted modules become unavailable without resetting other modules. +Each provider module gets one worker thread and QJSEngine; `createSource` is called once per account +with that account's configuration and a host that lives as long as the account. Keep account state in +that closure. Operations are called as `operation(args, host)` and return a plain value or a Promise. +Throw `new Error('snake_case_code')` to fail: the code reaches Spool (`http_401` asks the viewer to sign +in again), anything else becomes `provider_error`. ES2020 modules and Promises only: no `async`/`await`, +no Node or browser globals, and Qt's engine lacks some newer built-ins such as `Array.prototype.flatMap`. -The application registers its bundled portable module and restores this index after the first frame. Automatic migration of legacy account configuration and normal application browsing/playback to these contexts is not complete. The `Sources` QML singleton currently exposes only the cached public metadata snapshot; it does not expose arbitrary cross-source worker calls to provider UI. +| Limit | | +| --- | --- | +| Uninterrupted script | 500 ms; exceeding it turns the module off until restarted | +| Operation | settles within 15 s; eight in flight per account | +| HTTP | four at once per operation, 1 MiB bodies, 8 MiB responses, redirects returned not followed, no cookies | +| Sockets | `host.socket` on the source host, four per account | +| Timers | `host.delay`: 0–60 s on the source host, 0–10 s in an operation, 16 pending | +| Results | 50,000 values, depth 20, arrays of 10,000, 4 MiB of text; ticks as decimal strings | -## Source-bound UI action bridge +This is a reviewed, in-process profile, not a sandbox: install providers you trust. -`ProviderUiContext` is a native-owned action object constructed with one source and the GUI QML engine. Pass it as an initial property when mounting a trusted provider component; do not hand the component the global registry. `request(operation, arguments)` returns a GUI-engine Promise whose worker result is bounded to 512 values/64 KiB before JS conversion. +## Screens -Use `requestList(operation, arguments, append)` for bulk rows. The operation returns `{items: [...], ...smallPageMetadata}`; `items` moves into the context's native `rows` model, while the Promise resolves with just page metadata after insertion completes. The model exposes `record` and `title` roles, commits at most 32 rows per event-loop turn and retains at most 10,000 rows. Role reads do not invoke backend JS. Providers should use virtualised ListView/GridView delegates and plain text for untrusted labels. +A screen is mounted with a `provider` property (`ScreenContext` in `provider.d.ts`) and may +`import QtQuick`, `QtQuick.Layouts`, `QtQuick.Controls`, `QtQml`, `QtQml.Models` and `Spool` (the +app's theme, metrics, input keys and primitives). `request()` calls an operation of this account; +`requestList()` streams `items` into the native `rows` model; `complete()` or `close()` settles the +screen once. Map error codes to your own words. -`complete(result)` returns an action result with native `sourceId` authority; `close()` returns cancellation. Both settle once. Dismissing a component must call `close()`, and destroying the native context cancels outstanding work. Each context owns a separate native operation scope: closing a picker does not remove its source or cancel another picker on that source. Source removal/disable closes its open contexts. +## Testing -The `tests/providers/fixtures/Selection.qml` action exercises a 2,000-row virtualised list, async completion and dismissal. This bridge is not yet the full negotiated UI-host extension: shared routing, focus restoration, localisation/notification services and real Jellyfin component migration remain incomplete. Packages requiring `ui-host`, `native-list` or `Spool.Ui` continue to fail compatibility validation until that full public surface is provided. +``` +cmake -S sdk -B build/sdk && cmake --build build/sdk +build/sdk/provider-contract-runner tests/contract.mjs +QV4_FORCE_INTERPRETER=1 build/sdk/provider-contract-runner tests/contract.mjs +``` -## Native typed listing path (integration in progress) +`tests/contract.mjs` exports `run()`, which returns a Promise or throws; the runner prints why a +contract failed and gives up after 10 seconds. -`ScriptRuntime::callMediaPage()` and the generation-guarded -`ProviderRegistry::callSourceMediaPage()` return `QCoro::Task`. -The operation still returns the existing normalized JS page shape -`{items, cursor, total, exhausted}`. The worker validates/converts those fields -**directly** into native-owned media items; it does not first create a generic -`QVariantMap` tree and then convert all rows on the GUI thread. Small RPCs and -custom UI actions retain `callSource()` and their existing bounded native maps. +## Publishing -Each returned item carries the host-created source ID alongside `MovieItem` -and its complete normalized external-ID map. Keep that envelope through -routing/caching; do not discard it simply to fit an old single-source model. -Ticks accept safe nonnegative JS integers or exact decimal strings within -signed 64-bit range. Unknown totals stay optional and cursors stay opaque. -The caller selects a row bound (100 by default, hard ceiling 1,000); this is an -allocation/safety limit, not evidence that a 1,000-row page meets any latency -budget. Source removal, operation-scope cancellation and watchdog behaviour -are shared with ordinary calls. +Attach the package and its `spool-provider.json` (the `feed` output) to each release. Spool can then +install it from a link to the repository: GitHub resolves to +`releases/latest/download/spool-provider.json`, GitLab to +`-/releases/permalink/latest/downloads/spool-provider.json`, and any other site to +`/spool-provider.json` at the address given. Installed providers are updated from the same place. -This decoder covers the current provider's **listing** schema. It is not a -completed catalog adapter, details/variant/playback decoder, artwork credential -bridge, account bootstrap, or native/JS application switch. Add those pieces -and test against real Jellyfin before describing the portable client as wired. -The existing native Jellyfin implementation remains the normal application -path. These host-side types are experimental internals, not a new public SDK -version or a reason to rebuild the portable provider ZIP. +To be listed in the store, open a pull request on `spool-player/spool-providers` adding +`providers/.json` with that feed entry. CI downloads the package, checks its digest and validates +it; once merged, the store site is rebuilt and the provider appears in Spool. From 40740422b81685e80a3a06c711962cc966d7a1dc Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:59:53 +1000 Subject: [PATCH 052/146] fix(shell): issues found running the app against a real Jellyfin - Provider screens never loaded: the cached route page gets its context after construction, so the surface now (re)loads on context changes - Home stayed empty after the first sign-in: a change with no item (an account came or went) now reloads libraries and home rows - A launch with saved accounts opened the add-provider page: accounts are announced before restore completes, so the first route sees them - The details menu now lists the provider's item actions - Creating or joining a group dropped the server's joined event, which arrives over the socket before the request returns - Remove Settings.loadRemote() calls (native client) and the unused ProviderPickerPage.qml --- qml/pages/AddProviderPage.qml | 2 +- qml/pages/ItemDetailsPage.qml | 27 +++ qml/pages/ProviderPickerPage.qml | 305 ---------------------------- qml/pages/SettingsPage.qml | 1 - qml/pages/SubtitleSettingsPanel.qml | 9 +- qml/shell/ProviderSurface.qml | 25 ++- src/app/AppController.cpp | 11 +- src/app/GroupPlaybackController.cpp | 31 ++- src/provider/ProviderRegistry.cpp | 4 +- 9 files changed, 83 insertions(+), 332 deletions(-) delete mode 100644 qml/pages/ProviderPickerPage.qml diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml index 849030ee..8b97276a 100644 --- a/qml/pages/AddProviderPage.qml +++ b/qml/pages/AddProviderPage.qml @@ -354,7 +354,7 @@ FocusScope { AppText { Layout.fillWidth: true - text: row.isLink ? "Add from a link" : row.modelData.name + text: row.isLink ? "Add from a link" : row.modelData.name || "" font.pixelSize: Metrics.bodySizePx + Metrics.scaled(1) font.weight: Font.DemiBold elide: Text.ElideRight diff --git a/qml/pages/ItemDetailsPage.qml b/qml/pages/ItemDetailsPage.qml index 4a51ce8c..461ff361 100644 --- a/qml/pages/ItemDetailsPage.qml +++ b/qml/pages/ItemDetailsPage.qml @@ -818,6 +818,11 @@ FocusScope { const options = [] if (showContextPlaybackActions) options.push(playAllOption, shuffleOption) + for (let index = 0; index < providerActionOptions.count; ++index) { + const option = providerActionOptions.itemAt(index) + if (option) + options.push(option) + } if (mediaInfoAvailable) options.push(mediaInfoOption) if (showLetterboxdAction) @@ -853,6 +858,11 @@ FocusScope { } } + function runProviderAction(actionId) { + overflowOpen = false + Sources.runItemAction(actionId, String(item.movieId || ""), typeText) + } + function openMediaInfo() { overflowOpen = false if (shell) @@ -998,6 +1008,8 @@ FocusScope { openMediaInfo() else if (option === letterboxdOption) openLetterboxd() + else if (option && option.providerAction) + runProviderAction(option.providerAction) else if (option) openExternalUrl(option.externalUrl) } else { @@ -1624,6 +1636,21 @@ FocusScope { onActivated: root.playDetailContext(true) } + // Whatever else the item's provider does with it: playlists, + // collections, renaming. Declared in its manifest, so free to list. + Repeater { + id: providerActionOptions + model: root.overflowOpen ? Sources.itemActions(String(root.item.movieId || ""), root.typeText) : [] + + delegate: MenuOption { + required property var modelData + readonly property string providerAction: String(modelData.id || "") + iconName: String(modelData.icon || "more_horiz") + label: String(modelData.label || "") + onActivated: root.runProviderAction(providerAction) + } + } + MenuOption { id: mediaInfoOption visible: root.mediaInfoAvailable diff --git a/qml/pages/ProviderPickerPage.qml b/qml/pages/ProviderPickerPage.qml deleted file mode 100644 index 4293aec5..00000000 --- a/qml/pages/ProviderPickerPage.qml +++ /dev/null @@ -1,305 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../theme" -import "../primitives" -import "../providers/jellyfin" - -// The provider selection page: choose a media backend or download the -// portable Jellyfin provider before proceeding to authentication or browsing. -FocusScope { - id: root - - property var shell - - readonly property bool keyboardVisible: Qt.inputMethod.visible - readonly property bool dense: keyboardVisible || Metrics.units(height) < 620 - readonly property var providers: Sources.availableProviders - readonly property int providerCount: providers.length - readonly property bool canGoBack: Router.canGoBack || (shell && shell.canCancelSwitchUser) - - readonly property int tileSize: { - const preferred = Metrics.scaled(Metrics.laneAtLeast(width, "wide") ? 176 : Metrics.laneAtLeast(width, - "regular") - ? 156 : 132) - return Math.max(Metrics.scaled(80), Math.min(preferred, width - cellPadding * 2)) - } - readonly property int cellPadding: Metrics.scaled(16) - - focus: true - - function focusDefault() { - if (grid.currentIndex < 0 && grid.count > 0) - grid.currentIndex = 0 - InputKeys.focus(grid) - } - - Component.onCompleted: { - focusDefault() - } - - Connections { - target: Sources - function onProviderInstalled(moduleId) { - if (moduleId === "spool.jellyfin") { - Sources.switchProvider("jellyfin") - Router.reset("login") - } - } - } - - Item { - id: brandHeader - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.leftMargin: Metrics.pageMarginPx - anchors.rightMargin: Metrics.pageMarginPx - anchors.topMargin: Metrics.pageMarginPx - height: brandPanel.implicitHeight - - LoginBrandPanel { - id: brandPanel - anchors.fill: parent - dense: root.dense - backVisible: root.canGoBack - onBackRequested: { - if (Router.canGoBack) - Router.back() - else - Router.reset("login") - } - } - } - - Item { - id: content - anchors.left: parent.left - anchors.right: parent.right - anchors.top: brandHeader.bottom - anchors.bottom: parent.bottom - anchors.margins: Metrics.pageMarginPx - - ColumnLayout { - id: centerColumn - anchors.centerIn: parent - width: Math.min(parent.width, Math.max(Metrics.scaled(320), grid.width)) - spacing: Metrics.scaled(18) - - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Metrics.scaled(6) - - AppText { - Layout.alignment: Qt.AlignHCenter - text: "Choose a provider" - font.pixelSize: Metrics.titleSizePx - font.weight: Font.DemiBold - horizontalAlignment: Text.AlignHCenter - } - - AppText { - Layout.alignment: Qt.AlignHCenter - text: "Select a media source to connect to Spool" - color: Theme.textSecondary - font.pixelSize: Metrics.bodySizePx - horizontalAlignment: Text.AlignHCenter - } - } - - GridView { - id: grid - Layout.alignment: Qt.AlignHCenter - - readonly property int cellSpan: root.tileSize + root.cellPadding * 2 - readonly property int columnsInUse: Math.max(1, Math.min(root.providerCount, Math.floor((content.width - - Metrics.scaled( - 32)) / cellSpan))) - - width: cellSpan * columnsInUse - height: root.tileSize + Metrics.scaled(root.dense ? 70 : 100) + root.cellPadding - cellWidth: cellSpan - cellHeight: height - clip: false - focus: true - keyNavigationEnabled: false - boundsBehavior: Flickable.StopAtBounds - model: root.providers - currentIndex: 0 - - function activateCurrent() { - if (currentItem) - currentItem.activate() - } - - Keys.onLeftPressed: { - if (currentIndex > 0) - currentIndex-- - } - Keys.onRightPressed: { - if (currentIndex < count - 1) - currentIndex++ - } - Keys.onSelectPressed: activateCurrent() - Keys.onEnterPressed: activateCurrent() - Keys.onReturnPressed: activateCurrent() - - FastWheelHandler { - flickable: grid - } - - delegate: Item { - id: cell - required property var modelData - required property int index - - width: grid.cellWidth - height: grid.cellHeight - - function activate() { - tileScope.accepted() - } - - FocusScope { - id: tileScope - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: root.cellPadding - width: root.tileSize - height: root.tileSize + Metrics.scaled(80) - focus: cell.GridView.isCurrentItem && grid.activeFocus - - readonly property bool isInstalled: Boolean(cell.modelData.installed) - readonly property bool isJellyfin: cell.modelData.id === "spool.jellyfin" - readonly property bool isDownloading: isJellyfin && Sources.isInstalling - - signal accepted - - onAccepted: { - grid.currentIndex = cell.index - InputKeys.focus(grid) - if (isJellyfin) { - if (isInstalled) { - Sources.switchProvider("jellyfin") - Router.reset("login") - } else if (!isDownloading) { - Sources.installProvider("spool.jellyfin") - } - } else if (cell.modelData.id === "local") { - Sources.switchProvider("local") - Router.reset("home") - } - } - - Rectangle { - id: tileAvatar - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - width: root.tileSize - height: root.tileSize - radius: Theme.radiusLarge - color: tileScope.focus ? Theme.bgRaised : Theme.bg - border.width: tileScope.focus ? Theme.focusBorderWidth : hoverArea.containsMouse - ? Theme.hoverBorderWidth : 1 - border.color: tileScope.focus ? Theme.accent : hoverArea.containsMouse ? Theme.borderRaised : - Theme.border - antialiasing: true - - MaterialIcon { - anchors.centerIn: parent - name: cell.modelData.id === "local" ? "video_library" : "dns" - iconSize: Math.round(root.tileSize * 0.42) - iconColor: tileScope.focus ? Theme.accent : Theme.textPrimary - visible: !tileScope.isDownloading - } - - // Download progress spinner/indicator - ColumnLayout { - anchors.centerIn: parent - visible: tileScope.isDownloading - spacing: Metrics.scaled(8) - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - name: "cloud_download" - iconSize: Math.round(root.tileSize * 0.36) - iconColor: Theme.accent - } - - AppText { - Layout.alignment: Qt.AlignHCenter - text: Sources.installProgress + "%" - font.pixelSize: Metrics.metaSizePx - font.weight: Font.Bold - color: Theme.accent - } - } - } - - // Focus ring offset - Rectangle { - anchors.fill: tileAvatar - anchors.margins: -Metrics.scaled(5) - radius: tileAvatar.radius + Metrics.scaled(5) - color: "transparent" - border.width: Theme.focusRingWidth - border.color: Theme.accent - visible: tileScope.focus - antialiasing: true - } - - ColumnLayout { - anchors.top: tileAvatar.bottom - anchors.topMargin: Metrics.scaled(10) - anchors.left: parent.left - anchors.right: parent.right - spacing: Metrics.scaled(3) - - AppText { - Layout.fillWidth: true - text: String(cell.modelData.name || "") - font.pixelSize: Metrics.bodySizePx - font.weight: Font.DemiBold - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - } - - AppText { - Layout.fillWidth: true - text: tileScope.isDownloading ? Sources.installStatus : tileScope.isInstalled ? "Ready" : - "Download & Install" - font.pixelSize: Metrics.metaSizePx - color: tileScope.isDownloading ? Theme.accent : tileScope.isInstalled - ? Theme.textSecondary : Theme.accent - font.weight: tileScope.isInstalled ? Font.Normal : Font.Medium - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - } - } - - MouseArea { - id: hoverArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - tileScope.accepted() - } - } - } - } - } - - // Error display if installation failed - AppText { - Layout.alignment: Qt.AlignHCenter - visible: Sources.installError.length > 0 - text: Sources.installError - color: Theme.errorText - font.pixelSize: Metrics.metaSizePx - horizontalAlignment: Text.AlignHCenter - } - } - } -} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index d87e330c..e23051a7 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -596,7 +596,6 @@ FocusScope { // Only take focus if the page is actually active: the route host // prewarms an invisible instance, which must not steal focus. Component.onCompleted: Qt.callLater(function () { - Settings.loadRemote() ensureRowsBuilt() if (activeFocus) focusEntry() diff --git a/qml/pages/SubtitleSettingsPanel.qml b/qml/pages/SubtitleSettingsPanel.qml index 1afa3c6e..60dbd2e0 100644 --- a/qml/pages/SubtitleSettingsPanel.qml +++ b/qml/pages/SubtitleSettingsPanel.qml @@ -338,9 +338,8 @@ FocusScope { focus: true onActiveFocusChanged: if (activeFocus) - focusRow(list.currentIndex) + focusRow(list.currentIndex) Component.onCompleted: { - Settings.loadRemote() rebuildRows() } @@ -427,7 +426,7 @@ FocusScope { bottomMargin: root.choiceVisible && choiceLoader.item ? choiceLoader.item.panelHeight + Metrics.scaled(16) : 0 onCountChanged: { if (root.pendingFocusIndex < 0 || count !== root.rows.length) - return + return const targetIndex = root.pendingFocusIndex root.pendingFocusIndex = -1 Qt.callLater(function () { @@ -436,7 +435,7 @@ FocusScope { } onAccepted: index => root.activateRow(index) onEdgeUp: if (!root.overVideo && root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() delegate: Item { id: delegateItem @@ -532,7 +531,7 @@ FocusScope { font.family: { const value = String(Settings.values["subtitles/font"] || "") if (value.indexOf("system:") === 0) - return value.slice(7) + return value.slice(7) return value === "interface" ? Typography.sans : Typography.subtitle } font.pixelSize: Metrics.scaled(26) * Number(Settings.values["subtitles/scalePercent"] || 100) / 100 diff --git a/qml/shell/ProviderSurface.qml b/qml/shell/ProviderSurface.qml index 54e4e4c2..bc11c444 100644 --- a/qml/shell/ProviderSurface.qml +++ b/qml/shell/ProviderSurface.qml @@ -13,10 +13,26 @@ FocusScope { property bool overlay: false readonly property var provider: context ? Providers.modules.find(m => m.id === context.moduleId) : null readonly property Item screen: loader.item + property var loadedContext: null signal finished focus: true + // The route page is cached and its context arrives after construction, + // so a screen is (re)loaded whenever the context changes. + onContextChanged: load() + function load() { + if (context === loadedContext) + return + loadedContext = context || null + if (loadedContext) + loader.setSource(loadedContext.component, { + "provider": loadedContext + }) + else + loader.source = "" + } + // Provider screens are ordinary QML forms. Where they route keys // themselves they get first say; otherwise Up and Down walk the focus // chain, which is enough for a D-pad to reach every field and button. @@ -56,7 +72,7 @@ FocusScope { } Connections { - target: root.context + target: root.context || null function onFinished() { root.finished() } @@ -122,12 +138,7 @@ FocusScope { anchors.topMargin: Metrics.scaled(12) focus: true asynchronous: true - Component.onCompleted: { - if (root.context) - setSource(root.context.component, { - "provider": root.context - }) - } + Component.onCompleted: root.load() onLoaded: InputKeys.focus(item) onStatusChanged: if (status === Loader.Error && root.context) { App.toastMessage("This provider's screen could not be opened") diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 77c65be2..b0521609 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -145,10 +145,15 @@ AppController::AppController( connect(m_provider, &Provider::toastRequested, this, &AppController::showToast); connect(m_provider, &Provider::errorOccurred, this, &AppController::setErrorText); connect(m_provider, &Provider::contentChanged, this, [this](const QString& changedItemId) { - if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) + if (!changedItemId.isEmpty() && m_browse->descriptor().id == changedItemId) { goHome(); - else - beginBrowse(); + return; + } + // No item: an account came or went, or a whole library changed, so + // the library list and home rows are rebuilt too. + if (changedItemId.isEmpty()) + loadLibraries(); + beginBrowse(); }); connect(m_provider, &Provider::sessionStarted, this, [this]() { m_home->loadCachedPayload(); diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index 385fb7ec..72dfa31f 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -162,13 +162,18 @@ void GroupPlaybackController::refreshGroups() void GroupPlaybackController::createGroup(const QString& accountId, const QString& name) { + // The server announces the group over the account's socket before the + // request returns, so events from this account count from now. + m_account = accountId; Async::runScoped( this, m_hub->call(accountId, QStringLiteral("groupCreate"), { { QStringLiteral("name"), name } }), - [this, accountId](QVariantMap) { - m_account = accountId; - beginTimeSync(); + [this](QVariantMap) { beginTimeSync(); }, + [this](const std::exception_ptr& error) { + if (m_groupId.isEmpty()) + m_account.clear(); + emit errorText(exceptionMessage(error)); }, - [this](const std::exception_ptr& error) { emit errorText(exceptionMessage(error)); }, "group create"); + "group create"); } void GroupPlaybackController::joinGroup(const QString& groupId) @@ -176,23 +181,31 @@ void GroupPlaybackController::joinGroup(const QString& groupId) const QString account = m_hub->accountOf(groupId); if (account.isEmpty()) return; + // As with creating: the joined event can arrive before the reply. + m_account = account; Async::runScoped( this, m_hub->call(account, QStringLiteral("groupJoin"), { { QStringLiteral("groupId"), SourceHub::rawId(groupId) } }), - [this, account, groupId](QVariantMap) { - m_account = account; - m_groupId = SourceHub::rawId(groupId); + [this, groupId](QVariantMap) { m_player->setSyncPlaybackSpeed(1.0); + beginTimeSync(); + if (!m_groupId.isEmpty()) + return; + m_groupId = SourceHub::rawId(groupId); for (const QVariant& group : std::as_const(m_groups)) { if (group.toMap().value(QStringLiteral("id")) == groupId) applyInfo(group.toMap()); } m_joinedAtServerMs = serverNowMs(); - beginTimeSync(); emit groupChanged(); sendPlayerBufferingState(true); }, - [this](const std::exception_ptr& error) { emit errorText(exceptionMessage(error)); }, "group join"); + [this](const std::exception_ptr& error) { + if (m_groupId.isEmpty()) + m_account.clear(); + emit errorText(exceptionMessage(error)); + }, + "group join"); } void GroupPlaybackController::leaveGroup() diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index 6a2d0e6b..ace81f72 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -254,8 +254,10 @@ QCoro::Task ProviderRegistry::restore() persist(true); } m_restored = true; - emit restoredChanged(); + // Accounts first: the shell picks its first route when restored changes, + // from bindings on the account list. emit accountsChanged(); + emit restoredChanged(); for (const ProviderAccount& account : m_accounts) { if (account.enabled) Async::runScoped(this, start(account.id), [] { }, [](const std::exception_ptr&) { }, "provider start"); From 46b5ade74314b1d11d3b334af5c5def3553382e4 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:59:55 +1000 Subject: [PATCH 053/146] fix(build): key the QML disk cache on the bundled providers Reproducible builds stamp every resource with SOURCE_DATE_EPOCH, so a changed bundle within one app version loaded stale compiled provider code. Provider tests run without the disk cache for the same reason; CMake now reconfigures when an overridden provider file changes. --- CMakeLists.txt | 11 ++++++++++- cmake/ProviderBundle.cmake | 9 +++++++++ src/main.cpp | 6 ++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bf7fba7c..68961f5f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -781,6 +781,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) provider-qml-cache tests/providers/ProviderQmlCacheTest.cpp provider-package-unpack tests/providers/ProviderPackageTest.cpp stream-quality-control tests/providers/StreamQualityControlTest.cpp + live-jellyfin tests/providers/LiveJellyfinTest.cpp ) set(JELLYFIN_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp @@ -865,6 +866,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endforeach() endforeach() + # Test binaries share one QML disk cache across builds whose bundled + # provider resources all carry the same reproducible timestamp. + foreach(test_name IN LISTS JELLYFIN_TESTS_providers) + if(NOT test_name MATCHES "/") + set_property(TEST ${test_name} APPEND PROPERTY ENVIRONMENT "QML_DISABLE_DISK_CACHE=1") + endif() + endforeach() add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${JELLYFIN_TEST_ENV}") target_sources(providers-tests PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) @@ -877,7 +885,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") add_test(NAME bundled-jellyfin-interpreter COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") - set_tests_properties(bundled-jellyfin-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1") + set_tests_properties(bundled-jellyfin bundled-jellyfin-interpreter PROPERTIES ENVIRONMENT "QML_DISABLE_DISK_CACHE=1") + set_property(TEST bundled-jellyfin-interpreter APPEND PROPERTY ENVIRONMENT "QV4_FORCE_INTERPRETER=1") if(JELLYFIN_NATIVE_WEBOS) set(JELLYFIN_FFMPEG_AUDIT_PLATFORM webos) elseif(APPLE) diff --git a/cmake/ProviderBundle.cmake b/cmake/ProviderBundle.cmake index 30d8e5ca..034f84e4 100644 --- a/cmake/ProviderBundle.cmake +++ b/cmake/ProviderBundle.cmake @@ -15,6 +15,7 @@ function(spool_bundle_providers target) string(JSON count LENGTH "${json}" providers) math(EXPR last "${count} - 1") set(qrc "\n") + set(digest "") foreach(index RANGE 0 ${last}) string(JSON id GET "${json}" providers ${index} id) string(JSON archive GET "${json}" providers ${index} archive) @@ -34,6 +35,7 @@ function(spool_bundle_providers target) foreach(source IN LISTS sources) get_filename_component(directory "${root}/${source}" DIRECTORY) file(COPY "${override}/${source}" DESTINATION "${directory}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${override}/${source}") endforeach() else() set(package "${CMAKE_CURRENT_SOURCE_DIR}/providers/${archive}") @@ -53,6 +55,8 @@ function(spool_bundle_providers target) string(APPEND qrc " \n") foreach(file IN LISTS files) string(APPEND qrc " ${root}/${file}\n") + file(SHA256 "${root}/${file}" file_digest) + string(APPEND digest "${id}/${file}:${file_digest}\n") endforeach() string(APPEND qrc " \n") endforeach() @@ -60,5 +64,10 @@ function(spool_bundle_providers target) file(CONFIGURE OUTPUT "${out}/providers.qrc" CONTENT "${qrc}" @ONLY) qt_add_resources(resources "${out}/providers.qrc") target_sources(${target} PRIVATE ${resources}) + # Reproducible builds give every resource the same timestamp, so the QML + # disk cache cannot tell one bundle from the next; this names the bundle. + string(SHA256 digest "${digest}") + string(SUBSTRING "${digest}" 0 12 digest) + target_compile_definitions(${target} PRIVATE SPOOL_PROVIDER_BUNDLE_DIGEST="${digest}") set(SPOOL_PROVIDER_BUNDLE_SOURCES ${resources} PARENT_SCOPE) endfunction() diff --git a/src/main.cpp b/src/main.cpp index 61ce4d93..b300c2f2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -205,8 +205,10 @@ FILE *openAppLogFile(const QString& appRootPath) void configurePersistentStartupCaches(const QString& cacheRoot) { const QString qtShaderCache = QDir(cacheRoot).filePath(QStringLiteral("qtshadercache")); - const QString qmlDiskCache - = QDir(cacheRoot).filePath(QStringLiteral("qmlcache-") + QString::fromLatin1(kAppVersion)); + // Resource timestamps are fixed in reproducible builds, so compiled QML + // and provider modules are only valid for this exact version and bundle. + const QString qmlDiskCache = QDir(cacheRoot).filePath(QStringLiteral("qmlcache-") + QString::fromLatin1(kAppVersion) + + QLatin1Char('-') + QStringLiteral(SPOOL_PROVIDER_BUNDLE_DIGEST)); QDir().mkpath(qtShaderCache); QDir().mkpath(qmlDiskCache); From fc38cbc58ebb0425456789f605eaf1aba416199b Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:59:55 +1000 Subject: [PATCH 054/146] test(provider): opt-in live Jellyfin test through registry and hub Set SPOOL_LIVE_JELLYFIN (and SPOOL_LIVE_USER/SPOOL_LIVE_PASSWORD) to run sign-in, catalogue, item state, playback and stream fetch, reporting, remote commands, SyncPlay and item actions against a real server. --- tests/providers/LiveJellyfinTest.cpp | 220 +++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/providers/LiveJellyfinTest.cpp diff --git a/tests/providers/LiveJellyfinTest.cpp b/tests/providers/LiveJellyfinTest.cpp new file mode 100644 index 00000000..251b790b --- /dev/null +++ b/tests/providers/LiveJellyfinTest.cpp @@ -0,0 +1,220 @@ +#include "TestMain.h" +#include "cache/DatabaseManager.h" +#include "provider/PlaybackSource.h" +#include "provider/ProviderRegistry.h" +#include "provider/ProviderUiContext.h" +#include "provider/SourceHub.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +using namespace JellyfinNative; + +namespace { + +void require(bool condition, const char *message) +{ + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void waitUntil(const std::function& condition, const char *message, int milliseconds = 10000) +{ + QElapsedTimer timeout; + timeout.start(); + while (!condition() && timeout.elapsed() < milliseconds) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(2); + } + require(condition(), message); +} + +// Another Jellyfin client, for the things only a second client can do. +QByteArray admin(QNetworkAccessManager& network, const QByteArray& method, const QString& url, const QString& token, + const QByteArray& body = {}) +{ + QNetworkRequest request { QUrl(url) }; + request.setRawHeader("Authorization", + "MediaBrowser Client=\"live-test\", Device=\"admin\", DeviceId=\"live-admin\", Version=\"1\", Token=\"" + + token.toUtf8() + '"'); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + QNetworkReply *reply = network.sendCustomRequest(request, method, body); + QCoro::waitFor(qCoro(reply).waitForFinished()); + reply->deleteLater(); + return reply->readAll(); +} + +} // namespace + +// The bundled Jellyfin provider against a real server, end to end through the +// registry and hub. Opt-in: set SPOOL_LIVE_JELLYFIN to a server that has user +// SPOOL_LIVE_USER / SPOOL_LIVE_PASSWORD and a movies library. +JELLYFIN_TEST_MAIN("live-jellyfin") +{ + QCoreApplication app(argc, argv); + const QString server = qEnvironmentVariable("SPOOL_LIVE_JELLYFIN"); + if (server.isEmpty()) { + std::cout << "SPOOL_LIVE_JELLYFIN not set; skipped\n"; + return 0; + } + const QString user = qEnvironmentVariable("SPOOL_LIVE_USER", QStringLiteral("alice")); + const QString password = qEnvironmentVariable("SPOOL_LIVE_PASSWORD"); + + QTemporaryDir directory; + qputenv("JELLYFIN_CREDENTIAL_STORE_DIR", directory.filePath(QStringLiteral("credentials")).toUtf8()); + DatabaseManager database; + require(database.initialize(directory.filePath(QStringLiteral("cache.sqlite"))), "database opens"); + ProviderRegistry registry(&database); + registry.setInstallDirectory(directory.filePath(QStringLiteral("providers"))); + registry.setRuntimeEnvironment({ { "id", "spool-live-test" }, { "name", "Spool live test" }, { "app", "Spool" }, + { "version", "0.0.0" }, { "platform", "linux" }, { "locale", "en" } }, + {}); + registry.loadModules(); + SourceHub hub(®istry); + QList> events; + QObject::connect(&hub, &SourceHub::accountEvent, + [&events](const QString&, const QString& type, const QVariantMap& p) { events.append({ type, p }); }); + QCoro::waitFor(registry.restore()); + + // Sign in the way ui/Login.qml does. + auto *login = qobject_cast(registry.beginSetup(QStringLiteral("spool.jellyfin"))); + require(login, "Jellyfin needs a login screen"); + QCoro::waitFor(registry.allowSetupOrigin(login->sourceId(), QUrl(server))); + const QVariantMap probe = QCoro::waitFor( + registry.callSource(login->sourceId(), QStringLiteral("probe"), { { QStringLiteral("server"), server } })); + require(!probe.value(QStringLiteral("id")).toString().isEmpty(), "probe identifies the server"); + std::cout << "server: " << probe.value(QStringLiteral("name")).toString().toStdString() << ' ' + << probe.value(QStringLiteral("version")).toString().toStdString() << '\n'; + bool rejected = false; + try { + QCoro::waitFor(registry.callSource(login->sourceId(), QStringLiteral("authenticate"), + { { "server", server }, { "username", user }, { "password", password + "-wrong" } })); + } catch (const std::exception& error) { + rejected = true; + std::cout << "wrong password: " << error.what() << '\n'; + } + require(rejected, "a wrong password is refused"); + const QVariantMap account = QCoro::waitFor(registry.callSource(login->sourceId(), QStringLiteral("authenticate"), + { { "server", server }, { "username", user }, { "password", password } })); + require( + account.value(QStringLiteral("account")).toString().contains(QLatin1Char('@')), "sign-in names the account"); + QString accountId; + QObject::connect(®istry, &ProviderRegistry::accountAdded, [&accountId](const QString& id) { accountId = id; }); + login->complete(account); + require(!accountId.isEmpty(), "the account is added"); + waitUntil([&] { return hub.source(accountId) != nullptr; }, "the account starts"); + const QString token = registry.accountList().front().configuration.value(QStringLiteral("token")).toString(); + + // Catalogue. + const auto libraries = QCoro::waitFor(hub.fetchLibraries()); + require(!libraries.empty(), "libraries are listed"); + const LibraryItem movies = libraries.front(); + std::cout << "library: " << movies.name.toStdString() << " (" << movies.collectionType.toStdString() << ")\n"; + const auto page = QCoro::waitFor(hub.fetchBrowsePage(BrowseDescriptor::library(movies.id, movies.collectionType))); + require(!page.items.empty() && page.totalRecordCount == int(page.items.size()), "the library has items"); + for (const MovieItem& item : page.items) + std::cout << " " << item.title.toStdString() << " [" << item.id.toStdString() << "]\n"; + const MovieItem details = QCoro::waitFor(hub.fetchItemDetails(page.items.front().id)); + require(details.id == page.items.front().id && !details.mediaSources.empty(), "details carry the file"); + require(QCoro::waitFor(hub.searchItems(details.title.left(3))).size() >= 1, "search finds it"); + require(!QCoro::waitFor(hub.fetchLatestItems({}, 10)).empty(), "latest items"); + + // Item state round trips through the server. + QCoro::waitFor(hub.setItemFavorite(details.id, true)); + require(QCoro::waitFor(hub.fetchItemDetails(details.id)).favorite, "favourite is saved on the server"); + QCoro::waitFor(hub.setItemFavorite(details.id, false)); + QCoro::waitFor(hub.setItemPlaybackPosition(details.id, 10'000'000)); + require(QCoro::waitFor(hub.fetchItemDetails(details.id)).resumeTicks == 10'000'000, "resume point is saved"); + require(!QCoro::waitFor(hub.fetchResumeItems()).empty(), "and the item is resumable"); + + // Playback: resolve, then fetch the stream the way mpv would. + PlaybackSource *playback = hub.playback(); + const PlaybackSession session = QCoro::waitFor(playback->resolvePlayback(details, false)); + std::cout << "playback: " << session.playMethod.toStdString() << ' ' << session.container.toStdString() << '\n'; + require(session.itemId == details.id && !session.url.isEmpty(), "playback resolves"); + QNetworkAccessManager network; + QNetworkRequest streamRequest { QUrl(session.url) }; + for (const QByteArray& line : playback->mediaRequestHeaders().split('\n')) { + const qsizetype colon = line.indexOf(':'); + if (colon > 0) + streamRequest.setRawHeader(line.left(colon).trimmed(), line.mid(colon + 1).trimmed()); + } + QNetworkReply *stream = network.get(streamRequest); + QCoro::waitFor(qCoro(stream).waitForFinished()); + const int status = stream->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + std::cout << "stream: HTTP " << status << ", " << stream->readAll().size() << " bytes\n"; + require(status == 200, "the stream is served with the session's credentials"); + QCoro::waitFor(playback->reportPlaybackStart(session, 1.0, 100, false)); + QCoro::waitFor(playback->reportPlaybackStopped(session, 20'000'000, false, 1.0)); + + // Remote control and SyncPlay arrive over the provider's websocket. + const QJsonArray sessions + = QJsonDocument::fromJson(admin(network, "GET", server + "/Sessions?deviceId=spool-live-test", token)).array(); + require(!sessions.isEmpty(), "the server lists Spool's session"); + const QString sessionId = sessions.first().toObject().value(QStringLiteral("Id")).toString(); + const QJsonArray commands = sessions.first() + .toObject() + .value(QStringLiteral("Capabilities")) + .toObject() + .value(QStringLiteral("SupportedCommands")) + .toArray(); + require(commands.contains(QStringLiteral("DisplayMessage")), "Spool registered what it can be asked to do"); + admin(network, "POST", server + "/Sessions/" + sessionId + "/Message", token, + R"({"Text":"hello from the server","TimeoutMs":1000})"); + waitUntil( + [&] { + return std::any_of(events.begin(), events.end(), [](const auto& e) { + return e.first == QStringLiteral("remote") + && e.second.value(QStringLiteral("command")) == QStringLiteral("message"); + }); + }, + "a message sent to the session arrives as a remote command"); + QCoro::waitFor( + hub.call(accountId, QStringLiteral("groupCreate"), { { QStringLiteral("name"), QStringLiteral("Live") } })); + waitUntil( + [&] { + return std::any_of(events.begin(), events.end(), [](const auto& e) { + return e.first == QStringLiteral("group") + && e.second.value(QStringLiteral("type")) == QStringLiteral("joined"); + }); + }, + "creating a group joins it over the websocket"); + const QVariantMap groups = QCoro::waitFor(hub.call(accountId, QStringLiteral("groups"))); + require(!groups.value(QStringLiteral("items")).toList().isEmpty(), "the group is listed"); + const QVariantMap clock = QCoro::waitFor(hub.call(accountId, QStringLiteral("clock"))); + require(clock.value(QStringLiteral("sent")).toLongLong() > 0, "the server clock answers"); + QCoro::waitFor(hub.call(accountId, QStringLiteral("groupLeave"))); + + // Item actions: a new playlist through the picker's answer. + const QVariantMap added = QCoro::waitFor(hub.call(accountId, QStringLiteral("runItemAction"), + { { "action", "playlist" }, { "itemId", SourceHub::rawId(details.id) }, { "itemType", "Movie" }, + { "newName", "Live test" } })); + std::cout << "action: " << added.value(QStringLiteral("message")).toString().toStdString() << '\n'; + const QVariantMap targets + = QCoro::waitFor(hub.call(accountId, QStringLiteral("targets"), { { "kind", "playlist" } })); + require(!targets.value(QStringLiteral("items")).toList().isEmpty(), "the new playlist is a target"); + + std::cout << "events:"; + for (const auto& event : events) + std::cout << ' ' << event.first.toStdString() << '/' + << (event.second.value(QStringLiteral("type")).toString() + + event.second.value(QStringLiteral("command")).toString()) + .toStdString(); + std::cout << "\nlive jellyfin ok\n"; + return 0; +} From f8eb2d47081ccc110074cec7871fc954abc549e2 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 07:59:55 +1000 Subject: [PATCH 055/146] chore(provider): pin spool.jellyfin 0.2.0 built from spool-jellyfin@edbca83 --- .../bundled/spool.jellyfin-0.2.0.tar.zst | Bin 19452 -> 19455 bytes providers/lock.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/bundled/spool.jellyfin-0.2.0.tar.zst b/providers/bundled/spool.jellyfin-0.2.0.tar.zst index 872ede34a117f5b8e5a60c5b99042df7582b6bdf..fd351d955f7249d41f841a705d2ffced02e368a7 100644 GIT binary patch delta 18897 zcmV(xKUIRP5i)R7M~f838X`ku$Pae_V%vaedPq3C1$ zBi~uMij6go_3Vy$lkz+v(#kXPyUGnXejo)Jl zn>qdSwH2;_5k8FBr;1Cyy`p!g!aYjld7N|54B2<{aOFn?FdC4YdSZ{2)th(b?@n8$ z7V^(+f3UH)Q|8=>mNEr@w0y!?_4^mOrSEReE4&efdxVr?Mh)y1#=rRfJYvKTwQy$a ztutf?YGHzdm)$(QtQfz2p5EjCsZ8aXcyjF1o3Zmht^MZ~-EvHJ)lcP&EPAL}6kqi| zkTvP;Fs%5oW9{5(6@Ai|Z$5ixI`O?YVJsY9f6V#!*!AWJe$D)ix&lo7*5)P>1u`}t$YW%mV%(jr-t-B z!q!yrLSo;JoFa-kcIHHoPGnQ$DD}B1dzQ=IRPILVC0jzK_^C&IJ6&_y!PZS%)`!cL zJ`^71)U-<&K`1JomkoR35S1*R$K%0BGZzYDzw-sk@?U~Uv``9n|@gVrlzH1|wonttCGRuncnTwHP z|M599wbeTKTsPJHMuw5Gh^c-QLs`w{-7Hj;a7ZND=km|D{p}uGuf@*Qj39m}{dSJ{ ze^r%lxoOPJT!msOFl?xS#{hGhmpFkJI-)o@aWkX^f{~Tri7&B#yV7XW32ga{Lz5dq*ywX?|1v%;A2l zd58S$7N1}^ma?tKqed6Z4?P=Y+_e1%CCZR6y#2W%!)vQZ#fRh1(P;`d$j_}Kk`_ZB zbz3gy9W|tx=fsMA*~Kh=^E+x=%FtPeXYA8;SPH?$w@yYX(EqhnkL6T}YwwoWAXo8F^ z7Byi)At_Nhpl0+01N9(fA~?t>7Ukcjn?E3VllU z$O8u`ddhbpq|Xh$@nbHrEb8RRiAy*atC;*tMu{mKuHu8}V1mrv{=GQ@VyP-7WC<4c zu|hLZF4dfc?kW6BeiRi`{_RxFish705yCv2Pz}wjeC)IYOB9ZUg9WbUix;R#{*E~8 z4xd=hZUQ|;uJ8mnEoi|qe?d9%SlrX>cgT}m`%D_VP+&(0$}A4VI&R1z?c?%w4YPb# zAS}Hi@*G1Plu!?SkM1J+JRO#bWnU!A2`!<~o$mDbWj|L+!T6Zd4{C@IAy|e`7Q&j* zsSr`_W0F-`d<}OhGEP!aks-tcMK6~OjSCJ70578A@TD~s-vR{*e=uJSqMJ9CjVlghyODy>6Al)=?_ zjG&8x009SMrV9kE|&}CQK~MN%v(}! zN=zh9vnU5$0tt}t$MJv#Ffd2~f&c(0KmY&$K>!ANfClP@;@PQ> zS6E@`sKsL46;dV(TTYg)iPzi4t%lGNJ9Hoxi#iWwgnqPAa(RKw^X+xvOo}Dg*Oqc? zHB6N+|K-e7wUupnyp&2_UCYIo-UUehrjIf%lsa{aCpQVZsqecjfV*eQV`5)_W~L-k z1Zk^gG1iGgeKAi#E2p5beg=O78eTRJ_-eS9cp%QdC09;SpTuKed^)s*7`y)%sry~#IqhsixiUQ@YM<}0Bf$k}q?=%S%+wD+CJe_<|D1DGWOP`fMJWT2^w>+L zE4)h#*evt-Ts^)I9WY-heLVMnP;8{TlEK|V_ITTtP4Im^m3W?#w~yVBkG2%PF=n1k z2w`Acgl(Oc$g3DF7v+qbZ=n1{1oYrUW8O8_~V5?hjX);`my{*`OrREVP^xtJOx(OqtxO~DD_0eS?RVR%Mh-7 z@vEf1nCO=GQ2QbWFjz2u9je<`%YCv@RM_>fN7x-WeFikTZ}2{4dNE! zli7Gri*!#i!L;+e4)zZq=kzy*C;qPd$yC7$uFH5$oj@F%oHFClM930N4ccgq%2+q< z3)Fj{3a(rceFg2FCT3xsa(Lx%Q_f0U1;5JdnPkiFVu|GV`f*o(e!6qQOq(zw!5~I) z1>)D_HV7<}@KuY~GKXhMS>24daK$Rl;=59MR5HZCxZdfl_vaK!p%#uaO$a;UCS}e+ zwsq<@wD_8W$t|8vYc7m{_M~Bi4VL7&Q~rRBvUTBu$>)CMq#M%ZUmk9--^-vxSGC#J zMJw9NTB4jp%R}UUc}zumh|}ra$5@n=!lxPY~Y|D;4cVHTXskjwI7%$a;)n?Un%^zjHww&xfZVlR$Uup4GqIBH-;!}*>hgRAR znPWpPyeoGpY$Jf-$K}7MG%{@(_mOV8{mE z1=}fqqG?TZxS?k}mUrRgUvcBNdEv_r|nyjB{SWa95s`9xYx#&X7nOEu+JiYAZ86*mAS*cZhl_Bou>y|Ho-&beO-! zr9z7eC_|rkM-)V5&Gc-DftZOEYKqJ$Ihz)lXryXKkH}Xw&(r^7D{wB|L-o@3*OMfF z%MBPyqfp(6?+u8S>VRU|dGMZgRl}9Ax%BX}0B)*1+$UUvc%v-^AwVYb4kBRD*S+Hr z9Jz9`$pkd#p2~F6+joW7e^`;5-OAQb2x~agM6mqab%uv2FgbCSTB|&SV5g@sL`2S? z_Ab-T>z9p9to())nk>x+H2v;T`f4_Rx0SV!8HydVZc25hJMzDPqP&tpB)K}oL>z*Z zL-=d}p(uNM^gFR7KNly&GRgqaDa_PYP9Na7)=cQ0>!kVqa*bMijSxW=-JWy&jpjZ- zrnHiJ6%_0tI)3_%C3x5o?JYKtGo#f>sE)-Dz2Kqgd+4?-1MFp#3pvJBcJ{XwgVe{3jq?FB?4tK&>O zF+tZKpBOiDnvp!^d{!BGl;RAu+s&(REf)~4&WDQLlIvS!?QIAIN}mkgij32*C|)r9 zM+mopvd2$$?y~4pcHki|H@PK$a&59Y9M+B&c(HK*oF~$#XeKbzt=wF!^=S%+X*Oq4 zVOOBI;#O(?jn?J%Hq<~fHfZEo(*LG0`WcK0)dBjN>^b-Fl6Bpr(NU&U&X}>}FChMJ z@e|9-Y%gO~b3(E=ZE43zEkSzi->cQ;m=OU%UdrZ2X1rv~5Vr#ZXj#K08p2iK;WurKi_~yy_7T67`YTO3Hc{wM2vgM)9$q*ic zy%Efc=i&2n@ymkIl(uz+Ir%^Hg?Yoqw|;U3xAtvnVd$;LPI;)C&`i7pt zT2PgN4+fwliyQ&jcKrZxG5Dj@HoD)2IE%L~+CAu=5XzVy5)&@&Eki9BXA6C#5@8W`N8=)f>o zZ~`8`Us{@MFSH8(*qI{tYms&%85e%i0LvmZ=J2)(oJwyPbBTB+mG31BQqMtG}-EFeY^EuXtu8iA#h zqT~{E{N{}T!eYAYG;Xr3ePF!-A>!h%Wn!oq>n;$#;vFOTr8dDb(D9K@@89I?Qcp>m z{tp1`bflX!(lROIw(v1V$lB7VhT7*#LkQa|k58BG2+dVFf2T(bBHqjmVOdu^Sf( z2Am`e8t6Vo%sLzR$Erm+sDcfoick#{l-@p_qpMG7R6y~#JGr|L!jrT>p6uU%NqmAf zS6+WcNV}AOOdk6Pn*6AbV9cXcLwn=bmHE_^SrJdGaIY+>qvxHJ)q{$sUrmQ$umIW2 z2=O+l#a&0Y2NY)_#M+(>#&Yah#uS*%i)LZQkHA9<9z?CYC$O=_HTgrXO1v70^SYxK zW~Xg-%Rj0Kg3Nj9j8{ldvN38*^73a(swu53ln5|?+%=9s#5N6*k5+YzAue4*EjI{I z4O^mpt&t`(qnPfuXI0P%%z+es3=7Nw)3c?(I;Rl*Sh%(UKU6BF8Ti(s9EA8^lw-^Y z$=Ecurz65M%w+ljd-mrOXFbSr;DP6k2f6hsKCnxM;MXJfWllr-Ab-+#`9AgB%_UQL zQVSh_aCU0GwN|7*v5@OuOsaaZN-;FNv_aXU_dFI0ITW#sE3ynh!{X=yz0is*jNPj_ zDqj31o(>UnDpNAKYwI{b?dN~)tbx_ujaHAu$feOna5rFq<>q5E4%I9eEo!{*M`7m{EpaMpvdcDTWW)Cvw#aKni%9~it28t8B5tgI zRp5ej60#EtGtxNKk;E}IPf1&Aut|;|Wdc&1}YYbH#8dSW0kx(@o9kD+=d1i@{b@I ztO+?3)A|8<>M7fiSmwZn#ps-UH~EdMURmG&=3@&mX?%FZqoUUpi(X0pT8B%2g=~B) z@Zp(%Y-D?EI>1Sv(p48FYX=&YL^lDMYob2I;73M;^zZiYJy&W+9=!MXoCiU;D^S7^`>2VIh`z# z%#E$~*%Q zx?6y*cKU^*?iT#B+XVNNA2Zln(!FW}L`RP!uYrufI1}4OM6IG}6+3wq4SobQV29DG zvAu-;27!$^os_q$G*RR*np9%LL@o0=03ELrwx^9pq{c*u_PbHA8!Z!muy!u;#~>_n2_ z`nw5a{EwLhtoQf2sNw;znA(S=!p!VMsr6SmaP*oJ;2HmH;>J=k74`kck#yyVjD-4x z{6@~Vil`A}){i3B4U9m9P7dA|Z8p4jDZIcNuIQj+W<9rbhLbUW;ST8vF>5IPCA7}T z;iIrN5C6k}>kXz)!=!D{KgE-Llv9`8v)@-ctU5`W3~Ua>9F1|4yLeLB*BA8Tf)WZ8 zX+2#{H55yY`lvDl=O9n27-9|86m0NPI_IzjAzv_a>G=}|psJHmy(i7UlrK(&eRUL1 z+6U{v!Tz|7KzUVvq8=Bm7v-1*CHo*zHL;nn!U49vxfI*G&{?b4Cj^y>zq=Q{-Y{>W zaiiTC?G#N%2!fKA8MZDG3(#6MBx4P%Pj*#IaK5?Y9*N$Hl?;D9)>PNzX=CCz4Hkf0dpsg{T6XpK|P zcIzS_;laVmz+T8q_Uho5SARwjnP9W<_00lUJj*YO*NF*^a$=wqe2eDAEryba{Ks+n8(@zs>Na| zChi!GN>;-(V}kim)G(t6pvO#BBND(j#HaGLIz_U7)QzM~!O1vGU7q2k?VBEqTYuzh+<*Q}bxKXw+yhCi?>X8U{UU%2d(NgZvEho> zOFdG5Ym*;e5{|3;zA~ixXgfMm!|F9d&>ps>UP#?=Wy-5Ake|9(*Xf{cTF=y=>V_@K zE-#*nkS@-G!(P#?1h(2&avM+A`0J2dOn_LON#l$dZAEumC@ z3BcT#^K*ww70b>TUX6G2-<=%At1f~S2*Nww&l#sJOQ~weMNxZow55jnVLW~w_ zgm&zvy!a*+`Yb=9k#POW&xol$T4pm3Vy;p5(S2WyB&LR05>|N>*UK5vW71yfYkL!! z3D06FX;!4iELyclsHhnLyrR4yLi~acgq%|MC=tog$j0U65oH1YMeoRO3gPT2^@%1E z>`1{SsO5N4DnTn9uCJ4HX%aYp!!S_bxK&aW(kP93%U^v|Hmg+^JxE~l7)24z-w~cf z6h_S~j)C0YAFuBfT^7HJcJCcnbzHra#0|n{EL87;p8RYk$+dgc){@@@A}F*{&>~r{ zU$u1pB==BZ)Aqn43xwtXWbS(Ok)p>|?>F+vrp~KlxM`pdUXtR%Ey4?b6F`V{!8}7L zBVcpRME7dOD9S4+B4CV6*Wwoa*cpZ&la8BB)lp5#=NX9`;RD?A7$y%BX-G4X(XOb)ki|6MR2hpH3ub@TLgD<^E7YKOi_}1 zbu@Y$II@_<5$rL%j9YGhE!hUyvRP7)bpyJH6S7h_x=~qrcY=@*WB2x{FPDLy#(N9! zHwZz?N5a?7e|?EMFR+h(iG6Kp-4EzC`aA66Xq~}-9B1zTTTP^#1AUIdhIi-7(0%RF z@q&`h`j9G`xiwzlRvmz4iZC4JP#(cL_Wrv;_7}&C(Iy@RDg*_8-U?~MzQE|>*AyG3 zW7JvuuAG~Nmr={T_e2^#fBY7cR>fwv+@dH*oma%V9Y?P_$4eD5xm6@ zno@o+>dFu0q};Cd@BE z+E~=MPxJ|8&4=Yp6q2x)U0XVbprq)KQD_@L9Hl|Ij}H`opf(%4kLW?CL4kmO7I97u zLEw5{00d1ICL!5XB@@@t6U(5xPUAepb~;WjEDLN?;>f%{o3j5xR)C`~UohF+0%FJl zA2J&2{*88JpXygBGxuWjZN#$lxXLrb6{R3xf-Ybip4(>aYEKh7ItrM~>W}zz_mvaJ zZAyi6%aYjMCE5@s{bXHoq$b z1|Nt|L6pnv+}n42b4VpBZYbIVlN_zHaHtdmNph>`H#vRy1|O6KQDy-_#1|dV2^lb+ z!d35ovil{`ZOiP(757moCNg|U@L)Vx(SOhejz^y{VVQEG{+s zK_Ey`hQv8$mNz_@xJN;Hs(3IdD*HQ%OWiQ;2}eVe!Ocfh!1&Yr%L*kRp<|?ZcKoP+FNxj*yoY`2g5~Clp;@D#j2Hs(s@eVlLL1v)!cUV0f`(H>cUue%z`;=bzcyMIR4Gp5g*O)B!!nJvTprqk8k<>stF87On z3Xg$6S7k8;`sZB`XVYYtl#7quz0<8+NPXvdW6KXzt9CVjh($_I-ugO7$|^Li3yFnO z-)XbSeF(DzHTVU+*>3ug)!E;#`Y^CLZwh=dF$cFs^v^DX4+90gOQLe)iF-cC_09{a z6jIrjMHYtxfMr7(AMzwJzypLzk(u9rd@|qVC{buumbfn?-)aZtIHZ?=vox*2ii-rw|Zvo6>CCo z2i5D(`-gG;mu@|a*M^lv*ek-d3goJe$qoB0vqcZHaL@OFY2XkTh|1V5R~rmYcW#AQ;i7 zpy%ee20Nx*AbyY3Hp}$g+7O8>egTo%MB5JG*w+qN z{}LRIL3nU0rZ;g>tj2AN;Nq5+2`5)Jb(56x(T|*l5W-y~q_~Wcjpx(2}=G>&tl7{UTg@_CJGN8e&k`WLfL0LRvuTmp_1=CGXXSrFwK)eD| zx`EUrhdu;pF02lp&${>kh82lC*&p8L2>QRkoZ%GqBu>IGMu-m%qDn1eCPbL}?8CMN z+l@qV72cV~&VE4G<-91I?y%DU>SE8^RWM7Kie?Yj@oWXp6Q>0doJ45~QjLoK^TOo* zl!ho*+rH7K0!XZXMZB)8%iLSPpkp`u_trav)>dvLAq*-6dO#yudxdxyhp>}5L$ z1F+7n0VgXE15tvWaA>|~Tp6t;0ZM5o6g)7Astv%vz`(#jRYO5Rt)W-8CX(&6g&W)@ zokXU&tWWoU4e(C!WYv3a=XY|+R&CY69>uzh4OHT zw#-OMX>BSw$x(j~H-pY=G8zBAIk0VY(dp8EmdE)5V_aUGc(8?lmE@h?@pp~iarurS zh1)z*G8!{VVb1dAKJzCcCX$Q^KO>Av$L%h^`C{Z*dt^zEJNZyD!=qQ?wyS})$t%mG zF?M2zP97+&YI(rPf#Wj z2#5!V$N%Sg42PqgeyOLixRzqSHskSr7m2ROgz_3hlvtUNOcPVMUAE4z(Jxj|OkJ3) z-Fml|!h16(V}RjI-RWYf(Ao4)IS~lSfJ)bOGjJKJF*9Gpp5(;|fdZJl4}n-EbUpXH%$EEu@{Hch;D!r{5EGsNl6^40#`T}R=Wq7sP`YxgQUgzmMk%;rR^LJUMc<<5p z+djP=kLb&f$8Y3j@E{=|kvL2MfH;or-CYiaLiGfym4>PXm8!7qKqm&(i&gc>GTX3q z@6i#dKvr8#WTdhjtttlsk{99btc^Pzm~D;i2|BWTGY*UwhDWm}hMh|%ikkX=#cWwq zbX;FC@?t4(FJb%d+2Q^gxZ}WvlisjIeWRKD0&1GJ;b`_8L>hYBP3X?U>;G zk{;OV%*EpY`c#z)W&QT9%5EM{`ac;j$605KKDHDSisa$Jf#j$*SuU=BIa5V=?&X+@ z)K~P5UX&>b2+M_G!2T-Mf;D^Ty8ELQAn=f|r2@2Eo2^Qy(r+OL_vE{w9ND_f~ zu&Q60eLR_DwHVG`wna{Vefh}~@FsAu)ikw`?-rYtbL&jXFr`RQ>Dsl32H`Aeb?MTS zE4B_wH+vN4y*v6izpBwFheDUUH*?li#VcB-+?gZ!n(w5}WHnJD3j?dFs;X+%-oL$` zKEIv5s*mZnhq$dVARkl*{CD0k!M^ zhe3*?6vX2ijVcZ+=kNQ3S)y5_BwHQVS;cA-u}9(#qHR_RF~3L066 z1IFs6B8UPz@!aTu$KgFsvL+fv1obDZJ>AH<8 zJkm72qbOr0&<^m0`|X>`0*7;dl1roqf$~BB=GG_@&Ola@Ee&FCzNOUO49Z8iWO0Pu z8Kpu9q@cr`R6ySb!i!h-%I#OL#*(}O0e6T-(RPO)b3D}!#en;{DJc*df+;gdvUKEk zO~WV)#pzIgKyQK&%RP%}4qt*q?FyMc z0ruJ@%33RM=Kyzsq%TZ6JJ<$bXqI;!&&_=L-EufTAx5gdHMVK!|;ZlLurf zRM$+~uINR?t2K-@BQi*-OblPGdXx%7sfwb1xF;hcf9{iBM0_IJwds%#R68St1SV(? zQ)Hl$Aa?52>nlBos1}ltj8xY%g~c_7#S?^PDlOUk6j#0jDayvI8NmLlD%W#= zpf=x)2SB>HLBFTFD0IrxP*-W6f_Id3fG(b1RcJc07G{zKuls5)*E!2&I;=VDw8`>* zZ6_xcIauvZoAHmi%v1IP55R^$3hZ$-9%IkHr89#3K48f_Ew6xySpyuw@CR%uyEofD zz{cuP!h&^uz!=MCs?e{L*Q%anutJZ2BU|nXI$UA+;$OE%V5O8SeP;$PBMAp3kBj2k zE<6r5-QT-9i20JP=^s@4sX9NlIPT?tBbGyWR9j5fH}%P+we%+{@=hfN1a_^)ytbFfL%Ja?Ll~9(+04v93ArynLrpJDHM8nAqYpx4c7ts7mE; zS&%QCS*2j^OJXx7PWZlr#HI&^t8fmSG~>t+yA{ErgEAC%5jg&J>4U0$;DsAxl|i~8 z(VGqI9QjpL@dc#16%|8FgbWkr_GH2*E@$JMKwcp9#9cs1693Y*Q=I}HRZ|YhIW+ss zF?vQGb^Om759)ob;vBlS<`%Mlz5LtByy^b$BrW6q9er#SNYJs=B{yf0ksk7E&A;8qYi%3d$;1$WmX_=I3tn z6Iag1vEkEN6ztj>eZ@vU>1xg|=%=e~H@32pwkeXNT!c4XJbEqSHcSc@IYGna_dq< zP*OH%(>bjW99uF#a0X4?sU?qk4a}PBvegS1qJ*XjXU9d4S(%FIQa^wl_se_o*(Ddo^s=IF3w0x4(EVxLA?`;$(IAEv^R7Xq{L}|TDE{Gk@z5l$NRhp zqMCvt-NP43VX*1ZR{(@iSCu(o$|;ZHmuX^paAoScZY24XKc63k6CgTe1UJO zxe4Hl=Zw6w+Ly|I=S$`hl463&_5t|X004u`qZ(;&1%xZ1Ide4AMUC>tO>s)0=|4;e*a(USD zI-!jVH9e(U*aG7G`kcj@S6Xl)5p8*4%fQ`G!C*F5D}3nA&0;0ElvK0e{6t_%}q zd-X0AsDWWf&)j~C;e{cN3V1&?u+IAwl|-JXFcK89Kswf)86kUWi{PbK2@ziiWihAL zgOp91I0aG`@a;umHbUm722X%1B@#Xa`~bnm0zpVanA0PrxFtUOnr%qxZ{C{26MqcQ8x!NbYn@> z>$MPau1~MfZhEqm2+Yqk*^{%y9#{PK?1bZlJ?`-Jblc;sYQW9xVDP*i5Q(ZrRi@Ut zg~35jZQ4wWy+SSLiQre#+2djGSIMl3EBuFsQ?Vke0#QCix+@RtCOY zB=fxAh#7BVT4ssbyfks^syoib_a9kAHiT1MEYX83K`%bOb|&Lyrs+?+X{*pX779^o z5dcPDy_OWFt5T+ZeL^Thk7l7J>fj*`h~&%8WO2CPkFwGBn#E1IMW5W>2y+nYk*gTl zG`!<~hbd2|Aybf&D?Nh&huSMgwW8<7Jdt=PY!&vaiVF?j5`ol+QuPxV&+uyOk*5^uptb&{B2$TQ z;&aZi#9UCx0Ia!-D)Tdwq!IkA!aYAT9^dRl}T`0Jyolitv;A#4=SrSEp&WZV1}{LuR(Cbn+N=GSDL} zJa`B)D=waP!HYjACLN%ps(I4HjgfYLVY2;z zCcXiab%=yFUQ&QKgaqktx^`ZxlaC%+&}c4jzQTR^5XL13THhN`m%&ntj+{a?@-WK( zfX*jkAqSA){Se7JzAiC*>UYwOe`a%Vz6I<_3WPRvHHl46Jh4CSH} z3*SnpU>a@14t z@BwHM8VmkBn1;tT)>~stVBnCu<3IJ%!TfrQEQ4L%=VLj z!5{H9yhQTxGNqXlQLOcE0IPx=6KtIoWdZVoqrOPf;;FO|)SYZ#$n5(oWB&%~e9V97 z*6)V-CY)9#5Hu*X@JOY9xsii-s5gMbHe`#Vp_VLYa&w{`Ru^Cj(<;NJtvHF49!fx- z(w6YLvZ8adqL$HgIVIB^1n3Gn4rK3Pyn$qba&+E<)?+$8_Rj=izfjhIVoF2SgKd7y zM=+_8XaWxrC8gVAG0b7$98`#n@L|bn^x}w9-y0Sc@>x0pIuV$EW^TfFosn;5b!3J5 z5Zb9iE1w8mbpH18-j2$8Ohu{8gv<*turTw@4-B~42#(Vi;lX{kuZFKq(RG%uxRX&qcexG@_kHsHe zvD%lm5*~NC%6|xdhUlH=0ZpKPUPz@s53mFk--INFQ~K(wl&^^qZC8yHp8t~}Aocr6 z+bas%7C#606>xGt&{6*IAR6VT7{?d#6-_9;m> zMu*cE`(dyFAi;>BuoY35oae2=`GcSwSQKvC*Mj;oUjplCHHU_uSXhl7h=Dl?@nt&H zN+S-_fsW{ZXY4FF3-P(Snsdx0eddT4Xe(=nT8#3Ll;z@ToTfL?bs2{dh^ip6^Zq;4 z`Cei#_hJN2xs~znjPb2Kw8UpBvd{nLkv{3vQ|4TY1T4k)KNc`iW8C@D;XwP8c1fzb z*Yc}G%u6{p*x*gtG>9$2WfaK~VsLC!L2Xz;Lg-n4eKj~ZKDsu!{C)azzG4pqywWSML8*`iV@x6Ypb44vEfD(%eg9++%4!LF=G?@k5giQ{PlsxGkC4AJ<^0|1 z!@H3#??Y~%h;$(m*9W@nhB@`c+zcMhx1orv)gyc?UvBOF>vd3634okuO6mBC&;C&1#k7xb-Vml#toZkbb~mgLfvp51x~F9djv@B< zRE8J3-&O;~vdF>X^00novrt*aS`=TP@H+KHF!CeeBCxQrP zstmRHn}zJIN;e_W{=?LQ!6fS1M4E(ooXki%!g8M_s*xr>NI;J~z;DKEfR`4Q146F3 zUPml#`u*C4*_#6?Z&R}L!&(TwwM}hf$|KvTQB;pqQ7TG*hwi|RU?QfcX-?RGB)JCa zi(jItfXKJ}7UfC!TOl?!duiyTh*5D#6KpGgr&x5&9_4noo+cG?_Dp=%5_S0 z;NQbIrXfo>DT8DCM+@SM&#TFhN~OzW&nUrtll*p;BllTxOWxsxjTpzN2#FOp^1G-U zb7bzqaw!4=qhG4xxSuzfdvIre0ccn-G7c6U=k6QN&X&aaZQ_u!`VPJ;w+@iG@vK1z z6foqt4g!`C04oNqt}1v7su&~)n4tA(IGs29m@pR15U!-z!O1iLHWFv-BOBJx2k@qkO`&Ux<_(MA(fUpvfM8bXM1Zob?C{ zS3L-6s(gJ|4KQJ?zfS2MUxrgR2rte`8R9xUsbFn5>C@DMS#eDuT?tEOK|?(;fD@k_ zgQB#RJ}8Hs0`;$*VesF(xIu>*j&r9rSkPojcHFw7-8#j{f*G(bG4SByMD4l3H%}lP zFmw(T+HnLm0m~a8$LY#{mmk@M(oJ_k0|nuy=H&==YfO(n6q_p-BY(V>5;VVb`AEpt zWV>Kwh(D}-o^op?_U^TMYNS!j(LW~#oMlE(!O$SLDmHy_vhs0rIiJEp;5=th4{^gG-m)=-`T65mz2#%=U~z83G5+T_RpkX z=dcx#+S$lE0+Z2y1vFk(5bV=s6r5S$-G_PnVKA?~mw^RzZQkXL=@Pj1yhR!;WQ<(7 zd<7sHcw&Yx-i1OVO}Uk~mxnW<)D26+`bgCdIh0I+yd&07Pu%f^qBRxnKs0Ru8~obm zoP$}k;k*29T=0zK7?21KiU|^<)_IiADpib#8!n=V0E&EnTkK6Ehxc5fAjBDBMv=xK zS=4VV#VS|&2HTp5H`#6(f(d&yF0tO)2>`vPd1o7mV;L0#Q+!^@ay?B@!Qd6jAV|JP zTYeA$+X`;#(1c_INrh*uDvwzR#*h`LAu0c-T1{evm3v)YSqc?LdbRLY;_Dz$IGVos zXR&2d?MgO(EqT|Ut$9j=6Y;p1fierX%xp4^-YkzzDpV+F!yC&nSh*70ggTfFKmv%# ziLy{sK@?TF3bmr7Bw<&6-n2*6>{XB*R7jWBkY0Kf!?rMzqRY_nv0U9h?qrEM=O>52 zpS5&jso?FOY)N`-b9bedLIPb*&KZShji0lP07#i!3`B z9)7C=>c4Pftm5NaCQ-m=O2B3F|5_ID7b8W5_RUC)4Y9>O_VLgNM25`^hdEDbFrCr~ z^7N2Tj{rrEqaMyz5*ChK@d}2>qFYQ1T0v|+7n$=jJMtg!m+7KTFgU$rpmLE*V8K%! zN837oT3i_W8xo;1u55jpW6(F-BQxd{(1ch-R+oh$jMM?T2}SmkYAtmD3K6>JkZ2Rd zjb5PQ>thuJvF`qksVz^SXs9&WnC>B~mqpnN15!lEYoj}q3;vX3@M<9@>S5YsT97j7!VzN>Tv~YsEnCi!Ig%kxvW{^h0?6F z*lEZL+h@tm)>s>#QFR|F9wqlY8y(Ef1dd!S$BF+z6mv=jIS#)L6iT%Y?b4n zaYU|j0(m&;9-FAW6oZMLiVLswIq3- zf4V=c)@-yIKh%${Ux%YK-R1M;+rlh2i4oo%_bVvVLh=iwY4jGCDi3)6pUe!%{}NDs z?g@ggdEVs%Eb2G7WCy7^JyGyl%+RPr=c5a5IT=0CiLbJh=Be6#3ZW~ zoIvhW3={UGA6;Q8X#YBLmsnyxponz(%KJ>T`|C^p30AazWu(vAcUeT|d=x*NiM?$v}r$4CJ3$=6@wu1b8oSATS-O zs1^@}Y=wjk_pY})WYDtsRFPQXvd?UiSCY0Xo26D9;ZqyR>!&f(RA<{be^VQZ3kO$F zDCR=C&VtoGWbj?Cav%(AyZ4q-x6WNp=Mxo1a9*aamU7=iy;Tn zJ$pzq9OaNQ!ZUSIj6Sgl1Ioq6P&sgDUwSXZrk~aN*cr~?U1;@m23mB+Ga`dV8d`fY z=aHY37>Y)ul2ziIkTn?8f405$$aG=Arh_ir%5wl#qJqF&&BDt^l-Gh`uuP1N0HfW@ z?7*7P5DF~$q-}hrT=Rkq zk)K@?s{Pb$ZSWK`e}Qh&pJ8vzDKs!*SXR~?m=p@eUAd~O$P3FORX@YNaS({`FuEe&z%|`T;TaAVbb{10y(bEDre|ZnD7WFbl_u!%>c*wyqQ2=nl8l;GPF`0@4OynUgYf z{3n)=xk92$8*G>Pn_fl~zdR2ULq@l$xoklMU3wt(WrX4}HsO!oz=JLty)$4$Q%b<2 zaqa;E-jQZRf4e2(pC33)sk}_4P{3y-8r0ig-md#c3%X<^e2l|VXl@j+!=sWUW18$b^f0{T`v_2`+NlFLcIoQE4e^}hr4=p4AXRr{CbHx50szfnh zCn*x2A~Dggx=*JtY{ZVZprQA;(8;BEzt2=YN4?RC1C`~P2v^;^dT%;qe{cEN=ErEP zS6N}Ph}_KQHLZ(dd%uwAMgD+-(>?ZAkQ)qs!e&&BQE=tdImRmH#D0M&(6^#*+zew9 zly(7ze>WjeujcSkP*P?wi}V2q=b_A}&=BdV#3}q5NB1}fV7x3tJeHsH*(S{rBHqts z>XSuBxe#ea6!xMZym{dnll@{-*y)TemBPdHi*XOjO#S<`;s3EQ$4pT>A198VbdvpN zacNoTD~{$UngC!j!~S{T80&cMRI{Kae<|T$>9p8nH$gmRgQAq6btnw_q1lTU z(8Dm0CW72_T%gHTkKOXJNx`IN`=d2YEGmN+wUK=(5j5LvX0qw6BY+$l;E_iEDZ~@x zg2Eb^J;E)8uewMX*Q$4y9u91hxe7j9C2)N`0RBd1IaJ`96@X%QHD30>^kvK<~W&bl63C|gHlGY zH~GMRslA9iFifjS_vJ-i>m*`(DChEWwCftFlx@vfr7(0_b_<~&kCkzI7QzBVuF^=ZCbTy(V?edEY*5mZ9Kyo>4wSNRtEEu8^P3ae7SV6BK+!U68VONzsjG; zsrBs9*o8Mw7-@LrE9#sj9UqN}T;!OE;Cf2^p|IBY&Sf1QSdcIRQG?+>sA7e~&f#p2xRwf<6zjuUfI8=wtjN z-yySd6&q_F>)9RiCgpiTrb*<2nWp2}_itpfekhx9Rw&if_dZy5!p_8^6aA zHgo#tYb#sh~{lOW)m`S9l`|_XsJ)j2hT2jDPX{dBlhxYT?Y- zTW81))WQS@FS~hqSuuY5JiW*NQ<=&&@#NU2H)H32TKmr}y5*Scs-MakS@ck|D8A}_ zAZyawVOa5F$J)8oD*B`^-+cDYbmDt)!dN)If0*;{vFptd{F?byOJ@Iag|(VhuWZF6 zT&LuQYUk`GfrMPm79E0b_WXKMUyEP&Z9B`Yo-t6qB^*%2V`tKh)%b5!nR7p1=2Xto z*}?gGIjiip>S#P_M}zk4qJ^Hnd>zZiU_la%DY8dPDcEkl2K%>d>#1Foz&36E)2y70 zf7yr1*7RmYL?e`P^>_1j(|XgZNh~Q=<#6P7UdM zgsrLKg~YxcIYkt8?97QEoyexhQR;J3_AHmZsoag!OSXhe@l%ibcDm-YgRPsktPhtf zeJDK2scDxmf>2aEFB|s6Au3rskH>?NGAsasn)W%Rk-mbZRH{Jbc)>v3uVnDz_5RB>#*I z_dRak2_DUEzG8|MZ)(xM0N?bsC~uT>uQv#(dOKG8*-$Gs|k)m`x>#U~SGhCq_m?%Ssb zeIC6gc50t*W$MeX$@!w}>FtI!?=^C`6CbD)DQGdCbmPaAkB{eW>$A0;6m}w1(aSlG zuAMGt20?BU9-osH)mvHo=Sc?Rk~|N4p5`#gqWq0qcs&^3;#mVpEbUnpOS#W~wJSB- zi5vK|)r}ptM&-}P>3bi~GriU{#?vq^n8iO5$6L;`>?U|QehccoBNxv!zo~xaa6i_( zLwJzDjeqf>rGO}3I zgb9VDMCpK<(Gv{RgOrKjAfs56f17Ta?9MN=bVMF3*a`K3;jkqjNcI1JYA7i5DcK_r z9H8ha--VDqH~7Ynxx})llP4!G;asd@@-G=Brfj&152Aw!GJE^?<_L(Ts+f=^Slq`7 z%|y9Wa~8U%@GtpMR80A|Q#C7=Q$j@u^Ke2nG_&%t(-JIEI2H~TxSB6speFe{;;=h> zVm-SF^c1)ghqF|)8m)@TqyF9P)>M286eN&;C_FCNZLO#j(=-Xd zTs+{&(2NY-xNr&&*il3Ut3;0|0nqbF;qfRoY&^KoPy`yfp%)kuECrVfNJz#924iud zzyJZ!`1lY&;o}WaI zU{BY33_)Lv77Au#1A~JDKnjHk0G9x?D1ENx3O8 zkvQK0vnU5$0t*r-jfkkklnN9%%#)4@9)HO3fCVrx2myjX04P8J002P%26})7>xOdK zsgKu0Ve81nI^78=BMYl03$cmk+t*ej(Bd7sv5Q6BhcZGxS}VD{K<4@On&C{v66|YB zxwRUmikJU#W{TR%HauQR1+T8<0!{q_Bwy1*85c@@I>VDS3EkB9-4?*zvgI?eFMl(0 z5)Xoer~xhO?oeAys-V&-xL8EN-$Fw!TL8X_-7g*q=TFJ~Q`!i3gZKze5-i$i)9rVa?LEh>n&>oxmA&?2j@zXcw+=>hxl4vO905m=J znCS}d69cwoHa=C4?@I^FS4tnxHGdQv?XF~Sw}3s~HfIwYUvDLzr{wKxH{_!&g>Q_R zXI;73pcaM_@Ef8X7~in1r!9GQX&#$Lt$8@Wk&O1c{I{hMy$t&q>Oyw(WWbR^PrAZ@ z+Y%L1aKN4UK+_h+IhYiO{s=HLZ;MIV*h3hNUjRY?(gc`F?+dc=mMD$y#(#fuew9Bc zbpf{1pvoiq?tk|dlon&=tPZWTXZjK#$EO6V`f{M_3tg2w*f8gQ%Y^HuJzf=C)hgRI#060&x+3`P9P&9Ww288wkfcFliY5jD z3vn_v-qS_;l2WkN`5p&b2kV^vo8XCmS79d66bL;wj8QGdpZ=Mn4?Diu3hdlpYNkWniR7_Ll8)ilwj%$7vJ7kNA;N z=lIn+HXB;}nnL6jPp364Oh9|WFv13F^4uwZz(lEa(ZS?FQq|H`uRbutb;I zTgoJ8dzaDO~^pPE0!U9++5N8Y^DoJ|>1S|&brX7pEP7?}<6UlIx1jB!Bt z(-26As>$)At}urj&HIe3NpPvUm`I^dqV`TVH5^e+c6GITtxpCzIXYSnIYDkgG%Y=b z7o6-z60Gyag{g01u{2+ZMXdKoix87-K=DHklt~=`8IDs!-hbM_Y}mrD6f`t9J$tce zS;o;SS|-oC-;lpMeU2tcn@MOXOLs%+Gssf_m9yLKo`3Ef{cGmWM71V)q{nYShjTA6nP}Yb1w{`peA-?|~*gM9I!IIHWE2W?p-pAl&XmzQK!! zkzo%N7!)mlhV0}&tk-S^OqAVEND2HZ-n^bXwYJSI8FF6?udmE z-IjotnXkI#S$OUTE~*vl($R;p{ubZVCKIkmv_N^-Zhs6weB8Hx_9~gCyT5u$w)=XN z?!tN6&;Z`St`Nfsw)=_pk9TOK3&v`k*E+!=X*stXc#PK_d+g*B3Y2=IrF#Ncb1I%oUASHjef8kqjrVHrf?Ls;42>O8=DpO zbv3l=skvNTJhRY`n&Dw0Z`F>8594hKDA}-!&n@85E3xi>nw4#Nv)`&d8ZY`uN-!(- zKVUqi6zbgiHUi09OeRN7WIFD}aitlx$jsk45%XZv02@MVNMXbD;?5RQ7$I7_Wn9ulzA(-S+lo-i-Wa`}*!rK_(Xeb~U^~S~wMVC)8RxQ0d8d_(b)Uadj)z5nAUC&{J|2>} zb43%a0qs>P?FiYx{~^;wi5HAy+(YT_9CrH*?+l*pi* z2}w*Db>pLqTbyPwPl3;NMk=M0fz<7W6|Tz#TvW%QLRxZNi>!4U0)W!918*$j{uDh5 zhVc<}+gE<^%g)4E`jizsWSKi}iGNm`pblqR$O7*-+}-1eP%4@c%&aT7uC@M{!a@LPB<}PI;NA0-_~eqQ08Qa zkHg*w=Ed{j|GDU8!Dvd~=~9vMas~=xfeqZs;sCea+x)^lS_|c|l^jDhHGDm*D*1E^ zJ;KtWD*X=zpd=PK0avoxs4Y12Y&S* z88yTn#wNKRl4T}cG#4u%>EbBxENP1h3}SaO9W1_dt|_oFj(CY&&E^xLgP$50%uzIB zn6uzm&(6Pu!)&)`75LaWBKO-OeIwZye)j;&A~ojlwhEj|Zy4&J0e==-#AaO!wwHa_ zr;P{qxzthGz)&)jbvUF`+G2-u+7={wsf~-YL}*%J$0dnltdxv+yM(MG#%3*_b6pyd zr8Gr3C20K48v}%8y4h)LlC6DYy#j*anqTL{P!sEJAiwgRMDnFJgJsZhk-Ax_$OuKW2s-Eex{&}dt9Zs&ASkO2lgp@!hiN+r{l)VL2|!k*c%0g zy@U{ZGV~1`pq0_`6|vX0Fi8uHL6j`q$rI-R04wIiNP0#7<%PluPH>{7UBMd>B`0Gy zE))znX&98zeJ+?4YM>8Vi*`^5Hc%=;F(@egc3P{hn$XICR&#f9cOQ@^X@NZ1x&f0c z1Z}Ro{EQ*(D}Nb0_7OCNQ8$A9A8n47#t(Gnn<=vt+0S)z{5J1DCMDo(GO4#PkJ zvH&B#-Xw~*j(#3L&XN%8a2SlpF|mxP={8TEh1nm02rYP!T6s_428+w&4Y`td#UjpS zNAH-uw(XXGswN1TQI4%*}6i8;R>N1A3bdB0> zkU%B2tom9;+Qp1wx^ItFO(!r1RQNC~G${v(X|ZZySQy>hvS0ss91CeEVvj4a3`4`F)deQdidl?( zD{vIO*v+2KBj!|0GIH1L2!QhE_uQ=kt34PE9}baAqmAHhKmyCo$6*|*SvXqUc;S!2 z-ZzL#{KwWHY7S%_=IV-aD5VIZyrE@n|GaxY)PJmVv9Q&K)o71IQQl#rk2*U>x&-L# ztnHCz=7F%G2))cx32#yj8yb08h!^D`&c-g#8Y&j_(YWmicBQeAw-*Xs%ASBHL~X@ag)s-g6*DF4P7tU7e4czEdu6APt&5u@HnuatqJ5~ zF9CL#TMiJeH-5Gb5qhQzs9-$S&V7UMC*_Xa2`7T@zti@~ot}i?sy*;&fZN=LrWo>% zP#dfXITX|S0cq+f+mTr2z=p->oP8(xh^$^&-~Z-g3ovPXh{U6!*A)+*U-XwhK3Uzwf$=N5HsM-yO5B)*eV>l+r z4neKhBD1s$q0;f;i#!*i=K)2xk{!CF&A#Ht173E5R)4|{+u?SD27%y*#)9>xU3xjS zERoEOt?RuGQ^?0T77Adz?cXef7Jn$&w)hWC1W}%iMxsWAlhMuop0cDRsk>u#$7J?8 zwX3>YKv#GAjic@s{CC;}_mm$q+FR1SY6C<^?<1grj08Co>qbP4qG=Tyc@+(O1T|oX z)vK|;QEbeG!z}F1%;2Q^4Qd-4QCOimX=@2q-j#KmT`b@j>NE6<2Cc`wzkk?(roE{7 ze+FMRN%}}Lmb6#KK@N0QooHUtNCfh7f{c5g@@lXHP+U#YDO*%kn*OaOXK3Kjp$Z)y zUsBg3=Fg*5k8X`E)j$Bpc5N?VleRXne$BeV}mjpu$tidw(2c3|X6+6E7@T^l9@7bvt;-9_MGjm~wA0e;mU6>pk6x zl;HZi31s>oI}13L-|If&0iKw;54pn3*omffTRGxrnv>!gpV>s&Qex`s&BuZ0iir${ z`hq--&uu;;H4MmFbP7o!p!yaCk3GTPDZ0A&4Ve=oZ5YL zFQ2pn76AtfahrB|)qhAmG+MuuV*w>QOQP_xnIytj*!pmtvUd@?RZQ?IEZ8;v3IR~)5BsGrB{h~iOWs?1J>A^m%Ws6*<* zP^>tm$CodJe0npGUV5{=z4Mz#Vb-SLCrC!pOr4&f6q?L?p?}C;vJ~MdujKbY0X!>c z*w`kjs;Mn>*Ma{hp1c7}nK>I7MS?;LRWv!>vKk%`JJu;Oh*Z+7N(|N&V?5RK5WUuT z>)FCxL?V1RSRWV*SyHdA{1S>_QbvO~Qso{;Yo1IpVOGWvsy*_{tcD7ByZR3B{zM{01rW(eAYZOIF(8(x`m)djLs7wae;)Jxet&>rAkiwEnVg03ImdV_V9d4$Jjckv zc5d-^mEc51Ht~0Fkqw5AwGT293;|w{;&=}%D!yon7J3*|`nUBj{k9|B24PTECw3#GQ)`g(tC#%z1luy8i^7U?xIU5 zD}MnnH*0Wx!+#FSno!rMhl9+_!FfuhuQ-`D-zEcrEUT-o zImykRBhXNNF?Rm8@pZni`&Ccgh3K7Y{`RAo_`Re zON~$*vnemWNrf)Uk7y)ZU+FVqs;`#W%!8Wi)O~c{S0lyLWR`?cUd8otM#Px3SNhs! zB4gWGJSPo^^q57f7C99)1E5z_7(|F)@PUw1>K;8JIU3oxygZ^T;J>IH`As35J*GbK zW`Z3lxE!?{Ps%1}rNcFLl5R}`&VMil3LdvXszUmuQEmCFkIH7X>7ow_WFDg_!umS` zlZb?<*~c-S`}^YMy`szFSJAe;11p8=f|8&?I1vlfJJNeT!z8)lUVpXZoPY#9tyEek z>+e@ZI)CFHI&2zS@Th@o9Ke{n8huRB8?qYhk$XRNg_()F2P${zDVMeAKYwBZ2et@K z8tR&W{QFGXs~MxNuC9r=F*03?Z}d~oaC}ZWZf=T4_5&^$L|i=2N*)WF0mEbOk!)ed z9}1!f8}wT5cIg1~vUS-%qQLx=r9qFdOERUAWcpPf37Hnf#fH=z6tUb9-097ec|$W@ zN$%g#=yBl4dL~z}$M7<4xqr1}8)U;~$wJl*=rT^oO5NxNWoh0CLIR9EhEJ7ThIktL zE$H7M1TUW|Ur+z*3+lYUKKeEGrKNR0;M)xEz!x8NPXFUG6n}UNqz(E4r;C46 zY?z8sYB5Q^gLxz+AUVJ)sjDSzrXF;S^gAfK3SjDQlN?oI$^7-NS+m{I zt_X7vObKfC^J}>w2vXR}|LDk_e*rt~UaJXi_i=$*5|XxP+cy23`6z_95oe@o)igU}Ggl=Jly5`xi0=IDYwn$;K@p zLKgmz@mTlYxGVOlz6zOXFGg!47Ny6fJTtCn3IY>+0h{5u{noB}n$*#$fN56$h^M=+ zoOIk)rT&tWMSmEgJ)k}s89g2SB!^uY08Va_Oqbh!SMz)Q&DERauDZ0A2WQoZE-@-_ zOKV@)?raE!)p1*BO|sLPm^C#TU-db_Ope|WSz4W&!9`{k35V}sw$Uuv z2NaG^KAMb|DROiz4thgkHpBT`)lX9%9hMZLJgd9E7tR40XHMjvZ%}9bZqW>#k#)h5 zUsGB7z`>M^^2|QIebd(o0iybc0%%~e(W(VUC2t_PZWX0*r~hxrL3trc7EnXHql26f z1I8Av=zlItzYN`cnR{IMA1}or!z~GZmxFcugMQ|C;!L@>d2p9`w;g#l%oSse@=C&b zshJ;yuoNLA)-n5f1M0M1OrqbUi?B*h3f0Hdiz+pw+p81c$k? zGd1wse|z)r*ya59kQ_&2ajvdDQJc?`BJykFB@Tn9?*&d{4l3Knhoe>oV`Js0KiWb> ztcFV6-s&D?3$1#`>Jf_nS!$s|TZY%CPJ@pOM@DWCASyzQ;X*ImnTH8Vj&5^K4YuPF zUw<_500c@|7E2&;-jz6)CQC`Wc+uT$-AZKY?a!NkegLCtdjp68r1bRGmxH!!4Ac6M zTsSSAzD@2x7)VfyU*nqX){k7>{pG3`rhTr!RgV2C-X3!C)wWKu zOhC|*V&}vm5RWN=D<{HlEXeYM;PGF}+d<9XAQ`Zj1Zma^v2$e|MiT_DAF!>*ZR|RNlX09lqQK&;A++7f0jU}7yW=^Jg>0ATS&>A)G=JV? zO|~-=XBAhO!r*Ywb|{+StL}JtJtnQ9$TJl;s03#Wwx4na7^&AzMFI_y>Oj;apjKlU z+rRZ4%cC6|SS&Yh|3NUK zPa)6aM;{Tkv{?i4vd;N7if%SZBbKOdLZBM<>88i7W19cP_&iZ)1%70ZC6J`Bm;@++ z+6-X$d5%A@v##l(QJNxiGJj?|3u%*?N{u_Fy&P~7QLMzR6uIIGEej`6*6JpWa?puZb-xL7G=cw`5I8eEuRsYA)zc95v|glf`2(pkY`D(e;}5C zDZ8QClJgycDi@~1S!SJk5Qg=E3)v$cegv_<(0)0E;}SnG%*XHtS6Wre#1SH-{_OM8 z0`A68ycPzg3A2sSoiZ-|rW@HQhPvLfaW%}!O-0+oVRW_v;fZU31Sdh7f>fuX|9M7o zKcykcRkm;T=>-xa5r1bb>oWJ&FR0lK|DE*?A-0ttNeF`qK^~BZHeVr!#v$%x2oa8g z16%NO>m~+0?1jttkPWfwFD^iQV?;!GO)r#5DB*O(fCK{>FFP`Go8pA{IGLN;5yBYw zo}&D!`zmkS5*8owGQoL4eZ*I1hZy2pA>nfCNrCuT%`{+*<$w6&^8TYMRunu_5!0EU ztYI;94Jh=Aqnknke{49s!;!+)9F8WrhjK>@GJih|oPRY@i__p?Wb#?97Xf~AyCau` z(E#kTYs1M3)Zmn`CtQ;78CM34B?0ZP0W<+Vl4oarj?mxAs^WpdC4nP2 zah4C$gui_SUDH&w77wmt@z|e-tyV%|I|Br^ZHt_3W5-!U(!d@A;K}rIWjSFF?cw2( znSAGutEXOc+ zm|AVsUw?U<{{L_NziCwPT9_6!ILL&=uH_+|(ch5i@7Mai_Vet{E5|K=&}1+N2OERR zziE5W$fKWzF(|6ImSVp)3WLnG@HDna z+TN8zN*vv!lv2u&tajGONt1Xob+Fy>0vWc=M}M$)?|FT;&M(qX){~5FpsZbcx0l9y z^QglBd9LsE@kHqC`lqAI0fwOSeIF-35!BAuQ(1c(7lYi%^xMDdxoPj0%jV0mW2M4I zg8;eN;fyT^i1e?>7_77ML}ttqgK-36FpfaX1>*?BV4M}*otPyCvp{ZZ%4wb-$(!|a z)qhRVLOlwyyq9LK-MucQCOMS#D1=$wWi1@e+o~&;?=4I5lA8`TS}ZKz|0;cToiR%n zBN!INjF6Tx#6ih{7$`Fc3mkjj5;+}I!`!&-;rt?&SP&!UfiO;XG_3^6SDo*!v&vH= zm+lAgrzsGv^|p%e%s4X3I3TD$k?X(=uYXo+|KS${6!%`a*MF&$=Wyr$x(op!r1H0g zdOKE8m@Ad1m>Xk*LBL=PG61KMV|(|PQ7BY>ooc11>Vd^FY`@bBBZD%_OkHOSSl^fQ zWlGFyv&_6?)}vWwpxGc`pp5&|HYSncNSALnigLRsi8hBL>GP$erhY-JtRYCQH-CA7 zvXxm+vi&zV;{F==8^wZ`-myiUV&!s+xLU0!Jla7HihBG#XcFYu&C-Edd=BO<1x;2Lc!7epgvcSsx5#geXF)YbgY#APve(v9b2|A0532YB8WR zvaCKPIW88w3Ob^MMOf*eP+NS@tAF_L&w<-4`B+YxoU2jQC(cei>Us-N#T|%0)JdMC zG->zJ>2FydBL~jC@b1}>EIj~nFR3R-qSnm;kb`-Pt}w0s`8tTJ)J#^JbrJdly`49t zf~^i&R4OM^*2!Snv*4CliVB?maLUbb)>)&EEks6x*=#tbN43jyam}GDp?`EY2Svm_ zqgMsxj42#=Hc$X|*s&hGvX_1;wz?i^_dJbkVe+7>H9}Y-8$nBaUNRFi6Amylqkzc> zna)BvNx?Z&WH^Jd^-3R{Gg>tTtu84v?e%)|$FlqZ!gamG;;brVP=gX!dGDOUTn;u` z^-H^#FW0OVmOiq731Ahl#$q3t(rWNzOf@LSSbq?t;g^1DV+E*8S!g8Gr4{{%G(-*@?76SxFfE%W?%x1iKrwviMg{02nu6B05CL2EPs>>#c~0)>;U8e ziVIVQ3ud7pAr6-T8YX}+3~&Y@126y(DHs|iVFBax`p+HlO;E?zSN}i}9gL@f^=#Jf z#SG--25JAUE!Igbu8b{gWs-Z+k$2^uXxmCu%rYpnV7&c+d0G%OpNHgg`uf?MuP~cMWtW-c(;muI*5qT;TccHzB$P!BD$^P+C9pTS&zd zU^GF61IHK!F6mA4!!I*}hV%SQ^dp_P3BW;wC4kQ>DkrCMRn>9i&J`#^k_o z-70+*X$82rq;V2s^&V5*8|qdq^0~@S#|>;D4a{t2U?G7`1$F^9ng> zsw^o=4imv)IblP6wX;ANG*5Ex=pZ&fc)#p5N`f;SDz8cdEqlf8R|~K%UN!)lXRekYs9VCyAd|o0_@9 zZYsY3`jnI%wgqCm5BHOS^na5lM(8EzAzuj%o%O;XQ-2SSRuzFUGD_$Jq3TBnnN0z9 zxE`Ig-f(A8R{=>gH*ITR8ycSSA4JO%_OG7c0k*%A9eAgFjFXaAjdubyeU(}G)uzyx z5#$*n*PiAR`>rm`H|LX$bD{|P4!+j7BULvGR6QTb0g&+qkPV3Vdxa0TS-2tuY|p|S z9&o(&Nq=mYMVbfrY}8=#aw2{vu~Yat0E`B%jH-x)>y|NliC=X7T;7bOw<^~5%5N5R z_D&}=19VXpNjZd8(Zbq=>jvXb`daPL{goxDSX<1Mi<$sEUl9B`A+dl2`_3mHkhW0m zIE}QTSBaN44Eh?8tf#U{zN-9D>_f?k;$BbwB7gf&IlUS9@Y)5@AkAlFX^m1@Bl$!i$n+oHItc7>v%n7jjm6># zT*HmzC{idi6WB*z$uyE$s6u9>x``;9IC5hVG5OXgvD5lm$bYXqY|VAmL@+4uoA zR*w=Eoa;lBv3ygN_QL^Ig)EyQGzrBrymt;==NU)!+^B)ey!UdgRFYz0V9FRww!t_{DkB+piKMA&XKw@5L4lI^U z`#irbqctKlP(}4OAjDf~KE$+0nDCUI4}5=iF3#EZg@~SY1QC(ducKX#QvgwQ=A9fOvu}XW zt;{1L6kFr`-tl#vgJm<^qMqduoqxO4zqY>;o*);k8M8TkgESGD-M;=2aP4rgdV?c`tf@Fc4h0)N8H`O7_Y2Kw3u*J$ZhW{Lmx_{+xuL8*%$8Y?v09aq>jD9ORZ~#g@?SnD=EMsCoD!<%F7k_B%h( zyeMx>A<#-Q;L4PO|uKj*reiK=cws>ijiYx~y1Fo)w5CkZk%Tma#iyVTQ z-=LnhGlk$4lL7WJ=mO0b`TR9V(fr?4FB1}5nu?8GK9{qy)3BfHXm(H??|NV)$JiA* zdg+)WST-GgP@p}f02cx~qjWh?0+R(Al2{`%3Ph;Av4tS|{HC=8{C{&qaYH!W=XDbL z3W^In{4N;;!(e@d|ATKT*9Z$wNg_3y=IDVO1M7;HSW$-gfGErW(Sj>jNBv8g*O#FK z&UiH+$UB!rN;}mC5m$m8-1Ci4wli~S(5gCfuY(>9S_7xkY*Ne3QRH*moVXBWqnPFw zz&D<=+s>+A`p=iZg@3h?!W#=pJIcBwyd~44&_zV&5e>gC%K6#Jtp-J7XG8u`*~+Zd z*=X`u{p5zyXe%HnfZBvr1~!4k3ZvhoDYO`)wQ=`8Kt^?^t?1WXmE-U~D&foVWh-zB z7T4Z^q-$vlnj`kvXx8#*;dMkr?%j()07HfBkpPL&LlZ&=7JmU8Fugt^$zPxhR?I%+ z4Hazz#gIR9V~b%HN*o99{?x#l_mM?K4jcgEP&fk9u{O*cwyBK*WWBoI_^v3+-nUMn zY>L4Vkg`&5GTUq)QpGfm05^}31R*dCaA+(l!Vlq)Ua<9D5)(O7>E!+uJEk@L5kw5D zZ^dwVsB|Qlf`40p+w3%ACQ0>VLp@dh2sRVj=A9z}I_hCvjDSsJY$XJ$OJARr{+lo^ua# z5g4O@RCk(kiq~PglOeIv|!cMcvuh?ZWA)^4~FR&sMw$^?C%I?iA zZZ4A0!h~&Vf>UvcgHIDIy6(Dk=E=07agIQga(`XYe;}xXV=n>VrCLq6efnDGz1%LE zbQCAg3x=G6;!M}){(7uy%JC=mw`w^uW% zCj1{KmmlC zsDIICJ?9>W#?|#AtSU}FT-33xuUN_b9oP9Y)Fn=A#!7Ky_^@FMkT2)N;=ABp*Mq^*K?}JE-7sRjV=N;Vsu!FArf8J4e5}gkYP;a z`TTku)l!Tpm-!`E$P2a@Non$&9ge420)Gy|P<%!fB8T_R=f73sZV-?&j?iTgoL2cn z&`)ex3cCjIE>?-`Av$1iR+9=ekXQXS2?VTNwa*nb0qfR=_D@g@?JCziMla1F(vsi{ zny{_oX^Wk|qWO6)9s9e!xAPBwG^!N%K%(Cy0Z_h8hH(7b0<2@+7pBq}9)AO- z$fOY8c?kf@C=#@Xtjn#qPHlR0O(Vuk^gZ|Wjj+ocSb&du8iwTsb4QFe z3z1KT8+<{Iob_}OnD6PS0f_WK^$0^Wy(Sze&*FRzi)~H7>kQQG6nQd!W=)c^;SKdi zgl$*t!IJqBbs+q3NlcXMrb~KTR(}c##7wW@&P4Ii@o{uKF~W6~h#|FMsAi>D4g*6G z(^MN5=uVR@P{R9(@#HG`)@J=&WW8{K_5Mi?A3QKR`nRL$HmV>!5YAywxw27|5$LJo zKV{u05uoy5Gz3q9FLc9%bQuU$>yWDNBv}X^oIfBNpIn%uPuLMLLKKH)dw&`odVrn< znLSZ91_|;eB@dV9<_PF9QE-TrY|)gIOZ?-rmFW@OY(lJnc~W=8sMW*~hW#NR|0DJ> zn@HYH=4R$ZjP?8*g7hFwf}^ujUV!}IbSi^d3@mL#>&~DvWOjdt*mMJxKIRd+)OQTO z39prjy$l*HkVk25Lq}=utLTd5;k5U@DM~U)DVF!3)uqj)+v5BF$bUD-b!0_)2ra76 z`b~s>Z*%m_dmoFex)Y@yA>{Ocf$jk`D`envLnMy92=MN^b#*eWs}Dq&x{XlAz_Clk zJw;9j2-pRN!-!I1EX?@oOUU2CgpTic25^`^RuM!)6uhZ2f#s1&RlYMx_9y&dMXTL# z)7|2pOF5BXD8BOtpnm{8$rmE|{{WVw@j^%paMZH;Hu;7afr~0r)%DLZRQJC<#`avH zkn_(C{sMNl2AZ@Zlt6D(3bg!InVeEm?&m;SZtVY)fGJ{`w>!0@%qfswGwE$GSo)X| z{`SwJDyL&z4y?(ipr_#yX>%JVF{A}HT@I_9@!)!egMp`%MSls>MG;(?M~J8(g~f;><#caVxgRk8z`AgpUkm=1xe2UOtJxcVYGH76Cni!R#FObzD~&iz z2YRCCvA^Xk#((FU)tntd8p{!Dpnh4qv`EcEM3&2`abkKCt7Qd3z)eAzohRT_XL#v* zxv3E&hn3ijWVr8T zO*#~|?1C1-trgp}B1(B}&T@H1t2`Og9KWBr^rW+EGRPo8CJR);*Pn8$ln{BmorFSw zT4jqc#?eSaXs%C~Xzp5&eDUWkl?Zz05}ubqn;C1mo4Wx?7Fjfb)Ht}~Jsg^~Qw^Tk zY>{?57=J3HS%aPG4>M_IePUu`THmwmt+Fg(9zQqHG@^sd;+a=1FC+4I=01NX`gCq6 z*!!s4Cn8+9#PoqwY{OjfV$K0i@`+GH7RwYqR$r?3o2R4iF~1OBf}lA23znE|&0yLQ z3!(zU{RVvhg-Ad1t-u&~VRy==zC^;f2Sck?34i{5jcMRitSAO{-b7nwuG1Vjcti0N`1CKr&LbGntvkH)>GB_Yi8)B-{Y7!zE4t3H_)9o8PpPW z$D2{?u30x(t2BoZ(SvD~>y0Q0vosmgb>#g%gFuZe=OCd2@}S;~mjS*mYy?HFX}pez z+LZ0JC1<}5cy3Iwly9{VR#%ML$due|RfDK5T}4@t{wTVaj9|~5QrC7ER=CWlFMqQ{ zD>%`N@|)!$(W~ldm?}A=5? zI7S*v;4Xszd+HVx6n{mNL6f?d+;XCHZWDra24(JT#Vve?0V^;rQjriT&g6HYLZ12jg?&;Vk1N1eJMe|*rL zk_m#{hMrz2rawjUn&%d?T}?1_U_Gk=&7*I^U%!x=NsF*sJV5szw0zg(oPQY*818xy zs8qp{u$pkfOmVT%jiL-xHzrz~p)$mGN>ss0IH}XN2Wzg$SCJQ<*n)Eaei%GACPsC61x?U+>2hR{>&X`K zs387|`+35xLhMD&YUuikV($Db0^lq#f(~z1wv_H2!vcFqAU{?a8dqwI0%hF5I!epe z4hm*?XBJD)OEkOb3VDNm@eM}-n^JkH6O%~N2Kj>A+DqUS#P6}}pnoP2cDgoIzWB?Z zqE9tiEC`0hP_$T~CF48}HqFNdF{ix(g-aB1@)4M6c8I=TH!n{RawVe1jun3NBC7fx zU-(se{cNDR(Q%)HoTD_d053G^E4nb>jFIjPAmclG_4AXm`Ng?EdV2yZWJ|#wNEkY7 zF`9Dbdq+KDCjs}D#eV^N4jF|t3nKQMr+L@{Xm7``fTMl2yvKd2hcok#21bn0RU}>k zEDRpO@U?dm&`2-0it!RzCIEFqX;=%Xs)8I1QRs2RV(DwdcvP88MGA;HTWt+S`^@Kz z5{52DR} z5P&UJH=1DpvVjIgYe1CkEd0iV50q~x{ZB14G0n=yxSW>)J&s8&b}Qk=Oc#DOIj?0! zLsQ#Jo+!!Y&wsEyrH&IFu}p<91#ao%#3H>(olPXfRJg|q%V_v=<=E_ZEDRtL$S{dg ztYScH61o4iqTVM-R|dV=N!5U>t{p^}GOQs`x|osfVZ=t4`a_dk4KnU95~J|bAjj*q zfU|S_t(t74y-jjWSxfi>-8;_Fg{tz9{WQb4Lhk;iIDd`F8;2wXhpS1G(l!b$J%}H_ zuR`}<*kionH!7^EurVc2u`x$&hUkk0qFno8B*vAPVjsD9ECk}hu1|}ZPikNe=nQ|F z#HYD{2*=SC2C1Y8#{~EgLj;g5A^M-lP)1fS3nPpSljrB(PoZddHu{(jA*(k{*<%AzErKZ0ED_%v-CEG(^rc5M_K20TWE%JV zY->-L6qDe_!1KUH))5C#X4tyn06j%&o^>&ocJ3as1SNm#oB-1!^O&&^f0S)VcIF)N z1(;*h42)_bnh@Vz9XFhCZ;G)QV&6{gy(WIIQfm9HSNpx~wqGBL2y0uFa@dyc2rL|S zv?-n;Yv!6j9()sz%*~$RHdcqAjmoQLgTXrcewGhb6I=-m)Ym4?7f2 zZ?Sd7)`@>-69%iStR>nhfzF-p`{3=F73>ozpf;CvhOfzt0&eCwrtT{nxTrJL1 zV+HTCc)o?Tdc)Yb&j*hv~kW8olTMK^VI2;o3CUt+Ee!9MoyeI?T75vcmKfJrdadtU) zvS6oT6cLL0E>FikdUmoYsOPU#5(8Iz`$sQaHgz9;B-vu@&FMt}3|jOBh+7Nf<&04h z%TjL`*U%x&)60q^8V{<3MLz6R=O9acVxbXjSVB^PyN-Mqko9=nE2xn4bY(5m8b9}k z=o)_+t;~sbjOYrlw{%g1yVT57E?6{xav=40%YN^B_Th<-X3)(5jp? zITg4#{60VN(O3XCY@Hqvi#M8{q9fW;itT^Luubs7(tkT5{>&fSM#s)7dyN2inT1@w z!YR!3MKuAVgvh}kK`^gJ5~vG!PYNF6L2wK-7+`Mm+}T_xkHdNzJN3}xM98E?5gTJ5 zgc+jbUtTYEjSl-c(hpt_gnc*$)ysdQO)x8v4h02VhISV#Q(ybAn)=}SG?597It(&{ zCA*qasd>ucDBh&}1J~^eJaQOFGS1<{bjo04Zc1u8&SLg%M`XTNauR^&LWKgyp$OU9 zQ}tvHSkjjO@DDPZTM`3{S~O#QQmu1g zdd5*5kdfsRm11;&Mev}!JccHXllK7vLNfoMy5C;mC~idxrc0`|Vm#;^G`fjt&oqyZ ziOgmipO&mTPffPM40kI>2TFez)}H2c9ebWBuvscV=`& zga@I(leca2Gv%7J_>MNCrD6p8CMG1kWdsMIPOFC1HS5{wP~itGsuBYW^BUL_duA+E z-N?|HE_oph34tS&nNWUp2;jc$TS10c?I6 zlUVJix|P9qF%z)){)~UVL8qf}mth&Q=4n!3FmB4Vu0meejKuvJwvNLqgd+I>D&pJo(aq!GxFn6lSf&o{Gt2%=L#TRFFbp_2}6+{2ti({XCH ze2Wz-GOd}r4C(MPPyFKUFj;Zv*4kwoM-XZQQCxN|p1%p;_;poJG3`(WzUE56q;b{* z99WTNWOfVVpND@yO;33(OySROB${=zzh!V3$5UM7VvmDgHFf8u+hnRnnhZ&><$62t)2(>_L(@DC- zrzSD?YqHbH3pPqcT#)WPQfTi|-|t{5@1Wk!#evXr-M*{t(CnYo@ z*dmXHdCj@Qp}Aj(^&&dJ#p#IsbNC>g(&q$GmB)o zs;opzBTT(p5DvcZOJ(nD8sa3Q zmqx61?1$4GV_#mYv`%8Nhy5;ZMtfY4N-?fEIfZ@JW;YP}kyshChmrGqU;^uPyL;jg zQJw~XwX;MH#GNZy{UeI*s}XSKMK)kmO{jktaJf_p*g-d>0Uph!w-~4RN6bS^lHrE@ zCdOguXv3yGw{i3AoNpJ#a02@8VIos0k;vrLD zR8hX$#4S1h>xdygYi;DFK~V<92TumvX=PqBIIN@@yxWm@^JnNAeq8xIVZDW$iZ2c5 h9(?Gw-`G)Za+dc@gLaq{i3&?=IOfWt)$})JR|09`92o!r diff --git a/providers/lock.json b/providers/lock.json index 5940454a..2f9323fb 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -5,7 +5,7 @@ "id": "spool.jellyfin", "version": "0.2.0", "archive": "bundled/spool.jellyfin-0.2.0.tar.zst", - "sha256": "ba7d8817e1fdf5dcf8d43e56ecae8b5a0100a65155bd7e9b016958bacd0ac5a8" + "sha256": "c2931a9bbe27caa6c72b07ecc0b965fcd1f821d1a81f702ff9b48353b1a0f866" } ] } From 760ad1b391abb74a80cf069f5e9e55df56ed6a97 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 08:01:56 +1000 Subject: [PATCH 056/146] chore(provider): pin the published spool.jellyfin v0.2.0 release asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same files as the local build; the pin is now the exact archive the store serves (sha256 468005d7…). --- .../bundled/spool.jellyfin-0.2.0.tar.zst | Bin 19455 -> 19459 bytes providers/lock.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/bundled/spool.jellyfin-0.2.0.tar.zst b/providers/bundled/spool.jellyfin-0.2.0.tar.zst index fd351d955f7249d41f841a705d2ffced02e368a7..19dcbc5881305339aa82c40fb6661bb7c4907356 100644 GIT binary patch delta 21 dcmexAow0caBdf+%{l7~#vi|aBnfoa1003zh3JL%K delta 16 YcmZpk!T5hVBdf+%{l5z~vi|Z006X&sT>t<8 diff --git a/providers/lock.json b/providers/lock.json index 2f9323fb..1be6a1ee 100644 --- a/providers/lock.json +++ b/providers/lock.json @@ -5,7 +5,7 @@ "id": "spool.jellyfin", "version": "0.2.0", "archive": "bundled/spool.jellyfin-0.2.0.tar.zst", - "sha256": "c2931a9bbe27caa6c72b07ecc0b965fcd1f821d1a81f702ff9b48353b1a0f866" + "sha256": "468005d7e2cc7ac349f65321833e0f1b8bfe42aa638216b7e6170c5640687b9d" } ] } From 2a12aaed39fe4bcc9fbec661abcde86f3276ff0e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 08:03:26 +1000 Subject: [PATCH 057/146] test(provider): wait for accounts to finish starting before a hot install sourceRunning() is true from launch, so a late sourceStarted from restore could land in the restart count; 4 in 30 parallel runs failed. --- tests/providers/ProviderRegistryTest.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/providers/ProviderRegistryTest.cpp b/tests/providers/ProviderRegistryTest.cpp index 0bfb01f2..ea8b6cc5 100644 --- a/tests/providers/ProviderRegistryTest.cpp +++ b/tests/providers/ProviderRegistryTest.cpp @@ -228,8 +228,15 @@ JELLYFIN_TEST_MAIN("provider-registry") const ProviderAccount *bob = find(registry, QStringLiteral("Bob")); require( bob && !bob->enabled && find(registry, QStringLiteral("Alice"))->enabled, "which one is in use is restored"); - waitUntil([&] { return registry.sourceRunning(alice) && registry.sourceRunning(carol); }, - "enabled accounts start on restore"); + // Started means described and handed to the app, not merely launched. + const auto started = [&](const QString& id) { + for (const QVariant& row : registry.accounts()) { + if (row.toMap().value(QStringLiteral("id")) == id) + return row.toMap().value(QStringLiteral("running")).toBool(); + } + return false; + }; + waitUntil([&] { return started(alice) && started(carol); }, "enabled accounts start on restore"); require(QCoro::waitFor(registry.callSource(alice, QStringLiteral("configuration"))) .value(QStringLiteral("configuration")) .toMap() @@ -243,7 +250,8 @@ JELLYFIN_TEST_MAIN("provider-registry") QCoro::waitFor(registry.install(ProviderFixture::package(QStringLiteral("fixture.test"), QStringLiteral("1.1.0")))); require(registry.module(QStringLiteral("fixture.test"))->manifest.version == QStringLiteral("1.1.0"), "the newer version is loaded"); - waitUntil([&] { return restarted == 2; }, "running accounts restart on the new version"); + waitUntil([&] { return restarted >= 2 && started(alice) && started(carol); }, + "running accounts restart on the new version"); QCoro::waitFor(registry.uninstall(QStringLiteral("spool.jellyfin"))); require(registry.module(QStringLiteral("spool.jellyfin")), "a bundled provider cannot be removed"); From d61a498a3f3da2f45d274f849a48bb025a833266 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 08:13:49 +1000 Subject: [PATCH 058/146] fix(provider): list every installed provider; missing operations are empty - A provider installed while the store was unreachable, or copied in by hand, appeared nowhere, so no account could be added for it; everything installed is now listed with the community providers - Optional list, filter and segment operations a provider leaves out answer empty instead of logging failures (found with the example provider, which has no similar or filterOptions) --- src/provider/PortableProvider.cpp | 21 +++++++++++++++++---- src/provider/ProviderStore.cpp | 19 +++++++++++++------ tests/providers/ProviderStoreTest.cpp | 3 +++ tests/providers/SourceHubTest.cpp | 4 ++++ 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index fd08a6cf..1aa91cff 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -66,6 +66,19 @@ namespace { return segments; } + // Every operation but describe is optional: one a provider leaves out + // has nothing to list, which is not an error worth reporting. + template QCoro::Task orEmpty(QCoro::Task task) + { + try { + co_return co_await std::move(task); + } catch (const std::exception& error) { + if (QByteArray(error.what()) != "unsupported_operation") + throw; + } + co_return T {}; + } + QString cursorFor(int startIndex) { return startIndex > 0 ? QString::number(startIndex) : QString(); @@ -161,7 +174,7 @@ class PortableProvider::Playback final : public PlaybackSource { QCoro::Task> fetchMediaSegments(QString itemId) override { const QVariantMap result - = co_await m_owner->call(QStringLiteral("segments"), { { QStringLiteral("itemId"), itemId } }); + = co_await orEmpty(m_owner->call(QStringLiteral("segments"), { { QStringLiteral("itemId"), itemId } })); co_return segmentsFrom(result.value(QStringLiteral("segments")).toList()); } @@ -242,7 +255,7 @@ QCoro::Task> PortableProvider::list(QString operation, QV limit = std::clamp(limit, 1, 100); arguments.insert(QStringLiteral("limit"), limit); ProviderMediaPage page - = co_await m_registry->callSourceMediaPage(m_accountId, std::move(operation), arguments, limit); + = co_await orEmpty(m_registry->callSourceMediaPage(m_accountId, std::move(operation), arguments, limit)); co_return std::move(page.items); } @@ -366,8 +379,8 @@ QCoro::Task> PortableProvider::fetchLibraries() QCoro::Task PortableProvider::fetchLibraryFilterOptions(QString libraryId, QString collectionType) { - return call(QStringLiteral("filterOptions"), - { { QStringLiteral("parentId"), libraryId }, { QStringLiteral("collectionType"), collectionType } }); + return orEmpty(call(QStringLiteral("filterOptions"), + { { QStringLiteral("parentId"), libraryId }, { QStringLiteral("collectionType"), collectionType } })); } QCoro::Task> PortableProvider::fetchItemsByIds(QStringList itemIds) diff --git a/src/provider/ProviderStore.cpp b/src/provider/ProviderStore.cpp index 48a398eb..4171c6cc 100644 --- a/src/provider/ProviderStore.cpp +++ b/src/provider/ProviderStore.cpp @@ -226,17 +226,24 @@ QVariantList ProviderStore::community() const listed.append(text(value.toMap(), "id")); } } - // Providers added by URL sit with the community ones, marked as such. + // Everything installed is listed, catalogue or not: added by link, + // installed while the store was reachable, or copied in by hand. if (m_registry) { - for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) { - const ProviderModule *module = m_registry->module(it.key()); - if (it->channel != QStringLiteral("url") || !module || listed.contains(it.key())) + QStringList shown = listed; + for (const QVariant& value : official()) + shown.append(text(value.toMap(), "id")); + for (const QString& id : m_registry->moduleIds()) { + const ProviderModule *module = m_registry->module(id); + if (!module || shown.contains(id)) continue; - entries.append(QVariantMap { { QStringLiteral("id"), it.key() }, + const Origin origin = m_origins.value(id); + entries.append(QVariantMap { { QStringLiteral("id"), id }, { QStringLiteral("name"), module->manifest.name }, { QStringLiteral("summary"), module->manifest.summary }, { QStringLiteral("version"), module->manifest.version }, { QStringLiteral("api"), QLatin1String(kApi) }, - { QStringLiteral("publisher"), it->feed.host() }, { QStringLiteral("fromUrl"), true } }); + { QStringLiteral("publisher"), + origin.channel == QStringLiteral("url") ? origin.feed.host() : module->manifest.publisher }, + { QStringLiteral("fromUrl"), origin.channel == QStringLiteral("url") } }); } } return annotate(entries); diff --git a/tests/providers/ProviderStoreTest.cpp b/tests/providers/ProviderStoreTest.cpp index 5db7b133..16ce3899 100644 --- a/tests/providers/ProviderStoreTest.cpp +++ b/tests/providers/ProviderStoreTest.cpp @@ -240,6 +240,9 @@ JELLYFIN_TEST_MAIN("provider-store") waitUntil([&] { return !registry.module(QStringLiteral("fixture.other")); }, "a provider can be removed"); waitUntil([&] { return listed(store.community(), QStringLiteral("fixture.linked"), "fromUrl"); }, "providers added by link are remembered across launches"); + QCoro::waitFor(registry.install(ProviderFixture::package(QStringLiteral("fixture.manual")))); + require(listed(store.community(), QStringLiteral("fixture.manual"), "installed"), + "an installed provider is listed even with no catalogue entry for it"); store.checkForUpdates(QStringLiteral("ask")); waitUntil([&] { return store.updates().size() == 1; }, "a newer official release is offered"); diff --git a/tests/providers/SourceHubTest.cpp b/tests/providers/SourceHubTest.cpp index 8fd73b76..088322f7 100644 --- a/tests/providers/SourceHubTest.cpp +++ b/tests/providers/SourceHubTest.cpp @@ -124,6 +124,10 @@ JELLYFIN_TEST_MAIN("source-hub") byIds.size() == 3 && byIds[0].id == remoteItem && byIds[1].id == latest[1].id && byIds[2].id == latest[0].id, "lookups across accounts keep the order asked for"); + require(QCoro::waitFor(hub.fetchSimilarItems(remoteItem)).empty() + && QCoro::waitFor(hub.fetchLibraryFilterOptions(hub.scoped(remote, QStringLiteral("lib")))).isEmpty(), + "operations a provider leaves out answer empty rather than failing"); + ArtworkSource::ImageRequest image; image.itemId = remoteItem; image.tag = QStringLiteral("t"); From f29c1a8477825082790c139dde5931f362726315 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 08:19:18 +1000 Subject: [PATCH 059/146] docs: one providers.md for the platform as built; AGENTS.md seam section Replaces five planning documents that described the native client and the single-provider seam. --- AGENTS.md | 81 ++--- docs/portable-provider-decisions.md | 39 --- docs/provider-channel-policy.md | 48 --- docs/provider-next-agent-plan.md | 307 ------------------- docs/provider-portable-brief.md | 444 ---------------------------- docs/provider-split-plan.md | 366 ----------------------- docs/providers.md | 61 ++++ 7 files changed, 85 insertions(+), 1261 deletions(-) delete mode 100644 docs/portable-provider-decisions.md delete mode 100644 docs/provider-channel-policy.md delete mode 100644 docs/provider-next-agent-plan.md delete mode 100644 docs/provider-portable-brief.md delete mode 100644 docs/provider-split-plan.md create mode 100644 docs/providers.md diff --git a/AGENTS.md b/AGENTS.md index 195e8407..76205034 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,64 +29,31 @@ ## Module Seam -The goal is one core that several backends can sit on (spool-jellyfin, -spool-plex, spool-stremio). `CMakeLists.txt` groups sources into -`SPOOL_PLATFORM_SOURCES`, `SPOOL_PLAYER_SOURCES`, `SPOOL_SHELL_SOURCES`, -`SPOOL_JELLYFIN_SOURCES` and `SPOOL_LOCAL_SOURCES` to mark where that cut -goes. They still build as one library; the grouping exists so the split stays -mechanical. +The app knows no media backend. Every source is a provider package (JS logic +and QML screens) run by `src/provider/`; `docs/providers.md` describes the +pieces and `sdk/` is the provider contract (API 0.2). Jellyfin lives in +spool-player/spool-jellyfin and is bundled from the pin in +`providers/lock.json`. -- Core (the platform, player and shell groups plus everything under - `src/platform`, `src/player`, `src/media`, `src/provider`, `src/common`, - `src/cache` and `src/diagnostics`) never includes `src/api/`, - `src/discovery/` or `src/providers/`. `tools/check-module-seam.sh` enforces - that and runs as the `module-seam` ctest; `--strict` also treats every file - still listed in `SPOOL_JELLYFIN_SOURCES` as provider and passes too. -- A media source is a `Provider` (`src/provider/Provider.h`): an id, a - display name, capability flags, and the interfaces it serves. `playback()` - (`PlaybackSource`), `catalog()` (`Catalog`) and `artwork()` - (`ArtworkSource`) are required; `search()`, `itemState()`, - `streamQuality()`, `groupPlayback()` and `remotePlayback()` are null - without the matching capability. Session-shaped hooks (`ready()`, - `restoreFromStorage()`, `setDeviceId()`, `handleUnauthorized()`) and - signals (`sessionStarted`, `sessionEnded`, `busyChanged`, `errorOccurred`, - `toastRequested`, `contentChanged`) are how the app hears about the source - without knowing which one it is. `AppController` is the composition root, - is core, and talks to the source through nothing else. -- `ProviderRegistry` holds every provider the build knows and the active one, - and publishes the active flags as the `ProviderCapabilities` QML singleton - (twelve booleans: auth, discovery, search, userItemState, - playbackReporting, segments, libraryManagement, syncPlay, remoteControl, - quickConnect, peerRelay, streamQuality). Shared QML gates every - provider-specific control on one of those names; a provider registers the - singletons only its own QML reaches (`Session`, `SyncPlay`, - `RemoteControl`, `Management`, `QuickConnect`, `Discovery`, - `DiscoveredServers`) from `registerQmlSingletons()`, and shared QML never - names them without first checking the capability. -- Two providers exist. `JellyfinProvider` (`src/api`) composes everything - Jellyfin-specific: the facade, discovery and its cached server list, the - session, QuickConnect, remote control, and after `attach()` SyncPlay, - library management and the settings bridge; it owns their session-driven - lifecycle. `LocalProvider` (`src/providers/local`) serves a folder of media - files with no server: it is deliberately simple, proves the shell needs - nothing a folder cannot give it, and stays as the contract's permanent - fixture (`local-provider` ctest). `main.cpp` registers both and activates - one: `--provider ` or `SPOOL_PROVIDER` (default `jellyfin`), and for - the local one `--library-root

` or `SPOOL_LOCAL_LIBRARY` (default the - Videos directory). With no `auth` capability the shell opens on home. -- The player talks to its media source only through `PlaybackSource`, the - queue and pages through `Catalog`, `SearchSource` and `UserItemStateSink`, - the quality picker through `StreamQualityControl`, and SyncPlay and remote - control through `GroupPlayback` and `RemotePlayback`. `JellyfinApiFacade`, - `SyncPlayController` and `RemoteControlController` implement those. - `SettingsController` emits the preferences a source needs and takes the - account's remote settings back through `applyRemote*()`; - `JellyfinSettingsBridge` in `src/api` is the Jellyfin side of that. Its - schema drops the account rows when the active provider has no `auth`. - `configurePlatformPlaybackCapabilities()` takes an applier callback, not - the facade. Nothing under `src/platform` or `src/diagnostics` includes - `AppController`: the platform layer takes `ApplicationHooks` and the render - benchmark `RenderBenchmarkHooks`, both filled in by `main.cpp`. +- Core is everything under `src/` except `src/providers/` (native providers; + only `LocalProvider` today) and `src/main.cpp`, the composition root. Core + never includes `src/providers/`; `tools/check-module-seam.sh` is the + `module-seam` ctest. +- The app sees one `Provider`, `SourceHub`, which routes to every enabled + account. IDs leaving it are scoped (`<8 hex>:`) and stay opaque to + routes, caches and QML. Pages reach media through `Catalog`, + `SearchSource`, `UserItemStateSink`, `ArtworkSource`, `PlaybackSource` and + `StreamQualityControl`, never a provider type. +- Shared QML gates optional controls on the `ProviderCapabilities` singleton + (search, userItemState, playbackReporting, segments, groupPlayback, + remoteControl, streamQuality, trickplay): what the enabled accounts can do + between them. Provider-specific UI is the provider's own QML, mounted by + `shell/ProviderSurface` with a `ProviderUiContext`; item-menu entries come + from the manifest's `actions`. +- Group playback and remote control are generic (`GroupPlaybackController`, + `AppControllerRemote.cpp`); providers translate their protocol into + `group` and `remote` events. Do not touch `src/player` or + `qml/pages/Player*` for provider work beyond renames. - `qml/primitives` and `qml/theme` reach exactly two singletons, `Art.url` and `Settings.uiScalePercent`. Keep it that way; page- and shell-level QML is where backend-shaped data belongs. diff --git a/docs/portable-provider-decisions.md b/docs/portable-provider-decisions.md deleted file mode 100644 index 7c944f75..00000000 --- a/docs/portable-provider-decisions.md +++ /dev/null @@ -1,39 +0,0 @@ -# Portable provider implementation decisions - -## Baseline - -Inspected `refactor/provider-seam` at `e0de68c18c3740bc22421b99848fa9f7b2f0e788`. -`nix run .#tests` built the native release app and passed 77/77 tests before the implementation changes. This is desktop correctness evidence, not TV performance evidence. - -The current application has a single active `Provider`, source-global artwork and playback credentials, native Jellyfin authentication/API logic, and hardcoded Jellyfin route components. These remain migration work until explicitly marked completed below; the existence of a script runtime does not complete their replacement. - -## Jellyfin parity inventory - -The inspected `JellyfinApiFacade` surface requires all of the following before removing the native client: - -- Authentication by name and QuickConnect; current account identity, configuration and policy; cultures; discovery and manual server entry; stored profiles and credentials. -- Libraries, query options and filters, bounded browse pages, item details, bulk ID lookups, seasons, episodes, resume, next-up, latest, search, suggestions, similar titles, and person credits. -- Playlists: create, append/insert, remove, move and rename. Collections: create, append and remove. Item rename and deletion. -- Favourite, played and playback-position state. -- Media-source selection, subtitle and audio selection, direct/remux/transcoded negotiation, device profiles, quality limits, bandwidth policy, segments and trickplay. -- Start/progress/stop reports, session capabilities, remote-session discovery and commands. -- SyncPlay groups, server clock and ping, buffering, scheduled controls, queue replacement/editing and WebSocket events. -- Provider login/profile UI, management, remote-control and SyncPlay UI, plus account settings integration. - -Native transport, playback, rendering, bandwidth measurement and generic timing policies should remain native. Endpoint construction, backend parsing and backend session protocol belong in the independently packaged client. - -## Decisions - -### QML warming - -Retain `QQmlComponent` objects rather than instantiate every provider screen. Compilation uses `QQmlComponent::Asynchronous`, begins after the first frame plus a delay, and processes one component at a time. This avoids running hidden authentication flows, completion handlers or timers. Existing memory-budgeted route-instance caching serves a separate purpose and remains in place. Memory pressure abandons warming for the process lifetime and releases retained components. Retention is not a guarantee that every frame or object creation will be below 100 ms. - -### Experimental worker profile - -The draft worker profile uses module exports, explicit source factories and Promise-based operations. It does not assume Node/browser globals or async-function syntax. Host-authorised HTTP origins and persistent source IDs are supplied by native callers. Backend JS and its native transport live on one worker thread per runtime; native owned results cross the thread boundary. An interrupted module must be discarded, not silently resumed. - -This in-process runtime is for trusted/reviewed code. A QJSEngine is not a sandbox, and a host facade alone cannot enforce downloaded-QML security. Independent downloaded-package activation must not be enabled until package verification and channel policy are implemented. - -## Unexercised external gates - -No webOS device was built, deployed or tested. Minimum supported device/firmware performance, interpreter-only on-device timings, Android/TV and Apple/Google store approvals remain unverified. Desktop test timings must not be represented as those results. diff --git a/docs/provider-channel-policy.md b/docs/provider-channel-policy.md deleted file mode 100644 index 4d80005b..00000000 --- a/docs/provider-channel-policy.md +++ /dev/null @@ -1,48 +0,0 @@ -# Portable provider channel policy and review notes - -Status: implementation/review preparation, not a store approval or a claim that the package manager exists. Checked primary policy text on 21 September 2026. - -## Current implementation - -The app still uses its bundled native Jellyfin client. The experimental worker and source-package tooling do not expose an application installer or remote activation path. No remote switch can enable downloaded provider code. The standalone source ZIP validator does not authenticate its publisher, and the runtime is not a hostile-code sandbox. - -## Required delivery policy - -| Channel | Bundled source packages | Downloaded source activation | -| --- | --- | --- | -| Windows/Linux/direct macOS | Same pinned provider sources | Only after authenticated catalogue, verified archive installation and restart recovery are implemented | -| webOS Developer Mode | Same pinned provider sources | Same trust gate, plus actual unrooted app-data loading/import/interpreter validation | -| Android outside Play | Same pinned provider sources | Same trust gate and app-private storage; no native provider payloads | -| Android/TV Google Play | Bundle official packages regardless | Seek review of the interpreted JS/QML host surface; never an APK self-updater or native library installer | -| iOS/tvOS/Mac App Store | Multiple bundled providers from the same source implementations | Keep disabled pending review/permission for the actual hosted software and native API exposure | - -Package `channels` declarations express eligibility, not authority. Only the host's compiled distribution policy and verified publisher/catalogue metadata may permit activation. A provider must not grant itself new access, enable an installer, or change the channel. - -## Google Play reviewer explanation - -Proposed feature: Spool is a native media client for user-authorised media servers. Portable providers implement endpoint/authentication protocols and optional Qt Quick screens. They are not remote media data interpreted as program code. The native application continues to own transport/TLS, media models, playback/decoding/rendering and platform integration. - -The experimental implementation uses a separate worker QJSEngine for protocol logic. Provider operations receive an origin-scoped HTTP facade, not an Android Context, general shell, arbitrary filesystem or unrestricted player commands. Redirects and implicit cookie propagation are disabled. Source removal and watchdog interruption settle pending operations. GUI-side QML is a separate execution surface and must be reviewed independently; worker origin checks alone do not constrain every QML network/resource loader. - -The intended delivery is reviewed source-only ZIPs with manifests and explicit access requirements. Archive tooling rejects native executable payloads, plugin declarations by unsupported file type, unsafe paths, symlinks and size-limit violations. These static checks do not replace content review or a secure catalogue. The final implementation must authenticate artifacts and metadata, enforce freshness/revocation, preserve accounts on incompatible updates, and provide disable/recovery controls before downloaded activation is submitted. - -There is no intended APK, DEX, JAR or native `.so` download path in the Play channel. Host updates must use Google Play. The app must not use provider delivery to bypass permission, payment, intellectual-property, privacy, network-abuse or content policies. - -### Evidence required before submitting the feature - -- An actual Android/TV build containing the declared Qt/QML import set and showing both bundled and verified source-loaded operation. -- Reviewer-accessible demo server and synthetic/public-domain media, not personal credentials. -- Recording of install/account setup, access consent, version inspection, update/restart, incompatible-package rejection and disable/recovery. -- Exact host-facade and native-type exposure inventory, including QML imports, dynamic loading, external navigation and network-capable controls. -- Signed metadata/rotation/revocation design and tests; package validation alone is insufficient. -- Privacy/Data Safety declarations that match credentials, configured server URLs, diagnostics and remote-provider access. -- Content-access policy and moderation/reporting mechanisms appropriate to the actual offered services. -- Confirmation that downloaded packages cannot enable a host updater, invoke arbitrary processes, or load native plugins. - -These materials are an engineering/review checklist, not completed submissions. The current implementation is not ready for the downloaded-code submission. - -## Policy basis - -Google's [Device and Network Abuse full policy](https://support.google.com/googleplay/android-developer/answer/16559646?hl=en) prohibits self-updates outside Play and downloading executable code such as DEX/JAR/native libraries, with an exception for code running in a VM/interpreter providing indirect Android API access. It separately requires runtime-loaded interpreted code not to allow potential Play-policy violations. The exception does not automatically approve Qt JS/QML provider delivery. - -Apple's [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) section 4.7 permits certain non-embedded software but requires prior permission to expose native platform APIs/technologies (4.7.2), explicit consent for data/permission sharing (4.7.3), a software index with universal links (4.7.4), and age/content safeguards. Sections 2.5.2 and the Mac App Store-specific 2.4.5 rules also require review of the actual distribution design. Until that review is resolved, use bundled-provider updates through app releases; do not promise approval or remotely unlock downloading. diff --git a/docs/provider-next-agent-plan.md b/docs/provider-next-agent-plan.md deleted file mode 100644 index 8d10bd38..00000000 --- a/docs/provider-next-agent-plan.md +++ /dev/null @@ -1,307 +0,0 @@ -# Spool portable providers: remaining implementation and A/B handoff - -**22 September 2026 · implementation brief, not a completion claim** - -## 1. Objective and authority - -The next useful milestone is a build in which the user can select **native Jellyfin or portable JavaScript Jellyfin**, browse the same real library, search, open details and play a selected version, then compare responsiveness. Do not make that milestone wait for the whole provider ecosystem, updater or Stremio implementation. - -The final architecture remains one ruthlessly fast native Qt/libmpv application with multiple first- and third-party JS/QML providers and multiple accounts/servers usable concurrently. Provider authors publish **one source ZIP per release**, not a native platform/CPU build matrix. Official Jellyfin is bundled; independently installed compatible packages can override bundled packages on supported channels. Provider implementation, account, source, item and version identities are different things. - -The implementer may change interfaces, directory layout and sequencing when code or measurements justify it. Prefer a simple correct implementation over speculative frameworks. Rewrites are cheap at this stage: reuse working behaviour, tests, the native player/rendering/model infrastructure and useful dependency boundaries, not an unsuitable implementation merely because it exists. Preserve user credentials and authoritative state; disposable cache schemas may be reset deliberately. - -### Non-negotiable performance boundary - -``` -native controller / QCoro call - -> provider operation on its worker-owned QJSEngine - -> native asynchronous HTTP - -> worker-side JSON parsing and provider normalization - -> bounded conversion into native-owned page data - -> source/generation validation - -> bounded GUI-thread model updates - -> existing Qt Quick delegates and native scene graph -``` - -No backend-provider invocation from model-role reads, layout, scrolling or per-frame rendering. No worker QJSValue or QObject crosses into the UI engine. Small QML handlers may run on the GUI thread; backend calls, substantial transforms and broad result processing must not. Do not insert stringify/parse round trips between JS normalization and native data. Repeatedly visiting a cached page should bypass the provider when its freshness policy permits. - -Target the first useful page frame in **under 100 ms when the necessary response/cached data is available** on the minimum supported webOS device. Measure input-to-useful-frame separately, including network time. Preserve smooth animation while work is pending. A 100-ms transition allowance is not permission to block the GUI for 100 ms. Start with a provisional approximately 1–2 ms budget for an individual GUI-side model commit, then validate it on hardware; a fixed number of rows is not a time guarantee. - -No minimum webOS model/firmware is actually established in the supplied materials. Use available hardware for labelled provisional results and settle the supported minimum explicitly before claiming acceptance. Do not silently replace “minimum webOS” with a developer desktop or an assumed C2. - -## 2. Baseline and this handoff - -The uploaded Git repository is on `refactor/provider-seam` at: - -``` -c1cb0652a9d72ae954602482511f3d147e4f37c9 -``` - -The accompanying session report is `PORTABLE-PROVIDER-SESSION-20260921.md`. It reports 85/85 tests passing **before this handoff**. It explicitly says normal application browsing/playback still uses native Jellyfin. Stremio, the application cutover, independent package installation and device validation are not done. - -Two local implementation commits have been added on `review/provider-ab-handoff`: - -| Commit | Change | Important limit | -| --- | --- | --- | -| `215f67b` | Start route timing before synchronous construction; correctly classify cache hits; avoid double activation on synchronous promotion; use a cancellable benchmark deadline; record runtime/environment/failure metadata; reject unlike/incomplete report comparisons. | Timing schema 2 needs fresh baselines. Actual Qt rendering and full app compilation were not run here. | -| `c21f977` | Add worker-side, directly decoded native media pages with source identity, external IDs, opaque cursor, optional total and exact ticks. Expose `callMediaPage` and generation-guarded `callSourceMediaPage`; share cancellation/watchdog handling with small RPCs; add tests. | Listing foundation only. Normal app wiring remains native; these new C++ changes require compilation and Qt tests. | - -The documentation commit containing this plan follows those commits. No remote was pushed, no provider repository was modified, and no machine/TV belonging to the user was contacted. The original untracked `spool-portable-provider-implementation-plan.md` was left unchanged and uncommitted. - -### Verification in this environment - -Passed: - -- `tests/tools/RenderBenchmarkReportTest.py` against the updated comparison tool, including incomplete-run rejection and mismatched timing/environment cases. -- Ten `tests/tools/provider_package_test.py` tests. -- `tools/check-module-seam.sh --strict`. -- Five deterministic scenarios executing the actual `RouteStack.showRoute`/`handleLoaded` functions with JS loader/clock doubles under Node. The original function fails the cold-miss regression; the new function passes. This is a function-ordering test, not a QML rendering benchmark. -- Syntax check of the changed JS fixture; the existing bundled Jellyfin protocol smoke fixture under Node with only its import URL redirected to the bundled source. This is not verification in Qt's engine. -- `git diff --check`. - -Not run: the new C++ tests, the full Qt suite, actual application A/B tests, renderer timing, physical TV tests, store tests. CMake stops at Qt discovery because this environment has no Qt 6.11 development installation or Nix. Do not lower the project's Qt requirement just to accommodate this environment. The supplied handoff archive includes the configure failure log and patch application instructions. - -## 3. What the uploaded benchmark actually measures - -Source reports: `provider-baseline-warm.json`, `provider-baseline-cold.json`, `provider-baseline-summary.json` and the session report. The raw files are preserved separately in the handoff archive. - -- Linux desktop, offscreen Qt Quick **software adaptation**, native local-provider route workload. -- Five measured iterations, 30 transitions each. The route script is `home -> search -> settings -> home -> libraryGrid -> openSourceNotices -> home`; already-current routes are skipped. -- No named library (`library` is empty), no scroll samples, no submitted search query in this script. Opening a search page is not executing a backend search. -- All warm samples report zero instrumented delegate creations. Every cold sample reports `cacheHit: hit` and a `:warm` name. Neither observation establishes a populated media-view workload. -- The harness requests updates every **8 ms**, irrespective of the nominal 16.667-ms frame budget. That is not a physical 60-Hz display measurement. -- The “cold” option calls application critical-memory-pressure eviction before transitions. It is not a fresh process or a flush of every OS, Qt disk or AOT cache. - -Nearest-rank results, recomputed from the supplied raw samples: - -| Route | Warm wall median / p95 (ms) | Eviction wall median / p95 (ms) | Eviction GUI CPU median (ms) | -| --- | ---: | ---: | ---: | -| Home | 6.754 / 7.266 | 20.515 / 28.374 | 4.971 | -| Library grid | 6.115 / 6.432 | 4.116 / 4.743 | 2.544 | -| Open-source notices | 6.409 / 6.695 | 5.270 / 5.683 | 3.716 | -| Search page | 6.717 / 6.789 | 3.106 / 3.633 | 2.102 | -| Settings | 6.729 / 7.149 | 28.919 / 34.278 | 9.044 | - -Home has ten samples; each other route has five. The pooled p95 values (7.149 and 29.205 ms) are not per-route guarantees or robust tail estimates from a large experiment. - -### Measurement defects found in the uploaded source - -`qml/shell/RouteStack.qml::showRoute()` called synchronous `loaderFor()` and potentially completed a promoted loader **before** starting `InputLatency.beginUiTransition()`. It then classified the newly created ready loader as a cache hit. Construction could therefore be excluded from both the wall and GUI CPU sample. This is reproduced by the function-level regression test, not merely guessed from the labels. - -The first handoff commit moves classification and timing before construction. It preserves the foreground loading policy rather than mixing measurement repair with a speculative UI performance rewrite. A promoted loader can emit `onLoaded` synchronously, so the new pending-loader guard also prevents double activation. - -The benchmark also left a separate uncancelled timeout for each step. A successful earlier step's timeout could interrupt a later awaiting step. It now uses one owned deadline timer, records failures and fails incomplete runs. New metadata records the timing origin, QPA platform, Qt/ABI, renderer API, dimensions, requested interpreter state, frame-pump interval and actual native provider identity. A new `requestToSampleMs` stopwatch offers a cross-check around the route request. - -### Interpretation - -These numbers do **not** measure an added JS-provider cost. They provide no basis for claiming an extra two frames caused by JS. The approximately 8–10 ms of measured GUI CPU in the settings eviction samples is worth investigating on weaker hardware, especially since some construction was omitted. That is a pre-existing/cross-cutting UI concern, not a reason to revert the provider language decision. - -`presentMs` includes time after content readiness until Qt's observed frame callback; it is not panel latency. `actualSwaps` counts Qt callbacks. `budgetIntervals` rounds total time into nominal intervals; it is not dropped frames. `maxGapMs` is periodic timer lateness, not continuous GUI occupancy. Zero lateness in a short sample does not prove there was no blocking. GUI CPU is cumulative thread CPU across the measured interval, not necessarily one uninterrupted function. - -Do not scale desktop wall time by an invented TV CPU multiplier. Do not treat the software adaptation as Mesa llvmpipe or as the TV's GPU path. Qt documents the separate software adaptation and the semantics of frame callbacks [R1–R3]. - -## 4. Immediate work: obtain a trustworthy test build - -### Step A — validate the local commits and keep a native control - -1. Apply the handoff commits on the existing branch without rewriting its history. Preserve unrelated dirty/untracked work. There is no need to recreate the main repo or the Jellyfin repo. -2. Build with the project's pinned toolchain. The supported full entry point is `nix run .#tests`; targeted runs can use the generated build directory from that workflow. Run at least `route-transition-timing`, `provider-media-page`, `provider-media-page-interpreter`, `script-runtime`, `source-registry`, `provider-ui-context`, the bundled Jellyfin contracts and report/package tests, then the regular suite. -3. Inspect and fix any compilation/behaviour defects in the new C++ code. It was deliberately delivered with tests and an explicit uncompiled status, not presented as a green release. -4. Check the shared sink refactor with both ordinary `QVariantMap` calls and typed page calls. Validate cancellation, shutdown, invalid results and watchdog failure, not only the happy path. -5. Run the normal native application with no new selection flag and confirm unchanged user behaviour. Do not call it the JS build just because a script module was registered or warmed. -6. Re-record native route baselines using timing schema 2. Keep renderer, scale, window size, viewport and cache mode consistent. Do not compare old post-construction totals with corrected totals as a regression. - -Avoid spending this phase on renaming every namespace, extracting another shared repo, changing archive compression or implementing backwards compatibility. The API remains experimental 0.x; extensions are separately versioned. - -### Step B — wire the first real JS catalog/search path - -Use the existing provider interfaces and native models where appropriate. Add a portable/source-bound adapter that invokes actual bundled Jellyfin operations through the registry and returns typed values to the controller. `callSourceMediaPage()` now supplies the expensive worker-side part; it is not yet a `Catalog` or `SearchSource` implementation. - -The new `ProviderMediaPage` is intentionally a listing envelope. Each item retains `sourceId`, its current `MovieItem` fields and normalized external IDs. Keep that source identity through navigation, caches and subsequent operations; do not discard it to fit an old global-source assumption. Extend/refactor native models if necessary. Do not quietly route details/artwork through whichever account is selected later. - -Map real operations: libraries, bounded filtered/sorted browse, paged search, resume, next-up, latest, seasons/episodes, similar and details. First make a narrow real vertical slice work, then add the rest. New typed decoders are appropriate for large details/variant payloads; small authentication/settings operations may keep the bounded generic RPC path. Do not relocate bulk result conversion to GUI coroutine continuations. - -Resolve pagination deliberately. The old `Catalog` API takes numeric offsets; portable pages expose opaque cursors and possibly unknown totals. A temporary Jellyfin-only adapter may translate its known offset protocol, but core must not assume every provider cursor is an integer. Retain per-query cursor/exhaustion state; reject non-advancing repeated cursors or bound recovery. Do not treat an empty filtered page as final when it advertises a valid advancing cursor. Preserve cancellation and request-generation checks before committing rows. - -**Prevent false performance wins:** validate row counts, ordering, metadata used by delegates, artwork and page bounds against the native path. Returning fewer fields/items or an empty result is not an optimization unless the same product behaviour is preserved and the workload difference is explicitly measured. - -### Step C — bootstrap a real account, then bind resources correctly - -For the first A/B build, reuse the user's existing native saved-account/login UI as a temporary bootstrap into the portable source registry. Do not block that test on completing all provider-owned login screens. Clearly label this temporary mixed bootstrap; it must not proxy catalog/search/playback requests to the native facade. - -Resolve the saved profile to stable host-created account/source identity. Supply the current server/user/token/device/locale configuration and explicit allowed origins to the JS source factory. Reconfiguration must not generate new source IDs or overwrite the credentials of another account. Never put tokens in CLI arguments, benchmark outputs, public metadata or source IDs. - -Adopt source/resource-specific artwork and playback descriptors. Existing artwork handling still has global-auth assumptions. The UI must not show a real library by silently using a native global credential singleton. Image, subtitle and video headers/trust context can differ and must follow their resource. Keep native downloads, decoding, caching and GPU upload. - -Integrate the existing TLS trust policy with worker networking; self-signed/local-server handling must not become blanket `ignoreSslErrors()`. Unauthorized responses must produce a useful source-specific state rather than wiping unrelated results. Do not invent missing storage or credential-service support because the manifest names a permission. - -### Step D — add a genuine backend selector and enough playback - -Introduce a temporary explicit native/portable-JS Jellyfin selector (CLI/environment or developer setting; choose the smallest suitable surface). No flag currently exists for this. Make diagnostics report the **effective** backend, not just the requested value. Reject an unavailable JS selection rather than falling back silently. - -Select before composition and keep the same core UI, fonts, model policy and playback implementation. Ensure only the chosen path submits catalog requests and playback reports. Reusing a native session bootstrap must not instantiate duplicate progress reporting or protocol timers. Delayed QML warming, disk caches and diagnostics settings must be matched between comparison runs. - -Wire basic details, variant discovery, exact variant selection, direct playback and start/progress/stop reporting. Check seek/resume, audio/subtitles, error handling and the currently selected version. Follow-up work extends transcoding/device profiles and the remaining parity matrix, but a variant must never silently become a different edition/file. - -### First user-testable milestone: acceptance - -- User can explicitly choose native or JS Jellyfin in the same build. -- Actual source provenance shows JS calls for the operations under test. -- A real account browses and paginates a populated library, searches, opens details and plays the chosen version. -- Counts, visible metadata, artwork, identity and basic state/reporting are correct. -- Cancellation/back-navigation cannot publish stale results or corrupt selection. -- Scrolling never calls the backend script for existing row data. -- Repeated/cold page transitions can be measured on identical build/runtime conditions. -- Known unsupported features are visible in the handoff; there is no silent native fallback. - -Do not claim this milestone merely because all unit tests pass or local-fixture navigation is fast. Keep the native implementation until the comparison and parity work have actually justified removing it. - -## 5. Performance measurement that answers the real question - -Use three complementary comparisons: - -1. **Matched native versus JS in the same current build** to attribute provider overhead without confusing it with unrelated UI changes. -2. **Current app versus the actual pre-split executable** for total user-visible regression. Apply equivalent instrumentation to a throwaway baseline build or use a common external observation method; do not compare different timer origins. -3. **Controlled response replay/loopback fixture** to isolate processing from server/network variation, followed by a real server on LAN and target-device tests. - -Record build/provider revision and package hash, effective backend, Qt version, interpreter request, actual renderer/QPA, viewport/scale, device identifier chosen for diagnostics, source count, page limit, payload bytes, result count, cache state, warmup and samples. Do not log response contents, tokens or credential-bearing URLs. If a metadata field is merely a user label rather than measured fact, say so. - -Add optional request tracing with a host-generated operation ID. Useful boundaries: controller dispatch; worker dequeue; HTTP start/response complete; JS normalization complete; native conversion complete; future received; first GUI batch committed; first useful viewport/frame callback. HTTP waiting, queueing, conversion and rendering must not be collapsed into “JS time.” Measure bytes/rows and worker/GUI CPU when available. Keep hot-path tracing disabled or bounded in normal builds; do not add an unbounded event log. - -The worker sink creates a natural place to time native conversion. A small measured hook around provider normalization can separate it from transport. Choose practical instrumentation rather than a large tracing framework. Measure memory and GC/tail behaviour across sustained navigation, not only the first tiny page. - -Workloads must include: - -- Populated movie/TV pages at ordinary sizes (e.g. the existing 72-row default), later pages and filters/sorts; already-normalized cached revisits. -- A search query actually issued to one source and then several sources; responses finishing together and out of order. -- Detail/episode/variant lists, long titles/Unicode, missing metadata, unknown totals and a representative large but bounded response. -- Custom provider selection lists with tens/hundreds of rows and the existing 2,000-candidate fixture; verify virtualization and retained focus. -- Cold process, cold source/engine, evicted page instances, cold artwork and warmed resident pages as **separate labelled cases**. -- High-bitrate playback while browsing/searching, low-memory pressure, a slow/offline source, cancellation and prolonged repeated queries. -- Interpreter-only JS as well as the platform default. JIT must not be necessary to meet the supported profile. - -For display pacing, disable the artificial pump (`SPOOL_BENCH_PUMP_MS=0`) where the normal on-screen render loop suffices; record the setting. Offscreen reports remain useful regression diagnostics but never become panel measurements. Report per-route/per-stage distributions as well as pooled summaries. Use enough repeats for meaningful tails and preserve failures rather than dropping slow samples. - -If a trace regresses, optimize the measured cause: eliminate unused payloads, avoid repeated normalization, cache native pages, reduce bridge churn, bound concurrency, improve model commits, or fix costly QML construction/bindings. Do not jump to Wasm/native providers before identifying an actual provider execution bottleneck. - -**TV safety:** current repository instructions prohibit installing/launching without explicit user permission and prohibit mutating the installed app tree. `tools/webos/bench-device.sh` currently rewrites the installed executable into a wrapper and changes ownership; do not run it as-is. Provide a supported packaged diagnostic/launch path instead, using the platform's actual supported mechanism. Do not assume root is available. No TV action was performed in this session. - -## 6. Complete the source-aware application after the A/B slice - -Registry-level source identity already exists; application ownership still needs conversion. A selected browsing source only chooses the displayed library. The player, queue, outstanding requests, artwork and state writes retain their own source context independently. - -Make source-scoped item/version references first-class in routes, queue entries, cache keys and actions. Source/account removal or disable must cancel the appropriate work and invalidate stale handles, not redirect them. Resolve player features against the playing source and UI actions against their bound source, not a global capability singleton. Run two accounts in one module and another module simultaneously with deliberately colliding backend IDs. - -Add persistent account/source management and migrations from existing profiles. Preserve tokens, settings and authoritative watch state. Differentiate disconnect/disable/remove code/remove source/delete data. Background network failure must not erase a valid configured identity or another source's result cache. - -Review source/module/operation limits against realistic concurrent use. The current bounded runtime is a useful starting point, not a promise that hardcoding four modules is the final ecosystem policy. Avoid one network burst or provider CPU burst starving native rendering. Keep runtime lifetime management off the interactive hot path; the current runtime destructor waits for its worker and should not be triggered by a routine page transition. - -## 7. Complete Jellyfin behaviour and provider-owned UI - -The published Jellyfin repo is reported at `spool-player/spool-jellyfin`; the application pins payload revision `673647c83ff64714ee745aac09829fb63db6ee42` and a deterministic 11,291-byte ZIP. A later reported repo commit updates the SDK snapshot without changing the payload. Inspect the actual current repo and local override before altering pins; do not treat every repository commit as a changed provider release. - -Use `gh repo view`/`gh repo clone` or an existing sibling checkout when available. `SPOOL_JELLYFIN_SOURCE_DIR` already supports unreleased local integration. Do not force a release/tag/pin bump for every joint edit. Do not edit the vendored ZIP as the authoritative source or silently bypass its digest checks. No remote pushes unless explicitly authorized. - -Audit the native parity inventory in `docs/portable-provider-decisions.md` and preserve relevant protocol fixtures. The JS client has endpoint coverage, not proven complete parity. Specific inspection leads from the current payload: - -- `item()` normalizes a subset of requested metadata. Album fields, some inherited artwork, dates/status, external URLs and detailed media-source information used by existing screens need auditing; details requests ask for information the mapper may not return. -- `raw.ParentIndexNumber || null` conflates season zero with missing metadata. Preserve zero where meaningful. -- `String(raw.Id)` can turn a missing ID into the literal `undefined`; validate required IDs rather than manufacturing them. -- Full source negotiation, native capability/device-profile construction, stream-quality policy, audio/subtitle selection, segments/trickplay, transcoding cleanup and event-driven updates need real-server coverage. -- SyncPlay/remote functionality needs backend events/WebSockets as well as REST endpoints. Do not describe REST stubs as feature parity. - -Port protocol operations and provider-specific controllers to JS/QML while leaving generic native timing, playback and transport algorithms native. Avoid a permanent JS facade that still calls Jellyfin's old C++ endpoints; that defeats independently maintained portable providers. - -Move Jellyfin login, linking/profile, management, remote and SyncPlay UI to the provider repository once the public UI-host facilities exist. Provider-specific UI must not require editing shared Spool routes. Preserve navigation tests and behaviour, not fixed globals such as `Session`, `SyncPlay` and `Management` in shared QML. - -The existing `ProviderUiContext`/native list bridge is useful. Finish provider route/action descriptors, creation with source context, Back/cancel, focus restoration, page lifetimes, progress/errors, localization and constrained host operations. A provider should own a complete custom file/torrent/source-selection workflow and return an explicit variant or cancellation. Core need not understand that workflow's domain. - -Keep core content models native and efficient. The generic custom-list `record` map is a separate convenience surface; profile repeated map conversion for large custom delegates and expose appropriate native roles if necessary. Do not replace all core media models with generic JS arrays because the bridge exists. - -## 8. Federated search and exact-version selection - -Build a core coordinator over all enabled/searchable source instances. Query concurrently within a fair budget; display each completed page independently, retain per-source cursor/loading/error state and discard stale generations immediately when the query changes. One unavailable server cannot block or clear successful results. - -Group **logical titles**, retaining all source-specific items/versions. Prefer compatible external IDs, scoped by entity type. Otherwise provisionally group conservative normalized title + exact year + media type. Known conflicting IDs override heuristics; an ID-less result must not bridge two contradictory identified groups. Keep missing/uncertain matches separate unless additional evidence resolves them. - -Do not use grouping as permission to overwrite watched state or resume across copies. Editions can share an identity but differ in timeline. For TV, series identity and episode ordering are separate problems; episode matching needs compatible episode IDs or validated numbering. Keep stable result-group identity, remote focus and menu selection during incremental merges or later splits. - -The chooser shows source/server and provider, quality/provenance/edition and optional **basename**, disabled by default. Unknown metadata stays unknown; do not probe every stream to fill a search screen. One source may expose many variants. Propagate the selected `VariantRef` into resolution and fail rather than substitute an unrelated edition. - -On selecting a logical title, query applicable sources for availability even if they did not return the original text-search hit, where their protocol/index supports this. Catalog matches and confirmed stream versions are different states. Cross-provider scores are not automatically comparable; begin with a deterministic understandable ranking and avoid disruptive reranking. - -## 9. Stremio and future provider validation - -Stremio is still missing. It should exercise the generic contract early after the Jellyfin A/B slice, before stabilizing APIs. A minimal real catalog -> details -> stream-list -> chosen stream -> playback flow is more valuable than completing every generic manager first. - -Inspect with `gh repo view spool-player/spool-stremio`. If absent, create that independently maintained repo with the intended ownership, matching visibility and appropriate licence using `gh repo create` once remote creation is authorized. Do not recreate `spool-jellyfin`. No nested/mutually recursive application submodules; consume the documented small SDK/test tools and export/pin them reproducibly. - -A Spool Stremio provider is not the same thing as each remote Stremio add-on. Implement manifest/resource handling, configurable endpoints, catalog/search pagination, metadata identity, stream discovery, request headers, subtitles and source preparation as supported. Stream-only add-ons can contribute playable versions for an identity found elsewhere; they need not supply a catalog hit. - -Provider QML owns service-specific release/file selection and preparation screens. Distinguish an existing stream, a resource awaiting preparation and an unavailable resource. Actual peer-to-peer torrent transport, if later required, is separate native/remote service functionality; do not accidentally add an entire torrent engine to every script client. - -Plex/Emby remain future implementations unless the user expands scope. Keep the interface neutral enough for their account/server enumeration, media versions, artwork and reporting. Their future presence should not require a new single-active-backend rewrite. - -## 10. Finish the experimental SDK and extension model - -Keep base API revisions at 0.x; bump for an actual breaking base-contract change, not each app build. Version feature/UI extensions independently. No historical-API adapters or universal ABI framework now. The new typed native page envelope is host implementation work, not a native provider ABI or a reason for CPU-specific ZIPs. - -Separate offered provider capabilities from required/optional host services. Unknown offered extensions can be ignored. Missing optional host extensions require real fallback paths, including loading optional QML only when its imports/types exist. Missing required extensions reject activation. Test absent extensions by using the remaining UI, not only importing a module. - -Document the actual supported JS syntax/modules and QML import baseline. Provider factories, per-source state, asynchronous lifetime, cancellation, errors, storage, credentials, resource ownership and UI mounting are the important contracts. Do not expose all concrete core-controller pointers or every private UI component as public API. - -The current validator intentionally rejects unavailable public UI modules/extensions. Keep that fail-closed behaviour until those facilities really exist; update the runtime, validator, manifest capabilities and tests together. Prefer a small real interface to a schema that advertises future services. - -Retain contract tests in the actual shipped QJSEngine, including interpreter mode. Node can supplement syntax/protocol tests but is not proof of Qt compatibility. Produce one platform-independent source ZIP; platform runtime testing is not a native compilation matrix. Host-generated QML/JS caches are disposable runtime optimizations, not required architecture-specific provider payloads. - -## 11. Provider installation, updates and distribution - -ZIP packaging/structural validation already exists. Publisher authentication and activation do not. Keep those distinct. - -Implement a small catalogue/resolver/downloader/activation path with authenticated metadata, exact hashes/lengths, trusted publishers, key rotation, freshness and revocation. Use a maintained design/cryptographic implementation; do not invent a signing protocol. Existing artifact provenance alone does not establish who the user trusts or prevent stale catalogue replay. - -Validate/extract before execution, preserving path/type/size/symlink/case-collision and executable-payload protections. Store immutable versioned packages in user/app-private data, not inside the AppImage, APK or signed application bundle. Keep credentials and authoritative provider state elsewhere. Do not run provider installer scripts. - -Select one compatible version per provider per process. Bundle official Jellyfin; let a verified compatible installed release override it on restart. Updates affect all that module's source instances, preserving identity. Keep compatible known-good recovery, but respect state schema changes and revoked releases. An offline server is not evidence that the new module is broken. - -The resolver uses base/extension/JS/QML/channel/state requirements, not exact Spool version by default. Publish artifacts first and authenticated catalogue metadata last. Stage compatible provider replacements before app-managed incompatible transitions; external/store updates must still start with incompatible modules disabled and accounts retained. One abandoned third-party provider must not block the entire app ecosystem or essential security fixes. - -Expose module installation/enabling/removal separately from account/source configuration and remote add-on setup. Provide useful failure/recovery explanations without exposing credentials. Start with trusted/reviewed packages. Qt JS/QML in-process execution is **not a security sandbox**; static import checks and a narrow facade do not make arbitrary hostile QML safe. Explicitly resolve third-party publisher trust before enabling unrestricted repositories. - -### Channel requirements - -- Desktop direct distributions: bundled packages plus verified source downloads/overrides; retain native host signing/packaging. -- NixOS: AppImage through `appimage-run` is sufficient initially. No Nix-specific provider binaries, package manager or runtime-linking project. -- webOS: test ordinary JS/QML/resource loading from application data on an unrooted target. Native-library execution is no longer required for providers. Use replacement IPK composition only if a real source/resource deployment limitation remains. Never implement it speculatively or compile C++ on the TV. -- Android/Android TV: Google Play distribution is required. Build the reviewed interpreted-source path with documented host access, signed packages and controlled imports; bundle official providers regardless. No downloaded native payloads or native self-updater in that channel. Recheck the actual current Play policy and submission requirements before release. -- Apple: same source packages/runtime, initially bundled. Obtain review/permission for the actual downloaded code and native UI/host interface before enabling independent downloads. Do not represent JS as automatic approval or enable an unapproved path later through a remote flag. Multiple bundled providers and configurable remote services remain available. - -No platform should require provider authors to maintain separate JS implementations simply because its delivery policy differs. Platform-specific optional host capabilities must be declared and handled, not hidden in native binary variants. - -## 12. Final parity, acceptance and cleanup - -Before dropping native Jellyfin, verify the full inventory: authentication/QuickConnect, saved profiles and policy, discovery/manual server entry, all browse/filter/detail/person/list operations, playlists/collections/management, user state, playback and exact version negotiation, tracks/subtitles, transcode/device profiles/cleanup, reports, segments/trickplay, remote sessions and SyncPlay events/clock/queue/UI. - -Provider-agnostic Spool watch-together is subsequent core work, not a blocker for the first JS comparison. Reuse generic native clock/drift/playback hooks; leave Jellyfin-native group interoperability optional. The proposed initial room relay is Worker/Durable Object + WebSockets, not STUN alone. Resolve each participant's own authorized stream and handle edition/timeline differences explicitly; never relay other participants' credential-bearing stream URLs as generic room state. - -Run the multi-source matrix: two accounts in one provider plus another provider; overlapping IDs; source disable/removal during search/playback/UI actions; stale replies; failed/expired authentication; foreground browsing while another source plays; large simultaneous results; downloaded override/restart/recovery; missing optional/required extensions; low-memory pressure. Validate files/resources and source configurations across actual distribution packages. - -Remove obsolete C++ protocol code, hardcoded provider globals, dead compatibility scaffolding and stale documents only once the replacement is genuinely used and tested. Keep useful fixtures, native core optimizations and deterministic contract tests. Do not keep both protocol implementations permanently merely to avoid a rewrite; the temporary native selector is a measurement/parity tool. - -## 13. Working and reporting rules - -Make small conventional commits. Do not push, publish tags, mutate remote repositories, install/launch on a TV or drive the user's desktop without the appropriate explicit authorization. Do not modify mpv for this provider work unless a demonstrated independent issue requires it; its submodule history is separate. - -End the next session with an accurate handoff: commits, commands actually run, build/test results, effective backend now reachable, unsupported features, any data/schema changes, device/channel tests not run, benchmark provenance and the exact next implementation step. Report the first useful A/B milestone when it is reached; do not disappear into the full updater or a new framework first. - -**Priority order:** validate these commits -> real JS catalog/account/artwork integration -> explicit native/JS comparison build with basic playback -> source-aware application and full Jellyfin/UI parity -> federated search/version selection and real Stremio -> verified independent delivery/channel testing -> cleanup/stabilization. - -## References and evidence boundary - -Repository paths and commit IDs above refer to the uploaded snapshot plus the local handoff commits. Original benchmark statistics come from the attached JSON files. Session-report statements are attributed reports, not tests rerun here. Estimates, budgets and sequencing are engineering decisions to verify. - -- [R1] Qt, QQuickWindow: https://doc.qt.io/qt-6/qquickwindow.html — frame callbacks and rendering lifecycle. -- [R2] Qt Quick performance considerations: https://doc.qt.io/qt-6/qtquick-performance.html — GUI-thread budgets, batching and model/worker considerations. -- [R3] Qt Quick software adaptation: https://doc.qt.io/qt-6/qtquick-visualcanvas-adaptations-software.html — distinct non-accelerated rendering path. - -The existing channel-policy document links official Google/Apple rules. Revalidate them during channel implementation/submission; this plan is not a store approval. diff --git a/docs/provider-portable-brief.md b/docs/provider-portable-brief.md deleted file mode 100644 index d8d39183..00000000 --- a/docs/provider-portable-brief.md +++ /dev/null @@ -1,444 +0,0 @@ -# Spool: portable providers without compromising the native fast path - -**Implementation brief · 21 September 2026** -**Baseline:** `refactor/provider-seam`, the supplied source ZIP, and the supplied September 18–20 commit export. -**Decision:** Qt JavaScript provider logic, provider-owned QML, native Spool services/models/rendering/playback, independently published source packages. -**Status:** A plan, not a report of completed implementation, device benchmarks, repository creation, or store approval. - -## 1. Start here: objectives and authority to adapt - -Spool is an unusually performance-sensitive media client. The provider system must extend that product, not turn its native UI into a slow scripting framework. Optimise for the weakest supported LG webOS television, not a developer workstation. Preserve libmpv playback, platform/HDR integration, fast startup, responsive navigation and consistently smooth scrolling. - -Deliver one application that can use multiple first- and third-party providers, multiple accounts, and multiple servers concurrently. It must search them together, progressively group equivalent titles, and let users select an exact available version. A selected browsing source is not a global backend switch. - -Provider authors maintain one implementation: JavaScript API/client logic plus optional QML screens. Providers own backend-specific behaviour and bespoke UI, including Stremio add-on configuration, release/torrent/file selection and preparation workflows. Core provides generous reusable functionality, not provider-specific pages. Shared library providers, Wasm, a second maintained native provider implementation, and a new web renderer are **not** part of this implementation. - -Ship official providers with the application, at least Jellyfin, so setup and normal operation do not depend on downloading code. Create separate `spool-jellyfin` and `spool-stremio` repositories. Support installing/updating compatible provider source packages independently where the distribution channel permits it, including a deliberate Google Play path. NixOS support can use the Linux AppImage through `appimage-run`; native Nix provider packaging is not a priority. - -**Treat this document as an implementation brief, not a frozen class diagram.** Inspect the actual checkout first. Preserve the requirements and observable behaviour, but change names, internal interfaces, thread allocation, schema details, milestone ordering and repository layout when evidence supports a better solution. Record consequential deviations and their tests in a short decision log. Do not ask for approval on routine engineering choices or stop after producing another plan. - -Rewrites are inexpensive at this stage. Retain useful behaviour, performance work, tests and architectural separation—not code merely because someone wrote it recently. Do not build elaborate compatibility shims around unsuitable single-source or Jellyfin-shaped abstractions. Conversely, do not rewrite the proven renderer/player simply to make the provider migration aesthetically uniform. - -### Fixed performance pipeline - -```text -Native controller requests a bounded page - -> provider JavaScript constructs the backend request - -> native asynchronous HTTP - -> provider JavaScript parses/normalises the response off the GUI thread - -> one conversion into owned native page data - -> bounded incremental updates to the native model on its owning thread - -> existing Qt Quick delegates render the native model -``` - -“One conversion” means no avoidable stringify/parse round trip or repeated full-object conversions. It does not mean zero allocations, zero copies, or one giant GUI-thread transaction. HTTP decompression, text decoding, parsing, normalisation, conversion and rendering must all be measurable stages. - -Once data is in the model, reading rows/roles and rendering existing items must not call provider backend JavaScript. Provider-owned UI may use ordinary lightweight QML bindings and event handlers; it must not move bulk data processing into those bindings. - -## 2. Performance contract and evidence - -### Goals, not retrospective claims - -Target **under 100 ms to the first useful page frame** on the minimum supported webOS device. Define that for each page/workload and report at least median, p95 and tail behaviour. For a cached page, measure input/navigation to the frame. For a remote page, separately measure request latency and response-ready to the frame. Also report true end-to-end latency; do not hide slow request orchestration by only measuring rendering. - -Network/server delays and uncached artwork downloads are not under a 100 ms guarantee. A skeleton counts as immediate feedback, **not** a completed content page. Standard pages, provider-specific pages and cold component activation all need measurements; do not meet the target by quietly excluding every cold path. - -Use the following as initial engineering budgets, adjustable with recorded measurements rather than silently relaxed: - -| Area | Starting expectation | -| --- | --- | -| GUI model commits | Usually about 1–2 ms of blocking work per event-loop turn; split expensive batches without delaying the first visible rows. | -| Ordinary provider page | A provisional background processing budget around 10–30 ms for roughly 50–100 lightweight items / 100–300 KB of JSON. This is an unmeasured hypothesis, not an LG guarantee. | -| Frame pacing | At a 60 Hz presentation target the whole frame budget is about 16.7 ms; provider activity should cause no material regression in baseline frame pacing or input response. Measure the actual presentation rate. | -| Warm navigation | Reuse native normalised pages and compiled/cached UI resources when valid; avoid invoking providers merely to redisplay cached data. | -| Idle cost | No periodic provider polling or per-frame backend work without a real feature requirement. | - -Qt recommends asynchronous processing, worker threads and short blocking functions; its performance guidance also identifies conversions and model design as potential costs. These support the direction, not the numerical Spool budgets above. [R2] - -Identify the actual minimum hardware/firmware from the project's support policy and available devices; do not assume all LG TVs, or all webOS versions, perform like a C2. If the device is unavailable, implement repeatable instrumentation and report the device acceptance gate as pending. Do not substitute desktop timings for TV validation. - -Instrument request scheduling, first/last response bytes, decompression/decoding, JSON parsing, mapping, conversion, aggregation, queued-delivery delay, model commits, delegate creation and first useful frame. Track memory, allocation/GC pressure, queue depth and concurrency. Reuse the existing `RenderBenchmark`/input-latency machinery and fixture infrastructure where useful. - -Exercise small pages, broad searches, multi-megabyte responses, thousands of stream candidates, many simultaneous source completions, rapid query changes, pagination while scrolling, and browsing during high-bitrate playback. Benchmark interpreter-only execution as well as the normal shipped engine mode. Keep payload fixtures free of credentials and licensed appropriately. - -Protect responsiveness through bounded responses, server-side pagination/filtering when available, low-overhead batch conversion, virtualised delegates, fair background scheduling and memory limits. Async HTTP alone is not enough. Do not fetch complete libraries or every media track just to show a page; do not truncate an unpaginated service response and falsely report completion. Use explicit continuation, local batching or a visible limit as appropriate. - -## 3. What the branch contributes—and what to replace - -The supplied commit export documents the work below. Its descriptions and the existing plan are evidence of intended changes, not proof of runtime correctness or performance. The supplied source snapshot was also inspected for this plan. The implementing agent must reconcile these notes with the actual branch; the old document's “77 tests pass” statement is not independent validation in this document. [S1–S3] - -| Work / commit reference | Reuse decision | -| --- | --- | -| `adc13dbe`: `MediaTypes` moved out of Jellyfin types | Retain the native media-model foundation and useful metadata; audit fields and identity. The commit explicitly made no field changes, so relocation is not proof of provider neutrality. | -| `b9c5bec2`: `PlaybackSource`, token/header handling outside player | Retain dependency inversion, playback behaviour and tests. Replace source-global assumptions with resource/session-scoped requests. | -| `d021a76c`, `c8bf5f16`: catalog/search/item-state/artwork interfaces and core controllers | Retain native models, controllers, caching and test expectations where appropriate. These are internal adaptation points, not a frozen public script API. | -| `d45bcb52`: settings inversion | Keep the event/bridge separation. Backend settings operations belong in provider JS; remove direct backend dependencies from core. | -| `cc235686`, `c9afbcb7`: platform capability applier and application/benchmark hooks | Keep the platform and diagnostics separation. Generic host capability descriptions should replace backend-specific consumers without reintroducing coupling. | -| `542810b6`, `985829dc`: provider composition and `AppController` separation | Keep the ownership lesson and behavioural coverage. Replace broad concrete core-controller exposure, global singletons and captured “active provider” pointers. | -| `61cb6fb1`, `bf37ebe2`: provider QML moved/gated; focus-navigation tests | Retain reusable pages and navigation tests. Gating hardcoded Jellyfin globals is not a general source-aware UI integration. | -| `611b2e27`: registry/capability scaffolding | Rework into installed modules, configured source instances and per-operation contexts. A registry that selects one active provider is not the required multi-source registry. | -| `20bc39f4`: folder-of-files provider | Keep an offline fixture permanently. Add a portable script fixture exercising the actual runtime, and preserve local playback through host-authorised file access when useful. | -| `5abb2e7f`: seam checks and build grouping | Keep and strengthen the checks. Cover QML imports/routes, script imports and actual independently packaged providers, not just C++ includes. | -| `e0de68c1` / `4ad4b560`: split documentation | Replace stale architecture/status sections with this direction and measured implementation status. | - -Concrete snapshot issues to resolve include a `Provider::CoreServices` bundle of concrete controllers; one active provider wired into `AppController` and artwork; hardcoded Jellyfin route URLs/singletons; a single-source, finite-vector search interface; and playback resolution without an explicit selected variant. The archive also still combines core and provider sources in `jellyfin-core`. Treat these as inspection leads, not immutable line-number instructions. - -Remove the native plugin loader/build-matrix workstream, exact Spool-build provider indexes, mutual repository dependencies and the assumption that Catalog/Stream/Artwork must all be mandatory. Do not preserve the old C++ Jellyfin facade indefinitely behind a script-shaped forwarding layer: that would defeat independently updateable API logic. It may serve as a temporary reference and differential-test baseline during migration. - -## 4. Native host and worker runtime - -### Responsibilities - -| Native Spool | Portable provider | -| --- | --- | -| Async transport, TLS, bounded downloads, cancellation, credentials/storage services | Endpoint paths, parameters, authentication protocol, provider-specific error interpretation and retry decisions | -| Efficient typed media/custom list models, caching and incremental commits | Response parsing/normalisation and provider-specific data | -| Federated search, identity grouping, generic version chooser | Source-local queries, external IDs, version discovery and exact-source resolution | -| libmpv, media I/O, buffering, decode, HDR, timing and rendering | Playback negotiation, reports and backend-specific session messages | -| Generic routes, focus/navigation, reusable UI kit and UI-to-worker RPC | Bespoke QML pages, dialogs, actions and workflows | -| Device capability discovery and reusable selection policies | Translation to backend-specific device profiles/requests | - -A host feature should be reusable or genuinely performance/platform critical. Do not grow `host.jellyfinSearch()` or move entire backend response parsers into core to avoid rewriting them. Equally, a small generic native helper justified by measured cost is acceptable. Do not invent a universal media framework before the two real providers need it. - -### Engine and thread model - -Use a backend `QJSEngine` execution context separate from the frontend `QQmlEngine`. Keep each engine and its JS values confined to its owner thread; schedule calls through queued work, never concurrently into one engine. Engine count and thread count are implementation choices. A reasonable starting point is an isolated module execution context assigned to a small, bounded set of workers, with explicit source-instance objects inside it. Measure memory before creating one engine per account, request or page. - -JavaScript modules are cached/singleton-like within an engine. Create explicit account/source objects rather than putting “current token/current server” in module globals. Qt documents module caching, native object bridges and an interrupt facility; none of those makes arbitrary concurrent engine access safe. [R1] - -Expose small source-bound host facades, not raw `PlayerController`, `SettingsController`, models or arbitrary QObject trees. Calls to native networking/storage must execute on their correct threads; do not wrap a foreign-thread QObject and then invoke it synchronously from JS. Track request ownership by module, source, source generation and operation identity. - -Retain the frontend's coroutine-shaped interface. An internal script adapter can implement useful native interfaces with `QCoro::Task`-style results. It must settle each operation exactly once, preserve errors/cancellation, and resume work in the appropriate context. This is implementation work, not an automatic `QJSValue` to QCoro conversion. A QML-facing task adapter may be useful for small UI actions, but the main listings still consume native models. [R7] - -Specify a small portable JavaScript language/host profile and test it using the actual bundled Qt engine. Bare QJSEngine is not Node.js or a browser. Provide timers, native async HTTP, events and storage explicitly. Verify Promise/module/async syntax support before committing to author-facing examples. Prefer one small idiomatic async SDK; optional TypeScript may compile to the supported JS baseline, but must not require a second runtime or a large compatibility framework. [R1, R6] - -### Data path and lifetime - -Perform JSON parsing and normalisation on a worker, then validate and convert into owned native DTOs there. Send those values to the GUI-thread model without live JS objects or cross-thread JS heap references. Update QAbstractItemModel only on its owning thread. [R3] - -Default to JS parsing/mapping with a single native conversion, but profile actual representation costs. Avoid parsing in C++, materialising a full JS copy, stringifying it and parsing it in C++ again. Native buffers may be shared or moved when safe; correctness and lifetime take priority over speculative zero-copy claims. - -Use strings for opaque IDs, explicit time units and well-defined optional/unknown values. Validate safe integer ranges for file sizes, timestamps and backend counters; preserve large values losslessly where needed rather than silently rounding them through JavaScript numbers. Do not carry every backend's raw payload indefinitely inside every media item. - -Cancellation includes aborting native requests when possible, suppressing late results, releasing completion handlers and preventing use-after-free on source removal. Invalidate a search generation immediately when its text changes, not when the debounce timer eventually submits the next request. Disconnecting one account must not cancel or reroute other accounts. - -Provide limits on response bytes after decompression, outstanding work, per-source concurrency, stored data and returned items. Use a watchdog/interruption path for runaway backend JS and define what happens to all requests owned by an interrupted engine. `setInterrupted` can help stop JS execution; it is not a general process sandbox or a safe way to kill arbitrary native work. [R1] - -## 5. Source identity, storage and the provider contract - -Keep distinct identities for installed module, module release, account, persistent configured source, browsing selection, backend item, playable variant and playback session. Accounts and servers need not be one-to-one. A single Stremio module may manage several add-ons/configurations, while Plex may expose several servers through one login. - -Conceptually: - -```text -ItemRef = persistent source ID + opaque backend item ID -VariantRef = ItemRef + opaque variant/file/version ID -``` - -The implementation may represent these differently. What matters is retaining origin through details, artwork, caches, search groups, queues, reports, favourites and open UI actions. Host-created handles must be scoped to the caller; do not trust a provider-supplied source string as authority to read another provider's credentials. - -Store source IDs independently of tokens and URLs. Migrate existing accounts, settings, cache namespaces and resume state deliberately. Persist authoritative state separately from disposable caches. Providers backed by server state use that state; providers without it can use generic durable local watched/favourite/progress services. A heuristic title match is not permission to copy watch state across sources. - -Make request credentials resource-specific. Artwork, video and subtitles can be on different hosts. Scope tokens, TLS exceptions, cookies and redirect behaviour correctly; never apply the currently browsed source's global header to every request. Return opaque/native artwork and playback descriptors so render-time URL construction does not call JS. - -### Small base, separately versioned features - -Start with an experimental **0.x base API**, independent of Spool and provider release versions. A sensible policy is that `0.1.x` fixes do not break `0.1` consumers, while a breaking draft changes the compatibility revision to `0.2`. Document the exact rule instead of relying on loose semver assumptions. Initially load only explicitly supported draft revisions; do not implement historical-version adapters. - -The base covers module/source lifecycle, async operation semantics, error shapes and extension discovery. Keep capabilities separate and granular: catalog/browse, search, details, variants/playback, authentication, artwork descriptors, reports, segments, user state, backend group/remote control, and UI hosting as needed. “Catalog” must not imply support for every Jellyfin query. Stream-only and catalog-only sources must be representable. - -Version extensions independently, including the UI-host bridge and any public UI-kit/module dependencies. Distinguish interfaces a provider **offers**, host services it **requires**, and optional services with documented fallbacks. Capability flags may remain convenient UI views, but are not the compatibility protocol. - -Unknown offered extensions are ignored. Missing optional host extensions disable only the dependent feature when a valid fallback exists. Missing required features reject activation with an explanation. Make optional dependencies optional during module evaluation and QML loading too: no unconditional top-level import or initializer that fails before negotiation. Load dependent QML components only after their requirements are satisfied. Do not silently ignore unknown permissions, manifest formats or security requirements. - -Keep source/extension negotiation language-neutral internally, but implement only the JS adapter now. Do not design a C ABI, ship a Wasm runtime or promise future native binary compatibility. After real use stabilises the shape, an API 1.0 decision can be made separately. - -## 6. Concurrent browsing, federated search and version selection - -A browsing source controls that view only. Search all enabled/search-capable sources asynchronously, with bounded concurrency and incremental delivery. Multiple instances of the same provider must work simultaneously. Providers search their own sources; core orchestrates the federation. - -Use a page contract that can express opaque continuation, unknown total count, exhaustion, supported filters/sort orders, partial errors and provenance. Do not impose offset pagination on a cursor API or simulate unsupported sorting by returning a misleading first page. Query changes invalidate relevant caches/cursors. Any local index or fallback filtering is core-managed and explicitly scoped. - -Process each source/page independently, avoid all-or-nothing waits, show unavailable/unsupported sources honestly, and use fair “load more” scheduling. A slow source must not hold up the first results. Preserve focus/selection and scroll position while adding or merging rows. Keep stable result-group identities; do not reset the entire model or continuously reshuffle it as replies arrive. - -### Grouping policy - -Preserve namespaced external IDs with entity type and provenance; do not require TMDb as a universal primary key. Prefer non-contradictory shared IDs. Missing cross-database mappings may be cached/resolved lazily, but global search must not depend on a third-party metadata request for every hit. - -When usable matching IDs are absent, provisionally group **conservatively normalised title + exact year + media type**. Preserve sequel numbers and meaningful title differences. Missing years, fuzzy matches and ±1-year tolerance are not automatic merge rules. Known conflicting IDs prevent a merge, and an unidentified item must not bridge two conflicting groups. Allow later metadata to split provisional groups safely. - -Group series separately from episodes. Episode matching needs compatible parent identity and ordering; edition/file/timeline identity is stricter than title identity. Never infer identical cuts, resume positions or watch-party timelines from a shared film ID alone. - -Groups retain every source-specific item/version. Selecting a group opens an incremental chooser with provider, server/source or add-on label, edition and available quality metadata. Show resolution, video/HDR/audio details, bitrate and size where known. Distinguish reported/inferred values from verified ones. The optional filename setting is **off by default** and shows a basename, not private paths or token-bearing URLs. - -Discover detailed variants lazily; do not probe video files or resolve streams for every search hit. A catalog match is not confirmed availability. On title selection, query applicable source availability by identity even when another source's text search missed the title, without inventing support for APIs that cannot do that. - -Pass the **exact selected variant** into resolution. A bitrate/quality preference is not a substitute: resolution must not silently choose another server, edition or file. Carry any preparation/interaction state explicitly, return cancellation/failure honestly, and invalidate short-lived stream descriptors appropriately. - -## 7. Provider-owned QML without moving heavy work onto the UI thread - -Providers may supply complete pages, dialogs, settings flows, actions, panels and context-menu contributions—not only templates or a fixed set of forms. They can build sophisticated interfaces from native Qt Quick elements and the public Spool UI kit. Keep provider-specific layout, service terminology and workflows in the provider repository. Qt supports custom QML component types and dynamic component creation. [R4, R5] - -Provide a small UI-host extension for mounting routes/components, supplying source/item context, opening actions, returning async results, native list/tree models, navigation/focus, localisation and notifications. Let providers compose their own controls; new rendering primitives can be added to the reusable host when genuinely required. Do not expose arbitrary private shell objects as the public API. - -Provider UI runs on the GUI-side QML engine. Backend API logic remains on the worker. A UI action invokes the source-bound async bridge and consumes model updates or small result values; it must not obtain direct access to worker-engine JS objects. Large candidate/file lists use native models and virtualised views. Lightweight selection/binding logic is fine; network response parsing, bulk filtering and sorting are not. - -A generic interaction flow should support: - -```text -A core action requires provider interaction - -> mount provider-owned QML with scoped context - -> provider displays native-backed options/progress - -> return an exact variant/action result, or cancellation - -> core continues generic playback/navigation -``` - -For Stremio this permits add-on configuration, release filters, manual source/file choices and preparation status without hardcoding a torrent picker in core. The generic cross-source quality chooser can offer an advanced provider action rather than duplicate the whole detail page. - -Specify Back handling, focus restoration, keyboard/remote/touch accessibility, component lifetime, cancellation on dismissal and failure fallback. Use plain text for untrusted titles unless deliberately sanitised. Preload/cache frequently used components where measured useful, and include cold provider UI in benchmarks. - -Provider QML uses a declared set of host/Qt modules. Ensure release builds actually ship/register that allowed set: a static import scan of built-in QML cannot be assumed to find imports used only by downloaded providers. Do not import an unsupported module and hope an invisible item avoids evaluation. Component/resource/module names must not collide across providers, source instances or installed versions. - -Downloaded archives contain QML/JS **source**, not architecture-specific compiled QML artifacts. Host-generated caches are disposable and keyed to the actual runtime/package; bundled build-time acceleration may use the same source where compatible. A fallback source path must remain tested so installed updates do not depend on a developer's compiled cache. - -## 8. Trust, permissions and resource safety - -The goal includes third-party providers, but a scripting language is not proof of a security sandbox. Separate a restricted backend API from the broader Qt/QML execution environment. Audit import resolution, native type exposure, dynamic component loading, network/file access, external URL opening and inherited singleton access. Do not claim an engine/context boundary prevents malicious code from hanging the GUI or exhausting process memory. - -Initially distribute official and reviewed third-party packages through authenticated catalogues. Represent publisher identity, source provenance and declared access explicitly. Desktop/development builds may support consciously trusted additional repositories/local source directories; do not enable arbitrary unreviewed QML in a store build by default and describe it as safe. This trust decision must not require third-party maintainers to fork Spool or maintain a second provider implementation. - -Provide narrowly scoped host services for network origins, source-owned secrets, durable storage, file selection and external navigation. Support user-authorised LAN servers and HTTP where needed; a blanket private-network ban would break the product. Conversely, do not forward arbitrary tokens across redirects or grant a provider access to every configured server. Bind permissions to accounts/source contexts and request explicit approval for newly required access. - -Remote API responses, add-on manifests and content descriptions are data—not executable code. Only verified provider source may be evaluated. Reject bundled native libraries, executable installers, QML plugin declarations and undeclared native imports in portable packages. Resolve package imports/resources within verified boundaries and prevent providers from replacing shared host modules. Validate more than filename extensions. - -Account tokens and configured add-on URLs can contain secrets in paths as well as headers/query strings. Redact logs, diagnostics, telemetry, crash metadata and screenshots appropriately. Prefer opaque credential handles/injection where practical, but permit a provider to implement its own necessary authentication protocol using only its authorised credentials. No client-side distribution signing private key belongs in the application. - -Do not expose a general shell/process launcher, arbitrary filesystem APIs or unrestricted mpv command execution. Add explicit host actions for real needs. Audit network-capable QML types and resource loaders as well as the backend HTTP service; restricting one does not automatically restrict the others. - -A malicious or simply broken custom UI can still block a GUI thread. Use review, linting, benchmark tests, limited exposed types and recovery controls as initial protections; stronger isolation is a separate future project. If a required safety/policy property cannot be enforced, narrow the distribution/trust mode and document the limitation rather than pretending that a capability manifest enforces it. - -## 9. Portable packages and independent updates - -### Format and compatibility - -Use **ZIP** for the initial portable provider package. Include a manifest, JS modules, QML source, permitted resources, licence/notices and useful debug/source-map information. Use a maintained safe ZIP implementation; verify its availability and licence rather than assuming that an existing DEFLATE decoder includes archive handling. Do not spend this phase investigating Brotli/xz/zstd size differences. - -Illustrative layout, not a mandatory directory API: - -```text -manifest.json -logic/provider.mjs -logic/... -ui/SourcePicker.qml -ui/... -resources/... -LICENSE -NOTICE -``` - -The release is a source package, normally reusable across OS/CPU targets. Actual compatibility still includes the base API revision, extensions, JS feature baseline, allowed QML/host modules and channel permissions. No per-Spool-build native artifact matrix is required. Declare exceptional platform requirements honestly rather than treating all source packages as universally usable. - -The manifest/catalogue must identify module ID, independent release version, entry points, base API requirement, offered/required/optional extensions, UI/resource requirements, state schema/migrations, publisher, allowed channels and access requirements. Bind these to exact artifact bytes through authenticated metadata, hashes and sizes. Use Spool-version bounds only for genuine known incompatibilities/features, not as the routine version key. - -Do not overspecify a final wire schema before the real providers exercise it. Specify enough to implement a deterministic resolver and unit-test incompatible/missing/optional combinations before independent distribution begins. - -### Bundled plus installed - -Bundle a pinned, tested Jellyfin package in every initial release. Bundle Stremio and other official providers once ready for that channel. Bundled and downloaded copies use the same provider sources and logical runtime, not parallel implementations. - -A compatible, trusted installed update may override the bundled copy at the next startup. Select one version of each provider per process; never initialise both copies or merge their QML files. Store downloaded packages in app-private/user data, not inside the AppImage, signed `.app`, APK or package-managed installation. Keep bundled copies available for recovery, but never activate one whose contract or state schema is incompatible. - -Pin installed/loaded versions separately. An update applies to all source instances belonging to that module, preserves their persistent identities and credentials, and does not overwrite code used by existing operations. Prefer restart activation for the first release, despite scripts being easier to reload than native libraries. Qt caches imported modules, and UI/resource lifetimes make casual in-place replacement error-prone. [R1] - -### Installation transaction - -Resolve a compatible candidate against the current or proposed host and the channel's policy. Download to staging with bounded sizes, authenticate metadata/artifacts, safely extract and validate contents before execution. Defend against traversal, symlinks, duplicate/case-colliding paths and decompression bombs. Do not run provider-supplied installers. - -Commit an activation record referencing immutable versioned directories. Keep persistent state outside them, and make migrations recoverable where possible. On restart, initialise the candidate, restore sources and validate basic module/UI health before marking it good. Offline servers or expired logins are not evidence that the module code is corrupt. - -On failure, use a compatible known-good version or start a recovery UI with that provider disabled. Never roll an older package back onto a schema it cannot read, or restore an explicitly revoked artifact. Remove code and source/account data as separate user actions. - -Use an authenticated update design with key rotation, freshness/replay handling and revocation. TUF is a useful reference; reuse maintained cryptography/update primitives rather than inventing them. Temporary catalogue outages must not disable already verified functioning providers. [R11] - -### Release/update coordination - -| Situation | Behaviour | -| --- | --- | -| Provider fix supports installed host | Update independently; other providers and Spool do not need a release. | -| Latest provider requires newer host capability | Retain/offer the newest compatible version, not the latest incompatible one. | -| Spool update preserves provider requirements | Reuse existing provider ZIPs unchanged. | -| A draft API break requires new packages | Publish compatible artifacts before advertising the transition; bundled packages are tested together. | -| A third-party provider is not ready | Do not gate the entire ecosystem. Defer an app-managed incompatible transition for the affected user or explicitly update with the provider disabled and state retained. | -| Store/manual/package-manager host update arrives first | The app still starts. Revalidate packages, disable incompatible ones safely, retain accounts and offer recovery. | -| Mandatory security update conflicts with a provider | Surface the issue and allow updating securely without that provider; never silently load incompatible/revoked code. | - -Publish immutable packages first, verify they are reachable, then publish signed catalogue metadata. Preserve compatible older releases for supported draft runtimes. Catalogue absence alone is not evidence that an installed trusted version must be deleted. - -Provide a manager for installed/bundled versions, enable/disable, updates, compatibility explanations, publisher trust and recovery. Keep **installing a provider module**, **adding an account/server** and **configuring a remote Stremio add-on** distinct in the UI, even where a guided setup combines them. - -## 10. Platform and store delivery - -The default is the same portable provider architecture everywhere. Distribution policy is a host/channel concern, not separate provider source code. Do not carry forward native shared-library restrictions as though JS/QML source archives were `.so` files. - -| Target/channel | Implementation direction | -| --- | --- | -| Windows, ordinary Linux and direct macOS distribution | Bundle official packages; verify/download source ZIPs to user storage and activate on restart. Retain existing host signing/packaging. | -| Linux AppImage on NixOS | Use the same Linux app/provider packages through `appimage-run`; verify writable data paths, imports and cache behaviour. No Nix-specific provider build or `nix-ld` project. | -| webOS Developer Mode, including unrooted | Test downloaded JS/QML source loading from the real app-data directory first. Native executable mappings are no longer a provider requirement. Test interpreter-only execution, QML imports, resources, updates and restart on-device. | -| Android/Android TV outside Play | Same verified source-package path, appropriate app-private storage and host permissions. Do not add native provider download machinery. | -| Android/Android TV on Google Play | Pursue interpreted provider delivery with a constrained, documented host surface; bundle official packages regardless. Play permits an interpreted/VM exception to its code-download restriction, but runtime code must comply with all policies. No native payloads, APK self-updater or alternate native-module installer in this channel. [R8] | -| iOS/tvOS App Store and Mac App Store | Build the same provider runtime and bundled source packages. Seek review/permission for the actual downloaded-provider and native UI/host interface before enabling that path. Until approved, allow multiple bundled first-/third-party providers and configuration of supported remote services, updated through app releases. [R9] | - -For webOS, retain the demonstrated app replacement-IPK mechanism as a fallback if actual source/resource deployment constraints require it. A repair/replacement package can include selected provider source packages; no C++ compilation on the television is implied. Test installation state, restart persistence and recovery. Do not implement on-device package composition until a real loading restriction justifies it. - -JavaScript removes much native loading/signing/ABI complexity, not every platform limitation. Verify the actual Qt runtime's language features, resource imports, background execution and cold-start behaviour on each target. JIT availability must not be a prerequisite; source packages must work correctly with the supported interpreter path. - -Google Play approval is not guaranteed by the word “JavaScript.” Prepare a reviewable explanation of provider purpose, code provenance, capability exposure, content/access policy and update controls. Apple currently imposes additional requirements for downloaded plug-ins, including prior permission for native API/technology exposure, consent and software indexing. Do not assert automatic approval or silently enable a previously unapproved execution path remotely. [R8, R9] - -Use the same sources for bundled fallback builds. Store restrictions may limit available packages/features, but must not force authors to maintain a different client implementation or force users into one-provider-per-app forks. - -## 11. Implement Jellyfin and Stremio as real clients - -### Jellyfin - -Port API/auth/session logic to JavaScript; reuse endpoint knowledge, tested behaviours and fixtures from the existing facade rather than preserving C++ dependencies merely for convenience. Move/reuse provider-specific QML in the external repository and adapt it to source-bound proxies and the UI-host API. - -Cover login/QuickConnect, discovered and manually configured servers, multiple accounts, libraries, filters/paging, detail/episode navigation, search, resume/next-up, exact media sources, subtitles, direct/transcoded playback, reporting, segments/trickplay descriptors, favourites/played state and existing management/remote features where currently supported. Build an explicit parity checklist from the actual branch; do not declare success after a browse-and-play demo. - -Keep transport, decoding, bandwidth measurement and reusable player policy native. Let the provider translate native capability/preferences into Jellyfin requests and results. Translate server event/group messages into generic host actions instead of letting core continue parsing Jellyfin envelopes. Login refresh/reconnect/sign-out must affect the intended sources only. - -Use the old implementation temporarily for recorded-response differential tests, with deliberate exceptions for corrected bugs. Migrate real persisted accounts without duplicate logins/source identities. Remove obsolete facade/singleton/composition code after parity; do not ship both indefinitely. - -### Stremio - -Create `spool-stremio` early enough to challenge the contract while it is still 0.x. This is a **Spool provider implementing the Stremio protocol**, not the server-side add-on itself. One module can configure many remote add-ons and compose their resource contributions. Adding a remote add-on URL is configuration, not another executable-provider installation. - -Implement manifest validation/configuration, resource/type/ID-prefix matching, supported catalog search/filter/paging, metadata, series/episodes, stream and subtitle discovery, and timeout/error isolation. Stremio explicitly separates these resources and permits add-ons with no catalog, so do not require every configured add-on to behave like a complete Jellyfin server. [R12, R13] - -Begin with documented, authorised HTTP-stream flows and synthetic/local fixtures. Preserve opaque IDs; map recognised external IDs without assuming every add-on uses IMDb. Search catalogues first, then discover streams for selected/focused titles. Expose provenance and distinguish a metadata hit from confirmed playable versions. - -The provider owns its add-on settings, candidate selection and optional manual file/source workflow. Request native-backed models and return exact selected variants. Support per-resource headers/subtitles, expiry and useful next-episode hints where the protocol supplies them. Treat inferred quality conservatively. The protocol includes torrent references as well as URLs; do not present an info hash as an immediately playable HTTP resource. [R14] - -Actual peer-to-peer transfer is a separate transport capability, not JS metadata work. Where no approved native/external transport exists, state the limitation and offer supported sources. The implementer may add a necessary bounded integration when justified, but must not quietly grow a torrent engine in JS or claim complete Stremio source support from URL-only tests. - -Use generic durable local user-state services where no server is authoritative. Keep user-authorised service credentials and configured URLs private. Emby/Plex implementations are future work unless useful to validate a specific abstraction; design room for their identities, accounts, versions and pagination without creating speculative full SDKs for them now. - -## 12. Repository creation, SDK and release tooling - -Keep core, app, native services, runtime, public UI kit, tests, SDK documentation and packaging in `spool`. Create **`spool-player/spool-jellyfin`** and **`spool-player/spool-stremio`**, using the organisation shown in the supplied commit links after verifying the implementation environment's remotes and access. Do not create `spool-common`, a separate core repository or a reciprocal submodule graph. - -During implementation, use `gh` to inspect authentication, repository existence, visibility and permissions. Reuse existing repositories; a permissions/network error is not proof that a repository is absent. Create repositories only under the intended owner. Match the main project's visibility and preserve the applicable licence, attribution and notices. Scrub fixtures/history for secrets before publishing. - -Example commands for prepared local repositories, **only after confirming that new public repositories are appropriate and absent**: - -```sh -gh auth status -gh repo view spool-player/spool --json nameWithOwner,visibility,viewerPermission - -# Inspect/reuse these if they already exist before running creation commands. -gh repo view spool-player/spool-jellyfin --json nameWithOwner,visibility,viewerPermission -gh repo view spool-player/spool-stremio --json nameWithOwner,visibility,viewerPermission - -# Run creation individually after resolving the preconditions above. -gh repo create spool-player/spool-jellyfin --public \ - --source ../spool-jellyfin --remote origin --push \ - --description "Official portable Jellyfin provider for Spool" -gh repo create spool-player/spool-stremio --public \ - --source ../spool-stremio --remote origin --push \ - --description "Official portable Stremio protocol provider for Spool" -``` - -These commands illustrate GitHub CLI's documented existing-source workflow; they have not been executed by this plan. Initialise/commit the intended local content first and inspect existing remotes before using `--remote origin`. Preserve useful history where straightforward, but do not spend disproportionate effort history-filtering a rewrite. Record source commits and attribution. [R10] - -Each provider repository should contain its own logic, UI, manifest, fixtures/tests, contract requirements, documentation and source-package release workflow. No provider-specific login/torrent UI remains in Spool simply because extracting it was inconvenient. - -Publish lightweight SDK/type declarations, a local runner, contract tests and package-validation tooling from Spool. Provider CI consumes a pinned SDK/tooling version, not a nested checkout of the entire application with its own provider pins. Reuse workflows or a small distributable tool rather than creating a heavy package ecosystem. - -Spool integration pins exact provider source revisions or immutable ZIP digests in a reproducible lock mechanism. A one-way submodule is acceptable if it fits current tooling, but not mandatory. Local development must support sibling-repository/source overrides and testing an unreleased host plus unreleased providers together without tagging every edit. - -Provider CI validates manifests, JS runtime compatibility, optional-extension fallbacks, QML imports/lint, recorded responses and packaging. It builds one portable release archive, not one native artifact for each OS/CPU. Host CI tests that package on representative runtimes/channels. A Node-based test is useful only as a supplement; the real Qt JS engine must execute contract tests. - -Publish checked artifacts before catalogue updates, retain provenance and sign releases through secure CI credentials. Keep untrusted fork workflows away from signing/release secrets. Main builds bundle pinned verified packages and do not fetch mutable “latest” releases. GitHub workflow definitions stay in `.github/workflows`; shared scripts may live elsewhere. - -## 13. Provider-agnostic watch-together - -Retain this as an explicitly scoped subsequent feature, not a reason to delay the source/runtime foundation. Core owns rooms, clock synchronisation, scheduled play/pause/seek, buffering policy and drift correction. Each participant resolves their own authorised stream through their own provider. Do not share credential-bearing playback URLs or require the same provider/server. - -Extract genuinely reusable timing/control machinery from existing SyncPlay code; keep Jellyfin's server/group protocol in its provider. Existing backend-native groups remain an optional capability, distinct from Spool-to-Spool watch-together. - -A small Cloudflare Worker plus one Durable Object per room and WebSockets is a reasonable first coordination transport; validate deployment/cost/security details at that milestone. Cloudflare supports coordinating connected clients this way. No STUN server or peer-to-peer transport is required merely to relay state. [R15] - -Use short-lived room credentials, bounded messages, rate limits and reconnect/version handling. Relay control state and content identity, not media. Account for mismatched editions/timelines; shared title IDs do not prove synchronisation safety. Require compatible versions or explicit offset/manual matching rather than silently aligning different cuts. - -## 14. Delivery sequence and acceptance criteria - -Sequence to reduce architectural uncertainty early; adapt parallel work sensibly. Keep usable builds throughout, but do not let an obsolete “one tiny directory move at a time” plan prevent replacing an unsuitable interface. Namespace/layout cleanup is secondary to behaviour and performance. - -| Milestone | Required outcome / exit evidence | -| --- | --- | -| **A. Reconcile and baseline** | Inspect actual branch, record reused/replaced areas and existing feature coverage. Establish reproducible native baseline, real minimum-device target, phase timing and frame/memory metrics. Clearly distinguish tests actually run from reported results. | -| **B. Runtime vertical slice** | A small JS fixture performs host HTTP, maps a bounded page off-thread, returns a QCoro-style native result and updates the existing model. Open one provider-owned QML action. Test interpreter mode, cancellation and exceptions. Measure before expanding the abstraction. | -| **C. Identity and concurrency** | Two source instances of one module and a different module work together, with overlapping local IDs. Browse B while playing A and searching A/B/C; no token/state/callback confusion. Source removal and module failure are contained. | -| **D. Real clients and repositories** | Use `gh` to create/reuse the two repos. Implement a Jellyfin slice and a real Stremio catalog-to-variants-to-playback slice early, then complete the Jellyfin parity/migration checklist. No provider-specific route/global required in shared UI. | -| **E. Federation and custom flows** | Progressive cross-source search, conservative grouping, stable focus, lazy variants, explicit exact-version playback and a provider-owned custom selection workflow all work with partial failures. | -| **F. Independent delivery** | Signed source ZIPs, manager UI, bundled fallback, compatibility resolution, restart activation and recovery work end-to-end. A provider-only release updates two of its configured sources without rebuilding the host. An unchanged compatible ZIP survives a host update. | -| **G. Target/channel validation** | Measure real unrooted webOS loading/performance and Android/TV behaviour, including source-loaded QML. Prepare Google Play review material. Validate Windows/Linux/macOS and AppImage-on-NixOS. Keep Apple downloaded-code activation conditional on review; bundled mode must work. | -| **H. Consolidate and document** | Remove obsolete native provider implementation/plans, export tested SDK/examples, document supported 0.x revisions and channel policies, retain regressions/benchmarks in CI and make release behaviour reproducible. Do not label the contract stable 1.0 automatically. | - -Watch-together follows as its own delivery milestone, using the identity/playback foundations above. New production Plex/Emby providers, native plugin ABI support, Wasm, native Nix distribution, and permanent old-API adapters are not prerequisites. - -### Tests that must exist - -**Runtime and threading:** real Qt JS execution; source factories without global account state; async completion exactly once; network/script errors; timeout/cancellation; engine shutdown; late replies; model-thread correctness; runaway backend JS recovery; repeated install/use/remove without growing retained heaps. No backend JS invocation during native model role reads or frame rendering. - -**Data and federation:** pagination/cursors/unknown counts; unsupported filters; out-of-order and failed sources; immediate query invalidation; overlapping IDs; external-ID matches/conflicts; conservative title/year grouping; unidentified-item bridge prevention; episode ordering; different editions; late merge/split; stable remote focus; exact selected file resolution; source-specific credentials for artwork/video/subtitles. - -**UI and extensibility:** non-Jellyfin auth page; custom large virtualised candidate/file list; actions returning asynchronously/cancelled; missing optional extension/module remains usable; required extension rejected; imports outside policy denied; multiple UI instances; translated text, long labels and remote/touch/keyboard behaviour. Successful module loading alone is not sufficient. - -**Updates and security:** invalid signatures/hashes; unsafe ZIPs; oversized decompressed content; wrong identity/API/UI requirements; unknown required permissions; stale metadata; catalogue outage; failed migration; interrupted activation; incompatible externally updated host; bundled fallback with incompatible schema; revoked-version handling; permissions expanded by an update; secrets absent from logs. - -**Performance:** cold and warm provider activation; cached and uncached-data pages; normal and worst-case payloads; simultaneous search completions while scrolling; provider custom UI under load; high-bitrate playback concurrent with browsing; interpreter-only weakest-device runs; first-useful-frame and frame-tail metrics, not only average FPS or network-inclusive averages. - -Use controlled LAN/test services, recorded authorised responses and local fixtures. Do not depend on random public streaming add-ons or real private accounts to make CI deterministic. Add a build/test mode with no Jellyfin provider present so hidden shared-UI assumptions cannot return. - -## 15. Implementation handoff and completion standard - -Begin by reading the branch and implementing the measured JS-provider vertical slice; avoid a large up-front framework rewrite. Keep the public draft narrow while proving real Jellyfin and Stremio requirements. If conversion, page insertion, component creation or another stage dominates, fix that stage using evidence rather than immediately adding Wasm or restoring native provider clients. - -At completion, hand over the changed Spool code, both provider repositories, reproducible package/release tooling, contract/author documentation, migration/recovery notes, tests and benchmark results. Record any device/store/access gates that were not actually exercised. Missing credentials or hardware should not prevent completing local implementation and fixtures; do not claim the external step succeeded. - -The decisive acceptance scenario is: - -> One Spool process uses multiple Jellyfin accounts and multiple configured Stremio add-ons at once; progressively searches them; groups equivalent titles without losing origin; opens a provider-owned selection workflow; plays the exact chosen version through the native player; and keeps scrolling/navigation smooth while sources respond. A verified provider ZIP can update that client logic and UI without rebuilding Spool on an enabled channel, while bundled and store-restricted builds use the same provider sources. - -The durable design is **portable provider logic, native fast paths, explicit source identity, rich hosted UI and channel-aware delivery**. Everything else in this plan may be simplified or improved when the implementation provides a better answer. - ---- - -## Sources and verification boundaries - -Architecture and sequencing above are proposed decisions reflecting this discussion, not claims imposed by the external references. Performance numbers are provisional budgets, not measured results. External references were checked on 21 September 2026; recheck store policies and the actual pinned Qt/SDK versions when shipping. - -### Supplied project material - -- **[S1]** `Pasted markdown(20260920-182440).md`: complete supplied commit export, including the commit IDs in section 3 and links under `spool-player/spool`. It documents claimed branch changes, not independent test results. -- **[S2]** `spool-refactor-provider-seam.zip`: inspected source snapshot, notably `src/provider/`, `src/media/MediaTypes.h`, `src/app/`, `qml/shell/RouteStack.qml`, provider-specific QML, `src/diagnostics/RenderBenchmark.*`, tests and CMake. The implementing checkout may have advanced. -- **[S3]** `Spool Core Split Plan(1) (1).md` and `docs/provider-split-plan.md` in the snapshot: earlier native-provider proposal and branch status. This document supersedes conflicting native-plugin, single-active-provider, lockstep-build and exact Spool-version assumptions. Earlier review documents remain inspection aids, not controlling requirements. - -### Primary technical and policy references - -- **[R1]** [Qt: QJSEngine](https://doc.qt.io/qt-6/qjsengine.html) — module loading/caching, script/native calls, ownership and interruption. -- **[R2]** [Qt Quick: Performance considerations](https://doc.qt.io/qt-6/qtquick-performance.html) — asynchronous work, worker processing, conversions, models and delegates. -- **[R3]** [Qt: QAbstractItemModel](https://doc.qt.io/qt-6/qabstractitemmodel.html) — model-thread requirements and incremental updates. -- **[R4]** [Qt: Defining object types through QML documents](https://doc.qt.io/qt-6/qtqml-documents-definetypes.html). -- **[R5]** [Qt: Dynamic QML object creation](https://doc.qt.io/qt-6/qtqml-javascript-dynamicobjectcreation.html). -- **[R6]** [Qt: JavaScript host environment](https://doc.qt.io/qt-6/qtqml-javascript-hostenvironment.html) — verify against the exact deployed engine, not browser assumptions. -- **[R7]** [QCoro: QmlTask](https://qcoro.dev/reference/qml/qmltask/) — optional task presentation to QML, not a ready-made backend JS bridge. -- **[R8]** [Google Play: Device and Network Abuse](https://support.google.com/googleplay/android-developer/answer/9888379?hl=en) — native download restrictions and interpreted-code exception/conditions. -- **[R9]** [Apple: App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) — particularly downloaded code, section 4.7 and native technology exposure. -- **[R10]** [GitHub CLI: gh repo create](https://cli.github.com/manual/gh_repo_create). -- **[R11]** [The Update Framework specification](https://theupdateframework.github.io/specification/latest/) — authenticated updates, metadata freshness and trust lifecycle. -- **[R12]** [Stremio add-on protocol](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/protocol.md). -- **[R13]** [Stremio manifest format](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/manifest.md). -- **[R14]** [Stremio stream response format](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/api/responses/stream.md). -- **[R15]** [Cloudflare Durable Objects: WebSockets](https://developers.cloudflare.com/durable-objects/best-practices/websockets/). diff --git a/docs/provider-split-plan.md b/docs/provider-split-plan.md deleted file mode 100644 index ede1370a..00000000 --- a/docs/provider-split-plan.md +++ /dev/null @@ -1,366 +0,0 @@ -# Spool provider split plan - -Last updated 2026-09-20. Living document: the plan doc is at -https://claude.ai/code/artifact/dfa7b95e-4398-4f8c-b373-77fd06bb6a67 and this -file is the copy that travels with the code. - -## Decision - -One Spool app, many providers, two repos. This repo stays `spool` and keeps core -and app together, the way Kodi's main repo holds the player, the skin engine and -the app. Only providers get their own repos: `spool-jellyfin` first, and a debrid -or Stremio-style provider later against the same contract. Users pick a provider -on first run and the app fetches it at runtime once each platform is proven, with -providers built in until then. - -The boundary gets drawn in place first and proven with a throwaway second -provider. Nothing moves between repos until a fake provider runs with zero -changes to the UI layer. Core and app stay here as separate directories with a -public header boundary between them, so a separate `spool-core` can be extracted -by a directory move if a second app ever needs it. No repo rename. - -What this rules out: two independent apps that each build on core. Browse, -details, search, player overlay, queue and settings are the same screens -regardless of where the bytes come from. Forking them recreates the -Jellyfin-client situation where every UI fix lands twice. - -Also ruled out for now: a separate `spool-core` repo. With one app it would hold -about 3k lines of glue and branding, and every user-facing release would need a -core tag plus a pin bump in the app. Stremio splits core from shell because it -has four shells. Kodi has one app and does not split. Extract core the day a -second app or a community frontend actually exists. - -## Status - -Phases 1 to 4 are done on `refactor/provider-seam`. The tree builds, all 77 -tests pass, and `tools/check-module-seam.sh --strict` reports no core file -including provider code. A folder-of-files provider runs every core screen with -no Jellyfin singletons registered. Phase 5 is next. - -| Landed | What it is | -| --- | --- | -| Media model | `src/media/MediaTypes` replaces JellyfinTypes; AuthSession and DiscoveredServer moved provider-side | -| Player seam | `src/provider/PlaybackSource` is all the player sees; the X-Emby-Token header lives in the facade | -| Settings inversion | SettingsController emits, `JellyfinSettingsBridge` listens | -| Catalog contract | `Catalog`, `SearchSource`, `UserItemStateSink`, `ArtworkSource` interfaces; seven controllers and artwork moved to core | -| Registry | `Provider`, `ProviderRegistry`, `ProviderCapabilities` singleton driven from C++; `JellyfinProvider` composes everything Jellyfin-specific | -| QML gating | Every provider-specific control gated on `ProviderCapabilities`; Jellyfin-only QML under `qml/providers/jellyfin` | -| Seam check | `module-seam` ctest fails the build if core includes `src/api`, `src/discovery` or `src/providers` | -| Local provider | `src/providers/local` serves a folder of files; `--provider local --library-root DIR` selects it | - -## Target layout - -Two repos. This one keeps about 86% of the ~95k lines measured on `master` at -a69c8da. - -| Repo | Holds | Lines | -| --- | --- | --- | -| `spool` (this repo) | Core: player engine, 10-foot UI kit, all screens, platform layer for 6 targets, settings, diagnostics, cache, artwork pipeline, provider contract, build and packaging for every target, the mpv submodule. App: composition root, branding, store metadata, icons, i18n, VERSION. Contract spec under `docs/contract/` | ~82k | -| `spool-jellyfin` | Jellyfin auth and login screens, server discovery, item queries, playback negotiation, session reporting, SyncPlay, remote control, playlist and collection management | ~13k | - -**Why core and app stay together.** The app layer is about 3k lines: -AppController, main.cpp and the files a store or package manager cares about. -Almost everything that feels like "the app" is reusable: settings, router, -artwork loading, the content and browse models, the deep-link codec, the -Spool-to-Spool remote protocol. Those are core so the next provider gets them for -free, and what is left is too thin to be worth a release step of its own. It -lives in `apps/spool/` here, built only against core's public headers. If that -directory starts growing, core has leaked. - -## The provider contract - -A provider is a set of capabilities it opts into, not one fat backend interface. -A debrid provider implements the first three and nothing else. Jellyfin -implements all of them. - -| Capability | What it covers | Required | -| --- | --- | --- | -| Catalog | Libraries, browse pages, filter options, details, seasons and episodes, resume, next up, latest, similar, by person | Yes | -| Stream | Resolve an item to something mpv can open, including media sources, tracks and bandwidth choice | Yes | -| Artwork | Image URLs for an item, trickplay tiles | Yes | -| Auth | Login flow and its screens, session persistence, credential store use, expiry | No | -| Discovery | Find servers on the LAN | No | -| Search | Text search and suggestions | No | -| User item state | Favourite, played, playback position | No | -| Playback reporting | Start, progress, stop, capabilities | No | -| Segments | Intro and credit markers for skip cards | No | -| Stream quality | Bitrate and height overrides, the quality ladder | No | -| Library management | Playlists, collections, rename, delete | No | -| SyncPlay | Group watch with a shared clock | No | -| Remote control | Cast to and control other sessions of the same backend | No | -| Peer relay | A datagram path between two Spool instances via the backend | No | - -The player only needs Stream, Playback reporting and Segments. That is the whole -player-side seam and it is small. - -**Shape of the code.** Core defines the capability interfaces and a registry. A -provider registers itself, declares which capabilities it serves, and supplies -its own QML for the screens only it needs (login, account linking, remote -targets). The same provider builds as a static library for bundling and as a -shared library for runtime download. Core screens ask the registry whether the -active provider has a capability and show or hide controls accordingly. - -**The media model is core.** The structs in `src/media/MediaTypes.h` -(LibraryItem, MovieItem, MediaStreamInfo, MediaSourceInfo, PlaybackSession, -SubtitlePreferences, MediaSegment, TrickplayInfo and the rest) are what every -screen and the player consume. Fields that only Jellyfin has, like server and -session ids, become opaque provider handles. Library state that no server keeps, -which is the debrid case, lands in the existing cache tables, which are already -generic. - -Fields still carrying Jellyfin's shape, left for a human decision: the -`mediaSourceId`, `playSessionId` and `playMethod` on PlaybackSession; the image -`*Tag` fields on MovieItem, LibraryItem and PersonItem; `playlistItemId`, -`locationType`, `isVirtualItem`, `recursiveItemCount`; every `*Ticks` field, -which is Jellyfin's 100 ns unit; the string enums on MediaStreamInfo and -MediaSegment; and MetaJson's PascalCase policy with its `RuntimeTicks` and -`ImageTag` key aliases. - -## Provider picker and runtime download - -Users pick a provider on first run and the app fetches it. Providers ship built -into the binary until each platform's download path is proven, so the picker -looks and behaves the same on day one and the download is a detail users never -notice. The mechanism is the same one Kodi uses for add-ons; the difference is a -curated list of first-party providers and no add-on browser. - -**The picker.** A first-run screen listing first-party providers: Jellyfin now, -Stremio-style and debrid as they exist. Picking one runs that provider's login or -account-linking flow. Settings gets a "Change source" entry that returns to the -picker. One provider is active at a time. Aggregating several sources into one -home page is a real design problem and is out of scope. - -**Sequencing.** - -1. Picker with providers built in, static link. -2. Download path on Linux and Windows, where loading a fetched shared library - just works. -3. macOS once downloaded plugins are signed with the team ID so library - validation passes. -4. Android and webOS after the spike below. - -| Platform | Runtime load | Blocker | -| --- | --- | --- | -| Linux | Works | None | -| Windows | Works | None | -| macOS | Works with signing | Hardened runtime library validation: plugins signed with the same team ID, or drop the entitlement | -| Android | Works technically | Google Play forbids downloading native code; fine while updates stay outside Play | -| webOS | Expected to work | Likely fine without root: Kodi loads add-on binaries on webOS and Spool already self-updates there. Confirm with a half-day spike | - -**ABI-safe boundary.** A native plugin must match the app's Qt build, compiler -and struct layouts, so a stable logical API is not enough. The plugin interface -is pure virtual classes carrying an interface version integer, and item data -crosses it as QVariantMap or JSON, never as C++ structs. The app refuses a plugin -whose interface version differs from its own. Slightly slower than passing -structs, immune to layout changes. - -**Lockstep release train.** This repo's release CI builds every first-party -provider for all 6 targets from the pinned submodules, with this repo's own -toolchain, and publishes the plugin binaries plus a provider index JSON with each -release. The app downloads from the index for its own version and platform. -Providers cannot drift from the app because the same job built both. A -provider-only workflow rebuilds providers against the current `spool` tag and -updates the index, so a Jellyfin fix ships without an app release. Provider repos -never run their own multi-platform builds. - -**Fallback if webOS blocks native loading.** Providers as QML and JS bundles: no -ABI, no signing, no Play policy problem. The Jellyfin facade is mostly HTTP, JSON -and URL building, so it is about 3k lines of JS to write, with heavier pieces -like bandwidth policy and the SyncPlay clock kept in core as generic services. -Decide after the spike, not before. - -## Phases 1 to 4: draw the boundary in place (done) - -All of this happened in this repo with the app shipping normally throughout. - -```mermaid -flowchart LR - P1["1 Media model
JellyfinTypes becomes core types"] - P1 --> P2["2 Player seam
narrow interface"] - P2 --> P3["3 Registry
capabilities, provider QML"] - P3 --> P4["4 Fake provider
proves the boundary"] -``` - -**Phase 0: rename, deferred.** The `JellyfinNative` namespace, the -`JellyfinWebOS` QML module, the `jellyfin-core` and `jellyfin-native` CMake -targets and the `qtfin` i18n prefix all name provider-agnostic code after -Jellyfin. The rename is cosmetic and touches every file, so it is folded into the -Phase 5 directory move, where every file moves anyway. Nothing depends on it. - -**Phase 1: media model.** JellyfinTypes moved to `src/media/MediaTypes`, with -AuthSession and DiscoveredServer pushed provider-side. Fields unchanged; the -audit list is above. - -**Phase 2: player seam.** PlayerController, PlayQueueController and -PlaybackReporter take a `PlaybackSource *`. The facade implements it. Nothing -under `src/player` includes anything from `src/api`. SettingsController lost the -facade and talks through `JellyfinSettingsBridge`. The platform layer reports -codec capabilities through a callback instead of taking the facade. - -**Phase 3: provider registry.** `Provider` with capability flags, a -`ProviderRegistry`, and a `ProviderCapabilities` QML singleton driven from C++. -`JellyfinProvider` composes the facade, discovery, session, QuickConnect, -SyncPlay, remote control, library management and the settings bridge, and -registers its own QML singletons. Every provider-specific control in shared QML -is gated on a capability flag; Jellyfin-only QML lives under -`qml/providers/jellyfin`. - -**Phase 4: fake provider.** `src/providers/local` serves a folder of media files -as one library. It forced six real core changes: the start route is chosen from -the Auth capability rather than always being login; AppController null-guards -SyncPlay and remote control; a provider with no sign-in emits `sessionStarted` -from storage restore; the quality picker became a capability; the series -audio-track cache key stopped assuming a server and user id; and Catalog and -PlaybackSource grew the libraries, filter options, items-by-ids, segments and -stream resolution that only AppController had been getting from the facade. - -Done means: two providers build, the Jellyfin app is unchanged for users, and -core includes nothing from `src/api`, `src/discovery` or `src/providers`. The -`module-seam` ctest enforces it. - -## Phases 5 to 7: split out the provider - -One in-tree reshuffle, then one extraction, then a release. The app keeps -building at every step. - -**Phase 5: in-tree directories that mirror the future repos.** Move code so this -repo's layout already looks like three repos in one, and fold the deferred rename -into the same pass. - -| Directory | Becomes | Builds as | -| --- | --- | --- | -| `core/` (src, qml, tests, tools, cmake, packaging, mpv) | stays here | `spool-core` static library plus a `spool_add_app()` CMake function | -| `providers/jellyfin/` | `spool-jellyfin` | `spool-provider-jellyfin`, static for bundling and shared for download, only public core headers | -| `providers/local/` | stays here as the fixture | `spool-provider-local` | -| `apps/spool/` | stays here | The executable, via `spool_add_app()` | - -The rule that makes the split safe: `providers/*` and `apps/*` may include only -headers core exports on purpose, from a `spool/` include prefix. If the Jellyfin -provider builds against core's public headers alone while sitting in the same -tree, extracting it is a directory move. - -**Phase 6: extract the provider.** Create `spool-jellyfin` from -`providers/jellyfin/` with `git subtree split` so file history survives. It -consumes this repo as a git submodule pinned to a tag, the same way this repo -already consumes the mpv fork. Its CI builds the provider against that pin and -runs `tests/api` and `tests/discovery`. - -This repo then consumes `spool-jellyfin` back as a submodule under `providers/`, -pinned to a tag. Release CI builds it for all 6 targets alongside the app, -bundles it into the binary, and publishes the plugin binaries and provider index -for the download path. - -CI here keeps two app builds: the real `spool` with the Jellyfin pin, and the -local-fixture app that needs no external repo at all. - -**Phase 7: first release.** Tag `spool` at the current user-facing version and -`spool-jellyfin` v1.0.0, with the pins matching. Users see no change. - -After this the debrid provider starts as a third repo against a contract that -already has two real implementations and one fixture. - -## How core is consumed - -Pinned, not tracked at head. For a two-person team a core change needing a tag -before a provider can use it is a small tax, and it is far cheaper than a core -push breaking the app on a Tuesday. - -| Repo | Depends on | How | -| --- | --- | --- | -| `spool` | mpv fork, `spool-jellyfin` | git submodules, pinned to tags, as today for mpv | -| `spool-jellyfin` | `spool` (core headers only) | git submodule, pinned to a tag | - -Core uses semver on `spool`'s tags. A provider states the major it targets. The -provider's pin only serves its standalone CI: when built inside `spool` it -compiles against the checked-out core, and the semver major is the compatibility -promise. The existing submodule checks in `tools/` (flake sync, remote refs) -already cover this pattern for mpv and extend to the new pin with no new -machinery. - -**Packaging lives in `core/` and nowhere else.** This is the biggest hidden cost -of a bad split, so it is a hard rule. Core exports one CMake entry point, -`spool_add_app(NAME ID ICONS PROVIDERS ...)`, that produces every target's -output: webOS ipk, Android apk, macOS dmg with signing and notarisation, Windows -installer, AppImage, Arch package. `apps/spool/` supplies `appinfo.json.in`, the -desktop and metainfo files, icons, VERSION and the open source notices, and calls -that function. The flake, `build-ipk.sh`, the scripts under `tools/`, and the -GitHub workflows all move under `core/` and stay callable from any app directory. -Renovate stays in each repo for its own pins. - -Release flow for a normal user-facing change is unchanged from today: fix, tag, -release. For a Jellyfin-only fix: fix in `spool-jellyfin`, tag, bump the pin here, -and run the provider-only workflow to publish new plugin binaries against the -current `spool` tag. Once the download path is live on a platform, that needs no -app release. The version users see is this repo's VERSION file. - -## Where the tests go - -About 10.7k of the 12.5k test lines follow core. Only `tests/app` splits down the -middle. - -| Suite | Goes to | Notes | -| --- | --- | --- | -| tests/qml | core | Primitives, shell, navigation, provider gating | -| tests/player | core | Uses the PlaybackSource interface with a stub | -| tests/app (core half) | core | ContentModel, SettingsSchema, SettingsController, Router, UpdateManifest, SpoolLink, SpoolRemoteProtocol, ArtworkUrl, ProviderRegistry | -| tests/common, cache, platform, diagnostics, tools | core | MetaJsonTest's Jellyfin parsing cases move with the parser | -| tests/providers | core | The local fixture; the permanent contract test | -| tests/api, tests/discovery | jellyfin | Untouched | -| tests/app (provider half) | jellyfin | SyncPlay drift, clock, queue handoff, Session, RemoteControl, BrowseSession, LibraryQuery | -| tests/media (ffmpeg compliance) | core | Belongs with the player | - -Core gets contract tests that run against every registered provider, starting -with the local fixture, so a provider that claims a capability has to pass them. -The Jellyfin provider runs those same contract tests against a recorded server in -its own CI. The test runner and TestMain stay in core and get exported alongside -`spool_add_app()`, so providers do not each grow their own harness. - -## What will bite - -- **The rename touches every file.** ~300 files name the namespace, the QML - module or the CMake targets. Land it in a quiet window with no large branches - open, and rebase anything outstanding the same day. -- **SyncPlay and remote control reach deep into the player.** The player exposes - sync playback speed and the queue outline for SyncPlay's benefit. Those stay as - generic player hooks; only the controller that drives them is provider-side. -- **SpoolLink relays through the Jellyfin server.** The peer link is a core - feature but its only transport today is provider-owned. Design it so a provider - without a relay reports the capability as absent. -- **Runtime plugins are an ABI contract, not an API one.** A Qt patch bump or a - new struct field breaks every downloaded provider unless data crosses the - boundary as QVariant or JSON and the interface is versioned. Build the boundary - that way before any plugin is ever downloaded, and never let a provider repo - build its own binaries. -- **Do not extract a `spool-common` repo.** Shared-utility repos become dumping - grounds and a coordination tax on every change. Each layer keeps its own - helpers and tolerates a little duplication. -- **Do not start the debrid provider early.** It is the reward for finishing, not - a way to test the contract. The local fixture is the test. - -Rules while this is in flight: - -1. `master` builds and ships at every commit. No long-lived split branch. -2. New code lands on the correct side of the line. Anything Jellyfin-specific - goes under `src/api` or a provider directory, never into player, shell or - primitives. -3. Provider-specific controls in core QML are gated on a capability flag. -4. The fake provider is not deleted. It is the contract's regression test - forever. - -Known loose ends from Phase 4: - -- `SearchController` and `UserItemStateController` take their interfaces - unguarded, so a provider lacking those capabilities would crash rather than - degrade. -- Remote command payloads crossing `RemotePlayback` still carry Jellyfin's JSON - envelope. -- `settingsSchema` is a CONSTANT QML property, so switching provider at runtime - would not refresh the account rows. Fine while the provider is fixed at - startup. - -Open questions: - -- [ ] webOS spike: can the jailed app load a downloaded shared library? -- [ ] macOS: sign downloaded plugins with the team ID, or drop library - validation? -- [ ] Who owns the media model field audit? diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 00000000..e4715307 --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,61 @@ +# Providers + +Everything Spool plays comes from a provider: a package of JavaScript and QML that knows one kind of +source (a Jellyfin server, a service, a folder). The app itself knows none of them. This page is how +the pieces fit; `sdk/README.md` and `sdk/provider.d.ts` are the provider author's side. + +## Moving parts (`src/provider/`) + +| | | +| --- | --- | +| `ProviderPackage` | Reads a `.tar.zst` (ustar + zstd, vendored decoder in `third_party/zstd`), validates manifest format 2 and every path, installs versions through a staging directory | +| `ProviderRegistry` | Every module (bundled at `qrc:/providers//`, installed under the data directory; newest wins) and every account. Starts enabled accounts, owns setup drafts, screens (`ProviderUiContext`) and `pick()` | +| `ScriptRuntime` / `ScriptBridge` | One worker thread and QJSEngine per module; `createSource(configuration, host)` per account; host HTTP, sockets, timers, discovery, events. Only snake_case error codes cross back | +| `PortableProvider` | One running account as a `Provider`: catalogue, search, item state, playback and artwork from its operations and URL templates | +| `SourceHub` | The single `Provider` the app sees. Scopes IDs as `<8 hex of account>:`, fans list requests out to every account in parallel and interleaves them, routes everything else to the owning account | +| `ProviderStore` | `official.json` and `index.json` from spool-player/spool-providers (Pages), install by link, update checks and the `providers/updates` policy | +| `app/GroupPlaybackController` | Watching together over whichever account the group is on: clock, drift, buffering, queue handoff. Providers translate their protocol into `group` events | + +`src/providers/local/LocalProvider` is the one native provider (desktop only): a folder of files. +Core never includes `src/providers/`; `tools/check-module-seam.sh` (ctest `module-seam`) enforces it. + +## Accounts + +An account is one sign-in on one provider: its module, a key the provider chose (`user@server`), +a group, a label, the origins it may reach and its configuration. Metadata lives in the durable +database (`providers/accounts/2`); configuration, which holds tokens, lives in the platform credential +store and is read off the GUI thread. Accounts in the same group (users of one server) are +alternatives: using one sets the others aside. Accounts in different groups are shown together. + +A provider's login screen completes with the account; the registry keeps the origins the viewer +allowed during setup, never ones the provider claims. `http_401` from any operation marks the account +as needing sign-in again. On first launch after upgrading, sign-ins saved by the old native Jellyfin +client become Jellyfin accounts. + +## Where providers come from + +- **Bundled**: `providers/lock.json` pins each package by SHA-256; CMake checks and unpacks it into + a resource at configure time. `-DSPOOL_PROVIDER_OVERRIDES=id=/path/to/checkout` bundles a working + tree instead. Pin the published release asset, so the app ships exactly what the store serves. +- **Store**: spool-player/spool-providers lists reviewed releases; first-party ids (`spool.*`) follow + their own releases automatically, community ones change through pull requests. +- **Link**: a GitHub or GitLab project, a release's `.tar.zst`, or any site serving + `spool-provider.json`. Updated from the same feed. + +Every download is installed only when its SHA-256 matches the entry. Installing a newer version +restarts that module's accounts in place. + +Downloaded code is interpreted JS and QML in the app's process, not a sandbox. That is fine for +direct downloads, Linux and webOS developer builds; a Play Store or App Store build must keep +downloading off (bundled providers only) unless the store's review has accepted the interpreter +surface. There is no build switch for that yet. + +## Testing + +- `providers-tests` (ctest `provider-*`, `source-hub`, `script-runtime`, `local-provider`): package + format, registry, hub, store (against a local HTTP server) and screens, with the fixture provider in + `tests/providers/fixtures/`. +- `bundled-jellyfin` runs a small contract against the pinned package in this Qt; each provider + repository carries its full `tests/contract.mjs`. +- `live-jellyfin` runs sign-in to playback against a real server when `SPOOL_LIVE_JELLYFIN`, + `SPOOL_LIVE_USER` and `SPOOL_LIVE_PASSWORD` are set; otherwise it skips. From a629a569b91e3a0c3a73f86adcc3ca1552232955 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 08:57:58 +1000 Subject: [PATCH 060/146] fix(bench): start the route walk after accounts restore; empty home is ready The render benchmark runs with no accounts: home never reported itself settled (5 s per visit), and a walk started before restore raced the shell's first route. Found on the Linux packages CI job. --- qml/pages/HomePage.qml | 7 ++++--- src/main.cpp | 22 ++++++++++++---------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml index 9da34a23..fa4a7017 100644 --- a/qml/pages/HomePage.qml +++ b/qml/pages/HomePage.qml @@ -10,7 +10,8 @@ FocusScope { property var shell property var uiTransitionToken: 0 - readonly property bool contentReady: rows.firstRowReady + // With no account there is nothing to wait for: an empty home is ready. + readonly property bool contentReady: rows.firstRowReady || !Providers.hasAccounts focus: true @@ -120,9 +121,9 @@ FocusScope { focus: true onEdgeUp: if (root.shell) - root.shell.focusNavBar() + root.shell.focusNavBar() onActivated: (section, index, item) => root.activateAt(section, index, item) onFirstRowReadyChanged: if (firstRowReady) - InputLatency.mark(root.uiTransitionToken, "first_delegate") + InputLatency.mark(root.uiTransitionToken, "first_delegate") } } diff --git a/src/main.cpp b/src/main.cpp index b300c2f2..a2a80643 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -990,17 +990,19 @@ int main(int argc, char **argv) = [controller = controller.get()] { controller->onMemoryPressure(QStringLiteral("critical")); }; if (auto *benchmark = JellyfinNative::RenderBenchmark::createIfRequested( std::move(benchmarkHooks), router.get(), &inputLatencyMonitor, &window, &app)) { - if (controller->initialized()) { + // The shell picks its first route once accounts are restored; a walk + // that starts before then races that choice. + const auto started = std::make_shared(false); + const auto begin = [benchmark, started, controller = controller.get(), &providers] { + if (*started || !controller->initialized() || !providers.restored()) + return; + *started = true; benchmark->start(); - } else { - QObject::connect( - controller.get(), &JellyfinNative::AppController::initializedChanged, benchmark, - [benchmark, controller = controller.get()] { - if (controller->initialized()) - benchmark->start(); - }, - Qt::SingleShotConnection); - } + }; + QObject::connect(controller.get(), &JellyfinNative::AppController::initializedChanged, benchmark, begin); + QObject::connect( + &providers, &JellyfinNative::ProviderRegistry::restoredChanged, benchmark, begin, Qt::QueuedConnection); + begin(); } if (launchTest) { From 6aba8d1cdd721fd96a08c9bb7dc4503a37f69cbe Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 21:17:52 +1000 Subject: [PATCH 061/146] fix(android): drive the system media controls through ApplicationHooks Master's Android media session took AppController directly, which the platform layer no longer names. The hooks now carry the play queue, artwork, the playback-transition state and the stop/next/previous actions it uses, and main.cpp fills them in. --- src/main.cpp | 9 ++++++ src/platform/PlatformApplicationServices.h | 11 +++++++ .../android/AndroidLocalMediaSession.cpp | 29 +++++++++---------- .../android/AndroidLocalMediaSession.h | 6 ++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index a2a80643..26b8e00e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -849,8 +849,17 @@ int main(int argc, char **argv) JellyfinNative::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); applicationHooks.settings = controller->settings(); + applicationHooks.playQueue = controller->playQueue(); + applicationHooks.artwork = controller->artwork(); applicationHooks.memoryPressure = [controller = controller.get()](const QString& level) { controller->onMemoryPressure(level); }; + applicationHooks.playbackTransition + = [controller = controller.get()] { return controller->property("playbackTransition").toBool(); }; + applicationHooks.stopPlayback = [controller = controller.get()] { controller->stopPlayback(); }; + applicationHooks.playNext = [controller = controller.get()] { controller->playQueueNext(); }; + applicationHooks.playPrevious = [controller = controller.get()] { controller->playQueuePrevious(); }; + QObject::connect(controller.get(), &JellyfinNative::AppController::playbackTransitionChanged, &applicationHooks, + &JellyfinNative::ApplicationHooks::playbackTransitionChanged); QObject::connect(controller.get(), &JellyfinNative::AppController::aggressiveMemoryPressure, &applicationHooks, &JellyfinNative::ApplicationHooks::aggressiveMemoryPressure); QObject::connect(controller.get(), &JellyfinNative::AppController::diagnosticsReportSaved, &applicationHooks, diff --git a/src/platform/PlatformApplicationServices.h b/src/platform/PlatformApplicationServices.h index 1452470f..0f894674 100644 --- a/src/platform/PlatformApplicationServices.h +++ b/src/platform/PlatformApplicationServices.h @@ -10,7 +10,9 @@ class QGuiApplication; namespace JellyfinNative { +class ArtworkService; class NativeAppWindow; +class PlayQueueController; class PlayerController; class RouterController; class SettingsController; @@ -26,10 +28,19 @@ class ApplicationHooks final : public QObject { PlayerController *player = nullptr; SettingsController *settings = nullptr; + PlayQueueController *playQueue = nullptr; + ArtworkService *artwork = nullptr; // A platform memory warning at the given level ("low" or "critical"). std::function memoryPressure; + // Local playback, for the system media controls. + std::function playbackTransition; + std::function stopPlayback; + std::function playNext; + std::function playPrevious; signals: + // The app is between one queue item and the next. + void playbackTransitionChanged(); // The app has shed what it can; the window may drop its resources too. void aggressiveMemoryPressure(); void diagnosticsReportSaved(const QString& path); diff --git a/src/platform/android/AndroidLocalMediaSession.cpp b/src/platform/android/AndroidLocalMediaSession.cpp index f85b1e25..dd2b19cc 100644 --- a/src/platform/android/AndroidLocalMediaSession.cpp +++ b/src/platform/android/AndroidLocalMediaSession.cpp @@ -1,7 +1,7 @@ #include "AndroidLocalMediaSession.h" -#include "app/AppController.h" #include "app/ArtworkService.h" +#include "platform/PlatformApplicationServices.h" #include "player/PlayQueueController.h" #include "player/PlayerController.h" @@ -20,13 +20,13 @@ namespace { constexpr auto serviceClass = "com/sachk/spool/LocalMediaPlaybackService"; } -AndroidLocalMediaSession::AndroidLocalMediaSession(AppController& controller) - : m_controller(controller) - , m_player(*controller.player()) - , m_queue(*controller.playQueue()) +AndroidLocalMediaSession::AndroidLocalMediaSession(ApplicationHooks& hooks) + : m_hooks(hooks) + , m_player(*hooks.player) + , m_queue(*hooks.playQueue) { localMediaSession = this; - // End-of-file first clears PlayerController, then AppController negotiates the + // End-of-file first clears PlayerController, then the app negotiates the // successor. Observe the settled transition, not that intermediate empty player. connect(&m_player, &PlayerController::sessionActiveChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); @@ -38,7 +38,7 @@ AndroidLocalMediaSession::AndroidLocalMediaSession(AppController& controller) Qt::QueuedConnection); connect( &m_queue, &PlayQueueController::queueChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); - connect(&controller, &AppController::playbackTransitionChanged, this, &AndroidLocalMediaSession::update, + connect(&hooks, &ApplicationHooks::playbackTransitionChanged, this, &AndroidLocalMediaSession::update, Qt::QueuedConnection); connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &AndroidLocalMediaSession::clear); update(); @@ -54,8 +54,7 @@ void AndroidLocalMediaSession::update() { const bool active = m_player.sessionActive(); const MovieItem item = m_queue.currentItem(); - const bool continuing - = m_active && m_controller.property("playbackTransition").toBool() && item.itemType == QStringLiteral("Audio"); + const bool continuing = m_active && m_hooks.playbackTransition() && item.itemType == QStringLiteral("Audio"); if ((!active && !continuing) || (active && m_player.mediaKind() != QStringLiteral("audio"))) { clear(); return; @@ -81,7 +80,7 @@ void AndroidLocalMediaSession::update() static_cast(active ? !m_player.paused() : !m_transitionPaused.value_or(false)), static_cast(!active || m_player.buffering()), static_cast(m_player.effectivePlaybackSpeed()), static_cast(m_queue.canGoNext()), static_cast(m_queue.canGoPrevious())); - if (auto *artwork = m_controller.artwork()) + if (auto *artwork = m_hooks.artwork) updateArtwork(artwork->itemUrl(item, false, 512)); } @@ -100,8 +99,8 @@ void AndroidLocalMediaSession::updateArtwork(const QString& url) QJniObject::callStaticMethod(serviceClass, "setArtwork", "([B)V", static_cast(nullptr)); if (url.isEmpty()) return; - auto *response = m_controller.artwork()->requestImageResponse( - QString::fromLatin1(QUrl::toPercentEncoding(url)), QSize(512, 512)); + auto *response + = m_hooks.artwork->requestImageResponse(QString::fromLatin1(QUrl::toPercentEncoding(url)), QSize(512, 512)); m_artworkResponse = response; connect(response, &QQuickImageResponse::finished, this, [this, response]() { response->deleteLater(); @@ -166,14 +165,14 @@ void AndroidLocalMediaSession::control(int action, qint64 value) setPaused(m_player.sessionActive() ? !m_player.paused() : !m_transitionPaused.value_or(false)); break; case 3: - m_controller.stopPlayback(); + m_hooks.stopPlayback(); clear(); break; case 4: - m_controller.playQueueNext(); + m_hooks.playNext(); break; case 5: - m_controller.playQueuePrevious(); + m_hooks.playPrevious(); break; case 6: m_player.seek(static_cast(value) / 1000.0); diff --git a/src/platform/android/AndroidLocalMediaSession.h b/src/platform/android/AndroidLocalMediaSession.h index b4e9ae54..3cbcd413 100644 --- a/src/platform/android/AndroidLocalMediaSession.h +++ b/src/platform/android/AndroidLocalMediaSession.h @@ -9,14 +9,14 @@ class QQuickImageResponse; namespace JellyfinNative { -class AppController; +class ApplicationHooks; class PlayerController; class PlayQueueController; // Android owns the system controls; mpv remains the sole playback engine. class AndroidLocalMediaSession final : public QObject { public: - explicit AndroidLocalMediaSession(AppController& controller); + explicit AndroidLocalMediaSession(ApplicationHooks& hooks); ~AndroidLocalMediaSession() override; void control(int action, qint64 value); @@ -27,7 +27,7 @@ class AndroidLocalMediaSession final : public QObject { void setPaused(bool paused); void updateArtwork(const QString& url); - AppController& m_controller; + ApplicationHooks& m_hooks; PlayerController& m_player; PlayQueueController& m_queue; bool m_active = false; From 2cb3e45cd251007d5462f29df96d541fe20acc33 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 21:20:38 +1000 Subject: [PATCH 062/146] fix(app): fetch the autoplay item through the catalog Master's --play-item/SPOOL_PLAY_ITEM path read the item from the native Jellyfin API, which this branch removed. --- src/app/AppController.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index b0521609..5bd4a5c9 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -388,11 +388,11 @@ void AppController::playOrOpen(const MovieItem& item, bool fromStart) } void AppController::playItemId(const QString& itemId, bool fromStart) { - if (itemId.isEmpty() || !m_api) + if (itemId.isEmpty() || !m_catalog) return; setBusy(true, QStringLiteral("Loading item for playback…")); Async::runScoped( - this, m_api->fetchItemDetails(itemId), + this, m_catalog->fetchItemDetails(itemId), [this, fromStart](const MovieItem& item) { setBusy(false); if (!item.id.isEmpty()) From 818d2d1bae19b7838276e8e40e3dfcbd53fcc323 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 21:32:19 +1000 Subject: [PATCH 063/146] feat(provider): choose where providers may come from at build time SPOOL_PROVIDER_SOURCES is open (every release build), curated (the store's catalogues only, for Google Play and App Store submissions) or bundled (no store, nothing loaded from disk). Curated drops installing by link, following a linked provider's feed and pointing the app at another store; the add page hides what the build can't do. Store review, not the interpreter, is what limits downloaded providers there; docs/providers.md has the table. --- CMakeLists.txt | 9 ++++ docs/providers.md | 20 ++++++-- qml/pages/AddProviderPage.qml | 12 +++-- src/main.cpp | 18 +++++-- src/provider/ProviderRegistry.cpp | 6 ++- src/provider/ProviderRegistry.h | 1 + src/provider/ProviderStore.cpp | 42 ++++++++++++++- src/provider/ProviderStore.h | 30 ++++++++++- tests/providers/ProviderStoreTest.cpp | 73 ++++++++++++++++++++++++++- 9 files changed, 196 insertions(+), 15 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 68961f5f..2c20c90f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,14 @@ option(JELLYFIN_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) set(SPOOL_MACOS_CREDENTIAL_SERVICE "com.sachk.spool.dev" CACHE STRING "macOS Keychain service used for persisted session tokens") option(JELLYFIN_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +# Where the app may take provider code from. Release builds are open; a build +# submitted to Google Play or the App Store is curated (store catalogues only) +# or bundled (no downloads). See docs/providers.md. +set(SPOOL_PROVIDER_SOURCES "open" CACHE STRING "Provider sources: open, curated or bundled") +set_property(CACHE SPOOL_PROVIDER_SOURCES PROPERTY STRINGS open curated bundled) +if(NOT SPOOL_PROVIDER_SOURCES MATCHES "^(open|curated|bundled)$") + message(FATAL_ERROR "SPOOL_PROVIDER_SOURCES must be open, curated or bundled, not '${SPOOL_PROVIDER_SOURCES}'") +endif() include(CTest) set(MPV_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mpv" CACHE PATH "mpv source tree") @@ -1075,6 +1083,7 @@ if(UNIX AND NOT APPLE AND NOT ANDROID) endif() target_compile_definitions(jellyfin-native PRIVATE JELLYFIN_VERSION="${PROJECT_VERSION}" + SPOOL_PROVIDER_SOURCES="${SPOOL_PROVIDER_SOURCES}" $<$:TOUCHSCREEN> ) diff --git a/docs/providers.md b/docs/providers.md index e4715307..d81524f3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -45,10 +45,22 @@ client become Jellyfin accounts. Every download is installed only when its SHA-256 matches the entry. Installing a newer version restarts that module's accounts in place. -Downloaded code is interpreted JS and QML in the app's process, not a sandbox. That is fine for -direct downloads, Linux and webOS developer builds; a Play Store or App Store build must keep -downloading off (bundled providers only) unless the store's review has accepted the interpreter -surface. There is no build switch for that yet. +Downloaded code is interpreted JS and QML in the app's process, not a sandbox. No store forbids +the interpreter itself (Qt runs QML without a JIT on iOS), but store review decides what downloaded +code may add. `-DSPOOL_PROVIDER_SOURCES=` chooses what a build accepts: + +| Value | Store catalogues | By link | Installed from disk | For | +| --- | --- | --- | --- | --- | +| `open` (default) | yes | yes | yes | Every release build: GitHub, AUR, webOS, direct APKs | +| `curated` | yes | no | yes | Google Play and App Store submissions | +| `bundled` | no | no | no | A first store submission, or a store that rejects `curated` | + +A curated build also ignores `--provider-store`/`SPOOL_PROVIDER_STORE` and never follows the feed +of a provider an earlier open build added by link. Google Play allows interpreted code loaded at +run time as long as it can't be used to break Play policy; Apple allows JavaScript plug-ins under +guideline 4.7 when the app answers for every one of them (an index, reporting, age limits, and +no native APIs exposed to them without Apple's permission). Curation through the store's pull +requests is what makes that answerable. ## Testing diff --git a/qml/pages/AddProviderPage.qml b/qml/pages/AddProviderPage.qml index 8b97276a..fd103921 100644 --- a/qml/pages/AddProviderPage.qml +++ b/qml/pages/AddProviderPage.qml @@ -8,6 +8,7 @@ import "../primitives" // Choosing where media comes from: official providers first, then the // community's, then any provider by link. Installing and signing in are one // step from here, and the same page updates or removes what is installed. +// A curated build has no link row, and a bundled one only its own providers. FocusScope { id: root @@ -24,16 +25,19 @@ FocusScope { } ] out.push(...Store.official) + if (!Store.storeAvailable) + return out out.push({ "kind": "header", "title": "Community", "loading": Store.loading }) out.push(...Store.community) - out.push({ - "kind": "link", - "id": "link" - }) + if (Store.linksAllowed) + out.push({ + "kind": "link", + "id": "link" + }) return out } diff --git a/src/main.cpp b/src/main.cpp index 26b8e00e..dd3bd915 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -648,8 +648,15 @@ int main(int argc, char **argv) JellyfinNative::TlsTrustController tlsTrust; // Accounts outlive the player and the app controller, which hold their // parts by pointer, so the registry and the hub come first. + // Chosen by SPOOL_PROVIDER_SOURCES at configure time, which rejects + // anything else; an unknown name would fall to the narrowest. + const JellyfinNative::ProviderSources providerSources + = JellyfinNative::providerSourcesFromName(QString::fromLatin1(SPOOL_PROVIDER_SOURCES)) + .value_or(JellyfinNative::ProviderSources::Bundled); + logLine("providers: sources=%s", SPOOL_PROVIDER_SOURCES); JellyfinNative::ProviderRegistry providers(&database); - providers.setInstallDirectory(QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers"))); + if (providerSources != JellyfinNative::ProviderSources::Bundled) + providers.setInstallDirectory(QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers"))); providers.loadModules(); #if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) { @@ -676,9 +683,14 @@ int main(int argc, char **argv) [&hub, &providerCapabilities] { providerCapabilities.setFlags(hub.capabilities()); }); JellyfinNative::configurePlatformPlaybackCapabilities( [&hub](const QStringList& codecs, bool restrict) { hub.setVideoCodecs(codecs, restrict); }, hub); + // Only an open build may be pointed at another store; a curated one is + // curated by this one. + const QString officialStore = QStringLiteral("https://spool-player.github.io/spool-providers/"); JellyfinNative::ProviderStore store(&providers, &database, networkAccessManager, - QUrl(optionValue(arguments, QStringLiteral("--provider-store"), "SPOOL_PROVIDER_STORE", - QStringLiteral("https://spool-player.github.io/spool-providers/")))); + QUrl(providerSources == JellyfinNative::ProviderSources::Open + ? optionValue(arguments, QStringLiteral("--provider-store"), "SPOOL_PROVIDER_STORE", officialStore) + : officialStore), + providerSources); const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index ace81f72..e04e2edf 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -140,7 +140,9 @@ void ProviderRegistry::loadModules() if (auto manifest = ProviderManifest::parse(file.readAll())) registerPackage(std::move(*manifest), QUrl(QStringLiteral("qrc") + path + QLatin1Char('/')), true); } - const auto installed = ProviderPackage::installedVersions(m_installDirectory); + // A build with no install directory runs only what it bundles. + const auto installed = m_installDirectory.isEmpty() ? QMap {} + : ProviderPackage::installedVersions(m_installDirectory); for (auto it = installed.cbegin(); it != installed.cend(); ++it) { QFile file(it.value() + QStringLiteral("/manifest.json")); if (!file.open(QIODevice::ReadOnly)) @@ -373,6 +375,8 @@ void ProviderRegistry::persist(bool credentials) QCoro::Task ProviderRegistry::install(ProviderPackageContents package) { const QString root = m_installDirectory; + if (root.isEmpty()) + throw std::runtime_error("installs_disabled"); // Disk writes stay off the GUI thread. QString error; const auto directory = co_await Async::background( diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 06dfe74f..8f9a0fbd 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -75,6 +75,7 @@ class ProviderRegistry final : public QObject { ~ProviderRegistry() override; void setRuntimeEnvironment(QVariantMap device, ScriptRuntime::NetworkHooks hooks); + // Left unset, nothing is installed or loaded from disk. void setInstallDirectory(const QString& path); QString installDirectory() const { diff --git a/src/provider/ProviderStore.cpp b/src/provider/ProviderStore.cpp index 4171c6cc..fc9a2b5d 100644 --- a/src/provider/ProviderStore.cpp +++ b/src/provider/ProviderStore.cpp @@ -38,13 +38,25 @@ namespace { } } // namespace +std::optional providerSourcesFromName(QStringView name) +{ + if (name == QLatin1String("bundled")) + return ProviderSources::Bundled; + if (name == QLatin1String("curated")) + return ProviderSources::Curated; + if (name == QLatin1String("open")) + return ProviderSources::Open; + return std::nullopt; +} + ProviderStore::ProviderStore(ProviderRegistry *registry, DatabaseManager *database, QNetworkAccessManager *network, - QUrl catalogBase, QObject *parent) + QUrl catalogBase, ProviderSources sources, QObject *parent) : QObject(parent) , m_registry(registry) , m_database(database) , m_network(network) , m_catalogBase(std::move(catalogBase)) + , m_sources(sources) { connect(registry, &ProviderRegistry::modulesChanged, this, &ProviderStore::catalogChanged); Async::runScoped(this, loadOrigins(), [] { }, [](const std::exception_ptr&) { }, "provider origins"); @@ -145,6 +157,8 @@ QCoro::Task ProviderStore::fetchCatalog(QString name) void ProviderStore::refresh(bool includeCommunity) { + if (!storeAvailable()) + return; const auto load = [this](QString name, bool official) { ++m_loading; emit catalogChanged(); @@ -313,6 +327,10 @@ void ProviderStore::uninstall(const QString& id) void ProviderStore::addFromUrl(const QString& input) { + if (!linksAllowed()) { + emit problem(QStringLiteral("This version of Spool only installs providers from its store")); + return; + } const QUrl feed = feedUrlFor(input); if (feed.isEmpty()) { emit problem(QStringLiteral("That doesn't look like a link")); @@ -337,10 +355,28 @@ void ProviderStore::addFromUrl(const QString& input) Async::runScoped(this, add(this, feed), [] { }, [](const std::exception_ptr&) { }, "provider add"); } +bool ProviderStore::allows(const Origin& origin) const +{ + switch (m_sources) { + case ProviderSources::Bundled: + return false; + case ProviderSources::Curated: + return origin.channel != QStringLiteral("url"); + case ProviderSources::Open: + return true; + } + return false; +} + QCoro::Task ProviderStore::installEntry(QVariantMap entry, Origin origin) { const QString id = text(entry, "id"); const QString name = text(entry, "name"); + // Every download comes through here, so this is the one gate. + if (!allows(origin)) { + qWarning("providers: %s is not from a source this build installs from", qPrintable(id)); + co_return; + } if (text(entry, "api") != QLatin1String(kApi)) { emit problem(QStringLiteral("%1 needs a different version of Spool").arg(name)); co_return; @@ -391,6 +427,8 @@ QCoro::Task ProviderStore::installEntry(QVariantMap entry, Origin origin) void ProviderStore::checkForUpdates(const QString& policy) { + if (!storeAvailable()) + return; Async::runScoped(this, checkAsync(policy), [] { }, [](const std::exception_ptr&) { }, "provider update check"); } @@ -418,6 +456,8 @@ QCoro::Task ProviderStore::checkAsync(QString policy) const ProviderModule *module = m_registry->module(id); const Origin origin = m_origins.value(id, { QStringLiteral("official"), {} }); QVariantMap latest; + if (!allows(origin)) + continue; if (origin.channel == QStringLiteral("url")) { try { latest = QJsonDocument::fromJson(co_await fetch(origin.feed, kCatalogLimit)).object().toVariantMap(); diff --git a/src/provider/ProviderStore.h b/src/provider/ProviderStore.h index 7dd706e5..000dde16 100644 --- a/src/provider/ProviderStore.h +++ b/src/provider/ProviderStore.h @@ -8,6 +8,8 @@ #include #include +#include + class QNetworkAccessManager; namespace JellyfinNative { @@ -15,6 +17,20 @@ namespace JellyfinNative { class DatabaseManager; class ProviderRegistry; +// Where a build may take provider code from, chosen at compile time with +// SPOOL_PROVIDER_SOURCES (docs/providers.md). App store review decides what +// downloaded code may add, so a build submitted to a store narrows this. +enum class ProviderSources { + // Only the providers inside the app: no store and no downloads. + Bundled, + // The store's reviewed catalogues only; nothing by link. + Curated, + // The catalogues and any provider by link. Every release build. + Open, +}; + +std::optional providerSourcesFromName(QStringView name); + // Where providers come from and how they stay current. // // Two small JSON files on the store's GitHub Pages site describe everything: @@ -32,12 +48,14 @@ class ProviderStore final : public QObject { Q_PROPERTY(QString error READ error NOTIFY catalogChanged) Q_PROPERTY(QVariantList updates READ updates NOTIFY updatesChanged) Q_PROPERTY(QVariantMap busy READ busy NOTIFY busyChanged) + Q_PROPERTY(bool storeAvailable READ storeAvailable CONSTANT) + Q_PROPERTY(bool linksAllowed READ linksAllowed CONSTANT) public: static constexpr auto kApi = "0.2"; ProviderStore(ProviderRegistry *registry, DatabaseManager *database, QNetworkAccessManager *network, - QUrl catalogBase, QObject *parent = nullptr); + QUrl catalogBase, ProviderSources sources = ProviderSources::Open, QObject *parent = nullptr); QVariantList official() const; QVariantList community() const; @@ -53,6 +71,14 @@ class ProviderStore final : public QObject { { return m_updates; } + bool storeAvailable() const + { + return m_sources != ProviderSources::Bundled; + } + bool linksAllowed() const + { + return m_sources == ProviderSources::Open; + } // Provider id to what is happening to it: "downloading", "installing". QVariantMap busy() const { @@ -93,6 +119,7 @@ class ProviderStore final : public QObject { QCoro::Task fetch(QUrl url, qint64 limit); QCoro::Task fetchCatalog(QString name); QCoro::Task installEntry(QVariantMap entry, Origin origin); + bool allows(const Origin& origin) const; QCoro::Task checkAsync(QString policy); QVariantList annotate(const QVariantList& entries) const; QVariantMap entryFor(const QString& id) const; @@ -103,6 +130,7 @@ class ProviderStore final : public QObject { QPointer m_database; QNetworkAccessManager *m_network; QUrl m_catalogBase; + ProviderSources m_sources; QVariantList m_official; QVariantList m_community; QVariantList m_updates; diff --git a/tests/providers/ProviderStoreTest.cpp b/tests/providers/ProviderStoreTest.cpp index 16ce3899..43ee4d32 100644 --- a/tests/providers/ProviderStoreTest.cpp +++ b/tests/providers/ProviderStoreTest.cpp @@ -123,19 +123,21 @@ JELLYFIN_TEST_MAIN("provider-store") } QHash routes; + QList requested; int requests = 0; QTcpServer server; require(server.listen(QHostAddress::LocalHost), "fixture server listens"); QObject::connect(&server, &QTcpServer::newConnection, &app, [&] { while (QTcpSocket *socket = server.nextPendingConnection()) { QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); - QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket, &routes, &requests] { + QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket, &routes, &requested, &requests] { const QByteArray request = socket->property("request").toByteArray() + socket->readAll(); socket->setProperty("request", request); if (!request.contains("\r\n\r\n")) return; ++requests; const QByteArray path = request.split(' ').value(1); + requested.append(path); const bool found = routes.contains(path); const QByteArray body = routes.value(path); socket->write((found ? "HTTP/1.1 200 OK" : "HTTP/1.1 404 Not Found") @@ -257,5 +259,74 @@ JELLYFIN_TEST_MAIN("provider-store") && store.updates().isEmpty(), "the new version replaces the old and the offer goes away"); } + + // A curated build (Google Play, the App Store) installs from the store's + // catalogues and never follows a provider's own link. + const QByteArray linkedV2 + = ProviderFixture::archive(ProviderFixture::package(QStringLiteral("fixture.linked"), QStringLiteral("1.1.0"))); + routes.insert("/feed.test/linked2.tar.zst", linkedV2); + routes.insert("/feed.test/spool-provider.json", + QJsonDocument(QJsonObject::fromVariantMap(entryFor(linkedV2, QStringLiteral("fixture.linked"), + QStringLiteral("1.1.0"), QStringLiteral("https://feed.test/linked2.tar.zst")))) + .toJson()); + const auto checked = [&](ProviderStore& store) { + bool done = false; + const auto connection = QObject::connect(&store, &ProviderStore::updatesChanged, [&] { done = true; }); + store.checkForUpdates(QStringLiteral("ask")); + waitUntil([&] { return done; }, "the update check finishes"); + QObject::disconnect(connection); + }; + { + ProviderStore store( + ®istry, &database, &network, QUrl(QStringLiteral("https://store.test/")), ProviderSources::Curated); + require(store.storeAvailable() && !store.linksAllowed(), "a curated build has the store and no links"); + problems.clear(); + QObject::connect(&store, &ProviderStore::problem, [&](const QString& message) { problems.append(message); }); + requested.clear(); + store.addFromUrl(QStringLiteral("feed.test")); + require(problems.size() == 1 + && problems.last().contains(QStringLiteral("only installs providers from its store")) + && requested.isEmpty(), + "adding by link is refused without a request"); + checked(store); + require(!requested.contains("/feed.test/spool-provider.json") && store.updates().isEmpty(), + "a provider added by link is not updated from its link"); + store.refresh(true); + waitUntil([&] { return !store.loading(); }, "the curated catalogue loads"); + require(listed(store.official(), QStringLiteral("fixture.test"), "installed"), "and lists the store"); + } + { + ProviderStore store(®istry, &database, &network, QUrl(QStringLiteral("https://store.test/"))); + checked(store); + require(store.updates().size() == 1, "an open build does follow the link, so the check above meant something"); + } + + // A bundled build has no store and loads nothing from disk, even with + // providers installed there by an earlier build. + { + ProviderRegistry bundledRegistry(&database); + bundledRegistry.loadModules(); + require(bundledRegistry.module(QStringLiteral("spool.jellyfin")) + && !bundledRegistry.module(QStringLiteral("fixture.test")), + "a bundled build runs only its own providers"); + ProviderStore store(&bundledRegistry, &database, &network, QUrl(QStringLiteral("https://store.test/")), + ProviderSources::Bundled); + require(!store.storeAvailable() && !store.linksAllowed(), "a bundled build has no store"); + requested.clear(); + store.refresh(true); + store.checkForUpdates(QStringLiteral("auto")); + store.addFromUrl(QStringLiteral("feed.test")); + QCoreApplication::processEvents(); + require(!store.loading() && requested.isEmpty(), "and asks the network for nothing"); + require(listed(store.official(), QStringLiteral("spool.jellyfin"), "installed"), + "its bundled providers are still listed"); + bool refused = false; + try { + QCoro::waitFor(bundledRegistry.install(ProviderFixture::package(QStringLiteral("fixture.manual")))); + } catch (const std::exception&) { + refused = true; + } + require(refused, "and its registry has nowhere to install to"); + } return 0; } From 2e6d7185161e020a69a5026fdad84d41926a5106 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 22:18:49 +1000 Subject: [PATCH 064/146] fix(package-audit): resolve libraries by file name, and run the test The dynamic loader finds a NEEDED name as a file, but the audit also accepted a file whose SONAME matched, so a library shipped under the wrong name passed. PackageAuditTest.py never called main(), so the package-audit test checked nothing; its fixture also picked up the Nix dev shell's RPATHs. --- tests/tools/PackageAuditTest.py | 23 +++++++++++++++++++---- tools/package-audit.py | 5 ++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/tools/PackageAuditTest.py b/tests/tools/PackageAuditTest.py index 0bd8e54b..e3c9b5f2 100755 --- a/tests/tools/PackageAuditTest.py +++ b/tests/tools/PackageAuditTest.py @@ -18,6 +18,12 @@ def run(script: Path, *args: str, expected: int = 0) -> subprocess.CompletedProc return result +# Nix's compiler wrapper and dev shell add RPATHs (store paths, the shell's +# $out), which the audit rightly rejects; a packaged binary never has them. +COMPILE_ENV = {key: value for key, value in os.environ.items() if not key.startswith("NIX_LDFLAGS")} +COMPILE_ENV["NIX_DONT_SET_RPATH"] = "1" + + def compile_fixture(root: Path) -> None: (root / "lib").mkdir() (root / "dep.c").write_text("int package_audit_dep(void) { return 42; }\n", encoding="utf-8") @@ -28,13 +34,13 @@ def compile_fixture(root: Path) -> None: subprocess.run( ["cc", "-shared", "-fPIC", "-Wl,-soname,libpackage-audit-dep.so.1", "-o", str(root / "lib/libpackage-audit-dep.so.1"), str(root / "dep.c")], - check=True, + check=True, env=COMPILE_ENV, ) os.symlink("libpackage-audit-dep.so.1", root / "lib/libpackage-audit-dep.so") subprocess.run( ["cc", "-o", str(root / "app"), str(root / "main.c"), f"-L{root / 'lib'}", "-lpackage-audit-dep", "-Wl,-rpath,$ORIGIN/lib"], - check=True, + check=True, env=COMPILE_ENV, ) subprocess.run(["strip", "--strip-unneeded", str(root / "app"), str(root / "lib/libpackage-audit-dep.so.1")], check=True) @@ -64,7 +70,7 @@ def main() -> int: orphan = root / "lib/liborphan.so.1" subprocess.run( ["cc", "-shared", "-fPIC", "-Wl,-soname,liborphan.so.1", "-o", str(orphan), str(root / "dep.c")], - check=True, + check=True, env=COMPILE_ENV, ) subprocess.run(["strip", "--strip-unneeded", str(orphan)], check=True) unreachable = run( @@ -74,8 +80,17 @@ def main() -> int: orphan.unlink() (root / "lib/libpackage-audit-dep.so").unlink() run(script, "elf", str(root), "--root", "app", "--allow-system", "libc.so.6") - (root / "lib/libpackage-audit-dep.so.1").unlink() + # The loader finds libpackage-audit-dep.so.1 as a file; a copy under + # another name with that SONAME does not satisfy it. + (root / "lib/libpackage-audit-dep.so.1").rename(root / "lib/libpackage-audit-dep.so") + renamed = run(script, "elf", str(root), "--root", "app", "--allow-system", "libc.so.6", expected=1) + assert "missing ELF dependency" in renamed.stderr + (root / "lib/libpackage-audit-dep.so").unlink() missing = run(script, "elf", str(root), "--root", "app", "--allow-system", "libc.so.6", expected=1) assert "missing ELF dependency" in missing.stderr return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/package-audit.py b/tools/package-audit.py index d472f5b3..f7970833 100755 --- a/tools/package-audit.py +++ b/tools/package-audit.py @@ -137,10 +137,9 @@ def audit_elf(args: argparse.Namespace) -> int: regular_elfs = [path for path in root.rglob("*") if path.is_file() and not path.is_symlink() and is_elf(path)] infos = {path.resolve(): elf_info(path, readelf) for path in regular_elfs} providers: dict[str, set[Path]] = defaultdict(set) - for path, info in infos.items(): + # The dynamic loader finds a NEEDED name as a file, never by SONAME. + for path in infos: providers[path.name].add(path) - if info.soname: - providers[info.soname].add(path) for link in root.rglob("*"): if link.is_symlink() and is_elf(link): providers[link.name].add(link.resolve()) From b9d6ed70458bf3fb1a421306d7de5e14895aa191 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 22:18:49 +1000 Subject: [PATCH 065/146] fix(appimage): bundle glibc so Linux packages start on any distro Every Linux package asked for Nix's /nix/store glibc loader, so it only started where that exact store path existed. The loader and libraries now ship stripped in usr/lib/glibc; binaries name /lib64/ld-linux-x86-64.so.2 and AppRun runs them through the bundled loader unless the host glibc is at least as new (or is NixOS without nix-ld). secret-tool gets the same treatment through a wrapper. No gconv modules or musl. Nix's Qt and FFmpeg need libvulkan.so, a name the bundle rewrote to nothing the loader could find, so the host's Vulkan loader was used beside a second bundled copy, and hosts without one could not start. Development names now become the library's SONAME. Launch-tested on Ubuntu 24.04, Debian 12 and Arch root filesystems, and on NixOS in both modes. --- .github/workflows/build-artifacts.yml | 6 +- src/platform/linux/LinuxPlatformPaths.cpp | 4 + tools/package-appimage.sh | 123 ++++++++++++++++++---- 3 files changed, 112 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 9c2be0b5..a98c690b 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -395,7 +395,7 @@ jobs: exit 1 fi elf_args=(elf "$extracted" --root usr/bin/jellyfin-native) - test ! -f "$extracted/usr/bin/secret-tool" || elf_args+=(--root usr/bin/secret-tool) + test ! -f "$extracted/usr/libexec/secret-tool" || elf_args+=(--root usr/libexec/secret-tool) while IFS= read -r elf; do elf_args+=(--root "${elf#"$extracted/"}"); done \ < <(find "$extracted/usr/lib" -type f -name 'libmpv.so.*' | sort) for root in usr/plugins usr/qml; do @@ -403,8 +403,8 @@ jobs: elf_args+=(--root "${elf#"$extracted/"}") done < <(find "$extracted/$root" -type f -name '*.so*' | sort) done - for soname in ld-linux-x86-64.so.2 libc.so.6 libdl.so.2 libm.so.6 libmvec.so.1 \ - libpthread.so.0 libresolv.so.2 librt.so.1 libutil.so.1 libGLESv2.so.2 libgbm.so.1 libglapi.so.0 + # glibc is bundled; only these come from the host. + for soname in libGLESv2.so.2 libgbm.so.1 libglapi.so.0 do elf_args+=(--allow-system "$soname") done diff --git a/src/platform/linux/LinuxPlatformPaths.cpp b/src/platform/linux/LinuxPlatformPaths.cpp index 27e48617..60c69fcc 100644 --- a/src/platform/linux/LinuxPlatformPaths.cpp +++ b/src/platform/linux/LinuxPlatformPaths.cpp @@ -9,6 +9,10 @@ namespace JellyfinNative { QString resolveAppRoot(const char *argv0) { QString executable = QFileInfo(QStringLiteral("/proc/self/exe")).symLinkTarget(); + // The AppImage may start the app through its bundled glibc's loader, which + // /proc/self/exe then names; argv[0] is still the app. + if (QFileInfo(executable).fileName().startsWith(QLatin1String("ld-linux"))) + executable.clear(); if (executable.isEmpty() && argv0) executable = QFileInfo(QString::fromLocal8Bit(argv0)).canonicalFilePath(); if (executable.isEmpty()) diff --git a/tools/package-appimage.sh b/tools/package-appimage.sh index 07c925fb..371f1050 100755 --- a/tools/package-appimage.sh +++ b/tools/package-appimage.sh @@ -67,6 +67,7 @@ copy_elf_deps() { local dep local copied=1 local roots=("$APPDIR/usr/bin" "$APPDIR/usr/lib") + [[ -d "$APPDIR/usr/libexec" ]] && roots+=("$APPDIR/usr/libexec") [[ -d "$APPDIR/usr/plugins" ]] && roots+=("$APPDIR/usr/plugins") [[ -d "$APPDIR/usr/qml" ]] && roots+=("$APPDIR/usr/qml") @@ -241,14 +242,28 @@ audit_ffmpeg_closure() { normalize_nix_store_needed() { local elf="$1" - local needed + local needed name while IFS= read -r needed; do - if is_bundleable_elf_dep "$needed" && [[ ! -e "$APPDIR/usr/lib/${needed##*/}" ]]; then - cp -L "$needed" "$APPDIR/usr/lib/" - chmod u+w "$APPDIR/usr/lib/${needed##*/}" + # Nix adds some libraries by their development name (Qt and FFmpeg need + # libvulkan.so). The loader looks a name up as a file, so ask for the + # SONAME everything else uses, or the process loads two copies. + if [[ "$needed" == *.so && -f "$APPDIR/usr/lib/$needed" ]]; then + name="$("$PATCHELF_BIN" --print-soname "$APPDIR/usr/lib/$needed" 2>/dev/null || true)" + if [[ -n "$name" && "$name" != "$needed" ]]; then + [[ -e "$APPDIR/usr/lib/$name" ]] || cp "$APPDIR/usr/lib/$needed" "$APPDIR/usr/lib/$name" + "$PATCHELF_BIN" --replace-needed "$needed" "$name" "$elf" + fi + continue + fi + [[ "$needed" == /* ]] || continue + name="$("$PATCHELF_BIN" --print-soname "$needed" 2>/dev/null || true)" + name="${name:-${needed##*/}}" + if is_bundleable_elf_dep "$needed" && [[ ! -e "$APPDIR/usr/lib/$name" ]]; then + cp -L "$needed" "$APPDIR/usr/lib/$name" + chmod u+w "$APPDIR/usr/lib/$name" fi [[ "$needed" == /nix/store/* ]] || continue - "$PATCHELF_BIN" --replace-needed "$needed" "${needed##*/}" "$elf" + "$PATCHELF_BIN" --replace-needed "$needed" "$name" "$elf" done < <("$PATCHELF_BIN" --print-needed "$elf") } @@ -281,6 +296,38 @@ set_appdir_rpaths() { done < <(find "$APPDIR/usr" -type f) } +# The app runs on the glibc it was built with: its loader and libraries go in +# usr/lib/glibc, apart from usr/lib so programs the app starts never load them +# through LD_LIBRARY_PATH. AppRun uses the host's glibc instead when that is at +# least as new. The audit sweeps whichever of these nothing links. No gconv +# modules: mpv is built without iconv, and everything else only converts +# UTF-8, which glibc does itself. +bundle_glibc() { + local interp glibc_lib lib elf + interp="$("$PATCHELF_BIN" --print-interpreter "$APPDIR/usr/bin/jellyfin-native")" + glibc_lib="$(dirname "$interp")" + GLIBC_VERSION="$("$interp" --version | sed -n '1s/.* version \([0-9]*\.[0-9]*\).*/\1/p')" + if [[ -z "$GLIBC_VERSION" ]]; then + echo "error: could not read the glibc version from $interp" >&2 + exit 1 + fi + mkdir -p "$APPDIR/usr/lib/glibc" + for lib in ld-linux-x86-64.so.2 libc.so.6 libm.so.6 libmvec.so.1 libdl.so.2 libpthread.so.0 \ + librt.so.1 libresolv.so.2 libutil.so.1 libanl.so.1; do + [[ -f "$glibc_lib/$lib" ]] || continue + cp -L "$glibc_lib/$lib" "$APPDIR/usr/lib/glibc/" + chmod u+w "$APPDIR/usr/lib/glibc/$lib" + done + # Started through this loader, Qt looks for qt.conf beside it. + sed 's|^Prefix = \.\./$|Prefix = ../../|' "$APPDIR/usr/bin/qt.conf" >"$APPDIR/usr/lib/glibc/qt.conf" + # The host's loader path: AppRun runs these directly when the host glibc + # will do, and through usr/lib/glibc otherwise. + while IFS= read -r elf; do + file -b "$elf" | grep -q 'ELF.*interpreter' || continue + "$PATCHELF_BIN" --set-interpreter /lib64/ld-linux-x86-64.so.2 "$elf" + done < <(find "$APPDIR/usr/bin" "$APPDIR/usr/libexec" -type f 2>/dev/null) +} + strip_appdir_elfs() { local elf inode declare -A stripped_inodes=() @@ -298,21 +345,12 @@ audit_and_sweep_appdir_elfs() { local -a audit_args=( elf "$APPDIR" --root usr/bin/jellyfin-native - --allow-system ld-linux-x86-64.so.2 - --allow-system libc.so.6 - --allow-system libdl.so.2 - --allow-system libm.so.6 - --allow-system libpthread.so.0 - --allow-system libresolv.so.2 - --allow-system librt.so.1 - --allow-system libmvec.so.1 - --allow-system libutil.so.1 --allow-system libGLESv2.so.2 --allow-system libgbm.so.1 --allow-system libglapi.so.0 ) - if [[ -f "$APPDIR/usr/bin/secret-tool" ]]; then - audit_args+=(--root usr/bin/secret-tool) + if [[ -f "$APPDIR/usr/libexec/secret-tool" ]]; then + audit_args+=(--root usr/libexec/secret-tool) fi while IFS= read -r elf; do audit_args+=(--root "${elf#"$APPDIR/"}") @@ -359,7 +397,18 @@ rm -rf "$APPDIR" mkdir -p "$APPDIR/usr" "$APPDIR/usr/share/jellyfin-native/notices" cp -a "$APP_INSTALL/." "$APPDIR/usr/" if command -v secret-tool >/dev/null 2>&1; then - cp -f "$(command -v secret-tool)" "$APPDIR/usr/bin/secret-tool" + mkdir -p "$APPDIR/usr/libexec" + cp -f "$(command -v secret-tool)" "$APPDIR/usr/libexec/secret-tool" + # The app finds it on PATH; this runs it the way AppRun runs the app. + cat >"$APPDIR/usr/bin/secret-tool" <<'WRAPPER' +#!/bin/sh +tool="$(dirname "$(readlink -f "$0")")/../libexec/secret-tool" +if [ -n "${SPOOL_ELF_LOADER:-}" ]; then + exec "$SPOOL_ELF_LOADER" --library-path "$SPOOL_ELF_LIBRARY_PATH" "$tool" "$@" +fi +exec "$tool" "$@" +WRAPPER + chmod +x "$APPDIR/usr/bin/secret-tool" fi cp -f "$APP_ROOT/app/notices/OPEN_SOURCE_NOTICES.txt" "$APP_ROOT/LICENSE" \ "$APP_ROOT/qml/fonts/AtkinsonHyperlegible-LICENSE.txt" \ @@ -417,7 +466,43 @@ if [[ "${SPOOL_PORTABLE_BUNDLE:-0}" != 1 \ else export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland;xcb}" fi -exec "$HERE/usr/bin/jellyfin-native" "$@" + +# The bundle carries the glibc it was built with. A host glibc at least as new +# runs everything instead, so host GPU drivers get the glibc they were built +# for. NixOS's /lib64 loader is only real with nix-ld. +bundled_glibc=@GLIBC_VERSION@ +host_glibc="$(getconf GNU_LIBC_VERSION 2>/dev/null)" +host_glibc="${host_glibc#glibc }" +if [[ -n "$host_glibc" && -x /lib64/ld-linux-x86-64.so.2 && ( ! -e /etc/NIXOS || -n "${NIX_LD:-}" ) \ + && "$(printf '%s\n' "$bundled_glibc" "$host_glibc" | sort -V | head -n1)" == "$bundled_glibc" ]]; then + exec "$HERE/usr/bin/jellyfin-native" "$@" +fi +# Nix's loader knows no host library directories and reads no ld.so.cache, so +# name them: GPU drivers and what they link come from the host. +host_dirs="" +conf_files=(/etc/ld.so.conf) +while (( ${#conf_files[@]} )); do + conf="${conf_files[0]}" + conf_files=("${conf_files[@]:1}") + [[ -r "$conf" ]] || continue + while read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + if [[ "$line" == include\ * ]]; then + for included in ${line#include }; do conf_files+=("$included"); done + elif [[ -d "$line" ]]; then + host_dirs="$host_dirs:$line" + fi + done <"$conf" +done +for dir in /lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu /lib64 /usr/lib64 /lib /usr/lib; do + [[ -d "$dir" ]] && host_dirs="$host_dirs:$dir" +done +export SPOOL_ELF_LOADER="$HERE/usr/lib/glibc/ld-linux-x86-64.so.2" +export SPOOL_ELF_LIBRARY_PATH="$HERE/usr/lib/glibc:$LD_LIBRARY_PATH$host_dirs" +if [[ -z "${LOCALE_ARCHIVE:-}" && -f /usr/lib/locale/locale-archive ]]; then + export LOCALE_ARCHIVE=/usr/lib/locale/locale-archive +fi +exec "$SPOOL_ELF_LOADER" --library-path "$SPOOL_ELF_LIBRARY_PATH" "$HERE/usr/bin/jellyfin-native" "$@" APPRUN chmod +x "$APPDIR/AppRun" @@ -482,6 +567,8 @@ prune_appdir set_appdir_rpaths copy_elf_deps set_appdir_rpaths +bundle_glibc +sed -i "s/@GLIBC_VERSION@/$GLIBC_VERSION/" "$APPDIR/AppRun" chmod -R u+w "$APPDIR/usr" strip_appdir_elfs audit_and_sweep_appdir_elfs From 0f1d6b4cc0d126eff0d1df2300ec33db7cfd66f7 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Wed, 23 Sep 2026 22:37:36 +1000 Subject: [PATCH 066/146] test(package-audit): register the ELF audit test on Linux only Now that it runs, its cc-built ELF fixtures fail on Windows and macOS, which package with their own audits. --- CMakeLists.txt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c20c90f..4aa32586 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -967,11 +967,14 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" "${CMAKE_CURRENT_SOURCE_DIR}/tools/manifests/ffmpeg-capabilities.json" ) - add_test(NAME package-audit - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" - "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" - ) + # Builds ELF fixtures with cc; the AppImage is the only package it audits. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME package-audit + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/PackageAuditTest.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/package-audit.py" + ) + endif() add_test(NAME module-seam COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tools/check-module-seam.sh" ) From 02ecf9f4a701e59e513f80963e62144be543531e Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 24 Sep 2026 02:19:44 +1000 Subject: [PATCH 067/146] fix(ui): draw a text caret that can be seen on every screen Qt's default caret is one logical pixel, which disappears on a television and is easy to lose on a dense display. Text fields now draw an accent caret scaled with the UI that stays lit while typing and blinks when idle. --- qml/primitives/TextFieldRow.qml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/qml/primitives/TextFieldRow.qml b/qml/primitives/TextFieldRow.qml index eb55357c..e3682626 100644 --- a/qml/primitives/TextFieldRow.qml +++ b/qml/primitives/TextFieldRow.qml @@ -119,6 +119,34 @@ T.Control { onTextEdited: row.textEdited(text) onAccepted: row.accepted() + // Qt's own caret is one pixel wide, which vanishes on a television + // and is easy to lose on a dense screen. It holds steady while typing + // and blinks once the text is left alone. + cursorDelegate: Rectangle { + id: caret + property bool lit: true + width: Math.max(2, Metrics.scaled(2)) + color: Theme.accent + visible: field.activeFocus && field.selectionStart === field.selectionEnd && lit + + Timer { + id: blink + interval: 530 + repeat: true + running: field.activeFocus + onRunningChanged: caret.lit = true + onTriggered: caret.lit = !caret.lit + } + + Connections { + target: field + function onCursorPositionChanged() { + caret.lit = true + blink.restart() + } + } + } + // Templates carry the placeholder text but draw nothing for it, and a // hint is the difference between a labelled box and a guess. SecondaryText { From 543f6bd2c66218734248d3ca546b0de0230b4e83 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 24 Sep 2026 02:19:46 +1000 Subject: [PATCH 068/146] feat(search): search every server at once through the fewest accounts - Per server, search goes through the fewest signed-in users whose libraries cover everything any of them can see: a user who sees more stands in for one who sees less, and of two who see the same the one in use searches. Users set aside on a server run for search once the search page opens, but never join browsing, home rows or the library cache key. - Results show as each server answers instead of when the slowest is done, deduplicated per server (the user in use wins) and ranked together by how well the title matches, then by each server's own order. - A new query cancels the last one's provider operations, so stale searches stop holding operation slots. --- src/app/SearchController.cpp | 31 ++- src/provider/PortableProvider.cpp | 11 +- src/provider/PortableProvider.h | 2 +- src/provider/ProviderRegistry.cpp | 18 +- src/provider/ProviderRegistry.h | 6 +- src/provider/SearchSource.h | 11 + src/provider/SourceHub.cpp | 312 ++++++++++++++++++++++++-- src/provider/SourceHub.h | 31 ++- tests/providers/SourceHubTest.cpp | 69 ++++++ tests/providers/fixtures/provider.mjs | 17 +- 10 files changed, 477 insertions(+), 31 deletions(-) diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index 2e002b22..e8186742 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -4,7 +4,9 @@ #include "LibraryPrefetchController.h" #include +#include +#include #include namespace JellyfinNative { @@ -58,14 +60,26 @@ void SearchController::submit() const RequestGeneration::Token generation = m_searchGeneration.next(); setBusy(true); + // Results show as each source answers; busy lasts until the last one. + auto answered = std::make_shared(false); + QPointer guard(this); + auto update = [this, guard, generation, answered](std::vector items) { + if (!guard || !m_searchGeneration.isCurrent(generation)) + return; + *answered = true; + if (m_prefetch) + m_prefetch->prefetchPosters(items); + setResults(std::move(items)); + emit resultsChanged(); + }; Async::runLatest( - this, m_api->searchItems(m_query), m_searchGeneration, generation, - [this](std::vector items) { - if (m_prefetch) - m_prefetch->prefetchPosters(items); - setResults(std::move(items)); + this, m_api->searchProgressively(m_query, 80, std::move(update)), m_searchGeneration, generation, + [this, answered] { + if (!*answered) { + clearResults(); + emit resultsChanged(); + } setBusy(false); - emit resultsChanged(); }, [this](const std::exception_ptr& error) { clearResults(); @@ -101,7 +115,10 @@ void SearchController::clear() void SearchController::loadSuggestions() { - if (!authenticated() || m_suggestionsLoaded || m_suggestionsBusy) + if (!authenticated()) + return; + m_api->prepareSearch(); + if (m_suggestionsLoaded || m_suggestionsBusy) return; const RequestGeneration::Token generation = m_suggestionsGeneration.next(); diff --git a/src/provider/PortableProvider.cpp b/src/provider/PortableProvider.cpp index 1aa91cff..ba5e9f56 100644 --- a/src/provider/PortableProvider.cpp +++ b/src/provider/PortableProvider.cpp @@ -250,12 +250,13 @@ QCoro::Task PortableProvider::call(QString operation, QVariantMap a return m_registry->callSource(m_accountId, std::move(operation), std::move(arguments)); } -QCoro::Task> PortableProvider::list(QString operation, QVariantMap arguments, int limit) +QCoro::Task> PortableProvider::list( + QString operation, QVariantMap arguments, int limit, QString scope) { limit = std::clamp(limit, 1, 100); arguments.insert(QStringLiteral("limit"), limit); - ProviderMediaPage page - = co_await orEmpty(m_registry->callSourceMediaPage(m_accountId, std::move(operation), arguments, limit)); + ProviderMediaPage page = co_await orEmpty( + m_registry->callSourceMediaPage(m_accountId, std::move(operation), arguments, limit, std::move(scope))); co_return std::move(page.items); } @@ -392,7 +393,9 @@ QCoro::Task> PortableProvider::fetchItemsByIds(QStringLis QCoro::Task> PortableProvider::searchItems(QString searchTerm, int limit) { - return list(QStringLiteral("search"), { { QStringLiteral("query"), searchTerm } }, limit); + // One search per account in flight: SourceHub cancels this scope when + // the query moves on, so stale searches stop holding operation slots. + return list(QStringLiteral("search"), { { QStringLiteral("query"), searchTerm } }, limit, QStringLiteral("search")); } QCoro::Task> PortableProvider::fetchSearchSuggestions(int limit) diff --git a/src/provider/PortableProvider.h b/src/provider/PortableProvider.h index 19ee4846..6bc6cd47 100644 --- a/src/provider/PortableProvider.h +++ b/src/provider/PortableProvider.h @@ -103,7 +103,7 @@ class PortableProvider final : public Provider, private: class Playback; - QCoro::Task> list(QString operation, QVariantMap arguments, int limit); + QCoro::Task> list(QString operation, QVariantMap arguments, int limit, QString scope = {}); ProviderRegistry *m_registry; QString m_accountId; diff --git a/src/provider/ProviderRegistry.cpp b/src/provider/ProviderRegistry.cpp index e04e2edf..97d439f4 100644 --- a/src/provider/ProviderRegistry.cpp +++ b/src/provider/ProviderRegistry.cpp @@ -456,10 +456,10 @@ QCoro::Task ProviderRegistry::callSource( } QCoro::Task ProviderRegistry::callSourceMediaPage( - QString sourceId, QString operation, QVariantMap arguments, int maximumItems) + QString sourceId, QString operation, QVariantMap arguments, int maximumItems, QString scope) { return guarded(sourceId, [=](ScriptRuntime *runtime) { - return runtime->callMediaPage(sourceId, operation, arguments, {}, maximumItems); + return runtime->callMediaPage(sourceId, operation, arguments, scope, maximumItems); }); } @@ -741,6 +741,20 @@ void ProviderRegistry::useAccount(const QString& accountId) Async::runScoped(this, start(accountId), [] { }, [](const std::exception_ptr&) { }, "provider start"); } +void ProviderRegistry::startSetAside() +{ + for (const ProviderAccount& entry : m_accounts) { + if (entry.enabled || entry.group.isEmpty() || m_running.contains(entry.id) || m_expired.contains(entry.id)) + continue; + const bool groupInUse = std::any_of(m_accounts.begin(), m_accounts.end(), [&](const ProviderAccount& other) { + return other.enabled && other.module == entry.module && other.group == entry.group; + }); + const ProviderModule *owner = module(entry.module); + if (groupInUse && owner && (capabilitiesOf(owner->manifest) & Provider::Search)) + Async::runScoped(this, start(entry.id), [] { }, [](const std::exception_ptr&) { }, "provider start"); + } +} + void ProviderRegistry::setAccountEnabled(const QString& accountId, bool enabled) { if (enabled) { diff --git a/src/provider/ProviderRegistry.h b/src/provider/ProviderRegistry.h index 8f9a0fbd..6595e34d 100644 --- a/src/provider/ProviderRegistry.h +++ b/src/provider/ProviderRegistry.h @@ -111,7 +111,7 @@ class ProviderRegistry final : public QObject { QCoro::Task callSource( QString sourceId, QString operation, QVariantMap arguments = {}, QString scope = {}); QCoro::Task callSourceMediaPage( - QString sourceId, QString operation, QVariantMap arguments = {}, int maximumItems = 100); + QString sourceId, QString operation, QVariantMap arguments = {}, int maximumItems = 100, QString scope = {}); QCoro::Task callSourceItem(QString sourceId, QString operation, QVariantMap arguments = {}); void cancelSourceScope(const QString& sourceId, const QString& scope); bool sourceRunning(const QString& sourceId) const; @@ -130,6 +130,10 @@ class ProviderRegistry final : public QObject { QCoro::Task pick(QString accountId, QVariantMap arguments); Q_INVOKABLE QUrl componentUrl(const QString& moduleId, const QString& role) const; Q_INVOKABLE void useAccount(const QString& accountId); + // Starts the accounts set aside for another user of the same server, so + // search can reach libraries the account in use cannot. They stay out of + // browsing; SourceHub decides which of them a search needs. + void startSetAside(); Q_INVOKABLE void setAccountEnabled(const QString& accountId, bool enabled); Q_INVOKABLE void removeAccount(const QString& accountId); diff --git a/src/provider/SearchSource.h b/src/provider/SearchSource.h index 761f0b82..884f9bf2 100644 --- a/src/provider/SearchSource.h +++ b/src/provider/SearchSource.h @@ -6,6 +6,7 @@ #include +#include #include namespace JellyfinNative { @@ -20,6 +21,16 @@ class SearchSource { virtual bool signedIn() const = 0; virtual QCoro::Task> searchItems(QString searchTerm, int limit = 80) = 0; virtual QCoro::Task> fetchSearchSuggestions(int limit = 20) = 0; + + // Hands `update` everything found so far each time more arrives, so one + // slow source does not hold back the rest. A single source answers once. + using SearchUpdate = std::function)>; + virtual QCoro::Task searchProgressively(QString searchTerm, int limit, SearchUpdate update) + { + update(co_await searchItems(std::move(searchTerm), limit)); + } + // Search is about to be used; a chance to get connections ready. + virtual void prepareSearch() { } }; } // namespace JellyfinNative diff --git a/src/provider/SourceHub.cpp b/src/provider/SourceHub.cpp index b010bf28..f90419e6 100644 --- a/src/provider/SourceHub.cpp +++ b/src/provider/SourceHub.cpp @@ -35,8 +35,106 @@ namespace { } return merged; } + + // Case, accents and punctuation folded away: "Amélie" finds "amelie!". + QString folded(const QString& text) + { + const QString decomposed = text.normalized(QString::NormalizationForm_KD); + QString out; + out.reserve(decomposed.size()); + for (const QChar c : decomposed) { + if (c.isLetterOrNumber()) + out += c.toCaseFolded(); + else if (c.category() != QChar::Mark_NonSpacing && !out.isEmpty() && !out.endsWith(QLatin1Char(' '))) + out += QLatin1Char(' '); + } + return out.trimmed(); + } + + // Lower is closer: the whole title, its start, the start of each word, + // anywhere in it, or matched by the server on something else. + int matchRank(const QString& title, const QString& query) + { + static const QStringList articles { QStringLiteral("the "), QStringLiteral("a "), QStringLiteral("an ") }; + if (query.isEmpty() || title.isEmpty()) + return 4; + QString bare = title; + for (const QString& article : articles) { + if (bare.startsWith(article) && !query.startsWith(article)) { + bare = bare.mid(article.size()); + break; + } + } + if (bare == query) + return 0; + if (bare.startsWith(query)) + return 1; + const QStringList words = bare.split(QLatin1Char(' ')); + const QStringList wanted = query.split(QLatin1Char(' ')); + const bool everyWord = std::all_of(wanted.begin(), wanted.end(), [&](const QString& part) { + return std::any_of(words.begin(), words.end(), [&](const QString& word) { return word.startsWith(part); }); + }); + if (everyWord) + return 2; + return bare.contains(query) ? 3 : 4; + } + + int itemRank(const MovieItem& item, const QString& query) + { + int rank = matchRank(folded(item.title), query); + // An episode of the show asked for is a good answer, just not as + // good as one whose own title matches. + if (rank > 0 && !item.seriesName.isEmpty()) + rank = std::min(rank, matchRank(folded(item.seriesName), query) + 2); + return std::min(rank, 4); + } } // namespace +struct SourceHub::SearchRun { + std::vector plan; + std::vector> found; + QString query; + int limit = 0; + quint64 serial = 0; + SearchUpdate update; + + // Everything found so far, each item once per server, ranked by how well + // its title matches and then by each server's own order, so equally good + // answers from different servers interleave. + std::vector merged() const + { + struct Ranked { + int rank; + size_t position; + size_t target; + const MovieItem *item; + }; + std::vector ranked; + QSet seen; + for (size_t target = 0; target < found.size(); ++target) { + for (size_t position = 0; position < found[target].size(); ++position) { + const MovieItem& item = found[target][position]; + const QString key = plan[target].server + QLatin1Char('\n') + SourceHub::rawId(item.id); + if (seen.contains(key)) + continue; + seen.insert(key); + ranked.push_back({ itemRank(item, query), position, target, &item }); + } + } + std::stable_sort(ranked.begin(), ranked.end(), [](const Ranked& a, const Ranked& b) { + return std::tie(a.rank, a.position, a.target) < std::tie(b.rank, b.position, b.target); + }); + std::vector items; + items.reserve(std::min(ranked.size(), size_t(limit))); + for (const Ranked& entry : ranked) { + if (items.size() >= size_t(limit)) + break; + items.push_back(*entry.item); + } + return items; + } +}; + class SourceHub::Playback final : public PlaybackSource { public: explicit Playback(SourceHub *hub) @@ -164,6 +262,7 @@ SourceHub::SourceHub(ProviderRegistry *registry, QObject *parent) }); connect(registry, &ProviderRegistry::sourceStarted, this, &SourceHub::addSource); connect(registry, &ProviderRegistry::sourceStopped, this, &SourceHub::removeSource); + connect(registry, &ProviderRegistry::accountsChanged, this, &SourceHub::syncBrowse); connect(registry, &ProviderRegistry::restoredChanged, this, [this] { // Nothing to wait for: announce the empty state so the shell moves on. if (m_registry->restored() @@ -193,8 +292,11 @@ bool SourceHub::ready() const void SourceHub::addSource(Provider *provider) { const QString accountId = provider->id(); - m_entries.insert(prefixOf(accountId), { accountId, provider }); + const bool browse = accountEnabled(accountId); + m_entries.insert(prefixOf(accountId), { accountId, provider, browse }); connect(provider, &Provider::contentChanged, this, [this, accountId](const QString& itemId) { + if (itemId.isEmpty()) + m_access.remove(accountId); emit contentChanged(itemId.isEmpty() ? QString() : scoped(accountId, itemId)); }); connect(provider, &Provider::sourceEvent, this, [this, accountId](const QString& type, const QVariantMap& payload) { @@ -203,22 +305,46 @@ void SourceHub::addSource(Provider *provider) connect(provider, &Provider::errorOccurred, this, &Provider::errorOccurred); connect(provider, &Provider::toastRequested, this, &Provider::toastRequested); pushPlaybackContext(); - refresh(); + // A set-aside account joining for search changes nothing on screen. + if (browse) + refresh(); } void SourceHub::removeSource(const QString& accountId) { - m_entries.remove(prefixOf(accountId)); - refresh(); + const Entry entry = m_entries.take(prefixOf(accountId)); + m_access.remove(accountId); + if (entry.browse) + refresh(); +} + +bool SourceHub::accountEnabled(const QString& accountId) const +{ + const auto& accounts = m_registry->accountList(); + const auto found = std::find_if(accounts.begin(), accounts.end(), [&](const auto& a) { return a.id == accountId; }); + // A native source with no saved account (tests, the local folder) is browsed. + return found == accounts.end() || found->enabled; +} + +// Choosing another user of a server promotes an account that was already +// running for search, and sets the previous one aside. +void SourceHub::syncBrowse() +{ + bool changed = false; + for (Entry& entry : m_entries) { + const bool browse = accountEnabled(entry.accountId); + changed |= entry.browse != browse; + entry.browse = browse; + } + if (changed) + refresh(); } void SourceHub::refresh() { Capabilities capabilities; - for (const Entry& entry : std::as_const(m_entries)) { - if (entry.provider) - capabilities |= entry.provider->capabilities(); - } + for (Provider *provider : sources()) + capabilities |= provider->capabilities(); if (capabilities != m_capabilities) { m_capabilities = capabilities; emit capabilitiesChanged(); @@ -259,7 +385,7 @@ std::vector SourceHub::sources() const { std::vector list; for (const Entry& entry : std::as_const(m_entries)) { - if (entry.provider) + if (entry.provider && entry.browse) list.push_back(entry.provider); } std::sort(list.begin(), list.end(), [](Provider *a, Provider *b) { return a->id() < b->id(); }); @@ -399,13 +525,14 @@ template QCoro::Task> SourceHub::gather( bool SourceHub::signedIn() const { - return !m_entries.isEmpty(); + return !sources().empty(); } QString SourceHub::libraryScopeKey() const { - QStringList keys = m_entries.keys(); - keys.sort(); + QStringList keys; + for (Provider *provider : sources()) + keys.append(prefixOf(provider->id())); return keys.join(QLatin1Char('+')); } @@ -565,10 +692,167 @@ QCoro::Task> SourceHub::fetchItemsByIds(QStringList itemI co_return ordered; } +void SourceHub::prepareSearch() +{ + m_registry->startSetAside(); +} + +QCoro::Task>> SourceHub::accessOf(QString accountId) +{ + if (const auto cached = m_access.constFind(accountId); cached != m_access.cend()) + co_return *cached; + Provider *provider = source(accountId); + if (!provider) + co_return std::nullopt; + try { + QSet ids; + for (const LibraryItem& library : co_await provider->catalog()->fetchLibraries()) + ids.insert(library.id); + m_access.insert(accountId, ids); + co_return ids; + } catch (const std::exception& error) { + qWarning() << "hub:" << accountId.left(kPrefix) << "libraries unknown for search:" << error.what(); + } + co_return std::nullopt; +} + +QCoro::Task> SourceHub::searchPlan() +{ + struct Candidate { + QString accountId; + bool active = false; + qint64 lastUsed = 0; + }; + // Accounts are grouped by provider and server; a set-aside account is + // only considered while another user of its server is in use. + QHash> servers; + QStringList order; + const auto& accounts = m_registry->accountList(); + for (const Entry& entry : std::as_const(m_entries)) { + if (!entry.provider || !entry.provider->search()) + continue; + const auto account + = std::find_if(accounts.begin(), accounts.end(), [&](const auto& a) { return a.id == entry.accountId; }); + const QString server = account == accounts.end() || account->group.isEmpty() + ? entry.accountId + : account->module + QLatin1Char('/') + account->group; + if (!servers.contains(server)) + order.append(server); + servers[server].push_back({ entry.accountId, entry.browse, account == accounts.end() ? 0 : account->lastUsed }); + } + order.sort(); + + std::vector plan; + for (const QString& server : std::as_const(order)) { + std::vector candidates = servers.value(server); + if (std::none_of(candidates.begin(), candidates.end(), [](const Candidate& c) { return c.active; })) + continue; + if (candidates.size() == 1) { + plan.push_back({ candidates.front().accountId, server }); + continue; + } + std::stable_sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { + return std::tie(b.active, b.lastUsed) < std::tie(a.active, a.lastUsed); + }); + // Asked of every candidate at once; cached until the server changes. + std::vector>>> pending; + for (const Candidate& candidate : candidates) + pending.push_back(accessOf(candidate.accountId)); + std::vector>> access; + for (auto& task : pending) + access.push_back(co_await std::move(task)); + + QSet uncovered; + for (const auto& libraries : access) { + if (libraries) + uncovered.unite(*libraries); + } + std::vector chosen(candidates.size(), false); + // An account in use whose libraries could not be read still searches: + // nothing can be said to cover it. + for (size_t index = 0; index < candidates.size(); ++index) + chosen[index] = candidates[index].active && !access[index]; + // Greedy cover: the account adding the most unseen libraries next, + // the one in use first among equals. + while (!uncovered.isEmpty()) { + size_t best = candidates.size(); + qsizetype bestGain = 0; + for (size_t index = 0; index < candidates.size(); ++index) { + if (chosen[index] || !access[index]) + continue; + const qsizetype gain = QSet(*access[index]).intersect(uncovered).size(); + if (gain > bestGain) { + best = index; + bestGain = gain; + } + } + if (best == candidates.size()) + break; + chosen[best] = true; + uncovered.subtract(*access[best]); + } + if (std::none_of(chosen.begin(), chosen.end(), [](bool c) { return c; })) + chosen.front() = true; + for (size_t index = 0; index < candidates.size(); ++index) { + if (chosen[index]) + plan.push_back({ candidates[index].accountId, server }); + } + } + co_return plan; +} + +QCoro::Task SourceHub::searchProgressively(QString searchTerm, int limit, SearchUpdate update) +{ + // Whatever the last query still has in flight is no longer wanted. + const quint64 serial = ++m_searchSerial; + for (const Entry& entry : std::as_const(m_entries)) + m_registry->cancelSourceScope(entry.accountId, QStringLiteral("search")); + auto run = std::make_shared(); + run->plan = co_await searchPlan(); + if (serial != m_searchSerial) + co_return; + run->found.resize(run->plan.size()); + run->query = folded(searchTerm); + run->limit = limit; + run->serial = serial; + run->update = std::move(update); + if (run->plan.empty()) { + run->update({}); + co_return; + } + // Every account is asked before any answer is awaited, and each answer + // is shown as it lands rather than when the slowest server is done. + std::vector> pending; + for (size_t index = 0; index < run->plan.size(); ++index) + pending.push_back(searchOne(run, index, searchTerm)); + for (auto& task : pending) + co_await std::move(task); +} + +QCoro::Task SourceHub::searchOne(std::shared_ptr run, size_t index, QString searchTerm) +{ + const QString accountId = run->plan[index].accountId; + Provider *provider = source(accountId); + if (!provider || !provider->search()) + co_return; + try { + std::vector items = co_await provider->search()->searchItems(searchTerm, run->limit); + if (run->serial != m_searchSerial) + co_return; + run->found[index] = scopedItems(std::move(items), accountId); + run->update(run->merged()); + } catch (const std::exception& error) { + if (run->serial == m_searchSerial) + qWarning() << "hub:" << accountId.left(kPrefix) << "left out of search:" << error.what(); + } +} + QCoro::Task> SourceHub::searchItems(QString searchTerm, int limit) { - return gather( - [searchTerm, limit](Provider *p) { return p->search()->searchItems(searchTerm, limit); }, limit, true); + std::vector latest; + co_await searchProgressively( + std::move(searchTerm), limit, [&latest](std::vector items) { latest = std::move(items); }); + co_return latest; } QCoro::Task> SourceHub::fetchSearchSuggestions(int limit) diff --git a/src/provider/SourceHub.h b/src/provider/SourceHub.h index 1d04e22f..6deb689e 100644 --- a/src/provider/SourceHub.h +++ b/src/provider/SourceHub.h @@ -10,8 +10,11 @@ #include #include +#include #include +#include +#include #include namespace JellyfinNative { @@ -26,6 +29,9 @@ class ProviderRegistry; // finds its way back to the account it came from. Lists that have no single // owner (libraries, home rows, search) are asked of every account in // parallel and merged; one slow or failing account never empties the rest. +// +// Accounts set aside for another user of the same server still run for +// search (see searchPlan), but never show up in browsing. class SourceHub final : public Provider, public Catalog, public SearchSource, @@ -75,6 +81,7 @@ class SourceHub final : public Provider, QString accountOf(const QString& scopedId) const; static QString rawId(const QString& scopedId); Provider *source(const QString& accountId) const; + // The accounts being browsed; set-aside accounts kept for search are not. std::vector sources() const; // Calls an operation on the account behind a scoped or account ID. QCoro::Task call(QString accountId, QString operation, QVariantMap arguments = {}); @@ -105,9 +112,22 @@ class SourceHub final : public Provider, QCoro::Task fetchLibraryFilterOptions(QString libraryId, QString collectionType = {}) override; QCoro::Task> fetchItemsByIds(QStringList itemIds) override; - // SearchSource: every searchable account at once. + // SearchSource: every server at once, through as few accounts as reach + // all of its libraries, ranked together as the answers arrive. QCoro::Task> searchItems(QString searchTerm, int limit = 80) override; + QCoro::Task searchProgressively(QString searchTerm, int limit, SearchUpdate update) override; QCoro::Task> fetchSearchSuggestions(int limit = 20) override; + void prepareSearch() override; + + struct SearchTarget { + QString accountId; + // Accounts on one server share item IDs; results dedupe within it. + QString server; + }; + // Per server, the fewest accounts whose libraries cover everything any + // of its signed-in users can see: a user who sees more stands in for one + // who sees less, and of two who see the same the one in use searches. + QCoro::Task> searchPlan(); // UserItemStateSink QCoro::Task setItemFavorite(QString itemId, bool favorite) override; @@ -143,12 +163,19 @@ class SourceHub final : public Provider, struct Entry { QString accountId; QPointer provider; + bool browse = true; }; + struct SearchRun; void addSource(Provider *provider); void removeSource(const QString& accountId); void refresh(); void pushPlaybackContext(); + void syncBrowse(); + bool accountEnabled(const QString& accountId) const; + // The raw IDs of the libraries an account sees; empty when unknown. + QCoro::Task>> accessOf(QString accountId); + QCoro::Task searchOne(std::shared_ptr run, size_t index, QString searchTerm); Provider *owner(const QString& scopedId) const; MovieItem scopedItem(MovieItem item, const QString& accountId) const; std::vector scopedItems(std::vector items, const QString& accountId) const; @@ -167,6 +194,8 @@ class SourceHub final : public Provider, bool m_restrictVideoCodecs = false; QVariantMap m_preferences; QString m_lastDetailsAccount; + QHash> m_access; + quint64 m_searchSerial = 0; }; } // namespace JellyfinNative diff --git a/tests/providers/SourceHubTest.cpp b/tests/providers/SourceHubTest.cpp index 088322f7..0e38293e 100644 --- a/tests/providers/SourceHubTest.cpp +++ b/tests/providers/SourceHubTest.cpp @@ -163,5 +163,74 @@ JELLYFIN_TEST_MAIN("source-hub") for (const LibraryItem& library : remaining) require(!library.name.contains(QStringLiteral(" · ")), "a name no longer shared is shown plain"); require(hub.accountOf(hub.scoped(a, QStringLiteral("x"))) == a, "the others keep their scope"); + + // Several users of one server: search goes through as few of them as + // reach every library, and never shows the same item twice. + const auto user = [&](const char *key, const char *server, QStringList libraries, bool exact = false) { + const QString id = registry.finishSetup({}, + { { QStringLiteral("module"), QStringLiteral("fixture.test") }, + { QStringLiteral("account"), QLatin1String(key) }, { QStringLiteral("group"), QLatin1String(server) }, + { QStringLiteral("label"), QLatin1String(key) }, + { QStringLiteral("configuration"), + QVariantMap { { QStringLiteral("label"), QLatin1String(key) }, + { QStringLiteral("libraries"), libraries }, { QStringLiteral("exact"), exact } } } }); + registry.useAccount(id); + return id; + }; + // The account used last on each server is the one in use. + const QString wide = user("wide", "s1", { QStringLiteral("m"), QStringLiteral("anime") }); + const QString same = user("same", "s1", { QStringLiteral("m") }); + const QString narrow = user("narrow", "s1", { QStringLiteral("m") }); + const QString twin = user("twin", "s2", { QStringLiteral("m") }); + const QString used = user("used", "s2", { QStringLiteral("m") }); + const QString other = user("other", "s3", { QStringLiteral("m"), QStringLiteral("anime") }); + const QString mine = user("mine", "s3", { QStringLiteral("m"), QStringLiteral("k") }, true); + waitUntil([&] { return hub.source(narrow) && hub.source(used) && hub.source(mine); }, "the users in use start"); + const size_t browsed = hub.sources().size(); + const QString scopeKey = hub.libraryScopeKey(); + hub.prepareSearch(); + waitUntil([&] { return hub.source(wide) && hub.source(same) && hub.source(twin) && hub.source(other); }, + "users set aside start for search"); + require(hub.sources().size() == browsed && hub.libraryScopeKey() == scopeKey, + "and stay out of browsing and the library caches"); + + QStringList planned; + for (const SourceHub::SearchTarget& target : QCoro::waitFor(hub.searchPlan())) + planned.append(target.accountId); + require(planned.contains(wide) && !planned.contains(narrow) && !planned.contains(same), + "a user who sees more stands in for those who see less"); + require(planned.contains(used) && !planned.contains(twin), "of two who see the same, the one in use searches"); + require(planned.indexOf(mine) >= 0 && planned.indexOf(mine) < planned.indexOf(other), + "overlapping users both search, the one in use first"); + + int updates = 0; + std::vector found; + QCoro::waitFor(hub.searchProgressively(QStringLiteral("film"), 80, [&](std::vector items) { + ++updates; + found = std::move(items); + })); + require(updates >= 2, "results arrive as each account answers"); + QStringList s3; + for (const MovieItem& item : found) { + if (hub.accountOf(item.id) == mine || hub.accountOf(item.id) == other) + s3.append(SourceHub::rawId(item.id)); + } + s3.sort(); + require(s3 + == QStringList( + { QStringLiteral("anime-1"), QStringLiteral("exact"), QStringLiteral("k-1"), QStringLiteral("m-1") }), + "an item two users of a server can both see is listed once"); + require(!found.empty() && found.front().title == QStringLiteral("The Film"), "the exact title ranks first"); + const MovieItem shared = *std::find_if(found.begin(), found.end(), [&](const MovieItem& item) { + return SourceHub::rawId(item.id) == QStringLiteral("m-1") && hub.accountOf(item.id) != wide + && hub.accountOf(item.id) != used; + }); + require(hub.accountOf(shared.id) == mine, "and it comes from the user in use"); + + registry.useAccount(wide); + waitUntil([&] { return !hub.source(narrow); }, "choosing another user sets the last one aside"); + const auto browsing = hub.sources(); + require(std::find(browsing.begin(), browsing.end(), hub.source(wide)) != browsing.end(), + "an account running for search is promoted when chosen"); return 0; } diff --git a/tests/providers/fixtures/provider.mjs b/tests/providers/fixtures/provider.mjs index 9a1e84da..b011bd31 100644 --- a/tests/providers/fixtures/provider.mjs +++ b/tests/providers/fixtures/provider.mjs @@ -4,7 +4,22 @@ export function createSource(config, sourceHost) { const failing = function() { if (config.failing) throw new Error('offline'); }; return { describe: function() { return {artwork: 'https://img.invalid/{itemId}/{type}?w={width}'}; }, - libraries: function() { failing(); return {items: [{id: 'lib', title: 'Shelf', collectionType: 'movies'}]}; }, + libraries: function() { + failing(); + return {items: (config.libraries || ['lib']).map(function(id) { + return {id: id, title: id === 'lib' ? 'Shelf' : id, collectionType: 'movies'}; + })}; + }, + // One film per library the account sees, and one exact title on request. + search: function(args) { + failing(); + const rows = (config.libraries || ['lib']).map(function(id) { + return {id: id + '-1', title: 'Film in ' + id, type: 'Movie'}; + }); + if (config.exact) + rows.push({id: 'exact', title: 'The Film', type: 'Movie'}); + return {items: rows, cursor: null, exhausted: true}; + }, latest: function(args) { failing(); return {items: [item('new-1'), item('new-2'), item('new-3')].slice(0, args.limit), cursor: null, From 6b0c4ebb5cf43ff99c3a8ac02a12be37e1b84ff5 Mon Sep 17 00:00:00 2001 From: Sacha Korban Date: Thu, 24 Sep 2026 02:26:55 +1000 Subject: [PATCH 069/146] refactor: call the app Spool everywhere The app, its binary, CMake targets, build variables, environment variables, packages and release artifacts drop the Jellyfin name now that Jellyfin is one provider among several: jellyfin-native is spool, jellyfin-core is spool-core, JELLYFIN_* is SPOOL_*, the C++ namespace is Spool, and "Spool for Jellyfin" is "Spool". The settings store follows the name, so on first launch the data, cache and settings kept under the old name move to the new one once; nobody is signed out. Credential-store keys and Qt build-cache markers keep their old names, since renaming them would sign people out or rebuild Qt. --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/workflows/build-artifacts.yml | 52 +-- .github/workflows/release.yml | 4 +- AGENTS.md | 4 +- CMakeLists.txt | 362 +++++++++--------- CMakePresets.json | 12 +- CONTRIBUTING.md | 4 +- README.md | 4 +- SECURITY.md | 2 +- SUPPORT.md | 2 +- .../sachk/spool/RemoteMediaSessionBridge.java | 4 +- app/appinfo.json.in | 6 +- app/com.sachk.spool.desktop | 8 +- app/com.sachk.spool.metainfo.xml | 8 +- build-ipk.sh | 26 +- cmake/platform/Android.cmake | 6 +- cmake/platform/Common.cmake | 36 +- cmake/platform/Desktop.cmake | 2 +- cmake/platform/Linux.cmake | 4 +- cmake/platform/MacOS.cmake | 14 +- cmake/platform/WebOS.cmake | 10 +- cmake/platform/Windows.cmake | 8 +- cmake/platform/windows-version.rc.in | 10 +- docs/debug/lifecycle-diagnostics.md | 12 +- docs/debug/memory-profiling.md | 12 +- flake.nix | 88 ++--- i18n/qtfin_en_US.ts | 4 +- packaging/aur/.SRCINFO | 4 +- packaging/aur/PKGBUILD | 8 +- src/app/AppController.cpp | 4 +- src/app/AppController.h | 4 +- src/app/AppControllerRemote.cpp | 4 +- src/app/ArtworkImageProvider.h | 4 +- src/app/ArtworkPrefetcher.h | 4 +- src/app/ArtworkService.cpp | 8 +- src/app/ArtworkService.h | 4 +- src/app/BrowseSessionController.cpp | 4 +- src/app/BrowseSessionController.h | 6 +- src/app/ContentModelController.cpp | 4 +- src/app/ContentModelController.h | 14 +- src/app/CpuTopology.cpp | 6 +- src/app/CpuTopology.h | 4 +- src/app/GraphicsStartup.cpp | 20 +- src/app/GraphicsStartup.h | 6 +- src/app/GroupClock.cpp | 4 +- src/app/GroupClock.h | 4 +- src/app/GroupPlaybackController.cpp | 4 +- src/app/GroupPlaybackController.h | 4 +- src/app/HomeModelController.cpp | 4 +- src/app/HomeModelController.h | 8 +- src/app/LibraryPrefetchController.cpp | 4 +- src/app/LibraryPrefetchController.h | 4 +- src/app/LibraryQuery.cpp | 4 +- src/app/LibraryQuery.h | 4 +- src/app/LocalizationManager.cpp | 4 +- src/app/LocalizationManager.h | 4 +- src/app/MemoryBudget.cpp | 4 +- src/app/MemoryBudget.h | 4 +- src/app/RouterController.cpp | 4 +- src/app/RouterController.h | 4 +- src/app/SearchController.cpp | 4 +- src/app/SearchController.h | 14 +- src/app/SettingsController.cpp | 4 +- src/app/SettingsController.h | 4 +- src/app/SettingsSchema.cpp | 8 +- src/app/SettingsSchema.h | 4 +- src/app/UpdateManifest.cpp | 4 +- src/app/UpdateManifest.h | 4 +- src/app/UserItemStateController.cpp | 4 +- src/app/UserItemStateController.h | 4 +- src/cache/DatabaseManager.cpp | 9 +- src/cache/DatabaseManager.h | 4 +- src/common/AsyncTask.h | 4 +- src/common/LogRotation.h | 6 +- src/common/MetaJson.h | 4 +- src/common/NetworkAddress.h | 4 +- src/common/RequestGeneration.h | 4 +- src/common/SeriesAudioSelection.cpp | 4 +- src/common/SeriesAudioSelection.h | 4 +- src/common/TlsTrust.cpp | 8 +- src/common/TlsTrust.h | 6 +- src/common/VariantUtils.cpp | 4 +- src/common/VariantUtils.h | 4 +- src/diagnostics/Diagnostics.cpp | 18 +- src/diagnostics/Diagnostics.h | 4 +- src/diagnostics/InputLatencyMonitor.cpp | 10 +- src/diagnostics/InputLatencyMonitor.h | 4 +- src/diagnostics/RenderBenchmark.cpp | 28 +- src/diagnostics/RenderBenchmark.h | 4 +- src/diagnostics/SystemPerformanceMonitor.cpp | 4 +- src/diagnostics/SystemPerformanceMonitor.h | 4 +- src/main.cpp | 320 +++++++++------- src/media/MediaTypes.cpp | 4 +- src/media/MediaTypes.h | 36 +- src/models/LibraryListModel.cpp | 4 +- src/models/LibraryListModel.h | 4 +- src/models/MovieGridModel.cpp | 4 +- src/models/MovieGridModel.h | 4 +- src/platform/CredentialStore.h | 4 +- src/platform/MpvConfigPolicy.h | 4 +- src/platform/NativeAppWindow.h | 4 +- src/platform/PlatformApplicationServices.h | 4 +- src/platform/PlatformCapabilities.h | 4 +- src/platform/PlatformDisplayOutput.h | 4 +- src/platform/PlatformPaths.h | 4 +- src/platform/PlatformPerformanceSampler.h | 4 +- src/platform/PlatformPlaybackRuntime.h | 4 +- src/platform/PlatformPlaybackSurface.h | 4 +- src/platform/PlatformProcess.h | 4 +- src/platform/PlatformRemoteMediaSession.h | 4 +- src/platform/PlatformSettingsPolicy.h | 4 +- src/platform/PlatformStartup.h | 4 +- src/platform/PlatformSystemProbes.h | 4 +- src/platform/ScreenSaverInhibitor.h | 4 +- src/platform/UpdateController.cpp | 24 +- src/platform/UpdateController.h | 8 +- .../android/AndroidApplicationServices.cpp | 4 +- .../android/AndroidLocalMediaSession.cpp | 6 +- .../android/AndroidLocalMediaSession.h | 4 +- .../android/AndroidNativeAppWindow.cpp | 6 +- src/platform/android/AndroidPlatform.cpp | 8 +- .../android/AndroidPlaybackSurface.cpp | 4 +- .../android/AndroidProcessIntegration.cpp | 4 +- .../android/AndroidUpdateInstaller.cpp | 4 +- src/platform/android/AndroidUpdateInstaller.h | 4 +- .../common/CredentialStoreFileBackend.h | 6 +- .../common/LinuxPerformanceSampler.cpp | 4 +- src/platform/common/NativeAppWindowCommon.cpp | 4 +- src/platform/common/PlatformDisplayOutput.cpp | 12 +- .../common/PlatformRemoteMediaSession.cpp | 6 +- src/platform/common/ScreenSaverInhibitor.cpp | 4 +- .../common/UnixProcessIntegration.cpp | 4 +- .../desktop/DesktopApplicationServices.cpp | 4 +- .../desktop/DesktopMpvConfigPolicy.cpp | 4 +- .../desktop/DesktopNativeAppWindow.cpp | 8 +- .../desktop/DesktopPlaybackRuntime.cpp | 4 +- .../desktop/DesktopPlaybackSurface.cpp | 4 +- .../desktop/UnsupportedPerformanceSampler.cpp | 4 +- src/platform/linux/LinuxCredentialStore.cpp | 11 +- .../linux/LinuxPlatformCapabilities.cpp | 4 +- src/platform/linux/LinuxPlatformPaths.cpp | 6 +- src/platform/linux/LinuxPlatformStartup.cpp | 6 +- .../linux/LinuxScreenSaverInhibitor.cpp | 8 +- src/platform/linux/LinuxSettingsPolicy.cpp | 4 +- src/platform/linux/LinuxSystemProbes.cpp | 4 +- src/platform/linux/WaylandColorInfo.cpp | 4 +- src/platform/linux/WaylandColorInfo.h | 4 +- src/platform/macos/MacOSCredentialStore.cpp | 4 +- .../macos/MacOSPlatformCapabilities.cpp | 4 +- src/platform/macos/MacOSPlatformPaths.cpp | 6 +- src/platform/macos/MacOSPlatformStartup.cpp | 6 +- .../macos/MacOSScreenSaverInhibitor.cpp | 6 +- src/platform/macos/MacOSSettingsPolicy.cpp | 4 +- src/platform/macos/MacOSSystemProbes.cpp | 4 +- src/platform/macos/MacOSTitlebar.mm | 4 +- .../webos/WebOSApplicationServices.cpp | 4 +- src/platform/webos/WebOSAudioRoute.cpp | 4 +- src/platform/webos/WebOSAudioRoute.h | 4 +- src/platform/webos/WebOSAudioSyncPolicy.cpp | 2 +- src/platform/webos/WebOSAudioSyncPolicy.h | 2 +- src/platform/webos/WebOSCredentialStore.cpp | 4 +- src/platform/webos/WebOSDeviceName.cpp | 4 +- src/platform/webos/WebOSDeviceName.h | 4 +- src/platform/webos/WebOSMpvConfigPolicy.cpp | 4 +- src/platform/webos/WebOSMpvRuntime.cpp | 60 +-- src/platform/webos/WebOSMpvRuntime.h | 4 +- src/platform/webos/WebOSNativeAppWindow.cpp | 6 +- .../webos/WebOSPlatformCapabilities.cpp | 4 +- src/platform/webos/WebOSPlatformPaths.cpp | 6 +- src/platform/webos/WebOSPlatformStartup.cpp | 8 +- src/platform/webos/WebOSPlaybackRuntime.cpp | 4 +- src/platform/webos/WebOSPlaybackSurface.cpp | 6 +- .../webos/WebOSScreenSaverInhibitor.cpp | 4 +- src/platform/webos/WebOSSettingsPolicy.cpp | 4 +- src/platform/webos/WebOSSystemProbes.cpp | 4 +- src/platform/webos/WebOSUpdateInstaller.cpp | 4 +- src/platform/webos/WebOSUpdateInstaller.h | 4 +- .../webos/input/WebOSInputContext.cpp | 6 +- src/platform/webos/input/WebOSInputContext.h | 4 +- .../webos/input/WebOSInputContextPlugin.cpp | 2 +- src/platform/webos/input/WebOSKeysymMap.h | 4 +- .../windows/WindowsCredentialStore.cpp | 6 +- src/platform/windows/WindowsDisplayOutput.cpp | 4 +- src/platform/windows/WindowsDisplayOutput.h | 4 +- .../windows/WindowsPlatformCapabilities.cpp | 4 +- src/platform/windows/WindowsPlatformPaths.cpp | 6 +- .../windows/WindowsPlatformStartup.cpp | 6 +- .../windows/WindowsProcessIntegration.cpp | 4 +- .../windows/WindowsScreenSaverInhibitor.cpp | 4 +- .../windows/WindowsSettingsPolicy.cpp | 4 +- src/platform/windows/WindowsSystemProbes.cpp | 4 +- src/player/MpvLifecycle.cpp | 4 +- src/player/MpvLifecycle.h | 4 +- src/player/MpvOptionProfile.cpp | 4 +- src/player/MpvOptionProfile.h | 4 +- src/player/MpvVideoItem.cpp | 46 +-- src/player/MpvVideoItem.h | 18 +- src/player/PlayQueueController.cpp | 4 +- src/player/PlayQueueController.h | 6 +- src/player/PlayQueueOutlineModel.cpp | 4 +- src/player/PlayQueueOutlineModel.h | 4 +- src/player/PlaybackFailurePolicy.cpp | 4 +- src/player/PlaybackFailurePolicy.h | 4 +- src/player/PlaybackPositionTracker.cpp | 4 +- src/player/PlaybackPositionTracker.h | 4 +- src/player/PlaybackReporter.cpp | 4 +- src/player/PlaybackReporter.h | 4 +- src/player/PlaybackTimeline.cpp | 4 +- src/player/PlaybackTimeline.h | 4 +- src/player/PlaybackTrackParser.cpp | 4 +- src/player/PlaybackTrackParser.h | 4 +- src/player/PlaybackTrackState.cpp | 4 +- src/player/PlaybackTrackState.h | 4 +- src/player/PlayerController.cpp | 18 +- src/player/PlayerController.h | 10 +- src/player/RenderTargetProfile.cpp | 4 +- src/player/RenderTargetProfile.h | 4 +- src/provider/ArtworkSource.h | 4 +- src/provider/Catalog.h | 4 +- src/provider/GroupPlayback.h | 4 +- src/provider/PlaybackSource.h | 4 +- src/provider/PortableProvider.cpp | 4 +- src/provider/PortableProvider.h | 4 +- src/provider/Provider.h | 4 +- src/provider/ProviderCapabilities.h | 4 +- src/provider/ProviderMediaPage.cpp | 4 +- src/provider/ProviderMediaPage.h | 4 +- src/provider/ProviderPackage.cpp | 4 +- src/provider/ProviderPackage.h | 4 +- src/provider/ProviderQmlCache.cpp | 4 +- src/provider/ProviderQmlCache.h | 4 +- src/provider/ProviderRegistry.cpp | 4 +- src/provider/ProviderRegistry.h | 6 +- src/provider/ProviderStore.cpp | 4 +- src/provider/ProviderStore.h | 4 +- src/provider/ProviderUiContext.cpp | 4 +- src/provider/ProviderUiContext.h | 6 +- src/provider/ScriptBridge.cpp | 4 +- src/provider/ScriptBridge.h | 4 +- src/provider/ScriptRuntime.cpp | 4 +- src/provider/ScriptRuntime.h | 4 +- src/provider/SearchSource.h | 4 +- src/provider/SourceHub.cpp | 4 +- src/provider/SourceHub.h | 4 +- src/provider/StreamQualityControl.h | 4 +- src/provider/UserItemStateSink.h | 4 +- src/providers/local/LocalProvider.cpp | 4 +- src/providers/local/LocalProvider.h | 4 +- tests/RecordingArtworkSource.h | 4 +- tests/TestMain.h | 17 +- tests/TestRunner.cpp | 6 +- tests/app/ArtworkUrlTest.cpp | 6 +- tests/app/BrowseSessionControllerTest.cpp | 16 +- tests/app/ContentModelControllerTest.cpp | 44 +-- tests/app/GroupClockTest.cpp | 4 +- tests/app/GroupPlaybackPolicyTest.cpp | 4 +- tests/app/LibraryQueryTest.cpp | 16 +- tests/app/RouterControllerTest.cpp | 10 +- tests/app/SearchControllerTest.cpp | 4 +- tests/app/SettingsControllerTest.cpp | 14 +- tests/app/SettingsSchemaTest.cpp | 8 +- tests/app/UpdateManifestTest.cpp | 4 +- tests/cache/DatabaseManagerTest.cpp | 6 +- tests/common/BrowseDescriptorTest.cpp | 6 +- tests/common/DiagnosticRedactionTest.cpp | 9 +- tests/common/MetaJsonTest.cpp | 40 +- tests/common/RequestGenerationTest.cpp | 4 +- tests/common/TlsTrustTest.cpp | 6 +- tests/diagnostics/InputLatencyMonitorTest.cpp | 26 +- .../diagnostics/RouteTransitionTimingTest.cpp | 9 +- .../SystemPerformanceMonitorTest.cpp | 4 +- tests/platform/LinuxMemoryPolicyTest.cpp | 4 +- tests/platform/MacOSTitlebarTest.mm | 4 +- tests/platform/NativeAppWindowInputTest.cpp | 11 +- tests/platform/PlatformPathsTest.cpp | 13 +- tests/platform/ScreenSaverInhibitorTest.cpp | 10 +- tests/player/AudioSyncPolicyTest.cpp | 4 +- tests/player/MpvOptionProfileTest.cpp | 4 +- tests/player/MpvVideoItemTest.cpp | 12 +- tests/player/PlayQueueControllerTest.cpp | 4 +- tests/player/PlayQueueOutlineModelTest.cpp | 4 +- tests/player/PlaybackFailurePolicyTest.cpp | 12 +- tests/player/PlaybackPositionTrackerTest.cpp | 4 +- tests/player/PlaybackTimelineTest.cpp | 8 +- tests/player/PlaybackTrackParserTest.cpp | 6 +- tests/player/PlaybackTrackStateTest.cpp | 6 +- tests/player/RenderTargetProfileTest.cpp | 4 +- tests/player/SeriesAudioSelectionTest.cpp | 4 +- tests/providers/LiveJellyfinTest.cpp | 6 +- tests/providers/LocalProviderTest.cpp | 6 +- tests/providers/ProviderFixture.h | 8 +- tests/providers/ProviderMediaPageTest.cpp | 4 +- tests/providers/ProviderPackageTest.cpp | 6 +- tests/providers/ProviderQmlCacheTest.cpp | 9 +- tests/providers/ProviderRegistryTest.cpp | 6 +- tests/providers/ProviderStoreTest.cpp | 6 +- tests/providers/ProviderUiContextTest.cpp | 6 +- tests/providers/ScriptRuntimeTest.cpp | 4 +- tests/providers/SourceHubTest.cpp | 6 +- tests/providers/StreamQualityControlTest.cpp | 4 +- tools/android/build-apks.sh | 8 +- tools/build-linux-release.sh | 6 +- tools/build-macos.sh | 10 +- tools/check-release-version.sh | 4 +- tools/check-submodule-remote-refs.sh | 4 +- tools/check-version-sync.sh | 4 +- tools/format-staged.sh | 4 +- tools/generate-website-manifest.py | 12 +- tools/launch-native-app.sh | 12 +- tools/lib/build-common.sh | 32 +- tools/package-appimage.sh | 22 +- tools/package-arch.sh | 2 +- tools/package-linux-bundle.sh | 2 +- tools/package-macos-dmg.sh | 4 +- tools/package-release-source.sh | 6 +- tools/update-aur-checksums.sh | 2 +- tools/webos-native/patches/README.md | 2 +- .../qtbase-6.11-webos-no-cursor-set.patch | 4 +- tools/webos/bench-device.sh | 8 +- tools/webos/bigalloc-report.py | 6 +- tools/webos/collect-diagnostics-bundle.sh | 2 +- tools/webos/diagnose.sh | 2 +- tools/webos/heaptrack-launch-shim.sh | 20 +- tools/webos/kill-stale.sh | 2 +- tools/webos/package-heaptrack-profile.sh | 10 +- tools/webos/profile-memory.sh | 10 +- tools/webos/verify-device.sh | 8 +- tools/windows/build.ps1 | 2 +- tools/windows/common.ps1 | 14 +- tools/windows/install-qcoro.ps1 | 4 +- tools/windows/install-qt.ps1 | 2 +- tools/windows/installer.nsi | 56 +-- tools/windows/package.ps1 | 6 +- tools/windows/portable.nsi | 12 +- tools/windows/proton-run.sh | 8 +- tools/windows/run.ps1 | 2 +- tools/windows/stage.ps1 | 18 +- tools/windows/test-runtime-closure.ps1 | 6 +- tools/windows/test.ps1 | 2 +- 339 files changed, 1543 insertions(+), 1521 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 72549161..ce5a9cdd 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible Spool for Jellyfin defect +description: Report a reproducible Spool defect title: "[Bug]: " labels: [bug] body: diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index a98c690b..ba861918 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -305,10 +305,10 @@ jobs: - name: Lint compiled QML - run: nix develop .#native -c cmake --build build/linux-release/app --target jellyfin-native_qmllint_strict + run: nix develop .#native -c cmake --build build/linux-release/app --target spool_qmllint_strict - name: Launch-test host app UI - run: nix develop .#native -c bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + run: nix develop .#native -c bash tools/launch-native-app.sh build/linux-release/install/bin/spool # A page switch is meant to cost one frame. That is a number, so it is # measured every commit rather than noticed once it has got bad. @@ -327,7 +327,7 @@ jobs: SPOOL_BENCH=routes \ SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/warm.json" \ SPOOL_BENCH_ITERATIONS=4 \ - bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + bash tools/launch-native-app.sh build/linux-release/install/bin/spool # The same walk with every page dropped between switches: what a # memory-starved device pays, tracked but not gated. nix develop .#native -c env \ @@ -335,7 +335,7 @@ jobs: SPOOL_BENCH_COLD=1 \ SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/cold.json" \ SPOOL_BENCH_ITERATIONS=4 \ - bash tools/launch-native-app.sh build/linux-release/install/bin/jellyfin-native + bash tools/launch-native-app.sh build/linux-release/install/bin/spool - name: Report render latency run: | @@ -394,7 +394,7 @@ jobs: echo 'error: extracted AppImage contains qmltypes metadata' >&2 exit 1 fi - elf_args=(elf "$extracted" --root usr/bin/jellyfin-native) + elf_args=(elf "$extracted" --root usr/bin/spool) test ! -f "$extracted/usr/libexec/secret-tool" || elf_args+=(--root usr/libexec/secret-tool) while IFS= read -r elf; do elf_args+=(--root "${elf#"$extracted/"}"); done \ < <(find "$extracted/usr/lib" -type f -name 'libmpv.so.*' | sort) @@ -420,7 +420,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-linux + name: spool-linux path: | dist/*.AppImage dist/*-linux-x86_64.tar.zst @@ -577,7 +577,7 @@ jobs: --parallel "$(sysctl -n hw.ncpu)" --output-on-failure - name: Lint compiled QML - run: nix develop .#native --no-write-lock-file -c cmake --build build/macos/app --target jellyfin-native_qmllint_strict + run: nix develop .#native --no-write-lock-file -c cmake --build build/macos/app --target spool_qmllint_strict - name: Load bundled Vulkan runtime run: | @@ -613,7 +613,7 @@ jobs: - name: Launch-test macOS app UI env: - JELLYFIN_LAUNCH_TEST_QPA_PLATFORM: cocoa + SPOOL_LAUNCH_TEST_QPA_PLATFORM: cocoa run: nix develop .#native --no-write-lock-file -c bash tools/launch-native-app.sh build/macos/install/Spool.app - name: Sign macOS application @@ -650,7 +650,7 @@ jobs: if [[ '${{ inputs.enable_macos_signing }}' == true ]]; then codesign --verify --strict --deep --verbose=2 "$app" fi - JELLYFIN_LAUNCH_TEST_QPA_PLATFORM=cocoa \ + SPOOL_LAUNCH_TEST_QPA_PLATFORM=cocoa \ bash tools/launch-native-app.sh "$app" bash tools/detach-dmg.sh "$mountpoint" trap - EXIT @@ -670,7 +670,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-macos-dmg-${{ matrix.arch }} + name: spool-macos-dmg-${{ matrix.arch }} path: | dist/*.dmg dist/*.dmg.app.tar.gz @@ -798,7 +798,7 @@ jobs: test -n "$data_tar" tar -tf "$data_tar" | tee "$work/data-files.txt" >/dev/null grep -Eq '(^|/)appinfo\.json$' "$work/data-files.txt" - grep -Eq '(^|/)bin/jellyfin-native$' "$work/data-files.txt" + grep -Eq '(^|/)bin/spool$' "$work/data-files.txt" mkdir "$work/data" tar -xf "$data_tar" -C "$work/data" @@ -824,12 +824,12 @@ jobs: appinfo = json.load(handle) assert appinfo["id"] == "com.sachk.spool", appinfo.get("id") - assert appinfo["main"] == "bin/jellyfin-native", appinfo.get("main") + assert appinfo["main"] == "bin/spool", appinfo.get("main") assert re.fullmatch(r"\d+\.\d+\.\d+", appinfo["version"]), appinfo.get("version") print(f"Validated {appinfo['id']} {appinfo['version']} IPK") PY - launcher="$(find "$work/data" -path '*/bin/jellyfin-native' -type f -print -quit)" + launcher="$(find "$work/data" -path '*/bin/spool' -type f -print -quit)" test -n "$launcher" test -x "$launcher" python3 - "$launcher" <<'PY' @@ -843,9 +843,9 @@ jobs: header = handle.read(20) if header[:4] != b"\x7fELF": - binary = binary.with_name("jellyfin-native.real") - assert binary.is_file(), "launcher is not ELF and jellyfin-native.real is missing" - assert os.access(binary, os.X_OK), "jellyfin-native.real is not executable" + binary = binary.with_name("spool.real") + assert binary.is_file(), "launcher is not ELF and spool.real is missing" + assert os.access(binary, os.X_OK), "spool.real is not executable" with binary.open("rb") as handle: header = handle.read(20) @@ -860,7 +860,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-webos-ipk + name: spool-webos-ipk path: build/*.ipk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1028,7 +1028,7 @@ jobs: if: ${{ failure() && matrix.abi == 'x86_64' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-launch-test + name: spool-android-launch-test path: | build/android/launch-test build/android/emulator.log @@ -1036,7 +1036,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-apks-${{ matrix.abi }} + name: spool-android-apks-${{ matrix.abi }} path: dist/android/*.apk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1083,12 +1083,12 @@ jobs: # will wait for and one they will not. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: spool-for-jellyfin-android-apks-arm64-v8a + name: spool-android-apks-arm64-v8a path: dist/android - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: spool-for-jellyfin-android-apks-armeabi-v7a + name: spool-android-apks-armeabi-v7a path: dist/android - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -1152,7 +1152,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-android-apk-universal + name: spool-android-apk-universal path: dist/android/spool-universal.apk if-no-files-found: error retention-days: ${{ inputs.release && 1 || 7 }} @@ -1166,8 +1166,8 @@ jobs: env: QT_VERSION: ${{ needs.toolchain.outputs.qt_version }} QT_ARCH: win64_${{ needs.toolchain.outputs.qt_windows_kit }} - JELLYFIN_QT_ROOT: C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }} - JELLYFIN_QCORO_ROOT: C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }} + SPOOL_QT_ROOT: C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }} + SPOOL_QCORO_ROOT: C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2 @@ -1265,7 +1265,7 @@ jobs: - name: Upload portable executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-windows-x64-portable + name: spool-windows-x64-portable path: | dist/*-Portable.exe if-no-files-found: error @@ -1274,7 +1274,7 @@ jobs: - name: Upload installer executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-windows-x64-installer + name: spool-windows-x64-installer path: | dist/*-Setup.exe if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1aaf067..72013b30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Upload corresponding source uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: spool-for-jellyfin-corresponding-source + name: spool-corresponding-source path: release-source/* retention-days: 1 @@ -280,7 +280,7 @@ jobs: fi gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --pattern SHA256SUMS.txt --dir "$RUNNER_TEMP" - bundle="Spool-for-Jellyfin-$version-linux-x86_64.tar.zst" + bundle="Spool-$version-linux-x86_64.tar.zst" checksum="$(awk -v want="$bundle" \ '{ sub(/^\*/, "", $2); if ($2 == want) print $1 }' \ "$RUNNER_TEMP/SHA256SUMS.txt")" diff --git a/AGENTS.md b/AGENTS.md index 76205034..a4eb072a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project -- Spool for Jellyfin client for webOS and desktop. +- Spool: a media player for webOS, desktop and Android. - Prefer a smaller, faster, easier-to-extend codebase over compatibility scaffolding or speculative abstractions. - The app is prerelease software: bump/reset caches on schema changes instead of adding migrations or fallback readers. - `mpv/` is a submodule. Commit mpv changes inside `mpv/`, then commit its pointer here. @@ -76,7 +76,7 @@ spool-player/spool-jellyfin and is bundled from the pin in - Build without launching with `nix run .#build`. - Run the test suite the way CI does with `nix run .#tests` (release build, then the same ctest invocation and exclusions as the workflow). - Leave interactive UI testing to the user. Do not drive their desktop with xdotool/xdgtool or similar input automation, or launch visible smoke tests unless explicitly requested. Use builds and isolated/offscreen checks for verification. -- Use `nix develop .#native -c ...` for targeted development commands (e.g. `cmake --preset linux-dev`, then `cmake --build build/linux-dev/app --target jellyfin-native`). +- Use `nix develop .#native -c ...` for targeted development commands (e.g. `cmake --preset linux-dev`, then `cmake --build build/linux-dev/app --target spool`). - The image-diagnostics equivalents remain `nix run .#image-debug-build` followed by `nix run .#image-debug`. - Batch coherent edits, then run one build and one `qmlformat`/`clang-format` invocation over all touched files; don't build or format file-by-file. diff --git a/CMakeLists.txt b/CMakeLists.txt index 4aa32586..845b3d9f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,34 +1,34 @@ cmake_minimum_required(VERSION 3.22) -file(READ "${CMAKE_CURRENT_LIST_DIR}/VERSION" JELLYFIN_VERSION) -string(STRIP "${JELLYFIN_VERSION}" JELLYFIN_VERSION) -if(NOT JELLYFIN_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") +file(READ "${CMAKE_CURRENT_LIST_DIR}/VERSION" SPOOL_VERSION) +string(STRIP "${SPOOL_VERSION}" SPOOL_VERSION) +if(NOT SPOOL_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") message(FATAL_ERROR "VERSION must contain a semantic version such as 0.2.1") endif() -project(JellyfinNativeWebOS VERSION "${JELLYFIN_VERSION}" LANGUAGES C CXX) +project(SpoolWebOS VERSION "${SPOOL_VERSION}" LANGUAGES C CXX) if(WIN32) add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN) endif() -set(JELLYFIN_NATIVE_WEBOS_DEFAULT OFF) +set(SPOOL_WEBOS_DEFAULT OFF) if(CMAKE_CROSSCOMPILING AND NOT ANDROID) - set(JELLYFIN_NATIVE_WEBOS_DEFAULT ON) + set(SPOOL_WEBOS_DEFAULT ON) endif() -option(JELLYFIN_NATIVE_WEBOS "Build the webOS Starfish/native-surface client" ${JELLYFIN_NATIVE_WEBOS_DEFAULT}) +option(SPOOL_WEBOS "Build the webOS Starfish/native-surface client" ${SPOOL_WEBOS_DEFAULT}) option(TOUCHSCREEN "Enable mobile touchscreen interactions (runtime-disabled on TVs)" ${ANDROID}) -option(JELLYFIN_NATIVE_DEV_BUILD "Favor faster local development builds over smallest release artifacts" OFF) -option(JELLYFIN_DIAGNOSTICS "Build debug lifecycle/process/watchdog diagnostics" OFF) -option(JELLYFIN_DIAGNOSTICS_STACKDUMP "Allow diagnostics to request gdb stack dumps" OFF) -option(JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG "Abort after watchdog hang diagnostics are written" OFF) -option(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS "Log artwork decode requests whose target aspect would stretch the source" OFF) -option(JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS +option(SPOOL_DEV_BUILD "Favor faster local development builds over smallest release artifacts" OFF) +option(SPOOL_DIAGNOSTICS "Build debug lifecycle/process/watchdog diagnostics" OFF) +option(SPOOL_DIAGNOSTICS_STACKDUMP "Allow diagnostics to request gdb stack dumps" OFF) +option(SPOOL_DIAGNOSTICS_ABORT_ON_HANG "Abort after watchdog hang diagnostics are written" OFF) +option(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS "Log artwork decode requests whose target aspect would stretch the source" OFF) +option(SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS "Log image subtitle crop, segmentation, geometry, and recoloring per subtitle event" OFF) -option(JELLYFIN_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(SPOOL_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) set(SPOOL_MACOS_CREDENTIAL_SERVICE "com.sachk.spool.dev" CACHE STRING "macOS Keychain service used for persisted session tokens") -option(JELLYFIN_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +option(SPOOL_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) # Where the app may take provider code from. Release builds are open; a build # submitted to Google Play or the App Store is curated (store catalogues only) # or bundled (no downloads). See docs/providers.md. @@ -43,7 +43,7 @@ set(MPV_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mpv" CACHE PATH "mpv source tree set(MPV_ACTIVE_SOURCE_DIR "${MPV_SOURCE_DIR}") set(MPV_WEBOS_BUILD_DIR "${MPV_SOURCE_DIR}/build/webos-libmpv" CACHE PATH "webOS libmpv build directory") -if(JELLYFIN_NATIVE_DEV_BUILD) +if(SPOOL_DEV_BUILD) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) find_program(CCACHE_PROGRAM ccache) @@ -64,7 +64,7 @@ if(NOT WIN32) find_package(PkgConfig REQUIRED) endif() -set(JELLYFIN_QT_COMPONENTS +set(SPOOL_QT_COMPONENTS Core Gui Network @@ -77,30 +77,30 @@ set(JELLYFIN_QT_COMPONENTS WebSockets Svg ) -if(JELLYFIN_NATIVE_WEBOS) +if(SPOOL_WEBOS) set(OPENSSL_USE_STATIC_LIBS FALSE) - set(JELLYFIN_QT_MIN_VERSION 6.11) - list(APPEND JELLYFIN_QT_COMPONENTS + set(SPOOL_QT_MIN_VERSION 6.11) + list(APPEND SPOOL_QT_COMPONENTS GuiPrivate QuickLayouts WaylandClient ) else() - set(JELLYFIN_QT_MIN_VERSION 6.11) + set(SPOOL_QT_MIN_VERSION 6.11) # rhi/qrhi.h is how the video item reaches the graphics device it shares # with libplacebo, and how the swapchain is asked what the display can show. if(NOT ANDROID) - list(APPEND JELLYFIN_QT_COMPONENTS GuiPrivate) + list(APPEND SPOOL_QT_COMPONENTS GuiPrivate) endif() if(UNIX AND NOT APPLE AND NOT ANDROID) - list(APPEND JELLYFIN_QT_COMPONENTS DBus) + list(APPEND SPOOL_QT_COMPONENTS DBus) endif() endif() -find_package(Qt6 ${JELLYFIN_QT_MIN_VERSION} REQUIRED COMPONENTS ${JELLYFIN_QT_COMPONENTS}) +find_package(Qt6 ${SPOOL_QT_MIN_VERSION} REQUIRED COMPONENTS ${SPOOL_QT_COMPONENTS}) find_package(QCoro6 0.13 REQUIRED COMPONENTS Core Network) qt_standard_project_setup( - REQUIRES ${JELLYFIN_QT_MIN_VERSION} + REQUIRES ${SPOOL_QT_MIN_VERSION} I18N_TRANSLATED_LANGUAGES en_US en_GB en_AU ) include(cmake/platform/Common.cmake) @@ -110,7 +110,7 @@ include(cmake/platform/Common.cmake) # configuring is enough to get it, so no platform needs a second command. if(ANDROID) set(spool_splash_platform "android") -elseif(JELLYFIN_NATIVE_WEBOS) +elseif(SPOOL_WEBOS) set(spool_splash_platform "webos") else() set(spool_splash_platform "desktop") @@ -138,7 +138,7 @@ endif() message(STATUS "${spool_splash_output}") -jellyfin_resolve_platform_dependencies() +spool_resolve_platform_dependencies() # Core never includes src/providers/ (native providers); every media source # is reached through src/provider/. tools/check-module-seam.sh enforces it @@ -304,16 +304,16 @@ set(SPOOL_LOCAL_SOURCES src/providers/local/LocalProvider.h ) -set(JELLYFIN_CORE_SOURCES +set(SPOOL_CORE_SOURCES ${SPOOL_PLATFORM_SOURCES} ${SPOOL_PLAYER_SOURCES} ${SPOOL_SHELL_SOURCES} ${SPOOL_LOCAL_SOURCES} ) -add_library(jellyfin-core STATIC ${JELLYFIN_CORE_SOURCES}) +add_library(spool-core STATIC ${SPOOL_CORE_SOURCES}) -qt_add_executable(jellyfin-native +qt_add_executable(spool src/main.cpp src/app/GraphicsStartup.cpp src/app/GraphicsStartup.h @@ -321,14 +321,14 @@ qt_add_executable(jellyfin-native find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) include(cmake/ProviderBundle.cmake) -spool_bundle_providers(jellyfin-native) +spool_bundle_providers(spool) -set(JELLYFIN_NATIVE_TARGETS jellyfin-core jellyfin-native) -jellyfin_configure_platform_targets(jellyfin-native jellyfin-core) -foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) +set(SPOOL_TARGETS spool-core spool) +spool_configure_platform_targets(spool spool-core) +foreach(target IN LISTS SPOOL_TARGETS) if(MSVC) target_compile_options(${target} PRIVATE /W4) - if(JELLYFIN_WARNINGS_AS_ERRORS) + if(SPOOL_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE /WX) endif() target_link_options(${target} PRIVATE @@ -337,35 +337,35 @@ foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) ) elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) - if(JELLYFIN_WARNINGS_AS_ERRORS) + if(SPOOL_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE -Werror) endif() - if(JELLYFIN_SANITIZERS) + if(SPOOL_SANITIZERS) target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) target_link_options(${target} PRIVATE -fsanitize=address,undefined) endif() endif() endforeach() -if(JELLYFIN_SANITIZERS AND MSVC) - message(FATAL_ERROR "JELLYFIN_SANITIZERS requires GCC or Clang") +if(SPOOL_SANITIZERS AND MSVC) + message(FATAL_ERROR "SPOOL_SANITIZERS requires GCC or Clang") endif() -if(NOT JELLYFIN_NATIVE_DEV_BUILD) +if(NOT SPOOL_DEV_BUILD) include(CheckIPOSupported) - check_ipo_supported(RESULT JELLYFIN_IPO_SUPPORTED OUTPUT JELLYFIN_IPO_ERROR) - if(JELLYFIN_IPO_SUPPORTED) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) + check_ipo_supported(RESULT SPOOL_IPO_SUPPORTED OUTPUT SPOOL_IPO_ERROR) + if(SPOOL_IPO_SUPPORTED) + foreach(target IN LISTS SPOOL_TARGETS) set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL TRUE) endforeach() else() - message(STATUS "Interprocedural optimization unavailable: ${JELLYFIN_IPO_ERROR}") + message(STATUS "Interprocedural optimization unavailable: ${SPOOL_IPO_ERROR}") endif() if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) + foreach(target IN LISTS SPOOL_TARGETS) target_compile_options(${target} PRIVATE -ffunction-sections -fdata-sections) if(ANDROID) target_compile_options(${target} PRIVATE -fPIC) @@ -373,7 +373,7 @@ if(NOT JELLYFIN_NATIVE_DEV_BUILD) elseif(UNIX AND NOT APPLE) if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm|ARM)") target_compile_options(${target} PRIVATE -fPIC) - if(target STREQUAL "jellyfin-native") + if(target STREQUAL "spool") target_link_options(${target} PRIVATE -fPIC) endif() else() @@ -400,45 +400,45 @@ endif() # translation unit against the new base class while leaving the moc output # generated for the old one in place. That mismatch fails the build with # `use of undeclared identifier 'QQuickFramebufferObject'`. -if(ANDROID OR JELLYFIN_NATIVE_WEBOS) - set(JELLYFIN_MPV_ITEM_RHI_ENABLED 0) +if(ANDROID OR SPOOL_WEBOS) + set(SPOOL_MPV_ITEM_RHI_ENABLED 0) else() - set(JELLYFIN_MPV_ITEM_RHI_ENABLED 1) + set(SPOOL_MPV_ITEM_RHI_ENABLED 1) endif() file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated/MpvVideoItemBase.h" CONTENT "// Generated by CMake. Do not edit. #pragma once -#define JELLYFIN_MPV_ITEM_RHI @JELLYFIN_MPV_ITEM_RHI_ENABLED@ +#define SPOOL_MPV_ITEM_RHI @SPOOL_MPV_ITEM_RHI_ENABLED@ " @ONLY) -foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) +foreach(target IN LISTS SPOOL_TARGETS) target_include_directories(${target} PUBLIC "${CMAKE_CURRENT_BINARY_DIR}/generated") endforeach() -if(JELLYFIN_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS=1) +if(SPOOL_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS=1) endforeach() endif() -if(JELLYFIN_DIAGNOSTICS_STACKDUMP) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS_STACKDUMP=1) +if(SPOOL_DIAGNOSTICS_STACKDUMP) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS_STACKDUMP=1) endforeach() endif() -if(JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG=1) +if(SPOOL_DIAGNOSTICS_ABORT_ON_HANG) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_DIAGNOSTICS_ABORT_ON_HANG=1) endforeach() endif() -if(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=1) +if(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_ARTWORK_ASPECT_DIAGNOSTICS=1) endforeach() endif() -if(JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS=1) +if(SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS=1) endforeach() endif() @@ -451,22 +451,22 @@ set_source_files_properties( PROPERTIES QT_QML_SINGLETON_TYPE TRUE ) -qt_add_translations(jellyfin-native +qt_add_translations(spool TS_FILES i18n/qtfin_en_US.ts i18n/qtfin_en_GB.ts RESOURCE_PREFIX /i18n ) -set(JELLYFIN_QML_MODULE_OPTIONS) +set(SPOOL_QML_MODULE_OPTIONS) if(QT6_IS_SHARED_LIBS_BUILD) - list(APPEND JELLYFIN_QML_MODULE_OPTIONS NO_IMPORT_SCAN) + list(APPEND SPOOL_QML_MODULE_OPTIONS NO_IMPORT_SCAN) else() - list(APPEND JELLYFIN_QML_MODULE_OPTIONS DISCARD_QML_CONTENTS) + list(APPEND SPOOL_QML_MODULE_OPTIONS DISCARD_QML_CONTENTS) endif() -qt_add_qml_module(jellyfin-native - ${JELLYFIN_QML_MODULE_OPTIONS} +qt_add_qml_module(spool + ${SPOOL_QML_MODULE_OPTIONS} URI Spool VERSION 1.0 QML_FILES @@ -571,20 +571,20 @@ qt_add_qml_module(jellyfin-native # Qt Quick consumes baked shader packages on every RHI backend. Keep the # resource independent of the QML module so the early startup frame can use it. -qt_add_shaders(jellyfin-native "hdr_ui_shaders" +qt_add_shaders(spool "hdr_ui_shaders" PREFIX "/shaders" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/shell/shaders" FILES qml/shell/shaders/sdr-ui-to-scrgb.frag ) -if(NOT JELLYFIN_NATIVE_WEBOS) - qt_target_qml_sources(jellyfin-native +if(NOT SPOOL_WEBOS) + qt_target_qml_sources(spool QML_FILES qml/pages/DesktopFolderDialog.qml ) endif() -if(JELLYFIN_NATIVE_WEBOS) - qt_add_resources(jellyfin-native "platform_branding" +if(SPOOL_WEBOS) + qt_add_resources(spool "platform_branding" PREFIX "/branding" BASE "${CMAKE_CURRENT_SOURCE_DIR}/app/icons/png/spool" FILES app/icons/png/spool/64.png @@ -593,7 +593,7 @@ endif() # The mark is part of the interface now, not only the desktop entry, so every # platform carries its SVG source and requires QtSvg to render it. -qt_add_resources(jellyfin-native "application_icons" +qt_add_resources(spool "application_icons" PREFIX "/icons" BASE "${CMAKE_CURRENT_SOURCE_DIR}/app/icons" FILES @@ -613,17 +613,17 @@ if(ANDROID) # does. See splashCoreWidthDp() in main.cpp. string(JSON spool_splash_tv_dp GET "${spool_splash_manifest}" android tvCoreWidthDp) string(JSON spool_splash_phone_dp GET "${spool_splash_manifest}" android phoneCoreWidthDp) - target_compile_definitions(jellyfin-native PRIVATE + target_compile_definitions(spool PRIVATE SPOOL_SPLASH_CORE_WIDTH_DP_TV=${spool_splash_tv_dp} SPOOL_SPLASH_CORE_WIDTH_DP_PHONE=${spool_splash_phone_dp} ) endif() -target_compile_definitions(jellyfin-native PRIVATE +target_compile_definitions(spool PRIVATE SPOOL_SPLASH_CORE_ASPECT=${spool_splash_core_width}.0/${spool_splash_core_height}.0 SPOOL_SPLASH_CORE_WIDTH_FRACTION=${spool_splash_fraction} ) -qt_add_resources(jellyfin-native "startup_splash_qml" +qt_add_resources(spool "startup_splash_qml" PREFIX "/startup" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/shell" FILES @@ -632,8 +632,8 @@ qt_add_resources(jellyfin-native "startup_splash_qml" qml/shell/HdrUiLayer.qml ) # webOS reads its launch screen off disk, where appinfo.json already names it. -if(NOT JELLYFIN_NATIVE_WEBOS) - qt_add_resources(jellyfin-native "startup_splash_image" +if(NOT SPOOL_WEBOS) + qt_add_resources(spool "startup_splash_image" PREFIX "/startup" BASE "${SPOOL_SPLASH_DIR}" FILES "${SPOOL_SPLASH_DIR}/splash-core.png" @@ -641,7 +641,7 @@ if(NOT JELLYFIN_NATIVE_WEBOS) endif() if(ANDROID) - qt_add_resources(jellyfin-native "android_fonts" + qt_add_resources(spool "android_fonts" PREFIX "/fonts" BASE "${CMAKE_CURRENT_SOURCE_DIR}/qml/fonts" FILES @@ -654,26 +654,26 @@ if(ANDROID) endif() -add_custom_target(jellyfin-native_qmllint_strict +add_custom_target(spool_qmllint_strict COMMAND Qt6::qmllint --unqualified disable --missing-property disable --property-override warning - "@${CMAKE_CURRENT_BINARY_DIR}/.rcc/qmllint/jellyfin-native.rsp" + "@${CMAKE_CURRENT_BINARY_DIR}/.rcc/qmllint/spool.rsp" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" COMMENT "Linting QML property overrides" VERBATIM ) -add_dependencies(jellyfin-native_qmllint_strict all_qmltyperegistrations) +add_dependencies(spool_qmllint_strict all_qmltyperegistrations) -set_target_properties(jellyfin-native PROPERTIES +set_target_properties(spool PROPERTIES QT_QMLCACHEGEN_DIRECT_CALLS ON ) # qmlimportscanner also scans the target's SOURCE_DIR recursively. This project # keeps build trees and vendored sources below the repository root, so exclude # them and let the generated QRC files provide the application's exact QML set. -set_property(TARGET jellyfin-native APPEND PROPERTY +set_property(TARGET spool APPEND PROPERTY QT_QML_IMPORT_SCANNER_EXTRA_ARGS -exclude build -exclude build-arm @@ -684,33 +684,33 @@ set_property(TARGET jellyfin-native APPEND PROPERTY -exclude squashfs-root -exclude third_party ) -if(JELLYFIN_NATIVE_WEBOS) - set_property(TARGET jellyfin-native APPEND PROPERTY +if(SPOOL_WEBOS) + set_property(TARGET spool APPEND PROPERTY QT_QML_IMPORT_SCANNER_EXTRA_ARGS -exclude qml/pages/DesktopFolderDialog.qml ) endif() -get_target_property(JELLYFIN_NATIVE_INCLUDE_DIRS jellyfin-native INCLUDE_DIRECTORIES) -if(JELLYFIN_NATIVE_INCLUDE_DIRS) - list(REMOVE_ITEM JELLYFIN_NATIVE_INCLUDE_DIRS +get_target_property(SPOOL_INCLUDE_DIRS spool INCLUDE_DIRECTORIES) +if(SPOOL_INCLUDE_DIRS) + list(REMOVE_ITEM SPOOL_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}" ) - set_property(TARGET jellyfin-native PROPERTY - INCLUDE_DIRECTORIES "${JELLYFIN_NATIVE_INCLUDE_DIRS}" + set_property(TARGET spool PROPERTY + INCLUDE_DIRECTORIES "${SPOOL_INCLUDE_DIRS}" ) endif() -target_include_directories(jellyfin-core PUBLIC +target_include_directories(spool-core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src" "${CMAKE_CURRENT_SOURCE_DIR}/src/player" "${MPV_ACTIVE_SOURCE_DIR}" "${MPV_ACTIVE_SOURCE_DIR}/include" ) -target_link_libraries(jellyfin-core PUBLIC +target_link_libraries(spool-core PUBLIC QCoro::Core QCoro::Network Qt6::Core @@ -723,11 +723,11 @@ target_link_libraries(jellyfin-core PUBLIC Qt6::WebSockets ) if(NOT ANDROID) - target_link_libraries(jellyfin-core PUBLIC Qt6::GuiPrivate) + target_link_libraries(spool-core PUBLIC Qt6::GuiPrivate) endif() -target_link_libraries(jellyfin-native PRIVATE - jellyfin-core +target_link_libraries(spool PRIVATE + spool-core Qt6::Svg ) @@ -737,10 +737,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # own ctest entry; tests/TestRunner.cpp dispatches on the selector passed # by add_test(). One executable per test used to cost more to build on # Windows than the application itself, because every one of them repeated a - # whole-program-optimized link against all of jellyfin-core. - set(JELLYFIN_TEST_GROUPS app cache common diagnostics platform player providers) + # whole-program-optimized link against all of spool-core. + set(SPOOL_TEST_GROUPS app cache common diagnostics platform player providers) - set(JELLYFIN_TESTS_app + set(SPOOL_TESTS_app browse-session-controller tests/app/BrowseSessionControllerTest.cpp settings-schema tests/app/SettingsSchemaTest.cpp settings-controller tests/app/SettingsControllerTest.cpp @@ -753,32 +753,32 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) update-manifest tests/app/UpdateManifestTest.cpp artwork-url tests/app/ArtworkUrlTest.cpp ) - set(JELLYFIN_TESTS_cache + set(SPOOL_TESTS_cache database-manager tests/cache/DatabaseManagerTest.cpp ) - set(JELLYFIN_TESTS_common + set(SPOOL_TESTS_common request-generation tests/common/RequestGenerationTest.cpp diagnostic-redaction tests/common/DiagnosticRedactionTest.cpp tls-trust tests/common/TlsTrustTest.cpp browse-descriptor tests/common/BrowseDescriptorTest.cpp meta-json tests/common/MetaJsonTest.cpp ) - set(JELLYFIN_TESTS_diagnostics + set(SPOOL_TESTS_diagnostics input-latency-monitor tests/diagnostics/InputLatencyMonitorTest.cpp route-transition-timing tests/diagnostics/RouteTransitionTimingTest.cpp system-performance-monitor tests/diagnostics/SystemPerformanceMonitorTest.cpp ) - set(JELLYFIN_TESTS_platform + set(SPOOL_TESTS_platform screensaver-inhibitor tests/platform/ScreenSaverInhibitorTest.cpp native-window-input tests/platform/NativeAppWindowInputTest.cpp platform-paths tests/platform/PlatformPathsTest.cpp ) if(APPLE) - list(APPEND JELLYFIN_TESTS_platform + list(APPEND SPOOL_TESTS_platform macos-titlebar tests/platform/MacOSTitlebarTest.mm ) endif() - set(JELLYFIN_TESTS_providers + set(SPOOL_TESTS_providers local-provider tests/providers/LocalProviderTest.cpp script-runtime tests/providers/ScriptRuntimeTest.cpp provider-media-page tests/providers/ProviderMediaPageTest.cpp @@ -791,7 +791,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) stream-quality-control tests/providers/StreamQualityControlTest.cpp live-jellyfin tests/providers/LiveJellyfinTest.cpp ) - set(JELLYFIN_TESTS_player + set(SPOOL_TESTS_player playback-timeline tests/player/PlaybackTimelineTest.cpp playback-track-parser tests/player/PlaybackTrackParserTest.cpp playback-track-state tests/player/PlaybackTrackStateTest.cpp @@ -807,47 +807,47 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # Both of these assert nothing away from Linux: the memory policy parses # /proc and cgroup text, and the mpv item needs a real GL surface. if(UNIX AND NOT APPLE) - list(APPEND JELLYFIN_TESTS_platform + list(APPEND SPOOL_TESTS_platform linux-memory-policy tests/platform/LinuxMemoryPolicyTest.cpp ) - list(APPEND JELLYFIN_TESTS_player + list(APPEND SPOOL_TESTS_player mpv-video-item tests/player/MpvVideoItemTest.cpp ) # The same check over the Vulkan scene graph, which is the one an HDR # swapchain can be asked for. Its own entry rather than a loop inside # the test, so a machine with no Vulkan driver fails only this one. - list(APPEND JELLYFIN_TESTS_player + list(APPEND SPOOL_TESTS_player mpv-video-item-vulkan tests/player/MpvVideoItemTest.cpp ) endif() # WebOSAudioSyncPolicy is a webOS platform source, so desktop builds of - # jellyfin-core do not carry it; the player group compiles it directly. - set(JELLYFIN_TEST_EXTRA_SOURCES_player src/platform/webos/WebOSAudioSyncPolicy.cpp) + # spool-core do not carry it; the player group compiles it directly. + set(SPOOL_TEST_EXTRA_SOURCES_player src/platform/webos/WebOSAudioSyncPolicy.cpp) - get_target_property(JELLYFIN_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION_RELEASE) - if(NOT JELLYFIN_QT_CORE_LOCATION) - get_target_property(JELLYFIN_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION) + get_target_property(SPOOL_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION_RELEASE) + if(NOT SPOOL_QT_CORE_LOCATION) + get_target_property(SPOOL_QT_CORE_LOCATION Qt6::Core IMPORTED_LOCATION) endif() - set(JELLYFIN_TEST_ENV) - if(JELLYFIN_QT_CORE_LOCATION) - get_filename_component(JELLYFIN_QT_LIB_DIR "${JELLYFIN_QT_CORE_LOCATION}" DIRECTORY) - get_filename_component(JELLYFIN_QT_PREFIX "${JELLYFIN_QT_LIB_DIR}" DIRECTORY) - set(JELLYFIN_QT_PLUGIN_DIR "${JELLYFIN_QT_PREFIX}/lib/qt-6/plugins") - if(EXISTS "${JELLYFIN_QT_PLUGIN_DIR}") - list(APPEND JELLYFIN_TEST_ENV "QT_PLUGIN_PATH=${JELLYFIN_QT_PLUGIN_DIR}") + set(SPOOL_TEST_ENV) + if(SPOOL_QT_CORE_LOCATION) + get_filename_component(SPOOL_QT_LIB_DIR "${SPOOL_QT_CORE_LOCATION}" DIRECTORY) + get_filename_component(SPOOL_QT_PREFIX "${SPOOL_QT_LIB_DIR}" DIRECTORY) + set(SPOOL_QT_PLUGIN_DIR "${SPOOL_QT_PREFIX}/lib/qt-6/plugins") + if(EXISTS "${SPOOL_QT_PLUGIN_DIR}") + list(APPEND SPOOL_TEST_ENV "QT_PLUGIN_PATH=${SPOOL_QT_PLUGIN_DIR}") endif() endif() - foreach(test_group IN LISTS JELLYFIN_TEST_GROUPS) + foreach(test_group IN LISTS SPOOL_TEST_GROUPS) set(group_target "${test_group}-tests") - set(group_sources tests/TestRunner.cpp ${JELLYFIN_TEST_EXTRA_SOURCES_${test_group}}) + set(group_sources tests/TestRunner.cpp ${SPOOL_TEST_EXTRA_SOURCES_${test_group}}) set(group_names) - list(LENGTH JELLYFIN_TESTS_${test_group} group_entry_count) + list(LENGTH SPOOL_TESTS_${test_group} group_entry_count) math(EXPR group_entry_last "${group_entry_count} - 1") foreach(test_index RANGE 0 ${group_entry_last} 2) math(EXPR source_index "${test_index} + 1") - list(GET JELLYFIN_TESTS_${test_group} ${test_index} test_name) - list(GET JELLYFIN_TESTS_${test_group} ${source_index} test_source) + list(GET SPOOL_TESTS_${test_group} ${test_index} test_name) + list(GET SPOOL_TESTS_${test_group} ${source_index} test_source) list(APPEND group_sources "${test_source}") list(APPEND group_names "${test_name}") endforeach() @@ -864,25 +864,25 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) INTERPROCEDURAL_OPTIMIZATION OFF INTERPROCEDURAL_OPTIMIZATION_RELEASE OFF INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL OFF) - target_link_libraries(${group_target} PRIVATE jellyfin-core) + target_link_libraries(${group_target} PRIVATE spool-core) foreach(test_name IN LISTS group_names) add_test(NAME ${test_name} COMMAND ${group_target} ${test_name}) - if(JELLYFIN_TEST_ENV) - set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "${JELLYFIN_TEST_ENV}") + if(SPOOL_TEST_ENV) + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "${SPOOL_TEST_ENV}") endif() endforeach() endforeach() # Test binaries share one QML disk cache across builds whose bundled # provider resources all carry the same reproducible timestamp. - foreach(test_name IN LISTS JELLYFIN_TESTS_providers) + foreach(test_name IN LISTS SPOOL_TESTS_providers) if(NOT test_name MATCHES "/") set_property(TEST ${test_name} APPEND PROPERTY ENVIRONMENT "QML_DISABLE_DISK_CACHE=1") endif() endforeach() add_test(NAME provider-media-page-interpreter COMMAND providers-tests provider-media-page) - set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${JELLYFIN_TEST_ENV}") + set_tests_properties(provider-media-page-interpreter PROPERTIES ENVIRONMENT "QV4_FORCE_INTERPRETER=1;${SPOOL_TEST_ENV}") target_sources(providers-tests PRIVATE ${SPOOL_PROVIDER_BUNDLE_SOURCES}) add_test(NAME provider-sdk-tool COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/tools/provider_package_test.py") @@ -895,16 +895,16 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND provider-contract-runner "${CMAKE_CURRENT_SOURCE_DIR}/tests/providers/fixtures/bundled-jellyfin.mjs") set_tests_properties(bundled-jellyfin bundled-jellyfin-interpreter PROPERTIES ENVIRONMENT "QML_DISABLE_DISK_CACHE=1") set_property(TEST bundled-jellyfin-interpreter APPEND PROPERTY ENVIRONMENT "QV4_FORCE_INTERPRETER=1") - if(JELLYFIN_NATIVE_WEBOS) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM webos) + if(SPOOL_WEBOS) + set(SPOOL_FFMPEG_AUDIT_PLATFORM webos) elseif(APPLE) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM macos) + set(SPOOL_FFMPEG_AUDIT_PLATFORM macos) elseif(WIN32) - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM windows) + set(SPOOL_FFMPEG_AUDIT_PLATFORM windows) else() - set(JELLYFIN_FFMPEG_AUDIT_PLATFORM linux) + set(SPOOL_FFMPEG_AUDIT_PLATFORM linux) endif() - set(JELLYFIN_FFMPEG_CAPABILITY_HEADER + set(SPOOL_FFMPEG_CAPABILITY_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/FfmpegCapabilities.h") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" @@ -913,13 +913,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg-capabilities.py" cpp-header - --platform "${JELLYFIN_FFMPEG_AUDIT_PLATFORM}" - --output "${JELLYFIN_FFMPEG_CAPABILITY_HEADER}" - RESULT_VARIABLE JELLYFIN_FFMPEG_HEADER_RESULT - ERROR_VARIABLE JELLYFIN_FFMPEG_HEADER_ERROR + --platform "${SPOOL_FFMPEG_AUDIT_PLATFORM}" + --output "${SPOOL_FFMPEG_CAPABILITY_HEADER}" + RESULT_VARIABLE SPOOL_FFMPEG_HEADER_RESULT + ERROR_VARIABLE SPOOL_FFMPEG_HEADER_ERROR ) - if(NOT JELLYFIN_FFMPEG_HEADER_RESULT EQUAL 0) - message(FATAL_ERROR "FFmpeg capability header generation failed: ${JELLYFIN_FFMPEG_HEADER_ERROR}") + if(NOT SPOOL_FFMPEG_HEADER_RESULT EQUAL 0) + message(FATAL_ERROR "FFmpeg capability header generation failed: ${SPOOL_FFMPEG_HEADER_ERROR}") endif() if(NOT WIN32) @@ -940,8 +940,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # prefix is the documented way to make the lookup happen again; the # capability manifest is already a configure dependency, so this is what # makes the audit follow it. - get_cmake_property(JELLYFIN_CACHE_VARIABLES CACHE_VARIABLES) - foreach(cache_variable IN LISTS JELLYFIN_CACHE_VARIABLES) + get_cmake_property(SPOOL_CACHE_VARIABLES CACHE_VARIABLES) + foreach(cache_variable IN LISTS SPOOL_CACHE_VARIABLES) if(cache_variable MATCHES "^(pkgcfg_lib_FFMPEG_AUDIT_|__pkg_config_checked_FFMPEG_AUDIT|FFMPEG_AUDIT_)") unset(${cache_variable} CACHE) endif() @@ -993,7 +993,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) find_program(QMLTESTRUNNER_EXECUTABLE NAMES qmltestrunner) if(QMLTESTRUNNER_EXECUTABLE) - set(JELLYFIN_QML_TESTS + set(SPOOL_QML_TESTS route-policy tests/qml/tst_RoutePolicy.qml key-router tests/qml/tst_KeyRouter.qml player-overlay-input tests/qml/tst_PlayerOverlayInput.qml @@ -1021,85 +1021,85 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) # The subtitle panel test has never run on macOS; it was narrowed to # Windows alone while chasing that, which silently dropped Linux too. if(NOT APPLE) - list(APPEND JELLYFIN_QML_TESTS + list(APPEND SPOOL_QML_TESTS subtitle-settings-panel tests/qml/tst_SubtitleSettingsPanel.qml ) endif() - get_filename_component(JELLYFIN_QML_PREFIX "${Qt6Qml_DIR}/../../.." ABSOLUTE) - set(JELLYFIN_QML_IMPORT_DIR "${JELLYFIN_QML_PREFIX}/lib/qt-6/qml") - set(JELLYFIN_QML_TEST_ENV + get_filename_component(SPOOL_QML_PREFIX "${Qt6Qml_DIR}/../../.." ABSOLUTE) + set(SPOOL_QML_IMPORT_DIR "${SPOOL_QML_PREFIX}/lib/qt-6/qml") + set(SPOOL_QML_TEST_ENV "QT_QPA_PLATFORM=offscreen" "QT_QUICK_BACKEND=software" - "QML2_IMPORT_PATH=${JELLYFIN_QML_IMPORT_DIR}" - "QML_IMPORT_PATH=${JELLYFIN_QML_IMPORT_DIR}" - ${JELLYFIN_TEST_ENV} + "QML2_IMPORT_PATH=${SPOOL_QML_IMPORT_DIR}" + "QML_IMPORT_PATH=${SPOOL_QML_IMPORT_DIR}" + ${SPOOL_TEST_ENV} ) - list(LENGTH JELLYFIN_QML_TESTS JELLYFIN_QML_TEST_COUNT) - math(EXPR JELLYFIN_QML_TEST_LAST "${JELLYFIN_QML_TEST_COUNT} - 1") - foreach(qml_index RANGE 0 ${JELLYFIN_QML_TEST_LAST} 2) + list(LENGTH SPOOL_QML_TESTS SPOOL_QML_TEST_COUNT) + math(EXPR SPOOL_QML_TEST_LAST "${SPOOL_QML_TEST_COUNT} - 1") + foreach(qml_index RANGE 0 ${SPOOL_QML_TEST_LAST} 2) math(EXPR qml_source_index "${qml_index} + 1") - list(GET JELLYFIN_QML_TESTS ${qml_index} qml_test_name) - list(GET JELLYFIN_QML_TESTS ${qml_source_index} qml_test_source) + list(GET SPOOL_QML_TESTS ${qml_index} qml_test_name) + list(GET SPOOL_QML_TESTS ${qml_source_index} qml_test_source) add_test(NAME ${qml_test_name} COMMAND "${QMLTESTRUNNER_EXECUTABLE}" -input "${CMAKE_CURRENT_SOURCE_DIR}/${qml_test_source}" ) - set_tests_properties(${qml_test_name} PROPERTIES ENVIRONMENT "${JELLYFIN_QML_TEST_ENV}") + set_tests_properties(${qml_test_name} PROPERTIES ENVIRONMENT "${SPOOL_QML_TEST_ENV}") endforeach() endif() endif() # Static Qt: import plugins that the linker can't auto-discover. BUILD_SHARED_LIBS # controls this project's libraries, not whether the imported Qt build is static. -get_target_property(JELLYFIN_QT_CORE_TARGET_TYPE Qt6::Core TYPE) -if(JELLYFIN_QT_CORE_TARGET_TYPE STREQUAL "STATIC_LIBRARY") - jellyfin_import_static_platform_plugins(jellyfin-native) +get_target_property(SPOOL_QT_CORE_TARGET_TYPE Qt6::Core TYPE) +if(SPOOL_QT_CORE_TARGET_TYPE STREQUAL "STATIC_LIBRARY") + spool_import_static_platform_plugins(spool) # Let qmlimportscanner derive the static QML plugin link set from the # application's imports. New imports now fail when their plugin is absent. - qt_import_qml_plugins(jellyfin-native) + qt_import_qml_plugins(spool) # Add linker GC to strip unused code from static libs. # macOS ld64 doesn't accept --gc-sections (uses -dead_strip instead, on by # default at -O), so only apply this on non-Apple platforms. if(NOT APPLE) - target_link_options(jellyfin-native PRIVATE + target_link_options(spool PRIVATE "-Wl,--gc-sections" ) - target_compile_options(jellyfin-native PRIVATE + target_compile_options(spool PRIVATE "-ffunction-sections" "-fdata-sections" ) endif() endif() -set_target_properties(jellyfin-native PROPERTIES - OUTPUT_NAME "jellyfin-native" +set_target_properties(spool PROPERTIES + OUTPUT_NAME "spool" ) if(APPLE) - set_property(TARGET jellyfin-native PROPERTY OUTPUT_NAME "Spool") + set_property(TARGET spool PROPERTY OUTPUT_NAME "Spool") endif() if(UNIX AND NOT APPLE AND NOT ANDROID) - set_property(TARGET jellyfin-native PROPERTY INSTALL_RPATH "$ORIGIN/../lib") + set_property(TARGET spool PROPERTY INSTALL_RPATH "$ORIGIN/../lib") endif() -target_compile_definitions(jellyfin-native PRIVATE - JELLYFIN_VERSION="${PROJECT_VERSION}" +target_compile_definitions(spool PRIVATE + SPOOL_VERSION="${PROJECT_VERSION}" SPOOL_PROVIDER_SOURCES="${SPOOL_PROVIDER_SOURCES}" $<$:TOUCHSCREEN> ) -install(TARGETS jellyfin-native +install(TARGETS spool LIBRARY DESTINATION lib BUNDLE DESTINATION . RUNTIME DESTINATION bin ) if(NOT ANDROID) - set(JELLYFIN_FONT_INSTALL_DIRECTORY "fonts") + set(SPOOL_FONT_INSTALL_DIRECTORY "fonts") if(APPLE) - set(JELLYFIN_FONT_INSTALL_DIRECTORY "Spool.app/Contents/fonts") + set(SPOOL_FONT_INSTALL_DIRECTORY "Spool.app/Contents/fonts") endif() install(FILES qml/fonts/AtkinsonHyperlegible-Bold.otf @@ -1107,11 +1107,11 @@ if(NOT ANDROID) qml/fonts/IBMPlexSans-Variable.ttf qml/fonts/PTRootUI-Variable.ttf qml/fonts/MaterialIcons-Regular.ttf - DESTINATION "${JELLYFIN_FONT_INSTALL_DIRECTORY}" + DESTINATION "${SPOOL_FONT_INSTALL_DIRECTORY}" ) endif() -if(UNIX AND NOT APPLE AND NOT ANDROID AND NOT JELLYFIN_NATIVE_WEBOS) +if(UNIX AND NOT APPLE AND NOT ANDROID AND NOT SPOOL_WEBOS) install(FILES app/com.sachk.spool.desktop DESTINATION share/applications ) diff --git a/CMakePresets.json b/CMakePresets.json index e049486b..f0f5cb9c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -11,7 +11,7 @@ "hidden": true, "generator": "Ninja", "cacheVariables": { - "JELLYFIN_NATIVE_WEBOS": "OFF" + "SPOOL_WEBOS": "OFF" } }, { @@ -20,7 +20,7 @@ "binaryDir": "${sourceDir}/build/linux-dev/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "JELLYFIN_NATIVE_DEV_BUILD": "ON" + "SPOOL_DEV_BUILD": "ON" } }, { @@ -29,7 +29,7 @@ "binaryDir": "${sourceDir}/build/linux-release/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "JELLYFIN_NATIVE_DEV_BUILD": "OFF" + "SPOOL_DEV_BUILD": "OFF" } }, { @@ -46,9 +46,9 @@ "binaryDir": "${sourceDir}/build/windows-release/app", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_PREFIX_PATH": "$env{JELLYFIN_WINDOWS_PREFIX_PATH}", - "JELLYFIN_NATIVE_DEV_BUILD": "OFF", - "MPV_ROOT": "$env{JELLYFIN_MPV_ROOT}" + "CMAKE_PREFIX_PATH": "$env{SPOOL_WINDOWS_PREFIX_PATH}", + "SPOOL_DEV_BUILD": "OFF", + "MPV_ROOT": "$env{SPOOL_MPV_ROOT}" } } ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6cce9c8..ef8e9403 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Spool for Jellyfin welcomes focused bug reports and feature proposals. Before submitting code, open or find an issue for consequential behavior changes so the user-facing contract is clear. +Spool welcomes focused bug reports and feature proposals. Before submitting code, open or find an issue for consequential behavior changes so the user-facing contract is clear. ## Privacy @@ -32,7 +32,7 @@ Describe the problem, the chosen behavior, affected platforms, privacy/security ### Reviewing CI artifacts Open the PR's **build artifacts** check, follow **Details** to the workflow run, -and download a `spool-for-jellyfin-*` package from **Artifacts** (GitHub sign-in +and download a `spool-*` package from **Artifacts** (GitHub sign-in required). PRs use the same build, package, audit, and launch-test jobs as releases: Linux AppImage/portable tarball/Arch package, macOS DMGs for Apple Silicon and Intel, webOS IPK, Android per-ABI/universal APKs, and Windows diff --git a/README.md b/README.md index 8d26f2dc..266cca17 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Spool for Jellyfin +# Spool - libmpv: We've forked this and made it compatible in the directory above. keep libmpv behind a thin PlayerController / PlaybackController facade and do not let Jellyfin/network/UI code know about mpv internals. - Qt6.11 @@ -176,7 +176,7 @@ not require ADB or broad storage permissions. # Name -## Fast Jellyfin client for LG TVs and desktop +## Fast media player for LG TVs and desktop // Download link box diff --git a/SECURITY.md b/SECURITY.md index 306fa9d1..efaa5ba6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -Spool for Jellyfin is prerelease software. Security fixes are provided only for the latest published `v0.x` prerelease and the current `master` branch. Older prereleases are unsupported. +Spool is prerelease software. Security fixes are provided only for the latest published `v0.x` prerelease and the current `master` branch. Older prereleases are unsupported. ## Reporting a vulnerability diff --git a/SUPPORT.md b/SUPPORT.md index a7f83ea7..f696dbee 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,6 +1,6 @@ # Support Policy -Spool for Jellyfin is prerelease software. The latest `v0.x` prerelease is the only supported binary release; `master` is development code. +Spool is prerelease software. The latest `v0.x` prerelease is the only supported binary release; `master` is development code. Use GitHub issues for reproducible project defects and feature requests. General Jellyfin server administration, media encoding, network configuration, and unsupported operating-system questions belong in the appropriate Jellyfin community support channel. diff --git a/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java b/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java index e0ef19e7..76016b49 100644 --- a/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java +++ b/app/android-common/src/com/sachk/spool/RemoteMediaSessionBridge.java @@ -169,7 +169,7 @@ private Notification buildNotification(String title, String targetName) Notification.Builder builder = new Notification.Builder(activity, CHANNEL_ID) .setSmallIcon(com.sachk.spool.R.mipmap.ic_launcher) .setContentTitle(emptyFallback(title, "Remote playback")) - .setContentText("Playing on " + emptyFallback(targetName, "Jellyfin client")) + .setContentText("Playing on " + emptyFallback(targetName, "another device")) .setOnlyAlertOnce(true) .setOngoing(playing) .setVisibility(Notification.VISIBILITY_PUBLIC) @@ -210,7 +210,7 @@ private void createNotificationChannel() { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Remote playback", NotificationManager.IMPORTANCE_LOW); - channel.setDescription("Controls the Jellyfin client selected in Spool"); + channel.setDescription("Controls the device selected in Spool"); channel.setShowBadge(false); notifications.createNotificationChannel(channel); } diff --git a/app/appinfo.json.in b/app/appinfo.json.in index 379a4763..0f73321b 100644 --- a/app/appinfo.json.in +++ b/app/appinfo.json.in @@ -3,12 +3,12 @@ "version": "@VERSION@", "vendor": "Spool", "type": "native", - "main": "bin/jellyfin-native", - "title": "Spool for Jellyfin", + "main": "bin/spool", + "title": "Spool", "icon": "icon.png", "largeIcon": "large-icon.png", "splashBackground": "splash.png", - "appDescription": "Very fast mpv-based Jellyfin client for LG webOS.", + "appDescription": "Very fast mpv-based media player for LG webOS.", "tileSize": "normal", "supportTouchMode": "full", "spinnerOnLaunch": false, diff --git a/app/com.sachk.spool.desktop b/app/com.sachk.spool.desktop index 77ba8dea..3d9c7f80 100644 --- a/app/com.sachk.spool.desktop +++ b/app/com.sachk.spool.desktop @@ -1,10 +1,10 @@ [Desktop Entry] Type=Application -Name=Spool for Jellyfin -Comment=Browse and play media from a Jellyfin server -Exec=jellyfin-native +Name=Spool +Comment=Browse and play your media +Exec=spool Icon=com.sachk.spool Categories=AudioVideo;Video; -Keywords=Jellyfin;Media;Player;TV; +Keywords=Media;Player;TV;Jellyfin;Emby;Plex; StartupWMClass=com.sachk.spool Terminal=false diff --git a/app/com.sachk.spool.metainfo.xml b/app/com.sachk.spool.metainfo.xml index a323c6c7..23197641 100644 --- a/app/com.sachk.spool.metainfo.xml +++ b/app/com.sachk.spool.metainfo.xml @@ -3,14 +3,14 @@ com.sachk.spool CC0-1.0 MPL-2.0 - Spool for Jellyfin - Fast Jellyfin client for TVs and desktop + Spool + Fast media player for TVs and desktop -

Browse and play media from a Jellyfin server with a native mpv playback pipeline.

+

Browse and play media from Jellyfin, Emby, Plex and more with a native mpv playback pipeline.

com.sachk.spool.desktop - jellyfin-native + spool AudioVideo diff --git a/build-ipk.sh b/build-ipk.sh index ea21ff51..226dfcb9 100755 --- a/build-ipk.sh +++ b/build-ipk.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${IN_NIX_SHELL:-}" && "${JELLYFIN_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]]; then +if [[ -z "${IN_NIX_SHELL:-}" && "${SPOOL_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]]; then WORKSPACE_ROOT="$(cd "$ROOT/.." && pwd)" DEFAULT_SDK_ROOT="$ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" if [[ -z "${WEBOS_SDK_ROOT:-}" && -d "$WORKSPACE_ROOT/build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot" ]]; then @@ -11,7 +11,7 @@ if [[ -z "${IN_NIX_SHELL:-}" && "${JELLYFIN_BUILD_IPK_ENTERED_NIX:-0}" != "1" ]] fi cd "$ROOT" exec nix develop "$ROOT" -c env \ - JELLYFIN_BUILD_IPK_ENTERED_NIX=1 \ + SPOOL_BUILD_IPK_ENTERED_NIX=1 \ WEBOS_SDK_ROOT="${WEBOS_SDK_ROOT:-$DEFAULT_SDK_ROOT}" \ bash -c 'cd "$1" && shift && exec bash ./build-ipk.sh "$@"' bash "$ROOT" "$@" fi @@ -70,7 +70,7 @@ MPV_LTO_CROSS_FILE="$BUILD_DIR/webos-lto.cross.ini" WEBOS_BUILD_MEMORY_PER_JOB_MIB="${WEBOS_BUILD_MEMORY_PER_JOB_MIB:-1536}" WEBOS_BUILD_MEMORY_RESERVE_MIB="${WEBOS_BUILD_MEMORY_RESERVE_MIB:-2048}" WEBOS_BUILD_JOBS="$(recommended_parallel_jobs "$WEBOS_BUILD_MEMORY_PER_JOB_MIB" "$WEBOS_BUILD_MEMORY_RESERVE_MIB")" -IMAGE_SUBTITLE_DIAGNOSTICS="${JELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS:-0}" +IMAGE_SUBTITLE_DIAGNOSTICS="${SPOOL_IMAGE_SUBTITLE_DIAGNOSTICS:-0}" if [[ "$IMAGE_SUBTITLE_DIAGNOSTICS" == "0" ]]; then MPV_IMAGE_SUBTITLE_DIAGNOSTICS_FLAG= CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS=OFF @@ -93,7 +93,7 @@ if (( DO_BUILD || DO_STAGE )); then echo "Using static Qt6 build at $QT6_PREFIX" # Reject stale cached Qt prefixes that predate required local patches. - QT_PATCH_MARKERS=(JELLYFIN_QT_NO_CURSOR_SURFACE) + QT_PATCH_MARKERS=(SPOOL_QT_NO_CURSOR_SURFACE) for marker in "${QT_PATCH_MARKERS[@]}"; do if ! grep -rqal "$marker" "$QT6_PREFIX/lib" 2>/dev/null; then echo "error: Qt at $QT6_PREFIX is missing patch marker '$marker'." >&2 @@ -259,7 +259,7 @@ cmake -S "$ROOT" -B "$CMAKE_BUILD_DIR" -GNinja \ -DQt6_DIR="$QT6_PREFIX/lib/cmake/Qt6" \ -DQT_HOST_PATH="$QT6_HOST_PREFIX" \ -DCMAKE_INSTALL_PREFIX=/usr/palm/applications/com.sachk.spool \ - -DJELLYFIN_IMAGE_SUBTITLE_DIAGNOSTICS="$CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS" + -DSPOOL_IMAGE_SUBTITLE_DIAGNOSTICS="$CMAKE_IMAGE_SUBTITLE_DIAGNOSTICS" cmake --build "$CMAKE_BUILD_DIR" --parallel "$WEBOS_BUILD_JOBS" cmake --install "$CMAKE_BUILD_DIR" --prefix "$INSTALL_DIR" @@ -292,11 +292,11 @@ cp -f "$ROOT/app/notices/OPEN_SOURCE_NOTICES.txt" "$ROOT/LICENSE" \ "$APP_DIR/notices/" PATCHELF_BIN="$(command -v patchelf)" -[[ -x "$INSTALL_DIR/bin/jellyfin-native" ]] || { +[[ -x "$INSTALL_DIR/bin/spool" ]] || { echo "error: app install missing; run '$0 app' first" >&2 exit 1 } -cp -f "$INSTALL_DIR/bin/jellyfin-native" "$STAGE_BIN/jellyfin-native" +cp -f "$INSTALL_DIR/bin/spool" "$STAGE_BIN/spool" # mpv, curl, FFmpeg, and OpenSSL shared libraries are always needed. Stage # their real files and derive compatibility symlinks from each ELF SONAME so @@ -335,7 +335,7 @@ done # segment URL by protocol name before mpv's libcurl backend is consulted, and # an FFmpeg without a TLS backend registers no https protocol to find. It # moves no bytes; libcurl still carries playback. -for tls_consumer in "$STAGE_BIN/jellyfin-native" "$CURL_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY"; do +for tls_consumer in "$STAGE_BIN/spool" "$CURL_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY"; do TLS_DYNAMIC_SECTION="$("$READELF_BIN" -d "$tls_consumer")" if [[ "$TLS_DYNAMIC_SECTION" != *"[libssl.so"* && "$TLS_DYNAMIC_SECTION" != *"[libcrypto.so"* ]]; then echo "error: $(basename "$tls_consumer") does not use the packaged shared OpenSSL build" >&2 @@ -354,14 +354,14 @@ fi "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$MPV_STAGED_LIBRARY" "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$CURL_STAGED_LIBRARY" "$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN' "$AVFORMAT_STAGED_LIBRARY" -"$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN/../lib' "$STAGE_BIN/jellyfin-native" +"$PATCHELF_BIN" --force-rpath --set-rpath '$ORIGIN/../lib' "$STAGE_BIN/spool" "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --thumb-archive \ "$QT6_PREFIX/lib/libQt6Core.a" "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --thumb \ - "$STAGE_BIN/jellyfin-native" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ + "$STAGE_BIN/spool" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ "$FFMPEG_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY" echo "Stripping every staged webOS ELF" @@ -376,13 +376,13 @@ done < <(find "$APP_DIR" -type f -print0) "$ROOT/tools/webos-native/audit-arm-binaries.sh" \ "$READELF_BIN" "$OBJDUMP_BIN" --stripped \ - "$STAGE_BIN/jellyfin-native" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ + "$STAGE_BIN/spool" "$MPV_STAGED_LIBRARY" "$CURL_STAGED_LIBRARY" \ "$FFMPEG_STAGED_LIBRARY" "$AVFORMAT_STAGED_LIBRARY" "$OPENSSL_STAGED_LIBRARY" \ "$OPENSSL_CRYPTO_STAGED_LIBRARY" WEBOS_ELF_AUDIT_ARGS=( elf "$APP_DIR" - --root bin/jellyfin-native + --root bin/spool --readelf "$READELF_BIN" ) for runtime_root in \ @@ -412,7 +412,7 @@ python3 "$ROOT/tools/package-audit.py" inventory "$APP_DIR" \ fi if (( DO_PACKAGE )); then -[[ -f "$APP_DIR/appinfo.json" && -x "$STAGE_BIN/jellyfin-native" ]] || { +[[ -f "$APP_DIR/appinfo.json" && -x "$STAGE_BIN/spool" ]] || { echo "error: staged webOS app missing; run '$0 stage' first" >&2 exit 1 } diff --git a/cmake/platform/Android.cmake b/cmake/platform/Android.cmake index 685694e8..04b8ba42 100644 --- a/cmake/platform/Android.cmake +++ b/cmake/platform/Android.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_android_dependencies) +function(spool_resolve_android_dependencies) set(ANDROID_DEPS_PREFIX "" CACHE PATH "Android media dependency prefix") find_path(MPV_INCLUDE_DIR mpv/client.h HINTS "${ANDROID_DEPS_PREFIX}/include" @@ -22,7 +22,7 @@ function(jellyfin_resolve_android_dependencies) endif() endfunction() -function(jellyfin_configure_android_targets native_target core_target) +function(spool_configure_android_targets native_target core_target) target_sources(${core_target} PRIVATE src/platform/android/AndroidPlatform.cpp # Android is Linux, and every file this reads -- /proc/stat, @@ -87,7 +87,7 @@ function(jellyfin_configure_android_targets native_target core_target) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_LEGACY_PACKAGING TRUE) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR "${android_package_source}") set_property(TARGET ${native_target} PROPERTY QT_ANDROID_PACKAGE_NAME "${android_package_name}") - set_property(TARGET ${native_target} PROPERTY QT_ANDROID_APP_NAME "Spool for Jellyfin") + set_property(TARGET ${native_target} PROPERTY QT_ANDROID_APP_NAME "Spool") set_property(TARGET ${native_target} PROPERTY QT_ANDROID_MIN_SDK_VERSION 28) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_TARGET_SDK_VERSION 36) set_property(TARGET ${native_target} PROPERTY QT_ANDROID_VERSION_NAME "${PROJECT_VERSION}") diff --git a/cmake/platform/Common.cmake b/cmake/platform/Common.cmake index e0cfd7d4..e5c7e5b7 100644 --- a/cmake/platform/Common.cmake +++ b/cmake/platform/Common.cmake @@ -5,45 +5,45 @@ include(${CMAKE_CURRENT_LIST_DIR}/MacOS.cmake) include(${CMAKE_CURRENT_LIST_DIR}/Windows.cmake) include(${CMAKE_CURRENT_LIST_DIR}/WebOS.cmake) -macro(jellyfin_resolve_platform_dependencies) +macro(spool_resolve_platform_dependencies) if(ANDROID) - jellyfin_resolve_android_dependencies() - elseif(JELLYFIN_NATIVE_WEBOS) - jellyfin_resolve_webos_dependencies() + spool_resolve_android_dependencies() + elseif(SPOOL_WEBOS) + spool_resolve_webos_dependencies() elseif(WIN32) - jellyfin_resolve_windows_dependencies() + spool_resolve_windows_dependencies() elseif(APPLE) - jellyfin_resolve_macos_dependencies() + spool_resolve_macos_dependencies() else() - jellyfin_resolve_linux_dependencies() + spool_resolve_linux_dependencies() endif() endmacro() -function(jellyfin_configure_platform_targets native_target core_target) +function(spool_configure_platform_targets native_target core_target) if(ANDROID) - jellyfin_configure_android_targets(${native_target} ${core_target}) + spool_configure_android_targets(${native_target} ${core_target}) return() endif() - if(JELLYFIN_NATIVE_WEBOS) - jellyfin_configure_webos_targets(${native_target} ${core_target}) + if(SPOOL_WEBOS) + spool_configure_webos_targets(${native_target} ${core_target}) return() endif() - jellyfin_add_desktop_sources(${native_target} ${core_target}) + spool_add_desktop_sources(${native_target} ${core_target}) if(WIN32) - jellyfin_configure_windows_targets(${native_target} ${core_target}) + spool_configure_windows_targets(${native_target} ${core_target}) elseif(APPLE) - jellyfin_configure_macos_targets(${native_target} ${core_target}) + spool_configure_macos_targets(${native_target} ${core_target}) else() - jellyfin_configure_linux_targets(${native_target} ${core_target}) + spool_configure_linux_targets(${native_target} ${core_target}) endif() endfunction() -function(jellyfin_import_static_platform_plugins native_target) +function(spool_import_static_platform_plugins native_target) if(ANDROID) return() - elseif(JELLYFIN_NATIVE_WEBOS) - jellyfin_import_static_webos_plugins(${native_target}) + elseif(SPOOL_WEBOS) + spool_import_static_webos_plugins(${native_target}) else() find_library(XKBCOMMON_LIB xkbcommon REQUIRED) target_link_libraries(${native_target} PRIVATE ${XKBCOMMON_LIB}) diff --git a/cmake/platform/Desktop.cmake b/cmake/platform/Desktop.cmake index ab436d09..78c97e8c 100644 --- a/cmake/platform/Desktop.cmake +++ b/cmake/platform/Desktop.cmake @@ -1,4 +1,4 @@ -function(jellyfin_add_desktop_sources native_target core_target) +function(spool_add_desktop_sources native_target core_target) target_sources(${core_target} PRIVATE src/platform/desktop/DesktopNativeAppWindow.cpp src/platform/desktop/DesktopMpvConfigPolicy.cpp diff --git a/cmake/platform/Linux.cmake b/cmake/platform/Linux.cmake index dbe6c888..54d0e8e4 100644 --- a/cmake/platform/Linux.cmake +++ b/cmake/platform/Linux.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_linux_dependencies) +function(spool_resolve_linux_dependencies) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) find_package(OpenGL REQUIRED) # The compositor is the only thing on Linux that knows what the display can @@ -9,7 +9,7 @@ function(jellyfin_resolve_linux_dependencies) REQUIRED NO_CMAKE_FIND_ROOT_PATH) endfunction() -function(jellyfin_configure_linux_targets native_target core_target) +function(spool_configure_linux_targets native_target core_target) # Keep the pinned upstream XML in source; scanner output belongs to the # build tree. Provenance and its retained license live beside the XML. set(color_management_xml diff --git a/cmake/platform/MacOS.cmake b/cmake/platform/MacOS.cmake index 1b478ba5..8d64449a 100644 --- a/cmake/platform/MacOS.cmake +++ b/cmake/platform/MacOS.cmake @@ -4,11 +4,11 @@ if(APPLE) set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) endif() -function(jellyfin_resolve_macos_dependencies) +function(spool_resolve_macos_dependencies) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) endfunction() -function(jellyfin_configure_macos_targets native_target core_target) +function(spool_configure_macos_targets native_target core_target) if(NOT SPOOL_MACOS_CREDENTIAL_SERVICE MATCHES "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$") message(FATAL_ERROR "SPOOL_MACOS_CREDENTIAL_SERVICE must be a non-empty reverse-DNS service name") @@ -31,17 +31,17 @@ function(jellyfin_configure_macos_targets native_target core_target) src/platform/macos/MacOSPlatformStartup.cpp src/platform/common/UnixProcessIntegration.cpp ) - if(JELLYFIN_MACOS_ICON) - set_source_files_properties("${JELLYFIN_MACOS_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) - target_sources(${native_target} PRIVATE "${JELLYFIN_MACOS_ICON}") + if(SPOOL_MACOS_ICON) + set_source_files_properties("${SPOOL_MACOS_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(${native_target} PRIVATE "${SPOOL_MACOS_ICON}") endif() target_link_libraries(${core_target} PUBLIC PkgConfig::MPV "-framework Security" "-framework AppKit") target_link_libraries(${native_target} PRIVATE "-framework IOKit") set_target_properties(${native_target} PROPERTIES MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME "Spool for Jellyfin" + MACOSX_BUNDLE_BUNDLE_NAME "Spool" MACOSX_BUNDLE_GUI_IDENTIFIER "com.sachk.spool" - MACOSX_BUNDLE_ICON_FILE "jellyfin-native.icns" + MACOSX_BUNDLE_ICON_FILE "spool.icns" MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" INSTALL_RPATH "@executable_path/../Frameworks" diff --git a/cmake/platform/WebOS.cmake b/cmake/platform/WebOS.cmake index 14a3527e..7bf8b542 100644 --- a/cmake/platform/WebOS.cmake +++ b/cmake/platform/WebOS.cmake @@ -34,7 +34,7 @@ function(find_webos_sysroot_library out_var library_name) set(${out_var} "${${out_var}}" PARENT_SCOPE) endfunction() -function(jellyfin_resolve_webos_dependencies) +function(spool_resolve_webos_dependencies) pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) set(WEBOS_NATIVE_LIB_DIR "${CMAKE_SYSROOT}/usr/local/webos-native/lib") set(WEBOS_SYSROOT_LIB_DIR "${CMAKE_SYSROOT}/usr/lib") @@ -86,7 +86,7 @@ function(jellyfin_resolve_webos_dependencies) endforeach() endfunction() -function(jellyfin_configure_webos_targets native_target core_target) +function(spool_configure_webos_targets native_target core_target) target_sources(${core_target} PRIVATE src/platform/webos/WebOSAudioSyncPolicy.cpp src/platform/webos/WebOSCredentialStore.cpp @@ -117,8 +117,8 @@ function(jellyfin_configure_webos_targets native_target core_target) src/platform/webos/WebOSScreenSaverInhibitor.cpp src/platform/common/UnixProcessIntegration.cpp ) - foreach(target IN LISTS JELLYFIN_NATIVE_TARGETS) - target_compile_definitions(${target} PRIVATE JELLYFIN_NATIVE_WEBOS=1) + foreach(target IN LISTS SPOOL_TARGETS) + target_compile_definitions(${target} PRIVATE SPOOL_WEBOS=1) endforeach() message(STATUS "Using firmware-derived webOS input protocol tables; do not regenerate them from host Wayland XML") @@ -162,7 +162,7 @@ function(jellyfin_configure_webos_targets native_target core_target) ) endfunction() -function(jellyfin_import_static_webos_plugins native_target) +function(spool_import_static_webos_plugins native_target) find_webos_sysroot_library(XKBCOMMON_LIB xkbcommon) target_link_libraries(${native_target} PRIVATE ${XKBCOMMON_LIB}) qt_import_plugins(${native_target} diff --git a/cmake/platform/Windows.cmake b/cmake/platform/Windows.cmake index 37d2f898..2b127bc3 100644 --- a/cmake/platform/Windows.cmake +++ b/cmake/platform/Windows.cmake @@ -1,4 +1,4 @@ -function(jellyfin_resolve_windows_dependencies) +function(spool_resolve_windows_dependencies) set(MPV_ROOT "" CACHE PATH "Windows libmpv SDK prefix") find_path(MPV_INCLUDE_DIR mpv/client.h HINTS "${MPV_ROOT}/include" @@ -32,7 +32,7 @@ function(jellyfin_resolve_windows_dependencies) endif() endfunction() -function(jellyfin_configure_windows_targets native_target core_target) +function(spool_configure_windows_targets native_target core_target) # Packaged desktop launches must not allocate a console. Diagnostics are # written to the app-owned log directory even when no stderr handle exists. set_target_properties(${native_target} PROPERTIES WIN32_EXECUTABLE TRUE) @@ -54,9 +54,9 @@ function(jellyfin_configure_windows_targets native_target core_target) ) configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/platform/windows-version.rc.in" - "${CMAKE_CURRENT_BINARY_DIR}/jellyfin-native-version.rc" + "${CMAKE_CURRENT_BINARY_DIR}/spool-version.rc" @ONLY ) - target_sources(${native_target} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/jellyfin-native-version.rc") + target_sources(${native_target} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/spool-version.rc") target_link_libraries(${core_target} PUBLIC MPV::MPV Advapi32 PRIVATE User32) endfunction() diff --git a/cmake/platform/windows-version.rc.in b/cmake/platform/windows-version.rc.in index 33f215d9..1965deb2 100644 --- a/cmake/platform/windows-version.rc.in +++ b/cmake/platform/windows-version.rc.in @@ -13,12 +13,12 @@ BEGIN BEGIN BLOCK "040904b0" BEGIN - VALUE "CompanyName", "Spool for Jellyfin contributors\0" - VALUE "FileDescription", "Spool for Jellyfin client\0" + VALUE "CompanyName", "Spool contributors\0" + VALUE "FileDescription", "Spool client\0" VALUE "FileVersion", "@PROJECT_VERSION@\0" - VALUE "InternalName", "jellyfin-native\0" - VALUE "OriginalFilename", "jellyfin-native.exe\0" - VALUE "ProductName", "Spool for Jellyfin\0" + VALUE "InternalName", "spool\0" + VALUE "OriginalFilename", "spool.exe\0" + VALUE "ProductName", "Spool\0" VALUE "ProductVersion", "@PROJECT_VERSION@\0" END END diff --git a/docs/debug/lifecycle-diagnostics.md b/docs/debug/lifecycle-diagnostics.md index e4f0409f..6af56c2e 100644 --- a/docs/debug/lifecycle-diagnostics.md +++ b/docs/debug/lifecycle-diagnostics.md @@ -1,11 +1,11 @@ # Lifecycle Diagnostics -Build with `-DJELLYFIN_DIAGNOSTICS=ON` to enable debug-only evidence for stale process and slow relaunch issues. +Build with `-DSPOOL_DIAGNOSTICS=ON` to enable debug-only evidence for stale process and slow relaunch issues. Optional flags: -- `-DJELLYFIN_DIAGNOSTICS_STACKDUMP=ON` requests `gdb` stack dumps when available. -- `-DJELLYFIN_DIAGNOSTICS_ABORT_ON_HANG=ON` aborts after watchdog evidence is written. +- `-DSPOOL_DIAGNOSTICS_STACKDUMP=ON` requests `gdb` stack dumps when available. +- `-DSPOOL_DIAGNOSTICS_ABORT_ON_HANG=ON` aborts after watchdog evidence is written. Generated files: @@ -24,9 +24,9 @@ webOS helpers: Simulation knobs: -- `JELLYFIN_DIAGNOSTICS_DIR=/tmp/com.sachk.spool-diagnostics` overrides the output directory. -- `JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS=8000` blocks the GUI thread after startup to test watchdog capture. -- `JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS=8000` blocks shutdown to test shutdown-stall capture. +- `SPOOL_DIAGNOSTICS_DIR=/tmp/com.sachk.spool-diagnostics` overrides the output directory. +- `SPOOL_DIAGNOSTICS_BLOCK_GUI_MS=8000` blocks the GUI thread after startup to test watchdog capture. +- `SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS=8000` blocks shutdown to test shutdown-stall capture. Typical stale-process flow: diff --git a/docs/debug/memory-profiling.md b/docs/debug/memory-profiling.md index 77d7eeb3..a9e63270 100644 --- a/docs/debug/memory-profiling.md +++ b/docs/debug/memory-profiling.md @@ -16,16 +16,16 @@ for breaking down a resident heap footprint. webOS sysroot has no elfutils/libdw, so the device only records a *raw* trace; symbolisation happens on the desktop. Runtime deps (libunwind, libstdc++ with `CXXABI_1.3.15`) are satisfied by the TV + the app's bundled `libstdc++.so.6.0.33`. -- **Launch shim:** `app/bin/jellyfin-native` is a tiny shell shim. It is a - transparent passthrough to `jellyfin-native.real` **unless** the marker file - `/tmp/jellyfin-heaptrack.on` exists, in which case it `LD_PRELOAD`s heaptrack +- **Launch shim:** `app/bin/spool` is a tiny shell shim. It is a + transparent passthrough to `spool.real` **unless** the marker file + `/tmp/spool-heaptrack.on` exists, in which case it `LD_PRELOAD`s heaptrack for that one launch (one-shot) and writes the trace path to - `/tmp/jellyfin-heaptrack.last`. A failed/absent preload is non-fatal, so normal + `/tmp/spool-heaptrack.last`. A failed/absent preload is non-fatal, so normal playback can never break. - **Desktop side (analysis):** `heaptrack_interpret` + `heaptrack_gui` from nixpkgs. Symbols are resolved against a sysroot assembled from the live process's own `/proc//maps` (exactly the loaded modules) plus the - unstripped `build/jellyfin-native.unstripped` via `--extra-paths` (build-id + unstripped `build/spool.unstripped` via `--extra-paths` (build-id matched). ## Build and install the recorder @@ -59,7 +59,7 @@ Useful options: `--no-stop` (leave the app running; partial-but-live trace), instead of from the first play event, or `PLAY_WAIT=SEC` to change that wait. Output lands in `build/memory/heaptrack//`: -`heaptrack.jellyfin.gz` (open in `heaptrack_gui`), `summary.txt` +`heaptrack.spool.gz` (open in `heaptrack_gui`), `summary.txt` (`heaptrack_print`), `maps.txt`, and `smaps_rollup.txt` (RSS breakdown). ## Reading the result for the +350 MB question diff --git a/flake.nix b/flake.nix index 19c1000a..fccb6cc0 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Jellyfin webOS native build environment"; + description = "Spool build environment"; nixConfig = { extra-substituters = [ "https://spool.cachix.org" ]; extra-trusted-public-keys = [ "spool.cachix.org-1:yx+E3raAGXiyUNoMEscSu9c85b+WbgoV7swqyY8oL6s=" ]; @@ -555,7 +555,7 @@ native_qt_cmake_path="${pkgs.spoolQt6.qtbase}:${pkgs.spoolQt6.qtdeclarative}:${pkgs.spoolQt6.qtsvg}:${pkgs.spoolQt6.qttools}:${pkgs.spoolQt6.qtwebsockets}:${pkgs.spoolQt6.qtshadertools}" export CMAKE_PREFIX_PATH="$native_qt_cmake_path''${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}" unset native_qt_cmake_path - export JELLYFIN_NATIVE_SHELL=1 + export SPOOL_SHELL=1 # macdeployqt and linuxdeploy-plugin-qt both discover plugins under one # Qt prefix, and every Qt module is a separate store path here. That @@ -644,7 +644,7 @@ runHook preBuild export HOME="$TMPDIR/home" export XDG_CACHE_HOME="$TMPDIR/cache" - export JELLYFIN_NATIVE_SHELL=1 + export SPOOL_SHELL=1 export SPOOL_QT_CMAKE_DIR="${pkgs.spoolQt6.qtbase}/lib/cmake/Qt6" export CMAKE_PREFIX_PATH="${pkgs.spoolQt6.qtbase}:${pkgs.spoolQt6.qtdeclarative}:${pkgs.spoolQt6.qtsvg}:${pkgs.spoolQt6.qttools}:${pkgs.spoolQt6.qtwebsockets}:${pkgs.spoolQt6.qtshadertools}''${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}" mkdir -p "$HOME" "$XDG_CACHE_HOME" @@ -666,7 +666,7 @@ ''; meta = { - description = "Spool for Jellyfin"; + description = "Spool"; license = pkgs.lib.licenses.gpl3Plus; platforms = systems; }; @@ -788,11 +788,11 @@ binaryPath = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/run-install/Spool.app/Contents/MacOS/Spool" - else "build/linux-release/install/bin/jellyfin-native"; + else "build/linux-release/install/bin/spool"; nativeBuildStamp = if pkgs.stdenv.hostPlatform.isDarwin - then "build/macos/.jellyfin-nix-source-id" - else "build/linux-release/.jellyfin-nix-source-id"; + then "build/macos/.spool-nix-source-id" + else "build/linux-release/.spool-nix-source-id"; mpvLibraryPath = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/mpv-prefix/lib" @@ -823,7 +823,7 @@ if pkgs.stdenv.hostPlatform.isDarwin then "$(sysctl -n hw.ncpu)" else "$(nproc)"; - testScript = pkgs.writeShellScript "jellyfin-native-ctest" '' + testScript = pkgs.writeShellScript "spool-ctest" '' set -euo pipefail cd "$1" shift @@ -845,14 +845,14 @@ if buildRoot == "" then binaryPath else if pkgs.stdenv.hostPlatform.isDarwin then "${buildRoot}/run-install/Spool.app/Contents/MacOS/Spool" - else "${buildRoot}/install/bin/jellyfin-native"; + else "${buildRoot}/install/bin/spool"; runnerMpvLibraryPath = if buildRoot == "" then mpvLibraryPath else "${buildRoot}/mpv-prefix/lib"; runnerBuildStamp = - if buildRoot != "" then "${buildRoot}/.jellyfin-nix-source-id" - else if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/.jellyfin-nix-source-id" - else "build/linux-release/.jellyfin-nix-source-id"; + if buildRoot != "" then "${buildRoot}/.spool-nix-source-id" + else if pkgs.stdenv.hostPlatform.isDarwin then "build/macos/.spool-nix-source-id" + else "build/linux-release/.spool-nix-source-id"; buildRootExport = pkgs.lib.optionalString (buildRoot != "") ''export BUILD_ROOT="$REPO_ROOT/${buildRoot}"; ''; runnerBuildCommand = @@ -871,9 +871,9 @@ } stage_flake_source() { - cache_base="''${XDG_CACHE_HOME:-$HOME/.cache}/jellyfin-native/nix-run" + cache_base="''${XDG_CACHE_HOME:-$HOME/.cache}/spool/nix-run" staged="$cache_base/${stagedSourceId}" - marker="$staged/.jellyfin-staged-source" + marker="$staged/.spool-staged-source" if [ ! -f "$marker" ]; then tmp="$cache_base/.${stagedSourceId}.$$" rm -rf "$tmp" @@ -882,17 +882,17 @@ chmod -R u+w "$tmp" rm -rf "$tmp/mpv" ln -s "$MPV_SOURCE" "$tmp/mpv" - printf '%s\n' "${stagedSourceId}" > "$tmp/.jellyfin-staged-source" + printf '%s\n' "${stagedSourceId}" > "$tmp/.spool-staged-source" rm -rf "$staged" mv "$tmp" "$staged" fi printf '%s\n' "$staged" } - if [ -n "''${JELLYFIN_REPO:-}" ]; then - REPO_ROOT="$JELLYFIN_REPO" + if [ -n "''${SPOOL_REPO:-}" ]; then + REPO_ROOT="$SPOOL_REPO" if ! is_repo_root "$REPO_ROOT"; then - echo "error: JELLYFIN_REPO does not point to a usable jellyfin-webos checkout: $REPO_ROOT" >&2 + echo "error: SPOOL_REPO does not point to a usable spool checkout: $REPO_ROOT" >&2 exit 1 fi elif is_repo_root "$PWD"; then @@ -913,7 +913,7 @@ if [ -x "$BIN" ] && [ -f "$BUILD_STAMP" ] && [ "$(cat "$BUILD_STAMP")" = "${stagedSourceId}" ]; then echo "native build is current (${stagedSourceId}); skipping rebuild" else - nix develop "$REPO_ROOT#native" -c bash -c "$scrub; ${buildRootExport}export JELLYFIN_CMAKE_EXTRA_ARGS='${cmakeExtraArgs}'; ${runnerBuildCommand}" + nix develop "$REPO_ROOT#native" -c bash -c "$scrub; ${buildRootExport}export SPOOL_CMAKE_EXTRA_ARGS='${cmakeExtraArgs}'; ${runnerBuildCommand}" mkdir -p "$(dirname "$BUILD_STAMP")" printf '%s\n' "${stagedSourceId}" > "$BUILD_STAMP" fi @@ -938,13 +938,13 @@ ''; builder = makeRunner { - name = "jellyfin-native-build"; + name = "spool-build"; buildBeforeRun = true; buildOnly = true; }; runner = makeRunner { - name = "jellyfin-native-run"; + name = "spool-run"; buildBeforeRun = true; }; @@ -955,7 +955,7 @@ # retain the incremental build path. defaultRunner = assert builtins.getContext cachedPackageDerivation == {}; - pkgs.writeShellScriptBin "jellyfin-native-default" '' + pkgs.writeShellScriptBin "spool-default" '' export PATH="${pkgs.lib.makeBinPath [ pkgs.nix pkgs.bashInteractive pkgs.coreutils ]}:$PATH" set -euo pipefail @@ -963,10 +963,10 @@ [ -f "$1/CMakeLists.txt" ] && [ -f "$1/tools/build-macos.sh" ] && [ -f "$1/mpv/meson.build" ] } - if [ -n "''${JELLYFIN_REPO:-}" ]; then - CHECKOUT="$JELLYFIN_REPO" + if [ -n "''${SPOOL_REPO:-}" ]; then + CHECKOUT="$SPOOL_REPO" if ! is_repo_root "$CHECKOUT"; then - echo "error: JELLYFIN_REPO does not point to a usable jellyfin-webos checkout: $CHECKOUT" >&2 + echo "error: SPOOL_REPO does not point to a usable spool checkout: $CHECKOUT" >&2 exit 1 fi elif is_repo_root "$PWD"; then @@ -981,12 +981,12 @@ if [ -x "$BIN" ] && [ -f "$BUILD_STAMP" ] && [ "$(cat "$BUILD_STAMP")" = "${stagedSourceId}" ]; then echo "native checkout build is current (${stagedSourceId}); reusing it" cd "$CHECKOUT" - exec "${runner}/bin/jellyfin-native-run" "$@" + exec "${runner}/bin/spool-run" "$@" fi fi if ! ${if immutableRevision then "true" else "false"}; then - exec "${runner}/bin/jellyfin-native-run" "$@" + exec "${runner}/bin/spool-run" "$@" fi echo "no current checkout build; checking immutable package caches" @@ -1003,27 +1003,27 @@ export QML_IMPORT_PATH="$QML2_IMPORT_PATH" exec "$CACHED_OUT/Applications/Spool.app/Contents/MacOS/Spool" "$@" '' else '' - exec "$CACHED_OUT/bin/jellyfin-native" "$@" + exec "$CACHED_OUT/bin/spool" "$@" ''} ''; # Same build and ctest invocation the release workflow runs. tester = makeRunner { - name = "jellyfin-native-tests"; + name = "spool-tests"; buildBeforeRun = true; runTests = true; }; - noBuildRunner = makeRunner { name = "jellyfin-native-run-no-build"; }; + noBuildRunner = makeRunner { name = "spool-run-no-build"; }; imageDebugRunner = makeRunner { - name = "jellyfin-native-image-debug"; - cmakeExtraArgs = "-DJELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=ON"; + name = "spool-image-debug"; + cmakeExtraArgs = "-DSPOOL_ARTWORK_ASPECT_DIAGNOSTICS=ON"; buildRoot = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos-image-debug" else "build/linux-release-image-debug"; }; imageDebugBuilder = makeRunner { - name = "jellyfin-native-image-debug-build"; - cmakeExtraArgs = "-DJELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS=ON"; + name = "spool-image-debug-build"; + cmakeExtraArgs = "-DSPOOL_ARTWORK_ASPECT_DIAGNOSTICS=ON"; buildRoot = if pkgs.stdenv.hostPlatform.isDarwin then "build/macos-image-debug" else "build/linux-release-image-debug"; @@ -1036,7 +1036,7 @@ # nixpkgs `gammaray` probe must match the app's Qt; the #native shell # builds the app against nixpkgs Qt, so they line up. gammarayRunner = makeRunner { - name = "jellyfin-native-gammaray"; + name = "spool-gammaray"; # QuickInspector updates its scene-graph model on every render and # crashes GammaRay 3.4 during the mpv overlay transition. Keep the # default profiling runner stable; use .#gammaray-full for Quick Scenes. @@ -1044,13 +1044,13 @@ }; gammarayFullRunner = makeRunner { - name = "jellyfin-native-gammaray-full"; + name = "spool-gammaray-full"; launchPrefix = "${pkgs.gammaray}/bin/gammaray "; }; in { build = { type = "app"; - program = "${builder}/bin/jellyfin-native-build"; + program = "${builder}/bin/spool-build"; }; # Prefer an exact checkout build even after its source is committed. @@ -1058,27 +1058,27 @@ # realise the immutable package that release CI publishes to Cachix. default = { type = "app"; - program = "${defaultRunner}/bin/jellyfin-native-default"; + program = "${defaultRunner}/bin/spool-default"; }; run = { type = "app"; - program = "${noBuildRunner}/bin/jellyfin-native-run-no-build"; + program = "${noBuildRunner}/bin/spool-run-no-build"; }; tests = { type = "app"; - program = "${tester}/bin/jellyfin-native-tests"; + program = "${tester}/bin/spool-tests"; }; image-debug = { type = "app"; - program = "${imageDebugRunner}/bin/jellyfin-native-image-debug"; + program = "${imageDebugRunner}/bin/spool-image-debug"; }; image-debug-build = { type = "app"; - program = "${imageDebugBuilder}/bin/jellyfin-native-image-debug-build"; + program = "${imageDebugBuilder}/bin/spool-image-debug-build"; }; } // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { windows-proton-build = { @@ -1107,12 +1107,12 @@ gammaray = { type = "app"; - program = "${gammarayRunner}/bin/jellyfin-native-gammaray"; + program = "${gammarayRunner}/bin/spool-gammaray"; }; gammaray-full = { type = "app"; - program = "${gammarayFullRunner}/bin/jellyfin-native-gammaray-full"; + program = "${gammarayFullRunner}/bin/spool-gammaray-full"; }; }); }; diff --git a/i18n/qtfin_en_US.ts b/i18n/qtfin_en_US.ts index 2e062fb0..59a1246f 100644 --- a/i18n/qtfin_en_US.ts +++ b/i18n/qtfin_en_US.ts @@ -68,8 +68,8 @@ Skip Recap - Spool for Jellyfin - Spool for Jellyfin + Spool + Spool Native Qt 6.11 client for LG webOS diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 2adf40c6..ee04fbe2 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,5 +1,5 @@ pkgbase = spool-bin - pkgdesc = Jellyfin client for desktop and TV, built on Qt Quick and mpv + pkgdesc = Fast media player for desktop and TV, built on Qt Quick and mpv pkgver = 0.8.1 pkgrel = 1 url = https://github.com/spool-player/spool @@ -14,7 +14,7 @@ pkgbase = spool-bin options = !strip options = !debug options = !lto - source = https://github.com/spool-player/spool/releases/download/v0.8.1/Spool-for-Jellyfin-0.8.1-linux-x86_64.tar.zst + source = https://github.com/spool-player/spool/releases/download/v0.8.1/Spool-0.8.1-linux-x86_64.tar.zst sha256sums = SKIP pkgname = spool-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 13793d78..dcbb7f11 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -3,7 +3,7 @@ pkgname=spool-bin pkgver=0.8.1 pkgrel=1 -pkgdesc='Jellyfin client for desktop and TV, built on Qt Quick and mpv' +pkgdesc='Fast media player for desktop and TV, built on Qt Quick and mpv' arch=('x86_64') url='https://github.com/spool-player/spool' license=('MPL-2.0') @@ -16,7 +16,7 @@ provides=("spool=${pkgver}") conflicts=('spool') # Already stripped, and patchelf has set the RPATHs the bundle needs. options=('!strip' '!debug' '!lto') -_bundle="Spool-for-Jellyfin-${pkgver}-linux-x86_64" +_bundle="Spool-${pkgver}-linux-x86_64" source=("https://github.com/spool-player/spool/releases/download/v${pkgver}/${_bundle}.tar.zst") # Written by tools/update-aur-checksums.sh from SHA256SUMS.txt once the release # above is published; SKIP until then. @@ -54,8 +54,8 @@ LAUNCHER "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/com.sachk.spool.png" done - install -Dm644 "$_bundle/usr/share/jellyfin-native/notices/LICENSE" \ + install -Dm644 "$_bundle/usr/share/spool/notices/LICENSE" \ "$pkgdir/usr/share/licenses/$pkgname/LICENSE" - install -Dm644 "$_bundle/usr/share/jellyfin-native/notices/OPEN_SOURCE_NOTICES.txt" \ + install -Dm644 "$_bundle/usr/share/spool/notices/OPEN_SOURCE_NOTICES.txt" \ "$pkgdir/usr/share/licenses/$pkgname/OPEN_SOURCE_NOTICES.txt" } diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 5bd4a5c9..bea46e4f 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -38,7 +38,7 @@ #include #endif -namespace JellyfinNative { +namespace Spool { namespace { @@ -1307,4 +1307,4 @@ void AppController::setPlaybackTransition(bool transition) }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/AppController.h b/src/app/AppController.h index 16e52535..10c7a317 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -24,7 +24,7 @@ #include -namespace JellyfinNative { +namespace Spool { class ArtworkService; class Catalog; @@ -250,4 +250,4 @@ class AppController final : public QObject { std::unique_ptr m_instanceLock; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/AppControllerRemote.cpp b/src/app/AppControllerRemote.cpp index 5ca1811d..38e4abf6 100644 --- a/src/app/AppControllerRemote.cpp +++ b/src/app/AppControllerRemote.cpp @@ -15,7 +15,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { bool AppController::inGroup() const { @@ -180,4 +180,4 @@ void AppController::playRemoteItems(const QString& accountId, const QVariantMap& "remote playback"); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkImageProvider.h b/src/app/ArtworkImageProvider.h index 541bb542..403bc133 100644 --- a/src/app/ArtworkImageProvider.h +++ b/src/app/ArtworkImageProvider.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { class ArtworkImageProvider final : public QQuickAsyncImageProvider { public: @@ -16,4 +16,4 @@ class ArtworkImageProvider final : public QQuickAsyncImageProvider { ArtworkService *m_service = nullptr; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkPrefetcher.h b/src/app/ArtworkPrefetcher.h index 085bd370..32f487e1 100644 --- a/src/app/ArtworkPrefetcher.h +++ b/src/app/ArtworkPrefetcher.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct MovieItem; class ArtworkPrefetcher { @@ -14,4 +14,4 @@ class ArtworkPrefetcher { virtual QString itemUrl(const MovieItem& item, bool landscape, int width = 0) const = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkService.cpp b/src/app/ArtworkService.cpp index 1a2a9937..98769842 100644 --- a/src/app/ArtworkService.cpp +++ b/src/app/ArtworkService.cpp @@ -25,7 +25,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -49,7 +49,7 @@ namespace { return sourceSize.scaled(requestedSize, Qt::KeepAspectRatioByExpanding); } -#if defined(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) +#if defined(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) bool shouldLogAspectDiagnostic(const QSize& source, const QSize& requested, const QSize& decoded) { if (!source.isValid() || !requested.isValid() || !decoded.isValid() || source.width() <= 0 @@ -83,7 +83,7 @@ namespace { reader.setAutoTransform(false); const QSize scaledSize = decodeSizeForRequest(reader.size(), requestedSize); if (scaledSize.isValid()) { -#if defined(JELLYFIN_ARTWORK_ASPECT_DIAGNOSTICS) +#if defined(SPOOL_ARTWORK_ASPECT_DIAGNOSTICS) if (shouldLogAspectDiagnostic(reader.size(), requestedSize, scaledSize)) { qWarning() << "artwork: aspect-preserving decode" << "source=" << reader.size() << "requested=" << requestedSize << "decode=" << scaledSize; @@ -901,4 +901,4 @@ QQuickImageResponse *ArtworkImageProvider::requestImageResponse(const QString& i : new ArtworkImageResponse(nullptr, {}, requestedSize); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ArtworkService.h b/src/app/ArtworkService.h index 10818c85..3fc987d6 100644 --- a/src/app/ArtworkService.h +++ b/src/app/ArtworkService.h @@ -25,7 +25,7 @@ class QQuickImageResponse; -namespace JellyfinNative { +namespace Spool { class ArtworkByteCache final { public: @@ -148,4 +148,4 @@ class ArtworkService final : public QObject, public ArtworkPrefetcher { int m_nextRequestId = 1; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/BrowseSessionController.cpp b/src/app/BrowseSessionController.cpp index fc16b79b..eafc7ba6 100644 --- a/src/app/BrowseSessionController.cpp +++ b/src/app/BrowseSessionController.cpp @@ -8,7 +8,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { BrowseSessionController::BrowseSessionController(LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) @@ -323,4 +323,4 @@ void BrowseSessionController::clearBrowseIdentity() m_descriptor = {}; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/BrowseSessionController.h b/src/app/BrowseSessionController.h index e8a8a297..ab6be31c 100644 --- a/src/app/BrowseSessionController.h +++ b/src/app/BrowseSessionController.h @@ -10,13 +10,13 @@ #include -namespace JellyfinNative { +namespace Spool { class LibraryPrefetchController; class BrowseSessionController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieGridModel *items READ items CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *items READ items CONSTANT) Q_PROPERTY(bool loadingMore READ loadingMore NOTIFY pagingChanged) Q_PROPERTY(bool hasMore READ hasMore NOTIFY pagingChanged) Q_PROPERTY(int totalCount READ totalCount NOTIFY pagingChanged) @@ -163,4 +163,4 @@ class BrowseSessionController final : public QObject { QVariantMap m_filterOptions; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ContentModelController.cpp b/src/app/ContentModelController.cpp index ada33cd1..06a3f210 100644 --- a/src/app/ContentModelController.cpp +++ b/src/app/ContentModelController.cpp @@ -10,7 +10,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { ContentModelController::ContentModelController(Catalog *catalog, LibraryPrefetchController *prefetch, QObject *parent) : QObject(parent) @@ -418,4 +418,4 @@ void ContentModelController::finishDetailRowLoad(RequestGeneration::Token genera } } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/ContentModelController.h b/src/app/ContentModelController.h index 4a3e6ae6..9a5c8e19 100644 --- a/src/app/ContentModelController.h +++ b/src/app/ContentModelController.h @@ -12,17 +12,17 @@ #include #include -namespace JellyfinNative { +namespace Spool { class LibraryPrefetchController; class ContentModelController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieItem detailItem READ detailItem NOTIFY detailItemChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSeasons READ detailSeasons CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSeasonOptions READ detailSeasonOptions CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *detailSimilarItems READ detailSimilarItems CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *linkedItems READ linkedItems CONSTANT) + Q_PROPERTY(Spool::MovieItem detailItem READ detailItem NOTIFY detailItemChanged) + Q_PROPERTY(Spool::MovieGridModel *detailSeasons READ detailSeasons CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *detailSeasonOptions READ detailSeasonOptions CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *detailSimilarItems READ detailSimilarItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *linkedItems READ linkedItems CONSTANT) Q_PROPERTY(QVariantList personItemRows READ personItemRows NOTIFY personItemsChanged) Q_PROPERTY(bool detailRowsBusy READ detailRowsBusy NOTIFY detailRowsChanged) Q_PROPERTY(bool personItemsBusy READ personItemsBusy NOTIFY personItemsChanged) @@ -109,4 +109,4 @@ class ContentModelController final : public QObject { RequestGeneration m_personItemsGeneration; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/CpuTopology.cpp b/src/app/CpuTopology.cpp index 11076b3f..8c0f545f 100644 --- a/src/app/CpuTopology.cpp +++ b/src/app/CpuTopology.cpp @@ -4,14 +4,14 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { int environmentDecodeThreads() { bool ok = false; - const int value = QString::fromLocal8Bit(qgetenv("JELLYFIN_WEBP_DECODE_THREADS")).toInt(&ok); + const int value = QString::fromLocal8Bit(qgetenv("SPOOL_WEBP_DECODE_THREADS")).toInt(&ok); return ok ? value : 0; } @@ -35,4 +35,4 @@ CpuTopology detectCpuTopology() return { logical, physical, smt, decodeThreads, source }; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/CpuTopology.h b/src/app/CpuTopology.h index 9a661c34..e06b4853 100644 --- a/src/app/CpuTopology.h +++ b/src/app/CpuTopology.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct CpuTopology { int logicalCpus = 1; @@ -14,4 +14,4 @@ struct CpuTopology { CpuTopology detectCpuTopology(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GraphicsStartup.cpp b/src/app/GraphicsStartup.cpp index fe9c1675..11cc50ed 100644 --- a/src/app/GraphicsStartup.cpp +++ b/src/app/GraphicsStartup.cpp @@ -4,7 +4,7 @@ #include "platform/PlatformDisplayOutput.h" #include "platform/PlatformStartup.h" #include "player/RenderTargetProfile.h" -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) #include "platform/linux/WaylandColorInfo.h" #endif @@ -14,7 +14,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bool launchTest) { @@ -22,7 +22,7 @@ QSGRendererInterface::GraphicsApi GraphicsStartup::configureBeforeApplication(bo // compatibility path; Linux and macOS use Vulkan for HDR-capable rendering. #if defined(Q_OS_WIN) auto graphicsApi = QSGRendererInterface::Direct3D11; -#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) && QT_CONFIG(vulkan) +#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) && QT_CONFIG(vulkan) auto graphicsApi = QSGRendererInterface::Vulkan; #elif defined(Q_OS_MACOS) && QT_CONFIG(vulkan) auto graphicsApi = QSGRendererInterface::Vulkan; @@ -81,7 +81,7 @@ QByteArray GraphicsStartup::prepareBeforeWindow(QSGRendererInterface::GraphicsAp bool automaticHdr = false; bool allowHdrRequest = !launchTest && (graphicsApi == QSGRendererInterface::Vulkan || graphicsApi == QSGRendererInterface::Direct3D11); -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) allowHdrRequest = allowHdrRequest && QGuiApplication::platformName().startsWith(QLatin1String("wayland")); automaticHdr = allowHdrRequest; #elif defined(Q_OS_WIN) @@ -92,7 +92,7 @@ QByteArray GraphicsStartup::prepareBeforeWindow(QSGRendererInterface::GraphicsAp #endif const QByteArray hdrRequest = allowHdrRequest ? RenderTargetPolicy::startupSwapChainRequest(automaticHdr) : QByteArray(); -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) if (!hdrRequest.isEmpty()) { qputenv("QSG_RHI_HDR", hdrRequest); qInfo("startup: set QSG_RHI_HDR=%s in environment", hdrRequest.constData()); @@ -112,13 +112,13 @@ GraphicsStartup::GraphicsStartup(NativeAppWindow& window, const QByteArray& hdrR : QObject(&window) , m_window(window) { -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) m_waylandHdrSurface = std::make_unique(&window); #endif window.setProperty("_qt_sg_hdr_format", hdrRequest); qInfo("startup: output request=%s qpa=%s", hdrRequest.isEmpty() ? "SDR" : hdrRequest.constData(), qPrintable(QGuiApplication::platformName())); -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) // QQuickWindow stops its scene graph before destroying its QObject children. // Direct callbacks therefore retain a live owner through render shutdown; // queued GUI updates are cancelled automatically when the owner dies. @@ -133,7 +133,7 @@ GraphicsStartup::~GraphicsStartup() = default; void GraphicsStartup::probeSwapchain() { -#if !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) // QRhi access stays on the render thread. Retry until the swapchain exists, // then snapshot once per scene graph, not on every frame. Output-change // subscriptions are deliberately separate from this startup observation. @@ -151,7 +151,7 @@ void GraphicsStartup::probeSwapchain() PlatformDisplayOutput::updateDisplayLuminance(display, &m_window); bool scrgb = display.hdrAvailable && display.preferredFormat == RenderTargetProfile::Format::ExtendedSrgbLinear; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) if (!m_waylandHdrSurface->setEnabled(scrgb && display.needsWaylandDescription)) scrgb = false; #endif @@ -165,4 +165,4 @@ void GraphicsStartup::probeSwapchain() #endif } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GraphicsStartup.h b/src/app/GraphicsStartup.h index 94a54bb1..33d872f1 100644 --- a/src/app/GraphicsStartup.h +++ b/src/app/GraphicsStartup.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; class WaylandHdrSurface; @@ -34,9 +34,9 @@ class GraphicsStartup final : public QObject { NativeAppWindow& m_window; // Accessed only by direct scene-graph callbacks on the render thread. bool m_outputSnapshotPending = true; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(JELLYFIN_NATIVE_WEBOS) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) && !defined(SPOOL_WEBOS) std::unique_ptr m_waylandHdrSurface; #endif }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GroupClock.cpp b/src/app/GroupClock.cpp index 3db33c8c..5bd36f06 100644 --- a/src/app/GroupClock.cpp +++ b/src/app/GroupClock.cpp @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -85,4 +85,4 @@ void GroupClock::selectBestMeasurement() m_ready = true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GroupClock.h b/src/app/GroupClock.h index 0f3d8609..8071d022 100644 --- a/src/app/GroupClock.h +++ b/src/app/GroupClock.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct GroupClockSample { std::int64_t localRequestSentMs = 0; @@ -36,4 +36,4 @@ class GroupClock final { bool m_ready = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GroupPlaybackController.cpp b/src/app/GroupPlaybackController.cpp index 72dfa31f..2c4ef750 100644 --- a/src/app/GroupPlaybackController.cpp +++ b/src/app/GroupPlaybackController.cpp @@ -12,7 +12,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr int kGreedyTimeSyncIntervalMs = 1'000; @@ -804,4 +804,4 @@ void GroupPlaybackController::requestTimeSync() "group clock"); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/GroupPlaybackController.h b/src/app/GroupPlaybackController.h index 78b3c337..f7c1c516 100644 --- a/src/app/GroupPlaybackController.h +++ b/src/app/GroupPlaybackController.h @@ -11,7 +11,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class PlayQueueController; class PlayerController; @@ -274,4 +274,4 @@ class GroupPlaybackController final : public GroupPlayback { QString m_syncMethod = QStringLiteral("None"); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/HomeModelController.cpp b/src/app/HomeModelController.cpp index a0c527fb..537fac7c 100644 --- a/src/app/HomeModelController.cpp +++ b/src/app/HomeModelController.cpp @@ -17,7 +17,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr int kHomePayloadSchemaVersion = 12; @@ -536,4 +536,4 @@ QJsonObject HomeModelController::payloadFromSections(const std::vector #include -namespace JellyfinNative { +namespace Spool { class DatabaseManager; class LibraryPrefetchController; class HomeModelController final : public QObject { Q_OBJECT - Q_PROPERTY(JellyfinNative::MovieGridModel *resumeItems READ resumeItems CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *nextUpItems READ nextUpItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *resumeItems READ resumeItems CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *nextUpItems READ nextUpItems CONSTANT) Q_PROPERTY(QVariantList latestLibraryRows READ latestLibraryRows NOTIFY latestLibraryRowsChanged) Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) @@ -90,4 +90,4 @@ class HomeModelController final : public QObject { QStringList m_recentLibraryIds; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryPrefetchController.cpp b/src/app/LibraryPrefetchController.cpp index a28581b7..ff42e565 100644 --- a/src/app/LibraryPrefetchController.cpp +++ b/src/app/LibraryPrefetchController.cpp @@ -10,7 +10,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -170,4 +170,4 @@ void LibraryPrefetchController::startNext() }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryPrefetchController.h b/src/app/LibraryPrefetchController.h index 973296fa..4968cbd6 100644 --- a/src/app/LibraryPrefetchController.h +++ b/src/app/LibraryPrefetchController.h @@ -13,7 +13,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class ArtworkPrefetcher; @@ -56,4 +56,4 @@ class LibraryPrefetchController final : public QObject { int m_imagePrefetchAheadItems = 16; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryQuery.cpp b/src/app/LibraryQuery.cpp index 3a460b47..01bf0f69 100644 --- a/src/app/LibraryQuery.cpp +++ b/src/app/LibraryQuery.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -153,4 +153,4 @@ QString libraryCacheKey(const LibraryItem& library, const QVariantMap& query) return signature.isEmpty() ? baseKey : QStringLiteral("%1?%2").arg(baseKey, signature); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LibraryQuery.h b/src/app/LibraryQuery.h index 62f0c797..1cf38731 100644 --- a/src/app/LibraryQuery.h +++ b/src/app/LibraryQuery.h @@ -5,7 +5,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { QString libraryContentLabel(const LibraryItem& library); bool supportsLatestLibraryRow(const LibraryItem& library); @@ -15,4 +15,4 @@ int activeLibraryFilterCount(const QVariantMap& query); QString libraryCacheKey(const LibraryItem& library); QString libraryCacheKey(const LibraryItem& library, const QVariantMap& query); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LocalizationManager.cpp b/src/app/LocalizationManager.cpp index 2e2d3025..4775bbe7 100644 --- a/src/app/LocalizationManager.cpp +++ b/src/app/LocalizationManager.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr auto kSettingsKey = "i18n/locale"; @@ -101,4 +101,4 @@ void LocalizationManager::applyLocale(const QString& localeTag) emit localeChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/LocalizationManager.h b/src/app/LocalizationManager.h index 1dbaaa52..ac9de8ec 100644 --- a/src/app/LocalizationManager.h +++ b/src/app/LocalizationManager.h @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { // Owns the active Qt translator and exposes locale switching to QML. The // strategy follows the Qt-native approach: .qm files compiled from .ts files @@ -52,4 +52,4 @@ class LocalizationManager final : public QObject { bool m_useSystem = true; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/MemoryBudget.cpp b/src/app/MemoryBudget.cpp index aa9ea175..8f8e4ecf 100644 --- a/src/app/MemoryBudget.cpp +++ b/src/app/MemoryBudget.cpp @@ -3,7 +3,7 @@ #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -30,4 +30,4 @@ MemoryBudget MemoryBudget::detect() return budget; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/MemoryBudget.h b/src/app/MemoryBudget.h index 82664c94..81056474 100644 --- a/src/app/MemoryBudget.h +++ b/src/app/MemoryBudget.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct MemoryBudget { qint64 memTotalBytes = 0; @@ -16,4 +16,4 @@ struct MemoryBudget { static MemoryBudget detect(); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/RouterController.cpp b/src/app/RouterController.cpp index 83bf3664..94e1c688 100644 --- a/src/app/RouterController.cpp +++ b/src/app/RouterController.cpp @@ -6,7 +6,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -285,4 +285,4 @@ bool RouterController::restoreSnapshot() return true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/RouterController.h b/src/app/RouterController.h index a0fd405c..abdd2f8d 100644 --- a/src/app/RouterController.h +++ b/src/app/RouterController.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class RouterController final : public QObject { Q_OBJECT @@ -63,4 +63,4 @@ class RouterController final : public QObject { bool m_recoveryRequested = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SearchController.cpp b/src/app/SearchController.cpp index e8186742..303eb917 100644 --- a/src/app/SearchController.cpp +++ b/src/app/SearchController.cpp @@ -9,7 +9,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { constexpr int kSearchDebounceMs = 260; @@ -238,4 +238,4 @@ void SearchController::setSuggestionsBusy(bool busy) emit suggestionsChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SearchController.h b/src/app/SearchController.h index a86a5e34..97f980ff 100644 --- a/src/app/SearchController.h +++ b/src/app/SearchController.h @@ -9,7 +9,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class LibraryPrefetchController; @@ -17,12 +17,12 @@ class SearchController final : public QObject { Q_OBJECT Q_PROPERTY(QString query READ query NOTIFY queryChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *movieResults READ movieResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *seriesResults READ seriesResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *episodeResults READ episodeResults CONSTANT) - Q_PROPERTY(JellyfinNative::MovieGridModel *otherResults READ otherResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *movieResults READ movieResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *seriesResults READ seriesResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *episodeResults READ episodeResults CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *otherResults READ otherResults CONSTANT) Q_PROPERTY(int resultCount READ resultCount NOTIFY resultsChanged) - Q_PROPERTY(JellyfinNative::MovieGridModel *suggestions READ suggestions CONSTANT) + Q_PROPERTY(Spool::MovieGridModel *suggestions READ suggestions CONSTANT) Q_PROPERTY(bool suggestionsBusy READ suggestionsBusy NOTIFY suggestionsChanged) public: @@ -106,4 +106,4 @@ class SearchController final : public QObject { QTimer m_debounceTimer; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsController.cpp b/src/app/SettingsController.cpp index 947d58d7..eff62927 100644 --- a/src/app/SettingsController.cpp +++ b/src/app/SettingsController.cpp @@ -15,7 +15,7 @@ #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -895,4 +895,4 @@ void SettingsController::applySubtitlePreferencesToPlayer() m_player->setSubtitlePreferences(m_subtitlePreferences); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsController.h b/src/app/SettingsController.h index 0c6fbe3f..08159635 100644 --- a/src/app/SettingsController.h +++ b/src/app/SettingsController.h @@ -10,7 +10,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class DatabaseManager; class ArtworkService; @@ -184,4 +184,4 @@ class SettingsController final : public QObject { QString m_blueButtonAction = QStringLiteral("none"); }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsSchema.cpp b/src/app/SettingsSchema.cpp index 06df5dde..2ecc20c6 100644 --- a/src/app/SettingsSchema.cpp +++ b/src/app/SettingsSchema.cpp @@ -9,7 +9,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Enhanced puts every frame through libplacebo, which is where the @@ -540,7 +540,7 @@ const QVector& settingSpecs() // Only platforms with an in-app installer expose this preference. toggleSpec("updates/automatic", "Updates", "Automatic updates", "Check for new versions", true, SettingTarget::AutomaticUpdates) -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) .onWebOS(), #else .onAndroid(), @@ -568,7 +568,7 @@ const QVector& settingSpecs() pageSpec("action/clearLatencyStatistics", "Diagnostics", "Clear latency samples", "", SettingType::Action) .expert(), - pageSpec("about/version", "About", "Spool for Jellyfin", "", SettingType::ReadOnly), + pageSpec("about/version", "About", "Spool", "", SettingType::ReadOnly), pageSpec("about/locale", "About", "Active language", "", SettingType::ReadOnly), pageSpec("action/openSourceNotices", "About", "Open-source notices", "Licences and source for the bundled software", SettingType::Action), @@ -677,4 +677,4 @@ QVariantList settingSchemaModel() return model; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/SettingsSchema.h b/src/app/SettingsSchema.h index 139f3e00..632a86de 100644 --- a/src/app/SettingsSchema.h +++ b/src/app/SettingsSchema.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { enum class SettingType { Action, @@ -151,4 +151,4 @@ QVariant normalizedSettingValue(const SettingSpec& spec, const QVariant& value); QString serializedSettingValue(const SettingSpec& spec, const QVariant& value); QVariantList settingSchemaModel(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UpdateManifest.cpp b/src/app/UpdateManifest.cpp index 25bd74e2..c0c9171e 100644 --- a/src/app/UpdateManifest.cpp +++ b/src/app/UpdateManifest.cpp @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -111,4 +111,4 @@ UpdateManifestResult selectUpdate( return { std::move(selected), {} }; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UpdateManifest.h b/src/app/UpdateManifest.h index a0f3644d..3bbc7365 100644 --- a/src/app/UpdateManifest.h +++ b/src/app/UpdateManifest.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { enum class UpdateChannel { Release, @@ -32,4 +32,4 @@ struct UpdateManifestResult { UpdateManifestResult selectUpdate( const QByteArray& manifest, int currentVersionCode, bool allowPrerelease, const QString& assetKey); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UserItemStateController.cpp b/src/app/UserItemStateController.cpp index 0ba6d71b..ac9310ac 100644 --- a/src/app/UserItemStateController.cpp +++ b/src/app/UserItemStateController.cpp @@ -7,7 +7,7 @@ #include "HomeModelController.h" #include "SearchController.h" -namespace JellyfinNative { +namespace Spool { UserItemStateController::UserItemStateController(UserItemStateSink *sink, BrowseSessionController *currentItems, HomeModelController *home, ContentModelController *content, SearchController *search, QObject *parent) @@ -121,4 +121,4 @@ void UserItemStateController::clearProgress(const QString& itemId) [this](const std::exception_ptr& error) { emit errorOccurred(exceptionMessage(error)); }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/app/UserItemStateController.h b/src/app/UserItemStateController.h index a3215ac5..965b1f4e 100644 --- a/src/app/UserItemStateController.h +++ b/src/app/UserItemStateController.h @@ -5,7 +5,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class ContentModelController; class BrowseSessionController; @@ -41,4 +41,4 @@ class UserItemStateController final : public QObject { SearchController *m_search = nullptr; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/cache/DatabaseManager.cpp b/src/cache/DatabaseManager.cpp index ef2250f9..34655d59 100644 --- a/src/cache/DatabaseManager.cpp +++ b/src/cache/DatabaseManager.cpp @@ -22,7 +22,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class DatabaseWorker final : public QObject { Q_OBJECT @@ -154,8 +154,7 @@ class DatabaseWorker final : public QObject { bool openState(const QString& path) { - if (!ensureConnection(m_database, QStringLiteral("jellyfin_native_state"), path) - || !prepareConnection(m_database)) + if (!ensureConnection(m_database, QStringLiteral("spool_native_state"), path) || !prepareConnection(m_database)) return false; secureDatabaseFiles(path); QSqlQuery query(m_database); @@ -191,7 +190,7 @@ class DatabaseWorker final : public QObject { bool openCache(const QString& path) { - if (!ensureConnection(m_cacheDatabase, QStringLiteral("jellyfin_native_cache"), path) + if (!ensureConnection(m_cacheDatabase, QStringLiteral("spool_native_cache"), path) || !prepareConnection(m_cacheDatabase)) return false; secureDatabaseFiles(path); @@ -700,6 +699,6 @@ void DatabaseManager::evictCacheEntries(int maximumEntries) invokeOnWorkerAsync([this, maximumEntries]() { m_worker->evictCacheEntries(maximumEntries); }); } -} // namespace JellyfinNative +} // namespace Spool #include "DatabaseManager.moc" diff --git a/src/cache/DatabaseManager.h b/src/cache/DatabaseManager.h index 468b52ff..7455c5b6 100644 --- a/src/cache/DatabaseManager.h +++ b/src/cache/DatabaseManager.h @@ -18,7 +18,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { class DatabaseWorker; @@ -74,4 +74,4 @@ class DatabaseManager final : public QObject { QFuture m_initializationFuture; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/AsyncTask.h b/src/common/AsyncTask.h index d998a94c..1ff0e63d 100644 --- a/src/common/AsyncTask.h +++ b/src/common/AsyncTask.h @@ -16,7 +16,7 @@ #include #include -namespace JellyfinNative::Async { +namespace Spool::Async { // Runs `work` on `pool` (the global pool by default) and hands back a future // a coroutine can await, for disk and CPU work that must stay off the GUI @@ -119,4 +119,4 @@ void runLatest(Context *context, QCoro::Task task, const RequestGeneration& g } } -} // namespace JellyfinNative::Async +} // namespace Spool::Async diff --git a/src/common/LogRotation.h b/src/common/LogRotation.h index 84d9bffa..54398184 100644 --- a/src/common/LogRotation.h +++ b/src/common/LogRotation.h @@ -5,7 +5,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { // mkdir -p for the directory portion of a log path. The log directory lives // under /tmp on webOS and is wiped every boot, so every writer must be able @@ -18,7 +18,7 @@ inline void ensureParentDirectoryExists(const char *path) inline void makeWebOSSupportLogsReadable(const QString& current) { -#ifdef JELLYFIN_NATIVE_WEBOS +#ifdef SPOOL_WEBOS const QFileInfo info(current); QFile::setPermissions(info.absolutePath(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | QFileDevice::ReadGroup @@ -52,4 +52,4 @@ inline void rotateLogFile(const char *path) makeWebOSSupportLogsReadable(current); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/MetaJson.h b/src/common/MetaJson.h index 9487ab85..8c250250 100644 --- a/src/common/MetaJson.h +++ b/src/common/MetaJson.h @@ -11,7 +11,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { enum class MetaJsonKeyPolicy { CamelCase, @@ -241,4 +241,4 @@ QList metaListFromJson(const QJsonArray& array, MetaJsonKeyPolicy policy = Me return MetaJsonDetail::listFromJson(array, policy); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/NetworkAddress.h b/src/common/NetworkAddress.h index 01ea00f4..c1df4ef4 100644 --- a/src/common/NetworkAddress.h +++ b/src/common/NetworkAddress.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { inline bool isPrivateNetworkAddress(const QHostAddress& address) { @@ -32,4 +32,4 @@ inline bool isLanHost(const QString& host) || !normalized.contains(QLatin1Char('.')); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/RequestGeneration.h b/src/common/RequestGeneration.h index 6c09ce79..ad8b0e0d 100644 --- a/src/common/RequestGeneration.h +++ b/src/common/RequestGeneration.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { class RequestGeneration final { public: @@ -29,4 +29,4 @@ class RequestGeneration final { Token m_value = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/SeriesAudioSelection.cpp b/src/common/SeriesAudioSelection.cpp index 69863cef..f86b5494 100644 --- a/src/common/SeriesAudioSelection.cpp +++ b/src/common/SeriesAudioSelection.cpp @@ -1,7 +1,7 @@ #include "SeriesAudioSelection.h" #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -87,4 +87,4 @@ int matchingSeriesAudioStreamIndex(const QList& streams, const return -1; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/SeriesAudioSelection.h b/src/common/SeriesAudioSelection.h index c295e944..9fdb9357 100644 --- a/src/common/SeriesAudioSelection.h +++ b/src/common/SeriesAudioSelection.h @@ -2,7 +2,7 @@ #include "../media/MediaTypes.h" -namespace JellyfinNative { +namespace Spool { struct SeriesAudioPreference { QString language; @@ -19,4 +19,4 @@ struct SeriesAudioPreference { SeriesAudioPreference seriesAudioPreferenceForSelection(const QList& streams, int selectedStreamIndex); int matchingSeriesAudioStreamIndex(const QList& streams, const SeriesAudioPreference& preference); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/TlsTrust.cpp b/src/common/TlsTrust.cpp index f4063079..005d5633 100644 --- a/src/common/TlsTrust.cpp +++ b/src/common/TlsTrust.cpp @@ -13,7 +13,7 @@ #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -125,9 +125,9 @@ QVariantList TlsTrustController::rememberedCertificates() const void TlsTrustController::attachNetworkAccessManager(QNetworkAccessManager *manager, QString source) { - if (!manager || manager->property("jellyfinTlsTrustAttached").toBool()) + if (!manager || manager->property("spoolTlsTrustAttached").toBool()) return; - manager->setProperty("jellyfinTlsTrustAttached", true); + manager->setProperty("spoolTlsTrustAttached", true); connect( manager, &QNetworkAccessManager::sslErrors, this, [this, source = std::move(source)]( @@ -466,4 +466,4 @@ void TlsTrustController::reloadRemembered() m_rememberedCertificates = std::move(entries); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/TlsTrust.h b/src/common/TlsTrust.h index afb3f78f..12bed6de 100644 --- a/src/common/TlsTrust.h +++ b/src/common/TlsTrust.h @@ -18,13 +18,13 @@ #include #if !QT_CONFIG(ssl) -#error "Spool for Jellyfin requires a Qt Network build with TLS support" +#error "Spool requires a Qt Network build with TLS support" #endif class QNetworkAccessManager; class QWebSocket; -namespace JellyfinNative { +namespace Spool { // Writes everything the app trusts -- the platform store Qt resolves, plus any // certificate the viewer chose to remember -- to a PEM file and returns its @@ -112,4 +112,4 @@ class TlsTrustController final : public QObject { QQueue m_queue; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/VariantUtils.cpp b/src/common/VariantUtils.cpp index 9ddd0133..a8c9d1fa 100644 --- a/src/common/VariantUtils.cpp +++ b/src/common/VariantUtils.cpp @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { QStringList stringListFromVariant(const QVariant& value) { @@ -36,4 +36,4 @@ QStringList stringListFromVariantMap(const QVariantMap& map, const QString& key) return stringListFromVariant(map.value(key)); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/common/VariantUtils.h b/src/common/VariantUtils.h index a56de714..0605ca16 100644 --- a/src/common/VariantUtils.h +++ b/src/common/VariantUtils.h @@ -5,9 +5,9 @@ #include #include -namespace JellyfinNative { +namespace Spool { QStringList stringListFromVariant(const QVariant& value); QStringList stringListFromVariantMap(const QVariantMap& map, const QString& key); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/Diagnostics.cpp b/src/diagnostics/Diagnostics.cpp index c0499b13..9afc4eb3 100644 --- a/src/diagnostics/Diagnostics.cpp +++ b/src/diagnostics/Diagnostics.cpp @@ -31,23 +31,23 @@ #include #endif -namespace JellyfinNative::Diagnostics { +namespace Spool::Diagnostics { namespace { -#ifdef JELLYFIN_DIAGNOSTICS +#ifdef SPOOL_DIAGNOSTICS constexpr bool kDiagnosticsEnabled = true; #else constexpr bool kDiagnosticsEnabled = false; #endif -#ifdef JELLYFIN_DIAGNOSTICS_STACKDUMP +#ifdef SPOOL_DIAGNOSTICS_STACKDUMP constexpr bool kStackDumpEnabled = true; #else constexpr bool kStackDumpEnabled = false; #endif -#ifdef JELLYFIN_DIAGNOSTICS_ABORT_ON_HANG +#ifdef SPOOL_DIAGNOSTICS_ABORT_ON_HANG constexpr bool kAbortOnHang = true; #else constexpr bool kAbortOnHang = false; @@ -57,7 +57,7 @@ namespace { constexpr qint64 kShutdownWarnMs = 6000; bool diagnosticsEnabled() { - return kDiagnosticsEnabled && qEnvironmentVariableIntValue("JELLYFIN_NATIVE_DIAGNOSTICS") == 1; + return kDiagnosticsEnabled && qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS") == 1; } bool credentialKey(const QString& key) @@ -231,7 +231,7 @@ namespace { continue; const QString cmdline = QString::fromLocal8Bit(readSmallFile(QStringLiteral("/proc/%1/cmdline").arg(pid))) .replace(QLatin1Char('\0'), QLatin1Char(' ')); - if (!cmdline.contains(appId) && !cmdline.contains(QStringLiteral("jellyfin-native"))) + if (!cmdline.contains(appId) && !cmdline.contains(QStringLiteral("spool"))) continue; processes.append(procSnapshotObject(pid)); } @@ -358,7 +358,7 @@ QString supportReportPreview() QString saveSupportReport() { #ifdef Q_OS_ANDROID - QString directory = qEnvironmentVariable("JELLYFIN_NATIVE_LOG_DIR"); + QString directory = qEnvironmentVariable("SPOOL_LOG_DIR"); #else QString directory = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); #endif @@ -368,7 +368,7 @@ QString saveSupportReport() return {}; QFile::setPermissions(directory, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); - const QString path = QDir(directory).filePath(QStringLiteral("Spool-for-Jellyfin-Diagnostics-%1.json") + const QString path = QDir(directory).filePath(QStringLiteral("Spool-Diagnostics-%1.json") .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-hhmmss")))); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -547,4 +547,4 @@ void NetworkRequest::finish(int statusCode, const QString& errorText) { QStringLiteral("error"), errorText }, { QStringLiteral("durationMs"), nowMs() - m_startedMs } }); } -} // namespace JellyfinNative::Diagnostics +} // namespace Spool::Diagnostics diff --git a/src/diagnostics/Diagnostics.h b/src/diagnostics/Diagnostics.h index 7a494cd7..e94c7b59 100644 --- a/src/diagnostics/Diagnostics.h +++ b/src/diagnostics/Diagnostics.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative::Diagnostics { +namespace Spool::Diagnostics { void initialize(const QString& appId, const QString& rootPath); void shutdown(); @@ -62,4 +62,4 @@ class NetworkRequest final { bool m_finished = false; }; -} // namespace JellyfinNative::Diagnostics +} // namespace Spool::Diagnostics diff --git a/src/diagnostics/InputLatencyMonitor.cpp b/src/diagnostics/InputLatencyMonitor.cpp index 7c788ca4..6f54ea86 100644 --- a/src/diagnostics/InputLatencyMonitor.cpp +++ b/src/diagnostics/InputLatencyMonitor.cpp @@ -18,7 +18,7 @@ #include #include -namespace JellyfinNative::Detail { +namespace Spool::Detail { namespace { @@ -576,9 +576,9 @@ quint64 InputLatencyTimeline::epoch() const return m_epoch.load(std::memory_order_acquire); } -} // namespace JellyfinNative::Detail +} // namespace Spool::Detail -namespace JellyfinNative { +namespace Spool { namespace { constexpr auto kEnabledSetting = "diagnostics/inputLatencyGuard"; @@ -619,7 +619,7 @@ InputLatencyMonitor::InputLatencyMonitor(QObject *parent) const bool persistedEnabled = QSettings().value(QLatin1String(kEnabledSetting), false).toBool(); m_overlayEnabled = QSettings().value(QLatin1String(kOverlayEnabledSetting), true).toBool(); - const bool environmentEnabled = qEnvironmentVariable("JELLYFIN_INPUT_LATENCY_DIAGNOSTICS") == QLatin1String("1"); + const bool environmentEnabled = qEnvironmentVariable("SPOOL_INPUT_LATENCY_DIAGNOSTICS") == QLatin1String("1"); m_timeline.setEnabled(persistedEnabled || environmentEnabled); } @@ -1157,4 +1157,4 @@ bool InputLatencyMonitor::canCaptureInput() const return true; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/InputLatencyMonitor.h b/src/diagnostics/InputLatencyMonitor.h index 8a150ad8..5b327997 100644 --- a/src/diagnostics/InputLatencyMonitor.h +++ b/src/diagnostics/InputLatencyMonitor.h @@ -17,7 +17,7 @@ class QEvent; class QQuickWindow; class QScreen; -namespace JellyfinNative { +namespace Spool { namespace Detail { @@ -338,4 +338,4 @@ class InputLatencyMonitor final : public QObject { std::atomic_bool m_hasPresentedFrame { false }; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/RenderBenchmark.cpp b/src/diagnostics/RenderBenchmark.cpp index b61237a0..08665e68 100644 --- a/src/diagnostics/RenderBenchmark.cpp +++ b/src/diagnostics/RenderBenchmark.cpp @@ -10,14 +10,14 @@ #include #include #include +#include #include #include #include -#include -#include -#include #include #include +#include +#include #include #include @@ -26,7 +26,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Routes that stand up without a library behind them, so the same script runs @@ -111,9 +111,8 @@ RenderBenchmark::RenderBenchmark(RenderBenchmarkHooks hooks, RouterController *r return; const QString route = m_script.value(m_position); qWarning() << "render benchmark: route" << route << "never reported a frame"; - m_failures.append(QVariantMap { { QStringLiteral("route"), route }, - { QStringLiteral("pass"), m_pass }, { QStringLiteral("step"), m_position }, - { QStringLiteral("reason"), QStringLiteral("route_timeout") } }); + m_failures.append(QVariantMap { { QStringLiteral("route"), route }, { QStringLiteral("pass"), m_pass }, + { QStringLiteral("step"), m_position }, { QStringLiteral("reason"), QStringLiteral("route_timeout") } }); m_awaitingSample = false; m_pump->stop(); QTimer::singleShot(0, this, [this] { step(); }); @@ -373,8 +372,6 @@ void RenderBenchmark::step() m_router->replace(route); if (m_window) m_window->requestUpdate(); - - } void RenderBenchmark::recordSample() @@ -388,8 +385,8 @@ void RenderBenchmark::recordSample() m_stepDeadline->stop(); m_pump->stop(); if (!metrics.isEmpty() && m_pass >= 0) { - metrics.insert(QStringLiteral("requestToSampleMs"), - static_cast(m_stepTimer.nsecsElapsed()) / 1000000.0); + metrics.insert( + QStringLiteral("requestToSampleMs"), static_cast(m_stepTimer.nsecsElapsed()) / 1000000.0); metrics.insert(QStringLiteral("pass"), m_pass); metrics.insert(QStringLiteral("step"), m_position); m_samples.append(metrics); @@ -430,8 +427,8 @@ void RenderBenchmark::finish() report.insert(QStringLiteral("warmupPasses"), m_warmup); report.insert(QStringLiteral("settleMs"), m_settleMs); report.insert(QStringLiteral("stepTimeoutMs"), m_stepTimeoutMs); - report.insert(QStringLiteral("cacheMode"), m_forceCold ? QStringLiteral("memory-pressure-eviction") - : QStringLiteral("resident-after-warmup")); + report.insert(QStringLiteral("cacheMode"), + m_forceCold ? QStringLiteral("memory-pressure-eviction") : QStringLiteral("resident-after-warmup")); report.insert(QStringLiteral("interpreterRequested"), qEnvironmentVariable("QV4_FORCE_INTERPRETER")); report.insert(QStringLiteral("complete"), m_failures.isEmpty()); report.insert(QStringLiteral("failures"), QJsonArray::fromVariantList(m_failures)); @@ -479,7 +476,8 @@ void RenderBenchmark::finish() qWarning() << "render benchmark: could not write" << m_outputPath; } } - QCoreApplication::exit(!written || !m_failures.isEmpty() || (m_samples.isEmpty() && m_scrollSamples.isEmpty()) ? 1 : 0); + QCoreApplication::exit( + !written || !m_failures.isEmpty() || (m_samples.isEmpty() && m_scrollSamples.isEmpty()) ? 1 : 0); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/RenderBenchmark.h b/src/diagnostics/RenderBenchmark.h index 151e8682..2fcafa24 100644 --- a/src/diagnostics/RenderBenchmark.h +++ b/src/diagnostics/RenderBenchmark.h @@ -14,7 +14,7 @@ class QQuickWindow; class QQuickItem; -namespace JellyfinNative { +namespace Spool { class InputLatencyMonitor; class LibraryListModel; class RouterController; @@ -141,4 +141,4 @@ class RenderBenchmark final : public QObject { QElapsedTimer m_stepTimer; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/SystemPerformanceMonitor.cpp b/src/diagnostics/SystemPerformanceMonitor.cpp index 582f1017..9bfb5538 100644 --- a/src/diagnostics/SystemPerformanceMonitor.cpp +++ b/src/diagnostics/SystemPerformanceMonitor.cpp @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { SystemPerformanceMonitor::SystemPerformanceMonitor(QObject *parent) : QObject(parent) @@ -60,4 +60,4 @@ void SystemPerformanceMonitor::sample() emit metricsChanged(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/diagnostics/SystemPerformanceMonitor.h b/src/diagnostics/SystemPerformanceMonitor.h index 32446370..34b5c80d 100644 --- a/src/diagnostics/SystemPerformanceMonitor.h +++ b/src/diagnostics/SystemPerformanceMonitor.h @@ -7,7 +7,7 @@ #include -namespace JellyfinNative { +namespace Spool { class SystemPerformanceMonitor final : public QObject { Q_OBJECT @@ -137,4 +137,4 @@ class SystemPerformanceMonitor final : public QObject { qint64 m_systemTotalBytes = 0; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/main.cpp b/src/main.cpp index dd3bd915..df0ab942 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,7 +33,7 @@ #include "provider/ProviderStore.h" #include "provider/SourceHub.h" #include "providers/local/LocalProvider.h" -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) #include "platform/UpdateController.h" #endif #if defined(SPOOL_ANDROID) @@ -54,8 +54,10 @@ #include #include #include +#include +#include -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) #include "platform/webos/WebOSDeviceName.h" #endif #include @@ -146,7 +148,7 @@ class BoostedIncubationController final : public QObject, public QQmlIncubationC }; constexpr auto kAppId = "com.sachk.spool"; -constexpr auto kAppVersion = JELLYFIN_VERSION; +constexpr auto kAppVersion = SPOOL_VERSION; FILE *g_logFile = nullptr; QByteArray g_logPath; @@ -178,14 +180,14 @@ void writeStandardError(const QByteArray& line) FILE *openRotatedLogFile(const QByteArray& path) { - JellyfinNative::rotateLogFile(path.constData()); + Spool::rotateLogFile(path.constData()); return fopen(path.constData(), "w"); } FILE *openAppLogFile(const QString& appRootPath) { - const QByteArray fileName = QFile::encodeName(JellyfinNative::appLogFileName()); - for (const QString& directory : JellyfinNative::appLogDirectories(appRootPath)) { + const QByteArray fileName = QFile::encodeName(Spool::appLogFileName()); + for (const QString& directory : Spool::appLogDirectories(appRootPath)) { if (directory.isEmpty()) continue; QDir().mkpath(directory); @@ -195,13 +197,51 @@ FILE *openAppLogFile(const QString& appRootPath) if (FILE *file = openRotatedLogFile(path)) { QFile::setPermissions(QString::fromLocal8Bit(path), QFileDevice::ReadOwner | QFileDevice::WriteOwner); g_logPath = path; - qputenv("JELLYFIN_NATIVE_LOG_DIR", encodedDirectory); + qputenv("SPOOL_LOG_DIR", encodedDirectory); return file; } } return nullptr; } +// Through 0.8 the app was "Spool for Jellyfin" from "spool-jellyfin". Its data, +// cache and settings follow it to the new name once, so the rename signs +// nobody out. Android keys storage to the package and has nothing to move. +// Graphics startup reads the settings store, so this runs before it. +void setIdentity() +{ +#ifdef Q_OS_ANDROID + QCoreApplication::setOrganizationName(QStringLiteral("spool")); + QCoreApplication::setApplicationName(QStringLiteral("Spool")); +#else + const QStandardPaths::StandardLocation locations[] = { QStandardPaths::AppDataLocation, + QStandardPaths::AppLocalDataLocation, QStandardPaths::CacheLocation, QStandardPaths::AppConfigLocation }; + QCoreApplication::setOrganizationName(QStringLiteral("spool-jellyfin")); + QCoreApplication::setApplicationName(QStringLiteral("Spool for Jellyfin")); + QStringList previous; + for (const auto location : locations) + previous.append(QStandardPaths::writableLocation(location)); + const QSettings previousSettings; + const QStringList keys = previousSettings.allKeys(); + QCoreApplication::setOrganizationName(QStringLiteral("spool")); + QCoreApplication::setApplicationName(QStringLiteral("Spool")); + for (size_t index = 0; index < std::size(locations); ++index) { + const QString current = QStandardPaths::writableLocation(locations[index]); + const QString old = previous.at(qsizetype(index)); + if (old != current && QFileInfo::exists(old) && !QFileInfo::exists(current)) { + QDir().mkpath(QFileInfo(current).absolutePath()); + if (QDir().rename(old, current)) + QDir().rmdir(QFileInfo(old).absolutePath()); + } + } + QSettings settings; + if (settings.allKeys().isEmpty()) { + for (const QString& key : keys) + settings.setValue(key, previousSettings.value(key)); + } +#endif +} + void configurePersistentStartupCaches(const QString& cacheRoot) { const QString qtShaderCache = QDir(cacheRoot).filePath(QStringLiteral("qtshadercache")); @@ -254,7 +294,7 @@ void logLine(const char *fmt, ...) void qtMessageHandler(QtMsgType type, const QMessageLogContext& context, const QString& message) { - if (type == QtWarningMsg && !qEnvironmentVariableIsSet("JELLYFIN_NATIVE_VERBOSE_QT")) { + if (type == QtWarningMsg && !qEnvironmentVariableIsSet("SPOOL_VERBOSE_QT")) { const QString category = context.category ? QString::fromLatin1(context.category) : QString(); if (message.startsWith(QStringLiteral("Detected locale \"C\"")) || (category == QStringLiteral("qt.qpa.wayland") @@ -281,7 +321,7 @@ void qtMessageHandler(QtMsgType type, const QMessageLogContext& context, const Q break; } - const QByteArray local = JellyfinNative::sanitizedLogMessage(message).toLocal8Bit(); + const QByteArray local = Spool::sanitizedLogMessage(message).toLocal8Bit(); if (context.category && context.category[0]) logLine("[qt:%s] %s: %s", level, context.category, local.constData()); else @@ -297,7 +337,7 @@ void logQmlWarnings(const QList& warnings) logLine("[qml] %s", qPrintable(warning.toString())); } -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) QIcon applicationIcon(bool playerSelected) { const QString variant = playerSelected ? QStringLiteral("spool-film") : QStringLiteral("spool"); @@ -314,7 +354,7 @@ bool registerBundledFonts(const QString& appRootPath) "PTRootUI-Variable.ttf", "MaterialIcons-Regular.ttf", }; - const QDir fontsDirectory(JellyfinNative::bundledFontsPath(appRootPath)); + const QDir fontsDirectory(Spool::bundledFontsPath(appRootPath)); for (const char *fileName : fontFiles) { const QString path = fontsDirectory.filePath(QString::fromLatin1(fileName)); if (!QFileInfo::exists(path)) { @@ -341,7 +381,7 @@ double splashCoreWidthDp() // this has to make the same choice the resource system just made or Qt's // first frame lands on different pixels than the frame it replaces. return static_cast( - JellyfinNative::platformCapabilities().isTV ? SPOOL_SPLASH_CORE_WIDTH_DP_TV : SPOOL_SPLASH_CORE_WIDTH_DP_PHONE); + Spool::platformCapabilities().isTV ? SPOOL_SPLASH_CORE_WIDTH_DP_TV : SPOOL_SPLASH_CORE_WIDTH_DP_PHONE); #else return 0.0; #endif @@ -351,7 +391,7 @@ double splashCoreWidthDp() // everywhere else it is in the binary. QUrl splashImageUrl(const QString& appRootPath) { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) return QUrl::fromLocalFile(QDir(appRootPath).filePath(QStringLiteral("splash-core.png"))); #else Q_UNUSED(appRootPath); @@ -413,20 +453,20 @@ int main(int argc, char **argv) #ifdef Q_OS_UNIX umask(S_IRWXG | S_IRWXO); #endif - const JellyfinNative::ProcessStartupTiming processStartupTiming = JellyfinNative::captureProcessStartupTiming(); + const Spool::ProcessStartupTiming processStartupTiming = Spool::captureProcessStartupTiming(); g_startupTimer.start(); QElapsedTimer& startupTimer = g_startupTimer; bool launchTest = false; for (int i = 1; i < argc; ++i) { if (strcmp(argv[i], "--version") == 0 || strcmp(argv[i], "-v") == 0) { - printf("Spool for Jellyfin %s\n", kAppVersion); + printf("Spool %s\n", kAppVersion); return 0; } if (strcmp(argv[i], "--launch-test") == 0) launchTest = true; } - const QString appRootPath = JellyfinNative::resolveAppRoot(argv[0]); + const QString appRootPath = Spool::resolveAppRoot(argv[0]); if (appRootPath.isEmpty()) return 1; @@ -452,12 +492,12 @@ int main(int argc, char **argv) qputenv("LC_CTYPE", QByteArrayLiteral("en_US.UTF-8")); } - if (!JellyfinNative::configurePlatformEnvironment(appRootPath)) + if (!Spool::configurePlatformEnvironment(appRootPath)) return 1; if (!qgetenv("QSG_RENDER_LOOP").isEmpty()) logLine("QSG_RENDER_LOOP=%s", qgetenv("QSG_RENDER_LOOP").constData()); - const QString cachePath = JellyfinNative::startupCacheRoot(appRootPath); + const QString cachePath = Spool::startupCacheRoot(appRootPath); configurePersistentStartupCaches(cachePath); logLine("app root: %s", qPrintable(appRootPath)); @@ -472,18 +512,17 @@ int main(int argc, char **argv) // Graphics startup reads the persisted backend before QGuiApplication. // Establish the settings-store identity first; these setters are static. - QCoreApplication::setOrganizationName(QStringLiteral("spool-jellyfin")); - QCoreApplication::setApplicationName(QStringLiteral("Spool for Jellyfin")); + setIdentity(); QCoreApplication::setApplicationVersion(QString::fromLatin1(kAppVersion)); - const auto graphicsApi = JellyfinNative::GraphicsStartup::configureBeforeApplication(launchTest); + const auto graphicsApi = Spool::GraphicsStartup::configureBeforeApplication(launchTest); logLine("startup: constructing QGuiApplication"); QGuiApplication app(argc, argv); // The name, version and organisation are set above, before the settings // store is read. - app.setApplicationDisplayName(QStringLiteral("Spool for Jellyfin")); - JellyfinNative::TerminationSignalHandler terminationSignals(app); + app.setApplicationDisplayName(QStringLiteral("Spool")); + Spool::TerminationSignalHandler terminationSignals(app); logLine("startup: QGuiApplication constructed"); QString autoplayItemId; @@ -499,13 +538,13 @@ int main(int argc, char **argv) autoplayItemId = QString::fromLocal8Bit(qgetenv("SPOOL_PLAY_ITEM")); } - const QByteArray hdrRequest = JellyfinNative::GraphicsStartup::prepareBeforeWindow(graphicsApi, launchTest); + const QByteArray hdrRequest = Spool::GraphicsStartup::prepareBeforeWindow(graphicsApi, launchTest); // Native-window and scene-graph setup stay on the GUI thread. Put a surface // on screen now so later startup work can overlap its first frame. - JellyfinNative::InputLatencyMonitor inputLatencyMonitor; - JellyfinNative::NativeAppWindow window(QString::fromLatin1(kAppId)); - JellyfinNative::GraphicsStartup::attachToWindow(window, hdrRequest); + Spool::InputLatencyMonitor inputLatencyMonitor; + Spool::NativeAppWindow window(QString::fromLatin1(kAppId)); + Spool::GraphicsStartup::attachToWindow(window, hdrRequest); window.rootContext()->setContextProperty(QStringLiteral("startupNativeWindow"), &window); // The launch screen. Everything that draws it -- the frame put up before // the shell exists, the shell's own overlay, the slow-start page -- reads @@ -518,7 +557,7 @@ int main(int argc, char **argv) QStringLiteral("startupSplashCoreWidthFraction"), static_cast(SPOOL_SPLASH_CORE_WIDTH_FRACTION)); window.rootContext()->setContextProperty(QStringLiteral("startupSplashCoreWidthDp"), splashCoreWidthDp()); window.rootContext()->setContextProperty(QStringLiteral("startupSplashPixelsPerDp"), splashPixelsPerDp()); - JellyfinNative::configurePlatformWindow(window); + Spool::configurePlatformWindow(window); inputLatencyMonitor.attachWindow(&window); window.setInputLatencyMonitor(&inputLatencyMonitor); const auto directSingleShot = static_cast(Qt::DirectConnection | Qt::SingleShotConnection); @@ -556,19 +595,19 @@ int main(int argc, char **argv) if (!registerBundledFonts(appRootPath)) return 1; -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) const QIcon defaultApplicationIcon = applicationIcon(false); const QIcon playerApplicationIcon = applicationIcon(true); app.setWindowIcon(defaultApplicationIcon); app.setDesktopFileName(QStringLiteral("com.sachk.spool")); #endif - const auto& capabilities = JellyfinNative::platformCapabilities(); + const auto& capabilities = Spool::platformCapabilities(); - const QString diagnosticsRoot = qEnvironmentVariableIsSet("JELLYFIN_DIAGNOSTICS_DIR") - ? QString::fromLocal8Bit(qgetenv("JELLYFIN_DIAGNOSTICS_DIR")) + const QString diagnosticsRoot = qEnvironmentVariableIsSet("SPOOL_DIAGNOSTICS_DIR") + ? QString::fromLocal8Bit(qgetenv("SPOOL_DIAGNOSTICS_DIR")) : QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/diagnostics"); - JellyfinNative::Diagnostics::initialize(QString::fromLatin1(kAppId), diagnosticsRoot); - JellyfinNative::Diagnostics::EventLoopWatchdog eventLoopWatchdog(&app); + Spool::Diagnostics::initialize(QString::fromLatin1(kAppId), diagnosticsRoot); + Spool::Diagnostics::EventLoopWatchdog eventLoopWatchdog(&app); const QStringList arguments = app.arguments(); for (int i = 1; i < arguments.size(); ++i) { @@ -591,21 +630,21 @@ int main(int argc, char **argv) } if (arguments.contains(QStringLiteral("--diagnose-and-exit")) || arguments.contains(QStringLiteral("--dump-diagnostics"))) { - JellyfinNative::Diagnostics::dumpDiagnostics(QStringLiteral("command-line")); - JellyfinNative::Diagnostics::shutdown(); + Spool::Diagnostics::dumpDiagnostics(QStringLiteral("command-line")); + Spool::Diagnostics::shutdown(); return 0; } - if (qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS") > 0) { - const int blockMs = qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_BLOCK_GUI_MS"); + if (qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_BLOCK_GUI_MS") > 0) { + const int blockMs = qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_BLOCK_GUI_MS"); QTimer::singleShot(1000, &app, [blockMs]() { - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_begin"), + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_begin"), { { QStringLiteral("durationMs"), blockMs } }); QThread::msleep(static_cast(blockMs)); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_end")); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("block_gui_end")); }); } - const JellyfinNative::MemoryBudget memoryBudget = JellyfinNative::MemoryBudget::detect(); + const Spool::MemoryBudget memoryBudget = Spool::MemoryBudget::detect(); window.setSystemMemoryBytes(memoryBudget.memTotalBytes); logLine("memory budget: memTotal=%lld networkDisk=%lld qmlImageDisk=%lld artworkBytes=%d demuxer=%s/%s", static_cast(memoryBudget.memTotalBytes), static_cast(memoryBudget.networkDiskCacheBytes), @@ -620,47 +659,45 @@ int main(int argc, char **argv) diskCache->setMaximumCacheSize(memoryBudget.networkDiskCacheBytes); networkAccessManager->setCache(diskCache); QObject *platformUpdateController = nullptr; -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) // QML binds before the shell exists. Android waits for its settings; // the webOS experiment starts its unconditional check in the event loop. - auto updateController = std::make_unique(networkAccessManager, cachePath); + auto updateController = std::make_unique(networkAccessManager, cachePath); platformUpdateController = updateController.get(); #endif - JellyfinNative::DatabaseManager database; - QObject::connect( - &database, &JellyfinNative::DatabaseManager::initializationFailed, &app, [&app](const QString& message) { - logLine("database initialization failed: %s", qPrintable(message)); - app.exit(1); - }); - JellyfinNative::SystemPerformanceMonitor systemPerformanceMonitor; - systemPerformanceMonitor.setAudioDecodeCpuTimeProvider( - [] { return JellyfinNative::platformAudioDecodeCpuTimeNs(); }); + Spool::DatabaseManager database; + QObject::connect(&database, &Spool::DatabaseManager::initializationFailed, &app, [&app](const QString& message) { + logLine("database initialization failed: %s", qPrintable(message)); + app.exit(1); + }); + Spool::SystemPerformanceMonitor systemPerformanceMonitor; + systemPerformanceMonitor.setAudioDecodeCpuTimeProvider([] { return Spool::platformAudioDecodeCpuTimeNs(); }); // Start the SQLite worker before constructing the controllers. Device // identity and session reads are awaited after the first frame. { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("database_initialize")); - const QString databasePath = JellyfinNative::persistentDataRoot() + QStringLiteral("/cache.sqlite"); + Spool::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("database_initialize")); + const QString databasePath = Spool::persistentDataRoot() + QStringLiteral("/cache.sqlite"); if (!database.initialize(databasePath)) return 1; } - JellyfinNative::TlsTrustController tlsTrust; + Spool::TlsTrustController tlsTrust; // Accounts outlive the player and the app controller, which hold their // parts by pointer, so the registry and the hub come first. // Chosen by SPOOL_PROVIDER_SOURCES at configure time, which rejects // anything else; an unknown name would fall to the narrowest. - const JellyfinNative::ProviderSources providerSources - = JellyfinNative::providerSourcesFromName(QString::fromLatin1(SPOOL_PROVIDER_SOURCES)) - .value_or(JellyfinNative::ProviderSources::Bundled); + const Spool::ProviderSources providerSources + = Spool::providerSourcesFromName(QString::fromLatin1(SPOOL_PROVIDER_SOURCES)) + .value_or(Spool::ProviderSources::Bundled); logLine("providers: sources=%s", SPOOL_PROVIDER_SOURCES); - JellyfinNative::ProviderRegistry providers(&database); - if (providerSources != JellyfinNative::ProviderSources::Bundled) - providers.setInstallDirectory(QDir(JellyfinNative::persistentDataRoot()).filePath(QStringLiteral("providers"))); + Spool::ProviderRegistry providers(&database); + if (providerSources != Spool::ProviderSources::Bundled) + providers.setInstallDirectory(QDir(Spool::persistentDataRoot()).filePath(QStringLiteral("providers"))); providers.loadModules(); -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) { - JellyfinNative::ProviderManifest folder; + Spool::ProviderManifest folder; folder.id = QStringLiteral("spool.local"); folder.name = QStringLiteral("This computer"); folder.summary = QStringLiteral("Videos and music in a folder"); @@ -670,81 +707,77 @@ int main(int argc, char **argv) const QString root = optionValue(arguments, QStringLiteral("--library-root"), "SPOOL_LOCAL_LIBRARY", QStandardPaths::writableLocation(QStandardPaths::MoviesLocation)); providers.addNativeModule(folder, - [root](const QString& accountId, const QVariantMap& configuration, - QObject *parent) -> JellyfinNative::Provider * { - return new JellyfinNative::LocalProvider( + [root](const QString& accountId, const QVariantMap& configuration, QObject *parent) -> Spool::Provider * { + return new Spool::LocalProvider( accountId, configuration.value(QStringLiteral("folder"), root).toString(), parent); }); } #endif - JellyfinNative::SourceHub hub(&providers); - JellyfinNative::ProviderCapabilities providerCapabilities; - QObject::connect(&hub, &JellyfinNative::Provider::capabilitiesChanged, &providerCapabilities, + Spool::SourceHub hub(&providers); + Spool::ProviderCapabilities providerCapabilities; + QObject::connect(&hub, &Spool::Provider::capabilitiesChanged, &providerCapabilities, [&hub, &providerCapabilities] { providerCapabilities.setFlags(hub.capabilities()); }); - JellyfinNative::configurePlatformPlaybackCapabilities( + Spool::configurePlatformPlaybackCapabilities( [&hub](const QStringList& codecs, bool restrict) { hub.setVideoCodecs(codecs, restrict); }, hub); // Only an open build may be pointed at another store; a curated one is // curated by this one. const QString officialStore = QStringLiteral("https://spool-player.github.io/spool-providers/"); - JellyfinNative::ProviderStore store(&providers, &database, networkAccessManager, - QUrl(providerSources == JellyfinNative::ProviderSources::Open + Spool::ProviderStore store(&providers, &database, networkAccessManager, + QUrl(providerSources == Spool::ProviderSources::Open ? optionValue(arguments, QStringLiteral("--provider-store"), "SPOOL_PROVIDER_STORE", officialStore) : officialStore), providerSources); - const JellyfinNative::CpuTopology cpuTopology = JellyfinNative::detectCpuTopology(); + const Spool::CpuTopology cpuTopology = Spool::detectCpuTopology(); logLine("artwork: cpu logical=%d physical=%d smt=%s source=%s decodeThreads=%d", cpuTopology.logicalCpus, cpuTopology.physicalCores, cpuTopology.smtDetected ? "true" : "false", qPrintable(cpuTopology.source), cpuTopology.artworkDecodeThreads); - auto artworkService = std::make_unique( - qmlImageCachePath + QStringLiteral("/artwork"), memoryBudget.qmlImageDiskCacheBytes, - memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, &tlsTrust); + auto artworkService = std::make_unique(qmlImageCachePath + QStringLiteral("/artwork"), + memoryBudget.qmlImageDiskCacheBytes, memoryBudget.artworkByteCacheBytes, cpuTopology.artworkDecodeThreads, + &tlsTrust); artworkService->setUiWidth(window.width()); artworkService->setSource(hub.artwork()); - auto player = std::make_unique( - &window, hub.playback(), &tlsTrust, JellyfinNative::bundledFontsPath(appRootPath)); + auto player = std::make_unique( + &window, hub.playback(), &tlsTrust, Spool::bundledFontsPath(appRootPath)); player->setDemuxerBudget(memoryBudget.mpvDemuxerMaxBytes, memoryBudget.mpvDemuxerMaxBackBytes); - JellyfinNative::ScreenSaverInhibitor screenSaverInhibitor; + Spool::ScreenSaverInhibitor screenSaverInhibitor; const auto updateScreenSaver = [&screenSaverInhibitor, player = player.get()] { - screenSaverInhibitor.setInhibited(JellyfinNative::screenSaverShouldBeInhibited( - player && player->sessionActive(), player && player->paused())); + screenSaverInhibitor.setInhibited( + Spool::screenSaverShouldBeInhibited(player && player->sessionActive(), player && player->paused())); }; - QObject::connect(player.get(), &JellyfinNative::PlayerController::playbackStateChanged, &app, updateScreenSaver); - QObject::connect(player.get(), &JellyfinNative::PlayerController::sessionActiveChanged, &app, updateScreenSaver); -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) + QObject::connect(player.get(), &Spool::PlayerController::playbackStateChanged, &app, updateScreenSaver); + QObject::connect(player.get(), &Spool::PlayerController::sessionActiveChanged, &app, updateScreenSaver); +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) const auto updateApplicationIcon = [&app, &window, player = player.get(), &defaultApplicationIcon, &playerApplicationIcon] { const QIcon& icon = player->sessionActive() ? playerApplicationIcon : defaultApplicationIcon; app.setWindowIcon(icon); window.setIcon(icon); }; - QObject::connect( - player.get(), &JellyfinNative::PlayerController::sessionActiveChanged, &app, updateApplicationIcon); + QObject::connect(player.get(), &Spool::PlayerController::sessionActiveChanged, &app, updateApplicationIcon); updateApplicationIcon(); #endif - auto controller - = std::make_unique(&database, &hub, artworkService.get(), player.get()); - QObject::connect(controller->settings(), &JellyfinNative::SettingsController::playbackPreferencesChanged, &hub, - &JellyfinNative::SourceHub::setPlaybackPreferences); - QObject::connect(&providers, &JellyfinNative::ProviderRegistry::problem, controller.get(), - &JellyfinNative::AppController::toastMessage); - QObject::connect(&store, &JellyfinNative::ProviderStore::problem, controller.get(), - &JellyfinNative::AppController::toastMessage); -#if defined(SPOOL_ANDROID) || defined(JELLYFIN_NATIVE_WEBOS) + auto controller = std::make_unique(&database, &hub, artworkService.get(), player.get()); + QObject::connect(controller->settings(), &Spool::SettingsController::playbackPreferencesChanged, &hub, + &Spool::SourceHub::setPlaybackPreferences); + QObject::connect( + &providers, &Spool::ProviderRegistry::problem, controller.get(), &Spool::AppController::toastMessage); + QObject::connect(&store, &Spool::ProviderStore::problem, controller.get(), &Spool::AppController::toastMessage); +#if defined(SPOOL_ANDROID) || defined(SPOOL_WEBOS) // Settings own the update preference; platform installers own installation. - QObject::connect(controller->settings(), &JellyfinNative::SettingsController::automaticUpdatesChanged, + QObject::connect(controller->settings(), &Spool::SettingsController::automaticUpdatesChanged, updateController.get(), [updater = updateController.get()](bool enabled) { updater->setAutomaticUpdatesEnabled(enabled); }); #endif - QObject::connect(controller.get(), &JellyfinNative::AppController::clearLogsRequested, &app, [appRootPath]() { + QObject::connect(controller.get(), &Spool::AppController::clearLogsRequested, &app, [appRootPath]() { const std::lock_guard lock(g_logMutex); if (g_logFile) { fclose(g_logFile); g_logFile = nullptr; } - const QByteArray fileName = QFile::encodeName(JellyfinNative::appLogFileName()); - for (const QString& directory : JellyfinNative::appLogDirectories(appRootPath)) { + const QByteArray fileName = QFile::encodeName(Spool::appLogFileName()); + for (const QString& directory : Spool::appLogDirectories(appRootPath)) { if (directory.isEmpty()) continue; const QString path = QDir(directory).filePath(QString::fromUtf8(fileName)); @@ -758,7 +791,7 @@ int main(int argc, char **argv) // Tear down here so the mpv render-context handoff completes immediately; // aboutToQuit is too late because the window no longer produces frames. QObject::connect( - &window, &JellyfinNative::NativeAppWindow::closeRequested, controller.get(), + &window, &Spool::NativeAppWindow::closeRequested, controller.get(), [controller = controller.get()]() { logLine("window close requested: stopping controllers"); controller->shutdown(); @@ -775,23 +808,23 @@ int main(int argc, char **argv) // against null pointers and emit a flood of "Cannot read property // 'X' of null" warnings during the unwind. QObject::connect(&app, &QCoreApplication::aboutToQuit, &app, [controller = controller.get(), &window]() { - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("shutting_down")); - JellyfinNative::Diagnostics::Phase shutdownPhase(QStringLiteral("shutdown"), QStringLiteral("aboutToQuit")); + Spool::Diagnostics::setInstanceState(QStringLiteral("shutting_down")); + Spool::Diagnostics::Phase shutdownPhase(QStringLiteral("shutdown"), QStringLiteral("aboutToQuit")); logLine("aboutToQuit: stopping controllers"); - if (qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS") > 0) { - const int hangMs = qEnvironmentVariableIntValue("JELLYFIN_DIAGNOSTICS_SHUTDOWN_HANG_MS"); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_begin"), + if (qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS") > 0) { + const int hangMs = qEnvironmentVariableIntValue("SPOOL_DIAGNOSTICS_SHUTDOWN_HANG_MS"); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_begin"), { { QStringLiteral("durationMs"), hangMs } }); QThread::msleep(static_cast(hangMs)); - JellyfinNative::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_end")); + Spool::Diagnostics::logEvent(QStringLiteral("simulation"), QStringLiteral("shutdown_hang_end")); } { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("controller_shutdown")); + Spool::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("controller_shutdown")); controller->shutdown(); } logLine("aboutToQuit: clearing QML source"); { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("clear_qml_source")); + Spool::Diagnostics::Phase phase(QStringLiteral("shutdown"), QStringLiteral("clear_qml_source")); window.setSource(QUrl()); } logLine("aboutToQuit: QML source cleared"); @@ -801,40 +834,38 @@ int main(int argc, char **argv) window.engine()->setIncubationController(new BoostedIncubationController(window.engine())); // Provider screens (sign-in, settings, pickers) are compiled one at a time // after the first frame, so opening one never waits on the QML compiler. - auto *providerQmlCache = new JellyfinNative::ProviderQmlCache(window.engine()); + auto *providerQmlCache = new Spool::ProviderQmlCache(window.engine()); const auto warmProviderComponents = [providerQmlCache, &providers] { QList sources; for (const QString& id : providers.moduleIds()) { - const JellyfinNative::ProviderModule *module = providers.module(id); + const Spool::ProviderModule *module = providers.module(id); for (const QString& file : module->manifest.ui) sources.append(module->file(file)); } providerQmlCache->addSources(sources); }; warmProviderComponents(); - QObject::connect( - &providers, &JellyfinNative::ProviderRegistry::modulesChanged, providerQmlCache, warmProviderComponents); - QObject::connect(providerQmlCache, &JellyfinNative::ProviderQmlCache::finished, &app, + QObject::connect(&providers, &Spool::ProviderRegistry::modulesChanged, providerQmlCache, warmProviderComponents); + QObject::connect(providerQmlCache, &Spool::ProviderQmlCache::finished, &app, [providerQmlCache] { logLine("provider QML: warmed %d components", providerQmlCache->retainedCount()); }); QObject::connect( &window, &QQuickWindow::frameSwapped, providerQmlCache, [providerQmlCache] { providerQmlCache->start(); }, Qt::SingleShotConnection); - QObject::connect(controller.get(), &JellyfinNative::AppController::aggressiveMemoryPressure, providerQmlCache, - &JellyfinNative::ProviderQmlCache::clear); - window.engine()->addImageProvider( - QStringLiteral("artwork"), new JellyfinNative::ArtworkImageProvider(artworkService.get())); + QObject::connect(controller.get(), &Spool::AppController::aggressiveMemoryPressure, providerQmlCache, + &Spool::ProviderQmlCache::clear); + window.engine()->addImageProvider(QStringLiteral("artwork"), new Spool::ArtworkImageProvider(artworkService.get())); window.engine()->addImageProvider(QStringLiteral("mpv-overlay"), window.createOverlayImageProvider()); -#if !defined(JELLYFIN_NATIVE_WEBOS) && !defined(SPOOL_ANDROID) +#if !defined(SPOOL_WEBOS) && !defined(SPOOL_ANDROID) window.engine()->addImportPath(appRootPath + QStringLiteral("/qt-qml")); #endif QObject::connect(window.engine(), &QQmlEngine::warnings, &logQmlWarnings); QObject::connect(&window, &QQuickView::statusChanged, [](QQuickView::Status status) { logLine("view status changed: %d", static_cast(status)); }); - auto localization = std::make_unique(); + auto localization = std::make_unique(); localization->attachToEngine(window.engine()); // Providers start once the device identity they present is known: in // parallel, off the GUI thread, after the first frame is on its way. - QObject::connect(controller.get(), &JellyfinNative::AppController::deviceIdentityReady, &providers, + QObject::connect(controller.get(), &Spool::AppController::deviceIdentityReady, &providers, [&providers, &store, &capabilities, &tlsTrust, loc = localization.get(), settings = controller->settings()]( const QString& deviceId) { providers.setRuntimeEnvironment( @@ -849,7 +880,7 @@ int main(int argc, char **argv) [&tlsTrust](QWebSocket *socket, QUrl url) { tlsTrust.attachWebSocket(socket, [url] { return url; }, QStringLiteral("Provider")); } }); - JellyfinNative::Async::runScoped( + Spool::Async::runScoped( &providers, providers.restore(), [] { }, [](const std::exception_ptr&) { }, "provider restore"); // Checked once the launch has settled, so it never competes with // the first page for the network. @@ -857,8 +888,8 @@ int main(int argc, char **argv) store.checkForUpdates(settings->value(QStringLiteral("providers/updates")).toString()); }); }); - auto router = std::make_unique(QStringLiteral("home")); - JellyfinNative::ApplicationHooks applicationHooks; + auto router = std::make_unique(QStringLiteral("home")); + Spool::ApplicationHooks applicationHooks; applicationHooks.player = player.get(); applicationHooks.settings = controller->settings(); applicationHooks.playQueue = controller->playQueue(); @@ -870,15 +901,15 @@ int main(int argc, char **argv) applicationHooks.stopPlayback = [controller = controller.get()] { controller->stopPlayback(); }; applicationHooks.playNext = [controller = controller.get()] { controller->playQueueNext(); }; applicationHooks.playPrevious = [controller = controller.get()] { controller->playQueuePrevious(); }; - QObject::connect(controller.get(), &JellyfinNative::AppController::playbackTransitionChanged, &applicationHooks, - &JellyfinNative::ApplicationHooks::playbackTransitionChanged); - QObject::connect(controller.get(), &JellyfinNative::AppController::aggressiveMemoryPressure, &applicationHooks, - &JellyfinNative::ApplicationHooks::aggressiveMemoryPressure); - QObject::connect(controller.get(), &JellyfinNative::AppController::diagnosticsReportSaved, &applicationHooks, - &JellyfinNative::ApplicationHooks::diagnosticsReportSaved); - QObject::connect(&applicationHooks, &JellyfinNative::ApplicationHooks::toastRequested, controller.get(), - &JellyfinNative::AppController::toastMessage); - JellyfinNative::PlatformApplicationServices platformServices(app, window, applicationHooks, *router); + QObject::connect(controller.get(), &Spool::AppController::playbackTransitionChanged, &applicationHooks, + &Spool::ApplicationHooks::playbackTransitionChanged); + QObject::connect(controller.get(), &Spool::AppController::aggressiveMemoryPressure, &applicationHooks, + &Spool::ApplicationHooks::aggressiveMemoryPressure); + QObject::connect(controller.get(), &Spool::AppController::diagnosticsReportSaved, &applicationHooks, + &Spool::ApplicationHooks::diagnosticsReportSaved); + QObject::connect(&applicationHooks, &Spool::ApplicationHooks::toastRequested, controller.get(), + &Spool::AppController::toastMessage); + Spool::PlatformApplicationServices platformServices(app, window, applicationHooks, *router); platformServices.start(); QQmlPropertyMap *platformInfo = QQmlPropertyMap::create(&app); platformInfo->insert(QStringLiteral("isTV"), capabilities.isTV); @@ -931,7 +962,7 @@ int main(int argc, char **argv) qmlRegisterSingletonInstance("Spool", 1, 0, "SystemPerformance", &systemPerformanceMonitor); qmlRegisterSingletonInstance("Spool", 1, 0, "I18n", localization.get()); qmlRegisterSingletonInstance("Spool", 1, 0, "Platform", platformInfo); - qmlRegisterType("Spool", 1, 0, "MpvVideoItem"); + qmlRegisterType("Spool", 1, 0, "MpvVideoItem"); // Start asynchronous device, settings, account, and discovery reads before // QML construction. A sole saved account is resolved before routing begins. controller->initialize(); @@ -940,7 +971,7 @@ int main(int argc, char **argv) &app, &QCoreApplication::aboutToQuit, router.get(), [router = router.get()] { router->markCleanShutdown(); }); { - JellyfinNative::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("load_qml")); + Spool::Diagnostics::Phase phase(QStringLiteral("startup"), QStringLiteral("load_qml")); // Load through the module registry so AOT QML units are used and // constrained devices do not retain compiler buffers. window.loadFromModule("Spool", "Main"); @@ -994,7 +1025,7 @@ int main(int argc, char **argv) // as it always does and is then walked through a set of route switches // with what each one cost written out, so page-switch cost is a number in // CI rather than an impression. - JellyfinNative::RenderBenchmarkHooks benchmarkHooks; + Spool::RenderBenchmarkHooks benchmarkHooks; benchmarkHooks.providerId = QStringList(providers.moduleIds()).join(QLatin1Char(',')); benchmarkHooks.providerRuntime = QStringLiteral("js"); benchmarkHooks.libraries = controller->libraries(); @@ -1009,7 +1040,7 @@ int main(int argc, char **argv) }; benchmarkHooks.forceColdCaches = [controller = controller.get()] { controller->onMemoryPressure(QStringLiteral("critical")); }; - if (auto *benchmark = JellyfinNative::RenderBenchmark::createIfRequested( + if (auto *benchmark = Spool::RenderBenchmark::createIfRequested( std::move(benchmarkHooks), router.get(), &inputLatencyMonitor, &window, &app)) { // The shell picks its first route once accounts are restored; a walk // that starts before then races that choice. @@ -1020,9 +1051,8 @@ int main(int argc, char **argv) *started = true; benchmark->start(); }; - QObject::connect(controller.get(), &JellyfinNative::AppController::initializedChanged, benchmark, begin); - QObject::connect( - &providers, &JellyfinNative::ProviderRegistry::restoredChanged, benchmark, begin, Qt::QueuedConnection); + QObject::connect(controller.get(), &Spool::AppController::initializedChanged, benchmark, begin); + QObject::connect(&providers, &Spool::ProviderRegistry::restoredChanged, benchmark, begin, Qt::QueuedConnection); begin(); } @@ -1063,12 +1093,12 @@ int main(int argc, char **argv) logLine("launch test: invalid shell geometry content=%.1fx%.1f nav=%.1f@%.1f " "route=%.1fx%.1f@%.1f", contentWidth, contentHeight, navHeight, navY, routeWidth, routeHeight, routeY); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("launch_test_invalid_shell")); + Spool::Diagnostics::setInstanceState(QStringLiteral("launch_test_invalid_shell")); app.exit(1); return; } logLine("launch test: application UI rendered"); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("launch_test_rendered")); + Spool::Diagnostics::setInstanceState(QStringLiteral("launch_test_rendered")); app.exit(0); }, Qt::QueuedConnection); @@ -1081,7 +1111,7 @@ int main(int argc, char **argv) window.requestUpdate(); } if (!autoplayItemId.isEmpty()) { - QObject::connect(controller.get(), &JellyfinNative::AppController::initializedChanged, controller.get(), + QObject::connect(controller.get(), &Spool::AppController::initializedChanged, controller.get(), [autoplayItemId, c = controller.get()]() { if (c->initialized()) { logLine("startup: autoplay requested for item %s", qPrintable(autoplayItemId)); @@ -1094,14 +1124,14 @@ int main(int argc, char **argv) QTimer::singleShot(0, &window, [&startupTimer]() { logLine("startup: first event-loop turn at %lld ms", static_cast(startupTimer.elapsed())); - JellyfinNative::Diagnostics::setInstanceState(QStringLiteral("running")); + Spool::Diagnostics::setInstanceState(QStringLiteral("running")); }); logLine("startup: entering event loop at %lld ms", static_cast(startupTimer.elapsed())); const int exitCode = app.exec(); logLine("app.exec returned: %d", exitCode); - JellyfinNative::Diagnostics::setInstanceState( + Spool::Diagnostics::setInstanceState( QStringLiteral("app_exec_returned"), { { QStringLiteral("exitCode"), exitCode } }); - JellyfinNative::Diagnostics::shutdown(); + Spool::Diagnostics::shutdown(); return exitCode; } diff --git a/src/media/MediaTypes.cpp b/src/media/MediaTypes.cpp index 0beb01c0..9c37252c 100644 --- a/src/media/MediaTypes.cpp +++ b/src/media/MediaTypes.cpp @@ -11,7 +11,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -614,4 +614,4 @@ qint64 normalizedResumeTicks(qint64 resumeTicks, qint64 runtimeTicks) return isMeaningfulResumePosition(resumeTicks, runtimeTicks) ? resumeTicks : 0; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/media/MediaTypes.h b/src/media/MediaTypes.h index 58408441..d0a773ea 100644 --- a/src/media/MediaTypes.h +++ b/src/media/MediaTypes.h @@ -13,7 +13,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct LibraryItem { Q_GADGET @@ -154,7 +154,7 @@ struct MediaSourceInfo { Q_PROPERTY(qint64 size MEMBER size) Q_PROPERTY(int bitRate MEMBER bitRate) Q_PROPERTY(qint64 runtimeTicks MEMBER runtimeTicks) - Q_PROPERTY(QList streams MEMBER streams) + Q_PROPERTY(QList streams MEMBER streams) public: QString id; @@ -233,9 +233,9 @@ struct MovieItem { Q_PROPERTY(QString premiereDate MEMBER premiereDate) Q_PROPERTY(QString endDate MEMBER endDate) Q_PROPERTY(QString status MEMBER status) - Q_PROPERTY(QList people MEMBER people) - Q_PROPERTY(QList mediaSources MEMBER mediaSources) - Q_PROPERTY(QList externalUrls MEMBER externalUrls) + Q_PROPERTY(QList people MEMBER people) + Q_PROPERTY(QList mediaSources MEMBER mediaSources) + Q_PROPERTY(QList externalUrls MEMBER externalUrls) public: QString id; @@ -411,16 +411,16 @@ bool isMeaningfulResumePosition(qint64 resumeTicks, qint64 runtimeTicks); qint64 normalizedResumeTicks(qint64 resumeTicks, qint64 runtimeTicks); QVariantMap formatMediaInfo(const MovieItem& item, const QString& preferredAudioLanguage); -} // namespace JellyfinNative - -Q_DECLARE_METATYPE(JellyfinNative::LibraryItem) -Q_DECLARE_METATYPE(JellyfinNative::PersonItem) -Q_DECLARE_METATYPE(JellyfinNative::MediaStreamInfo) -Q_DECLARE_METATYPE(JellyfinNative::MediaSourceInfo) -Q_DECLARE_METATYPE(JellyfinNative::ExternalUrlInfo) -Q_DECLARE_METATYPE(JellyfinNative::MovieItem) -Q_DECLARE_METATYPE(JellyfinNative::MediaSegment) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(QList) +} // namespace Spool + +Q_DECLARE_METATYPE(Spool::LibraryItem) +Q_DECLARE_METATYPE(Spool::PersonItem) +Q_DECLARE_METATYPE(Spool::MediaStreamInfo) +Q_DECLARE_METATYPE(Spool::MediaSourceInfo) +Q_DECLARE_METATYPE(Spool::ExternalUrlInfo) +Q_DECLARE_METATYPE(Spool::MovieItem) +Q_DECLARE_METATYPE(Spool::MediaSegment) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(QList) diff --git a/src/models/LibraryListModel.cpp b/src/models/LibraryListModel.cpp index e5329ca3..2fde515b 100644 --- a/src/models/LibraryListModel.cpp +++ b/src/models/LibraryListModel.cpp @@ -1,6 +1,6 @@ #include "LibraryListModel.h" -namespace JellyfinNative { +namespace Spool { LibraryListModel::LibraryListModel(QObject *parent) : QAbstractListModel(parent) @@ -99,4 +99,4 @@ const std::vector& LibraryListModel::libraries() const return m_libraries; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/LibraryListModel.h b/src/models/LibraryListModel.h index b24e246a..c3c5e66e 100644 --- a/src/models/LibraryListModel.h +++ b/src/models/LibraryListModel.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { class LibraryListModel final : public QAbstractListModel { Q_OBJECT @@ -42,4 +42,4 @@ class LibraryListModel final : public QAbstractListModel { std::vector m_libraries; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/MovieGridModel.cpp b/src/models/MovieGridModel.cpp index 19f34fd4..60c3906d 100644 --- a/src/models/MovieGridModel.cpp +++ b/src/models/MovieGridModel.cpp @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -242,4 +242,4 @@ bool MovieGridModel::removeUnresumable() return removed; } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/models/MovieGridModel.h b/src/models/MovieGridModel.h index 903fbc62..a8a648d7 100644 --- a/src/models/MovieGridModel.h +++ b/src/models/MovieGridModel.h @@ -6,7 +6,7 @@ #include -namespace JellyfinNative { +namespace Spool { class MovieGridModel final : public QAbstractListModel { Q_OBJECT @@ -46,4 +46,4 @@ class MovieGridModel final : public QAbstractListModel { std::vector m_movies; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/CredentialStore.h b/src/platform/CredentialStore.h index 610dd17c..641c4eb3 100644 --- a/src/platform/CredentialStore.h +++ b/src/platform/CredentialStore.h @@ -2,11 +2,11 @@ #include -namespace JellyfinNative::CredentialStore { +namespace Spool::CredentialStore { QString load(const QString& profileId); bool save(const QString& profileId, const QString& accessToken); void remove(const QString& profileId); void clear(); -} // namespace JellyfinNative::CredentialStore +} // namespace Spool::CredentialStore diff --git a/src/platform/MpvConfigPolicy.h b/src/platform/MpvConfigPolicy.h index 7893f8b0..41a621a3 100644 --- a/src/platform/MpvConfigPolicy.h +++ b/src/platform/MpvConfigPolicy.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct MpvConfigPolicy { enum class Mode { @@ -20,4 +20,4 @@ struct MpvConfigPolicy { MpvConfigPolicy validatedPlatformMpvConfigPolicy(const QString& mode, const QString& directory); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/NativeAppWindow.h b/src/platform/NativeAppWindow.h index 8f7a96ed..b5dbfe1d 100644 --- a/src/platform/NativeAppWindow.h +++ b/src/platform/NativeAppWindow.h @@ -7,7 +7,7 @@ #include -namespace JellyfinNative { +namespace Spool { class InputLatencyMonitor; class NativeAppWindow final : public QQuickView { @@ -168,4 +168,4 @@ class NativeAppWindow final : public QQuickView { bool m_immersive = false; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformApplicationServices.h b/src/platform/PlatformApplicationServices.h index 0f894674..f3ffb401 100644 --- a/src/platform/PlatformApplicationServices.h +++ b/src/platform/PlatformApplicationServices.h @@ -8,7 +8,7 @@ class QGuiApplication; -namespace JellyfinNative { +namespace Spool { class ArtworkService; class NativeAppWindow; @@ -63,4 +63,4 @@ class PlatformApplicationServices final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformCapabilities.h b/src/platform/PlatformCapabilities.h index 4a1dc73d..9bfaf8fb 100644 --- a/src/platform/PlatformCapabilities.h +++ b/src/platform/PlatformCapabilities.h @@ -2,7 +2,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct PlatformCapabilities { QString deviceName; @@ -23,4 +23,4 @@ struct PlatformCapabilities { const PlatformCapabilities& platformCapabilities(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformDisplayOutput.h b/src/platform/PlatformDisplayOutput.h index 98e8ceb7..6a99644c 100644 --- a/src/platform/PlatformDisplayOutput.h +++ b/src/platform/PlatformDisplayOutput.h @@ -2,7 +2,7 @@ class QQuickWindow; -namespace JellyfinNative { +namespace Spool { struct DisplayOutputCapabilities; @@ -17,4 +17,4 @@ namespace PlatformDisplayOutput { } // namespace PlatformDisplayOutput -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPaths.h b/src/platform/PlatformPaths.h index 6fb4704f..72e4e6b3 100644 --- a/src/platform/PlatformPaths.h +++ b/src/platform/PlatformPaths.h @@ -3,7 +3,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { QString resolveAppRoot(const char *argv0); QString bundledFontsPath(const QString& appRootPath); @@ -12,4 +12,4 @@ QString persistentDataRoot(); QStringList appLogDirectories(const QString& appRootPath); QString appLogFileName(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPerformanceSampler.h b/src/platform/PlatformPerformanceSampler.h index 1aac9433..c06116d5 100644 --- a/src/platform/PlatformPerformanceSampler.h +++ b/src/platform/PlatformPerformanceSampler.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct PlatformPerformanceSample { // Whether this process's own CPU time could be read. On Android that is @@ -48,4 +48,4 @@ class PlatformPerformanceSampler final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPlaybackRuntime.h b/src/platform/PlatformPlaybackRuntime.h index 3d6fadf8..dc9ab5a8 100644 --- a/src/platform/PlatformPlaybackRuntime.h +++ b/src/platform/PlatformPlaybackRuntime.h @@ -7,7 +7,7 @@ class QObject; -namespace JellyfinNative { +namespace Spool { qint64 platformAudioDecodeCpuTimeNs(); @@ -18,4 +18,4 @@ qint64 platformAudioDecodeCpuTimeNs(); using VideoCodecCapabilityApplier = std::function; void configurePlatformPlaybackCapabilities(VideoCodecCapabilityApplier apply, QObject& callbackContext); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformPlaybackSurface.h b/src/platform/PlatformPlaybackSurface.h index 7dafa8d9..1ca27b4c 100644 --- a/src/platform/PlatformPlaybackSurface.h +++ b/src/platform/PlatformPlaybackSurface.h @@ -9,7 +9,7 @@ class QObject; struct mpv_handle; -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; struct PlaybackSession; @@ -41,4 +41,4 @@ void platformAudioTrackChanged(int index); // where the scene graph already letterboxes. void platformVideoSizeChanged(int width, int height); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformProcess.h b/src/platform/PlatformProcess.h index dc02811e..55bac91b 100644 --- a/src/platform/PlatformProcess.h +++ b/src/platform/PlatformProcess.h @@ -6,7 +6,7 @@ class QCoreApplication; -namespace JellyfinNative { +namespace Spool { struct ProcessStartupTiming { qint64 execToMainMs = -1; @@ -28,4 +28,4 @@ class TerminationSignalHandler final { std::unique_ptr m_platform; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformRemoteMediaSession.h b/src/platform/PlatformRemoteMediaSession.h index aa954615..79c8588a 100644 --- a/src/platform/PlatformRemoteMediaSession.h +++ b/src/platform/PlatformRemoteMediaSession.h @@ -5,7 +5,7 @@ #include -namespace JellyfinNative { +namespace Spool { struct RemoteMediaSessionState { QString title; @@ -44,4 +44,4 @@ class PlatformRemoteMediaSession : public QObject { std::unique_ptr createPlatformRemoteMediaSession(QObject *parent = nullptr); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformSettingsPolicy.h b/src/platform/PlatformSettingsPolicy.h index 793e7533..1c7c05d6 100644 --- a/src/platform/PlatformSettingsPolicy.h +++ b/src/platform/PlatformSettingsPolicy.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct SettingChoice; @@ -46,4 +46,4 @@ QString platformAudioRouteDisplayName(const QString& output); QString platformAudioDelayStorageKey(const QString& output); int platformAutomaticAudioDelayMs(const QString& output, int displayLatencyMs, int outputLatencyMs); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformStartup.h b/src/platform/PlatformStartup.h index e40f6fad..ada61537 100644 --- a/src/platform/PlatformStartup.h +++ b/src/platform/PlatformStartup.h @@ -4,7 +4,7 @@ class QString; -namespace JellyfinNative { +namespace Spool { class NativeAppWindow; @@ -12,4 +12,4 @@ bool configurePlatformEnvironment(const QString& appRootPath); QSurfaceFormat platformSurfaceFormat(); void configurePlatformWindow(NativeAppWindow& window); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/PlatformSystemProbes.h b/src/platform/PlatformSystemProbes.h index 2c6b792a..bf7cfbb5 100644 --- a/src/platform/PlatformSystemProbes.h +++ b/src/platform/PlatformSystemProbes.h @@ -4,7 +4,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct PlatformCpuProbe { int physicalCores = 0; @@ -30,4 +30,4 @@ qint64 effectiveLinuxMemoryBytes( const QByteArray& meminfo, const QByteArray& cgroupV2Limit, const QByteArray& cgroupV1Limit); QString platformProcessMemoryDiagnostics(); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/ScreenSaverInhibitor.h b/src/platform/ScreenSaverInhibitor.h index 5195ad2f..bb102274 100644 --- a/src/platform/ScreenSaverInhibitor.h +++ b/src/platform/ScreenSaverInhibitor.h @@ -4,7 +4,7 @@ #include -namespace JellyfinNative { +namespace Spool { class ScreenSaverBackend { public: @@ -35,4 +35,4 @@ class ScreenSaverInhibitor final { bool screenSaverShouldBeInhibited(bool mediaSessionActive, bool paused, bool slideshowAdvancing = false); QByteArray webOsScreenSaverResponsePayload(const QByteArray& requestPayload); -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/UpdateController.cpp b/src/platform/UpdateController.cpp index 6584d0a5..2e5d9342 100644 --- a/src/platform/UpdateController.cpp +++ b/src/platform/UpdateController.cpp @@ -2,7 +2,7 @@ #if defined(SPOOL_ANDROID) #include "android/AndroidUpdateInstaller.h" -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) #include "webos/WebOSUpdateInstaller.h" #include #endif @@ -18,7 +18,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { @@ -37,7 +37,7 @@ namespace { #if defined(SPOOL_ANDROID) return SPOOL_ANDROID_VERSION_CODE; #else - const QVersionNumber version = QVersionNumber::fromString(QStringLiteral(JELLYFIN_VERSION)); + const QVersionNumber version = QVersionNumber::fromString(QStringLiteral(SPOOL_VERSION)); return version.majorVersion() * 100000000 + version.minorVersion() * 100000 + version.microVersion() * 100 + 99; #endif } @@ -60,9 +60,9 @@ namespace { void configureRequest(QNetworkRequest& request, int timeout) { #if defined(SPOOL_ANDROID) - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-Android/%1").arg(JELLYFIN_VERSION)); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-Android/%1").arg(SPOOL_VERSION)); #else - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-webOS/%1").arg(JELLYFIN_VERSION)); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("Spool-webOS/%1").arg(SPOOL_VERSION)); #endif request.setTransferTimeout(timeout); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::UserVerifiedRedirectPolicy); @@ -105,7 +105,7 @@ UpdateController::UpdateController(QNetworkAccessManager *network, QString cache m_waitingForPermission = false; setStage(AndroidUpdateInstaller::canRequestPackageInstalls() ? Stage::Ready : Stage::PermissionRequired); }); -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) m_installer = new WebOSUpdateInstaller(this); connect(m_installer, &WebOSUpdateInstaller::statusChanged, this, [this](const QString& message) { qInfo() << "update: webOS installer:" << message; }); @@ -305,7 +305,7 @@ void UpdateController::offerRelease() QString UpdateController::packagePath() const { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) return m_packageDirectory ? m_packageDirectory->filePath(QStringLiteral("spool-update.ipk")) : QString(); #else return QDir(m_cacheRoot).filePath(QStringLiteral("updates/spool-update.apk")); @@ -324,7 +324,7 @@ void UpdateController::decline() QFile::remove(packagePath()); m_packageReady = false; } -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) m_packageDirectory.reset(); #endif m_errorText.clear(); @@ -339,7 +339,7 @@ void UpdateController::download() resetDownload(); m_packageReady = false; m_errorText.clear(); -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) // Only this application's freshly created directory is made traversable. // /tmp avoids permissions on app-cache ancestors owned by another service. m_packageDirectory = std::make_unique(QStringLiteral("/tmp/spool-update-XXXXXX")); @@ -431,7 +431,7 @@ void UpdateController::finishDownload() m_output.reset(); m_hash.reset(); -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) constexpr auto packagePermissions = QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther; if (!QFile::setPermissions(packagePath(), packagePermissions)) { @@ -468,7 +468,7 @@ void UpdateController::install() } if (!AndroidUpdateInstaller::install(packagePath())) fail(QStringLiteral("Android could not open the package installer.")); -#elif defined(JELLYFIN_NATIVE_WEBOS) +#elif defined(SPOOL_WEBOS) if (m_installer->isRunning()) return; // The service may terminate this process to replace the application. Do not @@ -537,4 +537,4 @@ void UpdateController::resetDownload() m_hash.reset(); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/UpdateController.h b/src/platform/UpdateController.h index 58c4f83f..0328a054 100644 --- a/src/platform/UpdateController.h +++ b/src/platform/UpdateController.h @@ -16,9 +16,9 @@ class QNetworkReply; class QSaveFile; class QTemporaryDir; -namespace JellyfinNative { +namespace Spool { -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) class WebOSUpdateInstaller; #endif @@ -113,10 +113,10 @@ class UpdateController final : public QObject { qint64 m_lastSpeedBytes = 0; qint64 m_lastSpeedMs = 0; bool m_packageReady = false; -#if defined(JELLYFIN_NATIVE_WEBOS) +#if defined(SPOOL_WEBOS) WebOSUpdateInstaller *m_installer = nullptr; std::unique_ptr m_packageDirectory; #endif }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidApplicationServices.cpp b/src/platform/android/AndroidApplicationServices.cpp index bd402118..770bb457 100644 --- a/src/platform/android/AndroidApplicationServices.cpp +++ b/src/platform/android/AndroidApplicationServices.cpp @@ -7,7 +7,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { struct PlatformApplicationServices::PlatformData { explicit PlatformData(ApplicationHooks& applicationHooks) @@ -61,4 +61,4 @@ void PlatformApplicationServices::start() }); } -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidLocalMediaSession.cpp b/src/platform/android/AndroidLocalMediaSession.cpp index dd2b19cc..74f8f660 100644 --- a/src/platform/android/AndroidLocalMediaSession.cpp +++ b/src/platform/android/AndroidLocalMediaSession.cpp @@ -13,7 +13,7 @@ #include #include -namespace JellyfinNative { +namespace Spool { namespace { // Only accessed on the Qt application thread, including JNI dispatch below. AndroidLocalMediaSession *localMediaSession = nullptr; @@ -195,10 +195,10 @@ void dispatchAndroidLocalMediaControl(int action, qint64 value) } } -} // namespace JellyfinNative +} // namespace Spool extern "C" JNIEXPORT void JNICALL Java_com_sachk_spool_LocalMediaPlaybackService_nativeControl( JNIEnv *, jclass, jint action, jlong value) { - JellyfinNative::dispatchAndroidLocalMediaControl(static_cast(action), static_cast(value)); + Spool::dispatchAndroidLocalMediaControl(static_cast(action), static_cast(value)); } diff --git a/src/platform/android/AndroidLocalMediaSession.h b/src/platform/android/AndroidLocalMediaSession.h index 3cbcd413..5dac4824 100644 --- a/src/platform/android/AndroidLocalMediaSession.h +++ b/src/platform/android/AndroidLocalMediaSession.h @@ -7,7 +7,7 @@ class QQuickImageResponse; #include -namespace JellyfinNative { +namespace Spool { class ApplicationHooks; class PlayerController; @@ -36,4 +36,4 @@ class AndroidLocalMediaSession final : public QObject { QPointer m_artworkResponse; }; -} // namespace JellyfinNative +} // namespace Spool diff --git a/src/platform/android/AndroidNativeAppWindow.cpp b/src/platform/android/AndroidNativeAppWindow.cpp index 42ab619c..03776955 100644 --- a/src/platform/android/AndroidNativeAppWindow.cpp +++ b/src/platform/android/AndroidNativeAppWindow.cpp @@ -7,7 +7,7 @@ #include