From 9e33a343ab4cb188d2f895acab50c0c04b35dd46 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 25 Oct 2026 09:42:27 +0100 Subject: [PATCH] Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release - IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 --- .gitea/workflows/release.yaml | 183 + .gitignore | 16 + .vscode/tasks.json | 12 + Directory.Build.props | 10 + .../Api/FloatplaneAdminController.cs | 201 + .../Api/FloatplaneApiClient.cs | 369 + .../Api/FloatplaneExceptions.cs | 41 + .../Api/FloatplaneStreamController.cs | 258 + .../Api/Models/Common.cs | 127 + .../Api/Models/Creator.cs | 119 + .../Api/Models/Delivery.cs | 177 + Jellyfin.Plugin.Floatplane/Api/Models/Post.cs | 166 + .../Api/ProxySignature.cs | 63 + .../Channel/CatalogService.cs | 350 + .../Channel/FloatplaneChannel.cs | 472 + .../Configuration/PluginConfiguration.cs | 64 + .../Configuration/configPage.html | 246 + .../Jellyfin.Plugin.Floatplane.csproj | 37 + .../Playback/DeliveryResolver.cs | 194 + .../Playback/HlsRewriter.cs | 94 + Jellyfin.Plugin.Floatplane/Plugin.cs | 59 + .../PluginServiceRegistrator.cs | 50 + Jellyfin.Plugin.Floatplane/Resources/logo.png | Bin 0 -> 8024 bytes .../Tasks/FloatplaneSyncTask.cs | 65 + README.md | 113 + build.yaml | 18 + doc/floatplane-api-notes.md | 239 + doc/plan.md | 152 + doc/research/floatplane-api-research.md | 221 + doc/research/jellyfin-plugin-research.md | 290 + doc/research/reference-sources/README.md | 18 + doc/research/reference-sources/bz_README.md | 24 + .../reference-sources/bz_auth-refresh.mjs | 185 + .../reference-sources/bz_constants.mjs | 131 + .../reference-sources/bz_device-flow.mjs | 245 + doc/research/reference-sources/bz_dpop.mjs | 159 + .../bz_refresh-lifetime-evidence.json | 39 + doc/research/reference-sources/bz_tree.json | 3880 +++++++++ .../.github/workflows/android-e2e.yml | 97 + .../chaosflix/.github/workflows/ci.yml | 117 + .../chaosflix/.github/workflows/contract.yml | 49 + .../.github/workflows/install-e2e.yml | 49 + .../chaosflix/.github/workflows/release.yml | 150 + .../chaosflix/Directory.Build.props | 7 + .../Api/CccApiClient.cs | 354 + .../Api/ChaosflixStatusController.cs | 414 + .../Api/ChaosflixStreamController.cs | 255 + .../Api/MemoryCache.cs | 207 + .../Api/Models/CccConference.cs | 57 + .../Api/Models/CccEvent.cs | 101 + .../Api/Models/CccLiveStream.cs | 143 + .../Api/Models/CccRecording.cs | 79 + .../Api/ProxySignature.cs | 55 + .../Jellyfin.Plugin.Chaosflix/Api/ProxyUrl.cs | 84 + .../Channel/ChaosflixChannel.cs | 1371 +++ .../Channel/ChaosflixSimilarItems.cs | 124 + .../Channel/ChaosflixSyncTask.cs | 221 + .../Channel/ChaosflixUserDataMirror.cs | 182 + .../ChaosflixServiceRegistrator.cs | 37 + .../Configuration/PluginConfiguration.cs | 76 + .../Configuration/configPage.html | 290 + .../Jellyfin.Plugin.Chaosflix.csproj | 33 + .../Jellyfin.Plugin.Chaosflix/Plugin.cs | 55 + .../Jellyfin.Plugin.Chaosflix/meta.json | 13 + .../reference-sources/chaosflix/LICENSE | 21 + .../reference-sources/chaosflix/README.md | 420 + .../reference-sources/chaosflix/manifest.json | 29 + .../reference-sources/floaty_README.md | 121 + .../floaty_fp_download_url_helper.dart | 212 + .../floaty_fp_rate_limit_manager.dart | 185 + .../reference-sources/floaty_fpapi.dart | 1016 +++ .../floaty_live_status_provider.dart | 55 + .../floaty_login_screen.dart | 472 + .../floaty_media_player_service.dart | 1313 +++ .../reference-sources/floaty_middleware.dart | 100 + .../floaty_oauth2_service.dart | 1137 +++ .../floaty_video_quality.dart | 9 + .../reference-sources/fn_AuthInterceptor.kt | 275 + .../reference-sources/fn_DPoPManager.kt | 234 + .../reference-sources/fn_ManualApi.kt | 16 + doc/research/reference-sources/fn_OAuthApi.kt | 49 + .../reference-sources/fn_PKCEManager.kt | 22 + doc/research/reference-sources/fn_README.md | 113 + .../reference-sources/fn_TokenManager.kt | 116 + .../fn_VideoPlayerManager.kt | 38 + .../fn_ios_AVPlayerManager.swift | 863 ++ .../reference-sources/fn_ios_AuthModels.swift | 111 + .../fn_ios_FloatplaneAPI.swift | 1346 +++ .../fn_ios_VideoResourceLoader.swift | 188 + doc/research/reference-sources/fn_repo.json | 114 + doc/research/reference-sources/fn_tree.json | 6147 +++++++++++++ .../reference-sources/fpapi_README.md | 139 + .../reference-sources/fpdl_Attachment.ts | 139 + doc/research/reference-sources/fpdl_Caches.ts | 61 + .../reference-sources/fpdl_Subscription.ts | 143 + doc/research/reference-sources/fpdl_Video.ts | 438 + .../reference-sources/fpdl_defaults.ts | 81 + doc/research/reference-sources/fpdl_float.ts | 109 + .../reference-sources/fpdl_floatplane.ts | 30 + doc/research/reference-sources/fpdl_logins.ts | 45 + .../reference-sources/fpdl_onDeviceCode.ts | 20 + .../reference-sources/fpdl_package.json | 58 + .../reference-sources/fpdl_quickStart.ts | 87 + .../reference-sources/fpdl_settings.md | 274 + .../fpdl_subscriptionFetching.ts | 58 + doc/research/reference-sources/fpdl_types.ts | 88 + doc/research/reference-sources/fpts_Core.ts | 12 + doc/research/reference-sources/fpts_README.md | 67 + doc/research/reference-sources/fpts_api.ts | 12 + .../reference-sources/fpts_apiSchema.ts | 7683 +++++++++++++++++ doc/research/reference-sources/fpts_cdn.ts | 33 + .../reference-sources/fpts_content.ts | 21 + .../reference-sources/fpts_creator.ts | 74 + doc/research/reference-sources/fpts_index.ts | 161 + doc/research/reference-sources/fpts_sanity.ts | 19 + doc/research/reference-sources/fpts_user.ts | 17 + .../reference-sources/hydra_README.md | 31 + .../reference-sources/hydra_apiConfig.brs | 76 + .../hydra_category_screen.brs | 74 + .../hydra_content_screen.brs | 71 + .../hydra_details_screen.brs | 355 + .../hydra_getVideoTokenTask.brs | 15 + .../reference-sources/hydra_home_screen.brs | 1592 ++++ .../reference-sources/hydra_liveTask.brs | 22 + .../reference-sources/hydra_login_screen.brs | 393 + doc/research/reference-sources/hydra_main.brs | 123 + .../hydra_oauthDeviceTask.brs | 220 + .../reference-sources/hydra_postTask.brs | 49 + .../reference-sources/hydra_registry.brs | 77 + .../hydra_setupCategoriesTask.brs | 146 + .../hydra_setupChannelsTask.brs | 65 + .../reference-sources/hydra_setupFeedTask.brs | 233 + .../hydra_streamCheckTask.brs | 123 + .../reference-sources/hydra_tokenUtil.brs | 308 + .../reference-sources/hydra_urlTask.brs | 47 + .../hydra_validateTokenTask.brs | 69 + doc/research/reference-sources/kodi_README.md | 2 + doc/research/reference-sources/kodi_addon.py | 94 + doc/research/reference-sources/oidc.json | 1 + .../reference-sources/test_AuthV3API.py | 7 + doc/research/reference-sources/test_Flow.py | 306 + .../reference-sources/wf_AuthViewModel.swift | 280 + .../reference-sources/wf_Authentication.swift | 61 + .../wf_CreatorContentViewModel.swift | 180 + .../wf_CreatorOrChannel.swift | 89 + .../reference-sources/wf_DeliveryHelper.swift | 46 + .../reference-sources/wf_FPAPIService.swift | 6229 +++++++++++++ .../wf_FloatplaneAPIClient+Extensions.swift | 125 + .../reference-sources/wf_HomeViewModel.swift | 124 + .../wf_LivestreamViewModel.swift | 190 + .../reference-sources/wf_LoginView2.swift | 154 + .../reference-sources/wf_PlayMediaView.swift | 98 + doc/research/reference-sources/wf_README.md | 73 + .../wf_SecondFactorView.swift | 81 + .../reference-sources/wf_VideoView.swift | 33 + .../reference-sources/wf_VideoViewModel.swift | 354 + .../wf_Wasserflug_tvOSApp.swift | 119 + .../reference-sources/wf_gen_AuthV3API.swift | 279 + .../wf_gen_DeliveryV3API.swift | 118 + .../reference-sources/ytdlp_floatplane.py | 387 + install-pi5.sh | 127 + scripts/package.sh | 60 + 162 files changed, 52004 insertions(+) create mode 100644 .gitea/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 Directory.Build.props create mode 100644 Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/Models/Common.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/Models/Post.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs create mode 100644 Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs create mode 100644 Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs create mode 100644 Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.Floatplane/Configuration/configPage.html create mode 100644 Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj create mode 100644 Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs create mode 100644 Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs create mode 100644 Jellyfin.Plugin.Floatplane/Plugin.cs create mode 100644 Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.Floatplane/Resources/logo.png create mode 100644 Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs create mode 100644 README.md create mode 100644 build.yaml create mode 100644 doc/floatplane-api-notes.md create mode 100644 doc/plan.md create mode 100644 doc/research/floatplane-api-research.md create mode 100644 doc/research/jellyfin-plugin-research.md create mode 100644 doc/research/reference-sources/README.md create mode 100644 doc/research/reference-sources/bz_README.md create mode 100644 doc/research/reference-sources/bz_auth-refresh.mjs create mode 100644 doc/research/reference-sources/bz_constants.mjs create mode 100644 doc/research/reference-sources/bz_device-flow.mjs create mode 100644 doc/research/reference-sources/bz_dpop.mjs create mode 100644 doc/research/reference-sources/bz_refresh-lifetime-evidence.json create mode 100644 doc/research/reference-sources/bz_tree.json create mode 100644 doc/research/reference-sources/chaosflix/.github/workflows/android-e2e.yml create mode 100644 doc/research/reference-sources/chaosflix/.github/workflows/ci.yml create mode 100644 doc/research/reference-sources/chaosflix/.github/workflows/contract.yml create mode 100644 doc/research/reference-sources/chaosflix/.github/workflows/install-e2e.yml create mode 100644 doc/research/reference-sources/chaosflix/.github/workflows/release.yml create mode 100644 doc/research/reference-sources/chaosflix/Directory.Build.props create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/CccApiClient.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/ChaosflixStatusController.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/ChaosflixStreamController.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/MemoryCache.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/Models/CccConference.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/Models/CccEvent.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/Models/CccLiveStream.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/Models/CccRecording.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/ProxySignature.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Api/ProxyUrl.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Channel/ChaosflixChannel.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Channel/ChaosflixSimilarItems.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Channel/ChaosflixSyncTask.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Channel/ChaosflixUserDataMirror.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/ChaosflixServiceRegistrator.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Configuration/PluginConfiguration.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Configuration/configPage.html create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/Plugin.cs create mode 100644 doc/research/reference-sources/chaosflix/Jellyfin.Plugin.Chaosflix/meta.json create mode 100644 doc/research/reference-sources/chaosflix/LICENSE create mode 100644 doc/research/reference-sources/chaosflix/README.md create mode 100644 doc/research/reference-sources/chaosflix/manifest.json create mode 100644 doc/research/reference-sources/floaty_README.md create mode 100644 doc/research/reference-sources/floaty_fp_download_url_helper.dart create mode 100644 doc/research/reference-sources/floaty_fp_rate_limit_manager.dart create mode 100644 doc/research/reference-sources/floaty_fpapi.dart create mode 100644 doc/research/reference-sources/floaty_live_status_provider.dart create mode 100644 doc/research/reference-sources/floaty_login_screen.dart create mode 100644 doc/research/reference-sources/floaty_media_player_service.dart create mode 100644 doc/research/reference-sources/floaty_middleware.dart create mode 100644 doc/research/reference-sources/floaty_oauth2_service.dart create mode 100644 doc/research/reference-sources/floaty_video_quality.dart create mode 100644 doc/research/reference-sources/fn_AuthInterceptor.kt create mode 100644 doc/research/reference-sources/fn_DPoPManager.kt create mode 100644 doc/research/reference-sources/fn_ManualApi.kt create mode 100644 doc/research/reference-sources/fn_OAuthApi.kt create mode 100644 doc/research/reference-sources/fn_PKCEManager.kt create mode 100644 doc/research/reference-sources/fn_README.md create mode 100644 doc/research/reference-sources/fn_TokenManager.kt create mode 100644 doc/research/reference-sources/fn_VideoPlayerManager.kt create mode 100644 doc/research/reference-sources/fn_ios_AVPlayerManager.swift create mode 100644 doc/research/reference-sources/fn_ios_AuthModels.swift create mode 100644 doc/research/reference-sources/fn_ios_FloatplaneAPI.swift create mode 100644 doc/research/reference-sources/fn_ios_VideoResourceLoader.swift create mode 100644 doc/research/reference-sources/fn_repo.json create mode 100644 doc/research/reference-sources/fn_tree.json create mode 100644 doc/research/reference-sources/fpapi_README.md create mode 100644 doc/research/reference-sources/fpdl_Attachment.ts create mode 100644 doc/research/reference-sources/fpdl_Caches.ts create mode 100644 doc/research/reference-sources/fpdl_Subscription.ts create mode 100644 doc/research/reference-sources/fpdl_Video.ts create mode 100644 doc/research/reference-sources/fpdl_defaults.ts create mode 100644 doc/research/reference-sources/fpdl_float.ts create mode 100644 doc/research/reference-sources/fpdl_floatplane.ts create mode 100644 doc/research/reference-sources/fpdl_logins.ts create mode 100644 doc/research/reference-sources/fpdl_onDeviceCode.ts create mode 100644 doc/research/reference-sources/fpdl_package.json create mode 100644 doc/research/reference-sources/fpdl_quickStart.ts create mode 100644 doc/research/reference-sources/fpdl_settings.md create mode 100644 doc/research/reference-sources/fpdl_subscriptionFetching.ts create mode 100644 doc/research/reference-sources/fpdl_types.ts create mode 100644 doc/research/reference-sources/fpts_Core.ts create mode 100644 doc/research/reference-sources/fpts_README.md create mode 100644 doc/research/reference-sources/fpts_api.ts create mode 100644 doc/research/reference-sources/fpts_apiSchema.ts create mode 100644 doc/research/reference-sources/fpts_cdn.ts create mode 100644 doc/research/reference-sources/fpts_content.ts create mode 100644 doc/research/reference-sources/fpts_creator.ts create mode 100644 doc/research/reference-sources/fpts_index.ts create mode 100644 doc/research/reference-sources/fpts_sanity.ts create mode 100644 doc/research/reference-sources/fpts_user.ts create mode 100644 doc/research/reference-sources/hydra_README.md create mode 100644 doc/research/reference-sources/hydra_apiConfig.brs create mode 100644 doc/research/reference-sources/hydra_category_screen.brs create mode 100644 doc/research/reference-sources/hydra_content_screen.brs create mode 100644 doc/research/reference-sources/hydra_details_screen.brs create mode 100644 doc/research/reference-sources/hydra_getVideoTokenTask.brs create mode 100644 doc/research/reference-sources/hydra_home_screen.brs create mode 100644 doc/research/reference-sources/hydra_liveTask.brs create mode 100644 doc/research/reference-sources/hydra_login_screen.brs create mode 100644 doc/research/reference-sources/hydra_main.brs create mode 100644 doc/research/reference-sources/hydra_oauthDeviceTask.brs create mode 100644 doc/research/reference-sources/hydra_postTask.brs create mode 100644 doc/research/reference-sources/hydra_registry.brs create mode 100644 doc/research/reference-sources/hydra_setupCategoriesTask.brs create mode 100644 doc/research/reference-sources/hydra_setupChannelsTask.brs create mode 100644 doc/research/reference-sources/hydra_setupFeedTask.brs create mode 100644 doc/research/reference-sources/hydra_streamCheckTask.brs create mode 100644 doc/research/reference-sources/hydra_tokenUtil.brs create mode 100644 doc/research/reference-sources/hydra_urlTask.brs create mode 100644 doc/research/reference-sources/hydra_validateTokenTask.brs create mode 100644 doc/research/reference-sources/kodi_README.md create mode 100644 doc/research/reference-sources/kodi_addon.py create mode 100644 doc/research/reference-sources/oidc.json create mode 100644 doc/research/reference-sources/test_AuthV3API.py create mode 100644 doc/research/reference-sources/test_Flow.py create mode 100644 doc/research/reference-sources/wf_AuthViewModel.swift create mode 100644 doc/research/reference-sources/wf_Authentication.swift create mode 100644 doc/research/reference-sources/wf_CreatorContentViewModel.swift create mode 100644 doc/research/reference-sources/wf_CreatorOrChannel.swift create mode 100644 doc/research/reference-sources/wf_DeliveryHelper.swift create mode 100644 doc/research/reference-sources/wf_FPAPIService.swift create mode 100644 doc/research/reference-sources/wf_FloatplaneAPIClient+Extensions.swift create mode 100644 doc/research/reference-sources/wf_HomeViewModel.swift create mode 100644 doc/research/reference-sources/wf_LivestreamViewModel.swift create mode 100644 doc/research/reference-sources/wf_LoginView2.swift create mode 100644 doc/research/reference-sources/wf_PlayMediaView.swift create mode 100644 doc/research/reference-sources/wf_README.md create mode 100644 doc/research/reference-sources/wf_SecondFactorView.swift create mode 100644 doc/research/reference-sources/wf_VideoView.swift create mode 100644 doc/research/reference-sources/wf_VideoViewModel.swift create mode 100644 doc/research/reference-sources/wf_Wasserflug_tvOSApp.swift create mode 100644 doc/research/reference-sources/wf_gen_AuthV3API.swift create mode 100644 doc/research/reference-sources/wf_gen_DeliveryV3API.swift create mode 100644 doc/research/reference-sources/ytdlp_floatplane.py create mode 100755 install-pi5.sh create mode 100755 scripts/package.sh diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..9c9f3bb --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,183 @@ +name: release + +# Bygger Jellyfin-pluginen (AnyCPU .NET 8 -> samma DLL for x64 och Pi5/arm64), +# kor testerna och publicerar en release pa Gitea: +# - varje push till master -> rullande release "latest" +# - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0) +# - workflow_dispatch -> valfri tag (tom = latest) +# Assets: floatplane_.zip (+.md5), manifest.json (Jellyfins plugin- +# repository-format) och install-pi5.sh. manifest.json laddas ALLTID upp +# till "latest" sa att URL:en nedan alltid pekar pa nyaste versionen: +# https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json +# +# Kors pa brasse-linux01-runnern (label linux-amd64, catthehacker/ubuntu:act-latest). +# Bygget ar IL-only sa arkitekturen spelar ingen roll, men Pi5:n ska inte +# belastas med byggen. .NET-SDK:t installeras med dotnet-install.sh eftersom +# runner-imagen saknar dotnet (och dotnet/sdk-imagen saknar node for actions). +on: + push: + branches: [master] + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release-tag (lamna tom for 'latest')" + required: false + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1" + REPO_URL: https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane + +jobs: + build-release: + runs-on: linux-amd64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Installera verktyg (zip, jq, curl) + run: | + set -euo pipefail + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends zip jq curl ca-certificates >/dev/null + git config --global --add safe.directory '*' + + - name: Cache NuGet-paket + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: nuget- + + - name: Installera .NET 8 SDK + run: | + set -euo pipefail + curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh + bash /tmp/dotnet-install.sh --channel 8.0 --install-dir "$HOME/.dotnet" + echo "$HOME/.dotnet" >> "$GITHUB_PATH" + echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV" + "$HOME/.dotnet/dotnet" --version + + - name: Bestam release-tag och plugin-version + id: t + run: | + set -euo pipefail + case "$GITHUB_REF" in + refs/tags/*) T="$GITHUB_REF_NAME" ;; + *) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;; + esac + # Jellyfin kraver en fyrdelad System.Version: vX.Y.Z -> X.Y.Z.0, annars 0.1.0. + case "$T" in + v[0-9]*.[0-9]*.[0-9]*) V="${T#v}.0" ;; + *) V="0.1.0.${{ github.run_number }}" ;; + esac + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "bygger release: $T (plugin-version $V)" + + - name: Tester + run: | + set -euo pipefail + if [ -d Jellyfin.Plugin.Floatplane.Tests ]; then + dotnet test Jellyfin.Plugin.Floatplane.Tests -c Release --nologo + else + echo "inget testprojekt annu - hoppar over" + fi + + - name: Paketera (zip + meta.json + manifest.json) + env: + TAG: ${{ steps.t.outputs.tag }} + VERSION: ${{ steps.t.outputs.version }} + run: | + set -euo pipefail + scripts/package.sh "$VERSION" "$REPO_URL/releases/download/$TAG" + ls -la build + cat build/manifest.json + + - name: Skapa/uppdatera Gitea-release + ladda upp assets + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.t.outputs.tag }} + VERSION: ${{ steps.t.outputs.version }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # gitea-d finns bara pa Pi5:ns dockernat; harifran anvands publika adressen + API=http://gitea-d:3000/api/v1 + curl -sf -m 5 "$API/version" >/dev/null 2>&1 || API=https://gitea.brasse-pc.eu/api/v1 + echo "API: $API" + auth() { curl -s -H "Authorization: token $TOKEN" "$@"; } + + ZIP="floatplane_${VERSION}.zip" + cp build/manifest.json build/manifest.json.keep + BODY="Jellyfin Floatplane plugin $TAG (version $VERSION, commit ${GITHUB_SHA:0:10}). + + Byggd for Jellyfin 10.10.x (.NET 8, targetAbi 10.10.0.0). En DLL for x64 och arm64 (Pi5). + + Installera via plugin-repository (rekommenderat): + Jellyfin Dashboard -> Plugins -> Repositories -> + -> URL: + $REPO_URL/releases/download/latest/manifest.json + Sedan Plugins -> Catalog -> Floatplane -> Install, starta om Jellyfin. + + Manuell installation pa en host med linuxserver/jellyfin-containern: + curl -fsSL $REPO_URL/releases/download/$TAG/install-pi5.sh | sh + (TAG=... JELLYFIN_CONFIG_DIR=... JELLYFIN_CONTAINER=... och --dry-run stods, se skriptet.) + + Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien." + + # --- release for TAG ------------------------------------------------- + if [ "$TAG" = "latest" ]; then + # rullande release: ta bort den gamla sa att taggen foljer master + rid=$(auth "$API/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty') + [ -n "$rid" ] && auth -X DELETE "$API/repos/$REPO/releases/$rid" -o /dev/null + auth -X DELETE "$API/repos/$REPO/tags/$TAG" -o /dev/null || true + JSON=$(jq -n --arg t "$TAG" --arg c "$GITHUB_SHA" --arg n "latest ($VERSION)" --arg b "$BODY" \ + '{tag_name:$t, target_commitish:$c, name:$n, body:$b, prerelease:true}') + else + JSON=$(jq -n --arg t "$TAG" --arg n "$TAG ($VERSION)" --arg b "$BODY" '{tag_name:$t, name:$n, body:$b}') + fi + RESP=$(auth -X POST "$API/repos/$REPO/releases" -H 'Content-Type: application/json' -d "$JSON") + rid=$(echo "$RESP" | jq -r '.id // empty') + [ -z "$rid" ] && rid=$(auth "$API/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty') + [ -n "$rid" ] && [ "$rid" != "null" ] || { echo "kunde inte skapa release: $RESP"; exit 1; } + echo "release id: $rid" + + upload() { # upload + local r="$1" name="$2" file="$3" aid + aid=$(auth "$API/repos/$REPO/releases/$r/assets" | jq -r ".[] | select(.name==\"$name\") | .id") + if [ -n "$aid" ] && [ "$aid" != "null" ]; then + auth -X DELETE "$API/repos/$REPO/releases/$r/assets/$aid" -o /dev/null + fi + echo "laddar upp $name -> release $r ..." + auth -X POST "$API/repos/$REPO/releases/$r/assets?name=$name" -F "attachment=@$file" -o /dev/null -w " -> HTTP %{http_code}\n" + } + + upload "$rid" "$ZIP" "build/$ZIP" + upload "$rid" "$ZIP.md5" "build/$ZIP.md5" + upload "$rid" "manifest.json" "build/manifest.json" + upload "$rid" "install-pi5.sh" "install-pi5.sh" + echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG" + + # --- manifest.json aven till "latest" vid taggad release -------------- + # Repository-URL:en i Jellyfin pekar pa latest/manifest.json; den ska + # alltid lista den nyaste versionen. sourceUrl i manifestet pekar redan + # pa den versionerade releasen, sa zippen behover inte dubbleras. + if [ "$TAG" != "latest" ]; then + lid=$(auth "$API/repos/$REPO/releases/tags/latest" | jq -r '.id // empty') + if [ -z "$lid" ] || [ "$lid" = "null" ]; then + LJSON=$(jq -n --arg c "$GITHUB_SHA" --arg n "latest ($VERSION)" --arg b "$BODY" \ + '{tag_name:"latest", target_commitish:$c, name:$n, body:$b, prerelease:true}') + lid=$(auth -X POST "$API/repos/$REPO/releases" -H 'Content-Type: application/json' -d "$LJSON" | jq -r '.id // empty') + fi + if [ -n "$lid" ] && [ "$lid" != "null" ]; then + upload "$lid" "manifest.json" "build/manifest.json.keep" + upload "$lid" "install-pi5.sh" "install-pi5.sh" + echo "manifest.json uppdaterat pa latest: $REPO_URL/releases/download/latest/manifest.json" + else + echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json" + fi + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eced4f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +build/ +# Captured browser sessions contain the Floatplane password, session JWTs and e-mail — never commit +doc/har/*.har +*.har +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ +# plugin packaging / local test server +dist/ +.jellyfin-test/ +# secrets +.env +*.local.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1270e1f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "scripts/package.sh", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1c374e6 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + 0.1.0.0 + 0.1.0.0 + 0.1.0.0 + brasse, claude + brasse-pc.eu + https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane + + diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs new file mode 100644 index 0000000..7028051 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Channel; +using Jellyfin.Plugin.Floatplane.Playback; +using MediaBrowser.Common.Api; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Api; + +/// Helpers for the dashboard configuration page (admin only). +[ApiController] +[Route("Floatplane")] +[Authorize(Policy = Policies.RequiresElevation)] +public class FloatplaneAdminController : ControllerBase +{ + private readonly FloatplaneApiClient _api; + private readonly CatalogService _catalog; + private readonly DeliveryResolver _delivery; + private readonly ILogger _logger; + + public FloatplaneAdminController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger logger) + { + _api = api; + _catalog = catalog; + _delivery = delivery; + _logger = logger; + } + + /// Validates a session cookie (without saving it) and lists the subscriptions it has. + [HttpPost("Session/Validate")] + public async Task> ValidateSession([FromBody] SessionValidationRequest request) + { + var cookie = request.Cookie?.Trim() ?? string.Empty; + if (cookie.StartsWith("sails.sid=", StringComparison.OrdinalIgnoreCase)) + { + cookie = cookie["sails.sid=".Length..]; + } + + cookie = cookie.Trim().TrimEnd(';'); + if (cookie.Length == 0) + { + return new SessionValidationResult { Ok = false, Message = "Cookie is empty" }; + } + + try + { + var ct = HttpContext.RequestAborted; + var self = await _api.GetSelfAsync(ct, cookie).ConfigureAwait(false); + var subs = await _api.GetSubscriptionsAsync(ct, cookie).ConfigureAwait(false); + return new SessionValidationResult + { + Ok = true, + Username = self.Username, + Cookie = cookie, + Subscriptions = subs.Select(s => (s.Plan?.Title ?? "?") + " (" + s.Creator + ")").ToList(), + Message = $"Logged in as {self.Username}, {subs.Count} active subscription(s)" + }; + } + catch (FloatplaneAuthException ex) + { + return new SessionValidationResult { Ok = false, Message = "Floatplane rejected the cookie: " + ex.Message }; + } + catch (FloatplaneApiException ex) + { + return new SessionValidationResult { Ok = false, Message = "Floatplane error: " + ex.Message }; + } + } + + /// Current plugin state for the settings page. + [HttpGet("Status")] + public ActionResult GetStatus() + { + return new PluginStatus + { + Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?", + HasSessionCookie = _api.HasSession, + SessionState = _catalog.SessionState ?? "unknown", + SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty, + LastError = _catalog.LastError, + Generation = _catalog.Generation, + LastRefresh = _catalog.LastRefresh == DateTimeOffset.MinValue ? null : _catalog.LastRefresh, + Creators = _catalog.Creators.Select(c => new CreatorSummary + { + Id = c.Id, + Title = c.Title, + Urlname = c.Urlname, + Channels = c.Channels.Select(ch => ch.Title).ToList() + }).ToList(), + CachedPostLists = _catalog.CachedPostListCount, + CachedDeliveries = _delivery.CachedEntries + }; + } + + /// Drops caches and reloads creators (post lists refill on browse). + [HttpPost("Refresh")] + public async Task> Refresh() + { + _catalog.Invalidate(); + await _catalog.GetCreatorsAsync(true, HttpContext.RequestAborted).ConfigureAwait(false); + return GetStatus(); + } + + /// Runs a Floatplane search (public endpoint) — for mapping/testing the search API. + [HttpGet("Search")] + public async Task>> Search( + [FromQuery] string creatorId, + [FromQuery] string? q, + [FromQuery] string? channelId, + [FromQuery] int limit = 20) + { + var posts = await _catalog.FetchPostsAsync(creatorId, channelId ?? string.Empty, Math.Clamp(limit, 1, 100), HttpContext.RequestAborted, q).ConfigureAwait(false); + return posts.Select(p => new SearchHit + { + Id = p.Id, + Title = p.Title, + Channel = p.Channel?.Title ?? p.Channel?.Id, + ReleaseDate = p.ReleaseDate, + DurationSeconds = p.DurationSeconds, + IsAccessible = p.IsAccessible, + Thumbnail = p.Thumbnail?.BestPath(400) + }).ToList(); + } + + /// Tags Floatplane knows for a creator (public endpoint). + [HttpGet("Tags")] + public Task> Tags([FromQuery] string creatorId) + => _api.GetTagsAsync(creatorId, HttpContext.RequestAborted); +} + +public class SessionValidationRequest +{ + public string? Cookie { get; set; } +} + +public class SessionValidationResult +{ + public bool Ok { get; set; } + + public string? Username { get; set; } + + public string? Cookie { get; set; } + + public string? Message { get; set; } + + public List Subscriptions { get; set; } = new(); +} + +public class PluginStatus +{ + public string Version { get; set; } = string.Empty; + + public bool HasSessionCookie { get; set; } + + public string SessionState { get; set; } = string.Empty; + + public string SessionUsername { get; set; } = string.Empty; + + public string? LastError { get; set; } + + public int Generation { get; set; } + + public DateTimeOffset? LastRefresh { get; set; } + + public List Creators { get; set; } = new(); + + public int CachedPostLists { get; set; } + + public int CachedDeliveries { get; set; } +} + +public class CreatorSummary +{ + public string Id { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Urlname { get; set; } = string.Empty; + + public List Channels { get; set; } = new(); +} + +public class SearchHit +{ + public string Id { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string? Channel { get; set; } + + public DateTimeOffset? ReleaseDate { get; set; } + + public double DurationSeconds { get; set; } + + public bool IsAccessible { get; set; } + + public string? Thumbnail { get; set; } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs new file mode 100644 index 0000000..8f7a883 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Api; + +/// +/// Thin typed client for Floatplane's (unofficial) REST API. See +/// doc/floatplane-api-notes.md for which endpoints are public and which need the +/// session cookie. +/// +public class FloatplaneApiClient +{ + public const string BaseUrl = "https://www.floatplane.com"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly Func _config; + + public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger) + : this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) + { + } + + /// Constructor for tests: configuration is supplied explicitly. + public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, Func config) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _config = config; + } + + /// True when a session cookie is configured (not necessarily valid). + public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie); + + // ───────────────────────────── auth / user ───────────────────────────── + + /// GET /api/v3/user/self — validates the cookie. Throws if invalid. + public Task GetSelfAsync(CancellationToken ct, string? cookieOverride = null) + => GetAsync("/api/v3/user/self", null, ct, cookieOverride); + + /// GET /api/v3/user/subscriptions (login required). + public Task> GetSubscriptionsAsync(CancellationToken ct, string? cookieOverride = null) + => GetAsync>("/api/v3/user/subscriptions", new() { ["active"] = "true" }, ct, cookieOverride); + + // ───────────────────────────── creators ───────────────────────────── + + /// GET /api/v3/creator/named?creatorURL[]= (public). + public Task> GetCreatorsByUrlNameAsync(IEnumerable urlNames, CancellationToken ct) + { + var q = new Dictionary(); + var i = 0; + foreach (var n in urlNames) + { + q[$"creatorURL[{i++}]"] = n; + } + + return GetAsync>("/api/v3/creator/named", q, ct); + } + + /// GET /api/v3/creator/info?id= (login required). + public Task GetCreatorAsync(string creatorId, CancellationToken ct) + => GetAsync("/api/v3/creator/info", new() { ["id"] = creatorId }, ct); + + /// GET /api/v3/creator/channels/list?ids[]= (public). + public Task> GetCreatorChannelsAsync(IEnumerable creatorIds, CancellationToken ct) + { + var q = new Dictionary(); + var i = 0; + foreach (var id in creatorIds) + { + q[$"ids[{i++}]"] = id; + } + + return GetAsync>("/api/v3/creator/channels/list", q, ct); + } + + /// GET /api/v3/content/tags?creatorIds[]= (public) → tag → count. + public Task> GetTagsAsync(string creatorId, CancellationToken ct) + => GetAsync>("/api/v3/content/tags", new() { ["creatorIds[0]"] = creatorId }, ct); + + // ───────────────────────────── content ───────────────────────────── + + /// + /// GET /api/v3/content/creator (public). FetchAfter is an offset + /// (multiples of Limit); max limit is 20. Returns an empty list at the end. + /// + public Task> ListPostsAsync(PostQuery query, CancellationToken ct) + { + var q = new Dictionary + { + ["id"] = query.CreatorId, + ["limit"] = Math.Clamp(query.Limit, 1, 20).ToString(CultureInfo.InvariantCulture), + ["fetchAfter"] = query.FetchAfter.ToString(CultureInfo.InvariantCulture), + ["sort"] = query.SortAscending ? "ASC" : "DESC" + }; + if (!string.IsNullOrEmpty(query.ChannelId)) + { + q["channel"] = query.ChannelId; + } + + if (!string.IsNullOrEmpty(query.Search)) + { + q["search"] = query.Search; + } + + if (query.HasVideo) + { + q["hasVideo"] = "true"; + } + + if (query.Tags != null) + { + var i = 0; + foreach (var t in query.Tags) + { + q[$"tags[{i++}]"] = t; + } + } + + if (query.FromDate.HasValue) + { + q["fromDate"] = query.FromDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + } + + if (query.ToDate.HasValue) + { + q["toDate"] = query.ToDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + } + + if (query.FromDurationSeconds.HasValue) + { + q["fromDuration"] = query.FromDurationSeconds.Value.ToString(CultureInfo.InvariantCulture); + } + + if (query.ToDurationSeconds.HasValue) + { + q["toDuration"] = query.ToDurationSeconds.Value.ToString(CultureInfo.InvariantCulture); + } + + return GetAsync>("/api/v3/content/creator", q, ct); + } + + /// GET /api/v3/content/post?id= (login required) — expands attachments. + public Task GetPostAsync(string postId, CancellationToken ct) + => GetAsync("/api/v3/content/post", new() { ["id"] = postId }, ct); + + /// GET /api/v3/content/video?id= (login required). + public Task GetVideoAsync(string videoId, CancellationToken ct) + => GetAsync("/api/v3/content/video", new() { ["id"] = videoId }, ct); + + /// GET /api/v3/content/progress?ids[]=&contentType=blogPost (login) → percentage per post. + public Task> GetProgressAsync(IEnumerable postIds, CancellationToken ct) + { + var q = new Dictionary { ["contentType"] = "blogPost" }; + var i = 0; + foreach (var id in postIds) + { + q[$"ids[{i++}]"] = id; + } + + return GetAsync>("/api/v3/content/progress", q, ct); + } + + /// POST /api/v3/content/progress {id, contentType:"video", progress:seconds} (login). + public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct) + { + var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds }); + using var request = BuildRequest(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null); + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await SendAsync(request, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + } + + // ───────────────────────────── delivery ───────────────────────────── + + /// GET /api/v3/delivery/info?scenario=&entityId= (login; rate-limited ≈2/min). + public Task GetDeliveryInfoAsync(string scenario, string entityId, CancellationToken ct) + => GetAsync("/api/v3/delivery/info", new() { ["scenario"] = scenario, ["entityId"] = entityId }, ct); + + /// Fetches a (sub)playlist from the CDN. No cookie — the signed token is enough. + public async Task GetPlaylistAsync(string url, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Referrer = new Uri(BaseUrl + "/"); + using var response = await SendAsync(request, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + /// + /// Fetches the HLS AES-128 key (/api/video/watchKey?token=). This is the one + /// playback request that needs the session cookie. + /// + public async Task GetWatchKeyAsync(string keyUrl, CancellationToken ct) + { + using var request = BuildRequest(HttpMethod.Get, keyUrl, null, null); + request.Headers.Referrer = new Uri(BaseUrl + "/"); + using var response = await SendAsync(request, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + } + + /// Opens an upstream media request (MP4 relay) with optional Range; caller disposes. + public async Task OpenMediaAsync(HttpMethod method, string url, RangeHeaderValue? range, CancellationToken ct) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Referrer = new Uri(BaseUrl + "/"); + if (range != null) + { + request.Headers.Range = range; + } + + var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName); + return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + } + + // ───────────────────────────── plumbing ───────────────────────────── + + internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary? query, string? cookieOverride) + { + if (query is { Count: > 0 }) + { + var sb = new StringBuilder(url); + sb.Append(url.Contains('?', StringComparison.Ordinal) ? '&' : '?'); + var first = true; + foreach (var (k, v) in query) + { + if (!first) + { + sb.Append('&'); + } + + first = false; + sb.Append(Uri.EscapeDataString(k)).Append('=').Append(Uri.EscapeDataString(v)); + } + + url = sb.ToString(); + } + + var request = new HttpRequestMessage(method, url); + var cookie = cookieOverride ?? _config().SessionCookie; + if (!string.IsNullOrWhiteSpace(cookie)) + { + request.Headers.TryAddWithoutValidation("Cookie", "sails.sid=" + cookie.Trim()); + } + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return request; + } + + private async Task GetAsync(string path, Dictionary? query, CancellationToken ct, string? cookieOverride = null) + { + using var request = BuildRequest(HttpMethod.Get, BaseUrl + path, query, cookieOverride); + using var response = await SendAsync(request, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + _logger.LogDebug("GET {Url} -> {Status}, {Length} bytes", request.RequestUri, (int)response.StatusCode, json.Length); + T? result; + try + { + result = JsonSerializer.Deserialize(json, FloatplaneJson.Options); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Could not parse Floatplane response from {Path}: {Head}", path, json.Length > 300 ? json[..300] : json); + throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected response from " + path); + } + + return result ?? throw new FloatplaneApiException(response.StatusCode, "emptyResponse", "Empty response from " + path); + } + + private Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName); + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + } + + private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + { + return; + } + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + var retry = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60); + _logger.LogWarning("Floatplane rate limit hit on {Url}; retry after {Retry}", response.RequestMessage?.RequestUri, retry); + throw new FloatplaneRateLimitException(retry, "Floatplane rate limit (429)"); + } + + string body = string.Empty; + try + { + body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // ignore – we only need the body for the error name + } + + ApiErrorModel? error = null; + if (body.StartsWith('{')) + { + try + { + error = JsonSerializer.Deserialize(body, FloatplaneJson.Options); + } + catch (JsonException) + { + // not an envelope + } + } + + var name = error?.Errors.FirstOrDefault()?.Name; + var message = error?.Message ?? error?.Errors.FirstOrDefault()?.Message ?? $"HTTP {(int)response.StatusCode} from Floatplane"; + var url = response.RequestMessage?.RequestUri?.GetLeftPart(UriPartial.Path); + _logger.LogDebug("Floatplane error {Status} {Name} on {Url}: {Message}", (int)response.StatusCode, name, url, message); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + || string.Equals(name, "notLoggedInError", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "notSubscribedError", StringComparison.OrdinalIgnoreCase)) + { + throw new FloatplaneAuthException(response.StatusCode, name, message); + } + + throw new FloatplaneApiException(response.StatusCode, name, message); + } +} + +/// Parameters of GET /api/v3/content/creator. +public class PostQuery +{ + public string CreatorId { get; set; } = string.Empty; + + public string? ChannelId { get; set; } + + public int Limit { get; set; } = 20; + + public int FetchAfter { get; set; } + + public string? Search { get; set; } + + public bool HasVideo { get; set; } + + public bool SortAscending { get; set; } + + public IEnumerable? Tags { get; set; } + + public DateTimeOffset? FromDate { get; set; } + + public DateTimeOffset? ToDate { get; set; } + + public int? FromDurationSeconds { get; set; } + + public int? ToDurationSeconds { get; set; } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs new file mode 100644 index 0000000..8704d08 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs @@ -0,0 +1,41 @@ +using System; +using System.Net; + +namespace Jellyfin.Plugin.Floatplane.Api; + +/// Floatplane answered with an error envelope. +public class FloatplaneApiException : Exception +{ + public FloatplaneApiException(HttpStatusCode status, string? errorName, string message) + : base(message) + { + Status = status; + ErrorName = errorName; + } + + public HttpStatusCode Status { get; } + + /// e.g. notLoggedInError, notSubscribedError, paramValidationError. + public string? ErrorName { get; } +} + +/// The session cookie is missing, expired or not subscribed to the item. +public class FloatplaneAuthException : FloatplaneApiException +{ + public FloatplaneAuthException(HttpStatusCode status, string? errorName, string message) + : base(status, errorName, message) + { + } +} + +/// HTTP 429 from Floatplane or Cloudflare. +public class FloatplaneRateLimitException : FloatplaneApiException +{ + public FloatplaneRateLimitException(TimeSpan retryAfter, string message) + : base(HttpStatusCode.TooManyRequests, "rateLimited", message) + { + RetryAfter = retryAfter; + } + + public TimeSpan RetryAfter { get; } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs new file mode 100644 index 0000000..dfb28c2 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs @@ -0,0 +1,258 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Channel; +using Jellyfin.Plugin.Floatplane.Playback; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Api; + +/// +/// Anonymous, signature-protected endpoints that Jellyfin's ffmpeg (or a client) plays +/// from. Floatplane's HLS is AES-128 encrypted and the key request needs the session +/// cookie, which Jellyfin cannot forward — so the playlist is served from here with the +/// key URI pointing back at …/Key. Segments stay on the CDN (signed token, CORS open). +/// +[ApiController] +[Route("Floatplane/Stream")] +public class FloatplaneStreamController : ControllerBase +{ + private readonly FloatplaneApiClient _api; + private readonly CatalogService _catalog; + private readonly DeliveryResolver _delivery; + private readonly ILogger _logger; + + public FloatplaneStreamController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger logger) + { + _api = api; + _catalog = catalog; + _delivery = delivery; + _logger = logger; + } + + /// HLS media playlist for one quality level, rewritten for the proxy. + [HttpGet("{postId}/{videoId}/{level}/index.m3u8")] + [Produces("application/vnd.apple.mpegurl")] + public async Task GetPlaylist( + [FromRoute] string postId, + [FromRoute] string videoId, + [FromRoute] string level, + [FromQuery(Name = ProxySignature.QueryParameter)] string? t) + { + if (!ProxySignature.Verify(t, "hls", postId, videoId, level)) + { + _logger.LogWarning("Rejected unsigned playlist request for {Post}/{Video}", postId, videoId); + return Unauthorized(); + } + + var ct = HttpContext.RequestAborted; + try + { + var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioOnDemand, videoId, ct).ConfigureAwait(false); + var variant = DeliveryResolver.SelectVariant(variants, level); + if (variant == null) + { + var denied = variants.Select(v => v.Variant.Meta?.Common?.Access?.DeniedMessage).FirstOrDefault(m => m != null); + _logger.LogWarning("No usable delivery variant for {Video}: {Reason}", videoId, denied ?? "none enabled"); + return StatusCode(403, denied ?? "No playable variant"); + } + + if (!string.Equals(variant.Name, level, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Level {Level} not available for {Video}; serving {Actual}", level, videoId, variant.Name); + } + + var playlistUrl = new Uri(variant.Url); + var text = await _api.GetPlaylistAsync(variant.Url, ct).ConfigureAwait(false); + var rewritten = HlsRewriter.Rewrite( + text, + playlistUrl, + keyUri => ProxyUrl("Key", videoId, keyUri), + playlistUri => ProxyUrl("Playlist", videoId, playlistUri)); + + Response.Headers.CacheControl = "no-store"; + return Content(rewritten, "application/vnd.apple.mpegurl"); + } + catch (FloatplaneAuthException ex) + { + _logger.LogWarning("Floatplane refused delivery for {Video}: {Message}", videoId, ex.Message); + return StatusCode(403, ex.Message); + } + catch (FloatplaneRateLimitException ex) + { + Response.Headers.RetryAfter = ((int)ex.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture); + return StatusCode(429, ex.Message); + } + catch (FloatplaneApiException ex) + { + _logger.LogError(ex, "Delivery lookup failed for {Video}", videoId); + return StatusCode(502, ex.Message); + } + } + + /// Nested playlist (master → media) proxy; same rewriting. + [HttpGet("{videoId}/Playlist")] + public async Task GetNestedPlaylist( + [FromRoute] string videoId, + [FromQuery] string? u, + [FromQuery(Name = ProxySignature.QueryParameter)] string? t) + { + var url = ProxySignature.DecodeUrl(u); + if (url == null || !ProxySignature.Verify(t, "Playlist", videoId, url)) + { + return Unauthorized(); + } + + var text = await _api.GetPlaylistAsync(url, HttpContext.RequestAborted).ConfigureAwait(false); + var rewritten = HlsRewriter.Rewrite( + text, + new Uri(url), + keyUri => ProxyUrl("Key", videoId, keyUri), + playlistUri => ProxyUrl("Playlist", videoId, playlistUri)); + Response.Headers.CacheControl = "no-store"; + return Content(rewritten, "application/vnd.apple.mpegurl"); + } + + /// AES-128 key, fetched from Floatplane with the session cookie. + [HttpGet("{videoId}/Key")] + public async Task GetKey( + [FromRoute] string videoId, + [FromQuery] string? u, + [FromQuery(Name = ProxySignature.QueryParameter)] string? t) + { + var url = ProxySignature.DecodeUrl(u); + if (url == null || !ProxySignature.Verify(t, "Key", videoId, url)) + { + return Unauthorized(); + } + + if (!url.StartsWith(FloatplaneApiClient.BaseUrl + "/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Refusing key fetch from unexpected host: {Url}", url); + return BadRequest(); + } + + try + { + var key = await _api.GetWatchKeyAsync(url, HttpContext.RequestAborted).ConfigureAwait(false); + Response.Headers.CacheControl = "no-store"; + return File(key, "application/octet-stream"); + } + catch (FloatplaneAuthException ex) + { + _logger.LogWarning("Key fetch refused for {Video}: {Message} (session cookie expired?)", videoId, ex.Message); + return StatusCode(403, ex.Message); + } + } + + /// + /// Progressive MP4 relay (Floatplane "download" delivery) with Range passthrough. + /// Experimental until verified that the token alone is accepted by the edge servers. + /// + [HttpGet("{postId}/{videoId}/{level}/video.mp4")] + [HttpHead("{postId}/{videoId}/{level}/video.mp4")] + public async Task GetMp4( + [FromRoute] string postId, + [FromRoute] string videoId, + [FromRoute] string level, + [FromQuery(Name = ProxySignature.QueryParameter)] string? t) + { + if (!ProxySignature.Verify(t, "mp4", postId, videoId, level)) + { + Response.StatusCode = 401; + return; + } + + var ct = HttpContext.RequestAborted; + ResolvedVariant? variant; + try + { + var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioDownload, videoId, ct).ConfigureAwait(false); + variant = DeliveryResolver.SelectVariant(variants, level); + } + catch (FloatplaneApiException ex) + { + _logger.LogWarning(ex, "download delivery failed for {Video}", videoId); + Response.StatusCode = ex is FloatplaneRateLimitException ? 429 : 502; + return; + } + + if (variant == null) + { + Response.StatusCode = 404; + return; + } + + RangeHeaderValue? range = null; + if (Request.Headers.TryGetValue("Range", out var rangeHeader) && !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range)) + { + range = null; + } + + var method = HttpMethods.IsHead(Request.Method) ? HttpMethod.Head : HttpMethod.Get; + HttpResponseMessage upstream; + try + { + upstream = await _api.OpenMediaAsync(method, variant.Url, range, ct).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Upstream MP4 request failed for {Video}", videoId); + Response.StatusCode = 502; + return; + } + + using var response = upstream; + Response.StatusCode = (int)response.StatusCode; + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && response.Content.Headers.ContentRange != null) + { + Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString(); + } + + return; + } + + if (response.Content.Headers.ContentLength.HasValue) + { + Response.ContentLength = response.Content.Headers.ContentLength.Value; + } + + Response.Headers.AcceptRanges = "bytes"; + Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "video/mp4"; + if (response.Content.Headers.ContentRange != null) + { + Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString(); + } + + if (method == HttpMethod.Head) + { + return; + } + + try + { + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await stream.CopyToAsync(Response.Body, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + _logger.LogDebug("Client aborted MP4 stream for {Video}", videoId); + } + } + + private string ProxyUrl(string kind, string videoId, string upstreamUrl) + { + var signature = ProxySignature.Create(kind, videoId, upstreamUrl); + var encoded = ProxySignature.EncodeUrl(upstreamUrl); + var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + return $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(videoId)}/{kind}?u={encoded}&{ProxySignature.QueryParameter}={signature}"; + } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/Models/Common.cs b/Jellyfin.Plugin.Floatplane/Api/Models/Common.cs new file mode 100644 index 0000000..0dd719c --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/Models/Common.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Floatplane.Api.Models; + +/// Image with optional pre-scaled variants (pbs.floatplane.com, public). +public class ImageModel +{ + public int Width { get; set; } + + public int Height { get; set; } + + public string Path { get; set; } = string.Empty; + + public List ChildImages { get; set; } = new(); + + /// Largest variant whose width does not exceed , else the original. + public string BestPath(int maxWidth = 1200) + { + var candidates = ChildImages.Where(c => c.Width <= maxWidth && !string.IsNullOrEmpty(c.Path)).OrderByDescending(c => c.Width).ToList(); + if (candidates.Count > 0) + { + return candidates[0].Path; + } + + return Path; + } +} + +/// Error envelope Floatplane returns for 4xx responses. +public class ApiErrorModel +{ + public string? Id { get; set; } + + public List Errors { get; set; } = new(); + + public string? Message { get; set; } + + public bool Has(string name) => Errors.Any(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); +} + +public class ApiErrorItem +{ + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Message { get; set; } +} + +/// +/// Floatplane sometimes sends a related object inline and sometimes only its id +/// (e.g. channel on a post, videoAttachments in list responses). +/// This converter accepts both: a string becomes { Id = string }. +/// +/// Model type with an Id property. +public class IdOrObjectConverter : JsonConverter + where T : class, IHasId, new() +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return new T { Id = reader.GetString() ?? string.Empty, IsReferenceOnly = true }; + } + + // Avoid infinite recursion: deserialise with a copy of the options minus this converter. + var inner = new JsonSerializerOptions(options); + var self = inner.Converters.FirstOrDefault(c => c is IdOrObjectConverter); + if (self != null) + { + inner.Converters.Remove(self); + } + + return JsonSerializer.Deserialize(ref reader, inner); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var inner = new JsonSerializerOptions(options); + var self = inner.Converters.FirstOrDefault(c => c is IdOrObjectConverter); + if (self != null) + { + inner.Converters.Remove(self); + } + + JsonSerializer.Serialize(writer, value, inner); + } +} + +public interface IHasId +{ + string Id { get; set; } + + /// True when only the id was present in the JSON. + bool IsReferenceOnly { get; set; } +} + +/// Shared serializer options for Floatplane JSON. +public static class FloatplaneJson +{ + public static JsonSerializerOptions Options { get; } = Create(); + + private static JsonSerializerOptions Create() + { + var o = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + o.Converters.Add(new IdOrObjectConverter()); + o.Converters.Add(new IdOrObjectConverter()); + o.Converters.Add(new IdOrObjectConverter()); + o.Converters.Add(new JsonStringEnumConverter()); + return o; + } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs b/Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs new file mode 100644 index 0000000..01eede0 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Floatplane.Api.Models; + +/// A creator (e.g. LinusTechTips). Returned by creator/info, creator/named, creator/list. +public class CreatorModel : IHasId +{ + public string Id { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsReferenceOnly { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Urlname { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string? About { get; set; } + + public ImageModel? Cover { get; set; } + + public ImageModel? Icon { get; set; } + + public ImageModel? Card { get; set; } + + public string? DefaultChannel { get; set; } + + public List Channels { get; set; } = new(); + + public LiveStreamModel? LiveStream { get; set; } + + public List SubscriptionPlans { get; set; } = new(); +} + +/// A sub-channel of a creator (e.g. TechLinked under LinusTechTips). +public class ChannelModel : IHasId +{ + public string Id { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsReferenceOnly { get; set; } + + public string? Creator { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Urlname { get; set; } = string.Empty; + + public string? About { get; set; } + + public int Order { get; set; } + + public ImageModel? Cover { get; set; } + + public ImageModel? Card { get; set; } + + public ImageModel? Icon { get; set; } +} + +public class LiveStreamModel +{ + public string Id { get; set; } = string.Empty; + + public string? Title { get; set; } + + public string? Description { get; set; } + + public ImageModel? Thumbnail { get; set; } + + public string? Owner { get; set; } + + public string? Channel { get; set; } + + public string? StreamPath { get; set; } +} + +public class SubscriptionPlanModel +{ + public string Id { get; set; } = string.Empty; + + public string? Title { get; set; } + + public string? Price { get; set; } + + public string? Currency { get; set; } +} + +/// Entry of GET /api/v3/user/subscriptions. +public class SubscriptionModel +{ + public string? StartDate { get; set; } + + public string? EndDate { get; set; } + + public string? Interval { get; set; } + + public bool PaymentCancelled { get; set; } + + public SubscriptionPlanModel? Plan { get; set; } + + /// Creator id (24 hex chars). + public string Creator { get; set; } = string.Empty; +} + +/// GET /api/v3/user/self. +public class UserSelfModel +{ + public string Id { get; set; } = string.Empty; + + public string Username { get; set; } = string.Empty; + + public string? Email { get; set; } + + public string? DisplayName { get; set; } + + public ImageModel? ProfileImage { get; set; } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs b/Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs new file mode 100644 index 0000000..b0a6aa3 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Jellyfin.Plugin.Floatplane.Api.Models; + +/// GET /api/v3/delivery/info response. +public class DeliveryInfoModel +{ + public List Groups { get; set; } = new(); + + /// Flattens every usable variant with its absolute URL. + public IEnumerable Resolve() + { + foreach (var group in Groups) + { + var groupOrigin = group.Origins.FirstOrDefault()?.Url; + foreach (var v in group.Variants) + { + var origin = v.Origins?.FirstOrDefault()?.Url ?? groupOrigin; + if (string.IsNullOrEmpty(v.Url)) + { + continue; + } + + string url; + if (IsHttpUrl(v.Url)) + { + url = v.Url; + } + else if (!string.IsNullOrEmpty(origin)) + { + url = origin.TrimEnd('/') + "/" + v.Url.TrimStart('/'); + } + else + { + continue; + } + + yield return new ResolvedVariant(v, url); + } + } + } + + /// + /// True for http(s) URLs only. Uri.TryCreate(…, UriKind.Absolute) must not be used + /// for this: on Linux a path like /Videos/x.m3u8 parses as an absolute file:/// URI. + /// + public static bool IsHttpUrl(string value) + => value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); +} + +public class DeliveryGroup +{ + public List Origins { get; set; } = new(); + + public List Variants { get; set; } = new(); +} + +public class DeliveryOrigin +{ + public string Url { get; set; } = string.Empty; + + public string? QueryUrl { get; set; } +} + +public class DeliveryVariant +{ + /// e.g. "1080-avc1". + public string Name { get; set; } = string.Empty; + + /// e.g. "1080p", "4K". + public string? Label { get; set; } + + /// Relative (to origin) or absolute URL incl. signed token. Empty when denied. + public string Url { get; set; } = string.Empty; + + public string? MimeType { get; set; } + + public long Order { get; set; } + + public bool Hidden { get; set; } + + public bool Enabled { get; set; } = true; + + public List? Origins { get; set; } + + public VariantMeta? Meta { get; set; } + + public bool IsUsable => Enabled && !Hidden && !string.IsNullOrEmpty(Url); + + public int Height => Meta?.Video?.Height ?? ParseHeight(Name); + + public static int ParseHeight(string name) + { + var dash = name.IndexOf('-', StringComparison.Ordinal); + var head = dash > 0 ? name[..dash] : name; + return int.TryParse(head, out var h) ? h : 0; + } +} + +public class VariantMeta +{ + public VariantVideoMeta? Video { get; set; } + + public VariantAudioMeta? Audio { get; set; } + + public VariantCommonMeta? Common { get; set; } + + public VariantLiveMeta? Live { get; set; } +} + +public class VariantVideoMeta +{ + public string? Codec { get; set; } + + public string? CodecSimple { get; set; } + + public BitrateMeta? Bitrate { get; set; } + + public int Width { get; set; } + + public int Height { get; set; } + + public bool IsHdr { get; set; } + + public double Fps { get; set; } + + public string? MimeType { get; set; } +} + +public class VariantAudioMeta +{ + public string? Codec { get; set; } + + public BitrateMeta? Bitrate { get; set; } + + public int ChannelCount { get; set; } + + public int Samplerate { get; set; } + + public string? MimeType { get; set; } +} + +public class BitrateMeta +{ + public long Average { get; set; } + + public long? Maximum { get; set; } +} + +public class VariantCommonMeta +{ + public VariantAccess? Access { get; set; } +} + +public class VariantAccess +{ + public string? DeniedReason { get; set; } + + public string? DeniedMessage { get; set; } +} + +public class VariantLiveMeta +{ + public string? LowLatencyExtension { get; set; } +} + +/// A delivery variant with its absolute URL. +public record ResolvedVariant(DeliveryVariant Variant, string Url) +{ + public string Name => Variant.Name; + + public int Height => Variant.Height; + + public bool IsUsable => Variant.IsUsable; +} diff --git a/Jellyfin.Plugin.Floatplane/Api/Models/Post.cs b/Jellyfin.Plugin.Floatplane/Api/Models/Post.cs new file mode 100644 index 0000000..468a20b --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/Models/Post.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Floatplane.Api.Models; + +/// +/// A blog post (the unit shown in Floatplane's feed). List endpoints return posts +/// without attachment details; GET /api/v3/content/post expands them. +/// +public class PostModel : IHasId +{ + public string Id { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsReferenceOnly { get; set; } + + public string? Guid { get; set; } + + public string Title { get; set; } = string.Empty; + + /// HTML body. + public string? Text { get; set; } + + public string? TextMarkdown { get; set; } + + public string? Type { get; set; } + + public List Tags { get; set; } = new(); + + public List AttachmentOrder { get; set; } = new(); + + public DateTimeOffset? ReleaseDate { get; set; } + + public int Likes { get; set; } + + public int Dislikes { get; set; } + + public int Score { get; set; } + + public int Comments { get; set; } + + public bool IsAccessible { get; set; } = true; + + public bool WasReleasedSilently { get; set; } + + public PostMetadata? Metadata { get; set; } + + public ImageModel? Thumbnail { get; set; } + + public CreatorModel? Creator { get; set; } + + public ChannelModel? Channel { get; set; } + + /// Full objects from /content/post; ids (or missing) from list endpoints. + public List? VideoAttachments { get; set; } + + [JsonIgnore] + public bool HasVideo => Metadata?.HasVideo ?? (VideoAttachments is { Count: > 0 }); + + [JsonIgnore] + public double DurationSeconds => Metadata?.VideoDuration ?? 0; +} + +public class PostMetadata +{ + public bool HasVideo { get; set; } + + public int VideoCount { get; set; } + + public double VideoDuration { get; set; } + + public bool HasAudio { get; set; } + + public int AudioCount { get; set; } + + public double AudioDuration { get; set; } + + public bool HasPicture { get; set; } + + public int PictureCount { get; set; } + + public bool HasGallery { get; set; } + + public int GalleryCount { get; set; } + + public bool IsFeatured { get; set; } + + public double DisplayDuration { get; set; } +} + +/// A video attachment (GET /api/v3/content/video or expanded in /content/post). +public class VideoModel : IHasId +{ + public string Id { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsReferenceOnly { get; set; } + + public string? Guid { get; set; } + + public string Title { get; set; } = string.Empty; + + public string? Type { get; set; } + + public string? Description { get; set; } + + public double Duration { get; set; } + + public string? Creator { get; set; } + + public string? PrimaryBlogPost { get; set; } + + public bool IsProcessing { get; set; } + + public bool IsAccessible { get; set; } = true; + + public List Levels { get; set; } = new(); + + public List TextTracks { get; set; } = new(); + + public ImageModel? Thumbnail { get; set; } + + public ImageModel? TimelineSprite { get; set; } + + /// Resume position in seconds (only present when logged in). + public double? Progress { get; set; } +} + +public class VideoLevel +{ + /// "360", "480", "720", "1080", "2160". + public string Name { get; set; } = string.Empty; + + public int Width { get; set; } + + public int Height { get; set; } + + public string? Label { get; set; } + + public int Order { get; set; } +} + +public class TextTrackModel +{ + public string Id { get; set; } = string.Empty; + + /// S3-presigned URL (expires after 15 minutes). + public string Src { get; set; } = string.Empty; + + public string? Kind { get; set; } + + public string? Language { get; set; } + + public bool Generated { get; set; } + + public bool Processing { get; set; } +} + +/// Entry of GET /api/v3/content/progress (percentage) or POST /content/get/progress. +public class ProgressModel +{ + public string Id { get; set; } = string.Empty; + + public double Progress { get; set; } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs b/Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs new file mode 100644 index 0000000..bb05bf1 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs @@ -0,0 +1,63 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Jellyfin.Plugin.Floatplane.Api; + +/// +/// Signs the URLs the channel hands to Jellyfin's ffmpeg, so the anonymous stream +/// endpoints only serve what this plugin selected. The secret lives for the process: +/// media sources are handed out per playback, so a restart just means fresh signatures. +/// +public static class ProxySignature +{ + public const string QueryParameter = "t"; + + private static readonly byte[] Secret = RandomNumberGenerator.GetBytes(32); + + public static string Create(params string[] parts) + { + var payload = string.Join('\n', parts); + var hash = HMACSHA256.HashData(Secret, Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool Verify(string? signature, params string[] parts) + { + if (string.IsNullOrEmpty(signature)) + { + return false; + } + + var expected = Create(parts); + return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); + } + + /// URL-safe base64 without padding (for embedding upstream URLs in proxy URLs). + public static string EncodeUrl(string value) + => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static string? DecodeUrl(string? encoded) + { + if (string.IsNullOrEmpty(encoded)) + { + return null; + } + + var s = encoded.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + } + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(s)); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs b/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs new file mode 100644 index 0000000..da02fcf --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Api; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Channel; + +/// +/// In-memory catalogue: which creators to show, their sub-channels, and the newest posts +/// per channel. Everything here is cheap public listing data; post details and delivery +/// (login required) live in and the channel. +/// +public class CatalogService +{ + private readonly FloatplaneApiClient _api; + private readonly ILogger _logger; + private readonly Func _config; + private readonly SemaphoreSlim _creatorLock = new(1, 1); + private readonly ConcurrentDictionary _posts = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _postDetails = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _postLocks = new(StringComparer.Ordinal); + + private IReadOnlyList _creators = Array.Empty(); + private DateTimeOffset _creatorsFetched = DateTimeOffset.MinValue; + private int _generation; + + public CatalogService(FloatplaneApiClient api, ILogger logger) + : this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) + { + } + + public CatalogService(FloatplaneApiClient api, ILogger logger, Func config) + { + _api = api; + _logger = logger; + _config = config; + } + + /// True when a session cookie is configured. + public bool HasSession => _api.HasSession; + + /// Bumped on every refresh so Jellyfin's 3-hour listing cache is bypassed. + public int Generation => _generation; + + public DateTimeOffset LastRefresh => _creatorsFetched; + + /// Session state as last observed (null = not checked yet). + public string? SessionState { get; private set; } + + public string? LastError { get; private set; } + + /// Cached creators (may be empty before the first refresh). + public IReadOnlyList Creators => _creators; + + public int CachedPostListCount => _posts.Count; + + /// Invalidates every cache and bumps the generation. + public void Invalidate() + { + _posts.Clear(); + _postDetails.Clear(); + _creatorsFetched = DateTimeOffset.MinValue; + Interlocked.Increment(ref _generation); + } + + /// Full refresh: creators + channels. Post lists are refetched lazily on browse. + public async Task RefreshAsync(IProgress? progress, CancellationToken ct) + { + _posts.Clear(); + _postDetails.Clear(); + await GetCreatorsAsync(true, ct).ConfigureAwait(false); + progress?.Report(50); + + // Warm the first page of every channel so the first browse is instant. + var creators = _creators; + var total = Math.Max(1, creators.Sum(c => Math.Max(1, c.Channels.Count))); + var done = 0; + foreach (var creator in creators) + { + var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List { string.Empty }; + foreach (var channelId in channelIds) + { + ct.ThrowIfCancellationRequested(); + try + { + await GetPostsAsync(creator.Id, channelId, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Could not warm posts for {Creator}/{Channel}", creator.Urlname, channelId); + } + + done++; + progress?.Report(50 + (50.0 * done / total)); + } + } + + Interlocked.Increment(ref _generation); + } + + /// + /// Creators to show: the account's subscriptions (when a valid session exists) plus the + /// configured extra url-names. Public data is used wherever possible so browsing works + /// without a session. + /// + public async Task> GetCreatorsAsync(bool force, CancellationToken ct) + { + var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes)); + if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl) + { + return _creators; + } + + await _creatorLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl) + { + return _creators; + } + + var result = new Dictionary(StringComparer.Ordinal); + var cfg = _config(); + + if (_api.HasSession) + { + try + { + var subs = await _api.GetSubscriptionsAsync(ct).ConfigureAwait(false); + SessionState = "ok"; + LastError = null; + foreach (var creatorId in subs.Select(s => s.Creator).Where(id => !string.IsNullOrEmpty(id)).Distinct(StringComparer.Ordinal)) + { + try + { + var creator = await _api.GetCreatorAsync(creatorId, ct).ConfigureAwait(false); + result[creator.Id] = creator; + } + catch (FloatplaneApiException ex) + { + _logger.LogWarning(ex, "Could not load subscribed creator {Id}", creatorId); + } + } + } + catch (FloatplaneAuthException ex) + { + SessionState = "invalid"; + LastError = ex.Message; + _logger.LogWarning("Floatplane session cookie rejected ({Message}); paste a fresh sails.sid in the plugin settings", ex.Message); + } + } + else + { + SessionState = "none"; + } + + var extra = (cfg.ExtraCreators ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(n => n.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (extra.Count > 0) + { + try + { + var named = await _api.GetCreatorsByUrlNameAsync(extra, ct).ConfigureAwait(false); + foreach (var creator in named) + { + result.TryAdd(creator.Id, creator); + } + } + catch (FloatplaneApiException ex) + { + LastError = ex.Message; + _logger.LogWarning(ex, "Could not resolve extra creators {Names}", string.Join(",", extra)); + } + } + + // creator/named and creator/info both include channels; fill in if missing. + var missing = result.Values.Where(c => c.Channels.Count == 0).Select(c => c.Id).ToList(); + if (missing.Count > 0) + { + try + { + var channels = await _api.GetCreatorChannelsAsync(missing, ct).ConfigureAwait(false); + foreach (var ch in channels) + { + if (ch.Creator != null && result.TryGetValue(ch.Creator, out var creator)) + { + creator.Channels.Add(ch); + } + } + } + catch (FloatplaneApiException ex) + { + _logger.LogDebug(ex, "channels/list failed"); + } + } + + foreach (var creator in result.Values) + { + creator.Channels = creator.Channels.OrderBy(c => c.Order).ThenBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList(); + } + + _creators = result.Values.OrderBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList(); + _creatorsFetched = DateTimeOffset.UtcNow; + _logger.LogInformation("Floatplane catalogue: {Count} creators ({Session})", _creators.Count, SessionState); + return _creators; + } + finally + { + _creatorLock.Release(); + } + } + + public CreatorModel? FindCreator(string creatorId) => _creators.FirstOrDefault(c => string.Equals(c.Id, creatorId, StringComparison.Ordinal)); + + /// + /// Newest video posts of a channel ( empty = whole creator), + /// up to MaxPostsPerChannel, cached for CatalogCacheMinutes. + /// + public async Task> GetPostsAsync(string creatorId, string channelId, CancellationToken ct) + { + var key = creatorId + "|" + channelId; + var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes)); + if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl) + { + return cached.Posts; + } + + var max = Math.Clamp(_config().MaxPostsPerChannel, 20, 2000); + var posts = await FetchPostsAsync(creatorId, channelId, max, ct).ConfigureAwait(false); + _posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow); + return posts; + } + + /// Pages through /content/creator until posts or the end. + public async Task> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null) + { + var list = new List(); + var seen = new HashSet(StringComparer.Ordinal); + // Without a session Floatplane reports every post as inaccessible, so the + // "locked" filter only makes sense once we are logged in. + var showLocked = _config().ShowLockedPosts || !_api.HasSession; + for (var offset = 0; list.Count < max; offset += 20) + { + ct.ThrowIfCancellationRequested(); + var page = await _api.ListPostsAsync( + new PostQuery + { + CreatorId = creatorId, + ChannelId = string.IsNullOrEmpty(channelId) ? null : channelId, + Limit = 20, + FetchAfter = offset, + HasVideo = true, + Search = search + }, + ct).ConfigureAwait(false); + if (page.Count == 0) + { + break; + } + + foreach (var post in page) + { + if (!post.HasVideo || !seen.Add(post.Id)) + { + continue; + } + + if (!post.IsAccessible && !showLocked) + { + continue; + } + + list.Add(post); + if (list.Count >= max) + { + break; + } + } + + if (page.Count < 20) + { + break; + } + } + + return list; + } + + /// Looks a post up in the cached lists (no network). + public PostModel? FindCachedPost(string postId) + { + foreach (var entry in _posts.Values) + { + var hit = entry.Posts.FirstOrDefault(p => string.Equals(p.Id, postId, StringComparison.Ordinal)); + if (hit != null) + { + return hit; + } + } + + return null; + } + + /// Post with expanded video attachments (login required). Cached for one hour. + public async Task GetPostDetailsAsync(string postId, CancellationToken ct) + { + if (_postDetails.TryGetValue(postId, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1)) + { + return cached.Post; + } + + var gate = _postLocks.GetOrAdd(postId, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_postDetails.TryGetValue(postId, out cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1)) + { + return cached.Post; + } + + var post = await _api.GetPostAsync(postId, ct).ConfigureAwait(false); + SessionState = "ok"; + _postDetails[postId] = new CachedPost(post, DateTimeOffset.UtcNow); + return post; + } + catch (FloatplaneAuthException ex) + { + SessionState = ex.ErrorName == "notSubscribedError" ? "ok" : "invalid"; + LastError = ex.Message; + throw; + } + finally + { + gate.Release(); + } + } + + private sealed record CachedPosts(IReadOnlyList Posts, DateTimeOffset Fetched); + + private sealed record CachedPost(PostModel Post, DateTimeOffset Fetched); +} diff --git a/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs b/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs new file mode 100644 index 0000000..8f2367e --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs @@ -0,0 +1,472 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Api; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Configuration; +using Jellyfin.Plugin.Floatplane.Playback; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Channels; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Channel; + +/// +/// The "Floatplane" channel: root → creators → sub-channels → video posts. +/// Item ids are stable strings (creator:…, channel:…, post:…); a post +/// only ever appears under its own channel, as Jellyfin allows one parent per item. +/// +public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia +{ + private const string CreatorPrefix = "creator:"; + private const string ChannelPrefix = "channel:"; + private const string PostPrefix = "post:"; + + private readonly CatalogService _catalog; + private readonly IServerApplicationHost _appHost; + private readonly ILogger _logger; + private readonly Func _config; + + public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger logger) + { + _catalog = catalog; + _appHost = appHost; + _logger = logger; + _config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration(); + } + + /// + public string Name => "Floatplane"; // never rename: the channel's library id derives from it + + /// + public string Description => "Your Floatplane subscriptions"; + + /// + public string DataVersion => "1"; + + /// + public string HomePageUrl => "https://www.floatplane.com"; + + /// + public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; + + /// + public bool IsEnabledFor(string userId) => true; + + /// + public string? GetCacheKey(string? userId) => "g" + _catalog.Generation.ToString(CultureInfo.InvariantCulture); + + /// + public InternalChannelFeatures GetChannelFeatures() => new() + { + MediaTypes = [ChannelMediaType.Video], + ContentTypes = [ChannelMediaContentType.Clip], + DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name, ChannelItemSortField.Runtime], + SupportsSortOrderToggle = true, + MaxPageSize = 100, + AutoRefreshLevels = 3, + SupportsContentDownloading = false + }; + + /// + public IEnumerable GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb]; + + /// + public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) + { + var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png"); + return Task.FromResult(new DynamicImageResponse + { + HasImage = stream != null, + Stream = stream, + Format = ImageFormat.Png + }); + } + + /// + public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) + { + var folderId = query.FolderId ?? string.Empty; + _logger.LogDebug("GetChannelItems folder='{Folder}' start={Start} limit={Limit}", folderId, query.StartIndex, query.Limit); + + List items; + if (string.IsNullOrEmpty(folderId)) + { + items = await GetRootItemsAsync(cancellationToken).ConfigureAwait(false); + } + else if (folderId.StartsWith(CreatorPrefix, StringComparison.Ordinal)) + { + items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false); + } + else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal)) + { + var rest = folderId[ChannelPrefix.Length..]; + var sep = rest.IndexOf(':', StringComparison.Ordinal); + var creatorId = sep > 0 ? rest[..sep] : rest; + var channelId = sep > 0 ? rest[(sep + 1)..] : string.Empty; + items = await GetPostItemsAsync(creatorId, channelId, cancellationToken).ConfigureAwait(false); + } + else + { + _logger.LogWarning("Unknown folder id {Folder}", folderId); + items = new List(); + } + + items = ApplySort(items, query); + var total = items.Count; + if (query.StartIndex.HasValue && query.StartIndex.Value > 0) + { + items = items.Skip(query.StartIndex.Value).ToList(); + } + + if (query.Limit.HasValue && query.Limit.Value > 0 && items.Count > query.Limit.Value) + { + items = items.Take(query.Limit.Value).ToList(); + } + + return new ChannelItemResult { Items = items, TotalRecordCount = total }; + } + + /// + public async Task> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken) + { + var creators = await _catalog.GetCreatorsAsync(false, cancellationToken).ConfigureAwait(false); + var latest = new List(); + foreach (var creator in creators) + { + try + { + var posts = await _catalog.FetchPostsAsync(creator.Id, string.Empty, 20, cancellationToken).ConfigureAwait(false); + latest.AddRange(posts.Select(p => ToItem(p, creator, _catalog.HasSession))); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Latest media for {Creator} failed", creator.Urlname); + } + } + + return latest.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).Take(40).ToList(); + } + + /// + public async Task> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken) + { + if (!id.StartsWith(PostPrefix, StringComparison.Ordinal)) + { + return Array.Empty(); + } + + var postId = id[PostPrefix.Length..]; + PostModel post; + try + { + post = await _catalog.GetPostDetailsAsync(postId, cancellationToken).ConfigureAwait(false); + } + catch (FloatplaneAuthException ex) + { + _logger.LogWarning("Cannot play Floatplane post {Post}: {Message}. Paste a valid sails.sid cookie in the plugin settings.", postId, ex.Message); + return Array.Empty(); + } + + var attachments = OrderedVideos(post); + if (attachments.Count == 0) + { + _logger.LogWarning("Post {Post} has no playable video attachment", postId); + return Array.Empty(); + } + + var cfg = _config(); + var baseUrl = ProxyBaseUrl(); + var sources = new List(); + var first = true; + foreach (var video in attachments) + { + var levels = video.Levels + .Where(l => l.Height > 0 && l.Height <= Math.Max(360, cfg.MaxHeight)) + .OrderByDescending(l => Math.Abs(l.Height - DeliveryVariant.ParseHeight(cfg.PreferredQuality)) == 0 ? 1 : 0) + .ThenByDescending(l => l.Height) + .ToList(); + if (levels.Count == 0) + { + levels = video.Levels.OrderByDescending(l => l.Height).Take(1).ToList(); + } + + if (levels.Count == 0) + { + // No level info (still processing?) — still try the preferred quality. + levels = [new VideoLevel { Name = cfg.PreferredQuality, Height = DeliveryVariant.ParseHeight(cfg.PreferredQuality), Label = cfg.PreferredQuality + "p" }]; + } + + var titlePrefix = attachments.Count > 1 ? (string.IsNullOrWhiteSpace(video.Title) ? "Video" : video.Title) + " · " : string.Empty; + foreach (var level in levels) + { + var signature = ProxySignature.Create("hls", postId, video.Id, level.Name); + var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/index.m3u8?{ProxySignature.QueryParameter}={signature}"; + sources.Add(BuildSource(id, first, titlePrefix + (level.Label ?? level.Name + "p"), path, "ts", video, level, hls: true)); + first = false; + } + + if (cfg.EnableMp4Sources) + { + var level = levels[0]; + var signature = ProxySignature.Create("mp4", postId, video.Id, level.Name); + var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/video.mp4?{ProxySignature.QueryParameter}={signature}"; + sources.Add(BuildSource(id, false, titlePrefix + (level.Label ?? level.Name + "p") + " (MP4)", path, "mp4", video, level, hls: false)); + } + } + + return sources; + } + + // ───────────────────────────── folders ───────────────────────────── + + private async Task> GetRootItemsAsync(CancellationToken ct) + { + var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false); + return creators.Select(c => new ChannelItemInfo + { + Id = CreatorPrefix + c.Id, + Name = c.Title, + Type = ChannelItemType.Folder, + FolderType = ChannelFolderType.Container, + Overview = c.Description, + ImageUrl = c.Icon?.BestPath(600) ?? c.Card?.BestPath(1200) ?? c.Cover?.BestPath(1200), + HomePageUrl = "https://www.floatplane.com/channel/" + c.Urlname + "/home" + }).ToList(); + } + + private async Task> GetCreatorItemsAsync(string creatorId, CancellationToken ct) + { + var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false); + var creator = creators.FirstOrDefault(c => c.Id == creatorId); + if (creator == null) + { + return new List(); + } + + if (creator.Channels.Count == 0) + { + return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false); + } + + return creator.Channels.Select(ch => new ChannelItemInfo + { + Id = ChannelPrefix + creator.Id + ":" + ch.Id, + Name = ch.Title, + Type = ChannelItemType.Folder, + FolderType = ChannelFolderType.Container, + Overview = ch.About, + ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), + HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname + }).ToList(); + } + + private async Task> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct) + { + var creator = _catalog.FindCreator(creatorId); + var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false); + // Anonymous listings flag everything as inaccessible; only mark locks when logged in. + var markLocked = _catalog.HasSession; + return posts.Select(p => ToItem(p, creator, markLocked)).ToList(); + } + + /// Maps a post to a channel item. Internal for tests. + internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true) + { + var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title; + var item = new ChannelItemInfo + { + Id = PostPrefix + post.Id, + Name = name, + Type = ChannelItemType.Media, + MediaType = ChannelMediaType.Video, + ContentType = ChannelMediaContentType.Clip, + Overview = Overview(post), + ImageUrl = post.Thumbnail?.BestPath(1200), + PremiereDate = post.ReleaseDate?.UtcDateTime, + DateCreated = post.ReleaseDate?.UtcDateTime, + ProductionYear = post.ReleaseDate?.Year, + RunTimeTicks = post.DurationSeconds > 0 ? (long)(post.DurationSeconds * TimeSpan.TicksPerSecond) : null, + HomePageUrl = "https://www.floatplane.com/post/" + post.Id, + Tags = post.Tags.ToList(), + Studios = creator != null ? [creator.Title] : [], + Genres = post.Channel is { IsReferenceOnly: false, Title.Length: > 0 } ? [post.Channel.Title] : [], + ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Floatplane"] = post.Id } + }; + if (post.Score != 0) + { + item.CommunityRating = (float)Math.Clamp(5 + Math.Log10(Math.Max(1, post.Score)) * 1.5, 0, 10); + } + + return item; + } + + private static string? Overview(PostModel post) + { + var text = post.TextMarkdown; + if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(post.Text)) + { + text = StripHtml(post.Text); + } + + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + text = text.Replace("\\-", "-", StringComparison.Ordinal).Replace("\\_", "_", StringComparison.Ordinal).Trim(); + return text.Length > 2000 ? text[..2000] + "…" : text; + } + + [GeneratedRegex("|

||", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BlockBreaks(); + + [GeneratedRegex("<[^>]+>", RegexOptions.CultureInvariant)] + private static partial Regex AnyTag(); + + internal static string StripHtml(string html) + { + var text = BlockBreaks().Replace(html, "\n"); + text = AnyTag().Replace(text, string.Empty); + text = System.Net.WebUtility.HtmlDecode(text); + return Regex.Replace(text, "\n{3,}", "\n\n").Trim(); + } + + private static List ApplySort(List items, InternalChannelItemQuery query) + { + IEnumerable sorted = query.SortBy switch + { + ChannelItemSortField.Name => items.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), + ChannelItemSortField.Runtime => items.OrderBy(i => i.RunTimeTicks ?? 0), + ChannelItemSortField.PremiereDate or ChannelItemSortField.DateCreated => items.OrderBy(i => i.PremiereDate ?? DateTime.MinValue), + _ => null! + }; + if (sorted == null) + { + // default: folders alphabetical as given, media newest first + return items.All(i => i.Type == ChannelItemType.Folder) + ? items + : items.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).ToList(); + } + + return (query.SortDescending ? sorted.Reverse() : sorted).ToList(); + } + + // ───────────────────────────── media sources ───────────────────────────── + + private static List OrderedVideos(PostModel post) + { + var videos = (post.VideoAttachments ?? new List()) + .Where(v => !v.IsReferenceOnly && !v.IsProcessing) + .ToList(); + if (post.AttachmentOrder.Count > 0) + { + videos = videos.OrderBy(v => + { + var i = post.AttachmentOrder.IndexOf(v.Id); + return i < 0 ? int.MaxValue : i; + }).ToList(); + } + + return videos; + } + + private string ProxyBaseUrl() + { + var configured = _config().ProxyBaseUrl?.Trim().TrimEnd('/'); + if (!string.IsNullOrEmpty(configured)) + { + return configured; + } + + return _appHost.GetApiUrlForLocalAccess(null, false).TrimEnd('/'); + } + + private static MediaSourceInfo BuildSource(string itemId, bool isPrimary, string name, string path, string container, VideoModel video, VideoLevel level, bool hls) + { + var width = level.Width > 0 ? level.Width : (int)Math.Round(level.Height * 16.0 / 9.0); + var streams = new List + { + new() + { + Type = MediaStreamType.Video, + Index = 0, + Codec = "h264", + Profile = "High", + Level = level.Height >= 2160 ? 51 : level.Height >= 1080 ? 40 : 31, + Width = width, + Height = level.Height, + IsDefault = true, + IsInterlaced = false, + AverageFrameRate = 30, + RealFrameRate = 30, + PixelFormat = "yuv420p", + BitRate = EstimateBitrate(level.Height) + }, + new() + { + Type = MediaStreamType.Audio, + Index = 1, + Codec = "aac", + Profile = "LC", + Channels = 2, + SampleRate = 48000, + BitRate = 160000, + IsDefault = true, + Language = "eng" + } + }; + + return new MediaSourceInfo + { + // The first source keeps the item id (what clients echo back as mediaSourceId). + Id = isPrimary ? DeterministicGuid(itemId).ToString("N") : DeterministicGuid($"{itemId}|{video.Id}|{level.Name}|{container}").ToString("N"), + Name = name, + Path = path, + Protocol = MediaProtocol.Http, + Container = container, + VideoType = VideoType.VideoFile, + IsRemote = false, + IsInfiniteStream = false, + RunTimeTicks = video.Duration > 0 ? (long)(video.Duration * TimeSpan.TicksPerSecond) : null, + Bitrate = EstimateBitrate(level.Height) + 160000, + SupportsProbing = false, + SupportsDirectPlay = false, + SupportsDirectStream = !hls, + SupportsTranscoding = true, + RequiresOpening = false, + RequiresClosing = false, + ReadAtNativeFramerate = false, + DefaultAudioStreamIndex = 1, + MediaStreams = streams + }; + } + + private static int EstimateBitrate(int height) => height switch + { + >= 2160 => 8_000_000, + >= 1080 => 2_500_000, + >= 720 => 1_200_000, + >= 480 => 600_000, + _ => 350_000 + }; + + internal static Guid DeterministicGuid(string input) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return new Guid(hash); + } +} diff --git a/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..a5ed3d0 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs @@ -0,0 +1,64 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.Floatplane.Configuration; + +/// +/// Plugin settings. Serialised to XML by Jellyfin +/// (plugins/configurations/Floatplane.xml), so keep properties simple and public. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Value of the sails.sid cookie copied from a logged-in browser session. + /// Floatplane's password login needs a Cloudflare Turnstile token, which a server + /// cannot obtain, so the session is pasted in. Needed for playback; browsing is public. + /// + public string SessionCookie { get; set; } = string.Empty; + + /// + /// Username reported by Floatplane the last time the cookie was validated (display only). + /// + public string SessionUsername { get; set; } = string.Empty; + + /// + /// Comma-separated creator url-names (e.g. linustechtips) to show in addition to + /// (or, when not logged in, instead of) the account's subscriptions. + /// + public string ExtraCreators { get; set; } = string.Empty; + + /// Preferred quality level name: 360, 480, 720, 1080 or 2160. + public string PreferredQuality { get; set; } = "1080"; + + /// Highest level offered as a version at all (keeps 4K out of the list by default). + public int MaxHeight { get; set; } = 1080; + + /// How many posts (newest first) are listed per Floatplane channel. + public int MaxPostsPerChannel { get; set; } = 100; + + /// Show posts the account cannot access (higher tier) as locked items. + public bool ShowLockedPosts { get; set; } + + /// + /// Also offer a progressive MP4 version (Floatplane "download" delivery). Experimental: + /// the download URLs have not been verified to play with the signed token alone. + /// + public bool EnableMp4Sources { get; set; } + + /// + /// Base URL the server's own ffmpeg uses to reach the plugin's stream proxy, e.g. + /// http://127.0.0.1:8096. Empty = Jellyfin's local API url. + /// + public string ProxyBaseUrl { get; set; } = string.Empty; + + /// Budget for /api/v3/delivery/info calls (Floatplane rate-limits ≈2/min). + public int DeliveryCallsPerMinute { get; set; } = 2; + + /// How long listings are cached inside the plugin before Floatplane is asked again. + public int CatalogCacheMinutes { get; set; } = 30; + + /// Hours between automatic catalogue refreshes (scheduled task). + public int SyncIntervalHours { get; set; } = 6; + + /// Report watch progress back to Floatplane (not implemented yet). + public bool SyncProgressToFloatplane { get; set; } +} diff --git a/Jellyfin.Plugin.Floatplane/Configuration/configPage.html b/Jellyfin.Plugin.Floatplane/Configuration/configPage.html new file mode 100644 index 0000000..f8ea473 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Configuration/configPage.html @@ -0,0 +1,246 @@ + + + + Floatplane + + +
+
+
+
+
+

Floatplane

+
Loading status…
+
+ +
+

Session

+
+ + +
+ Floatplane's password login needs a browser captcha, so paste the session instead: + log in at floatplane.com, open DevTools → Application (Chrome) / Storage (Firefox) → Cookies → + https://www.floatplane.com → copy the Value of sails.sid. + Browsing works without it; playback and subscriptions need it. +
+
+
+ + +
+
+ +
+

Catalogue

+
+ + +
Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).
+
+
+ + +
Newest first. Floatplane pages 20 at a time, so 100 = 5 requests per channel.
+
+
+ +
+
+ + +
+
+ + +
Also available as the scheduled task "Refresh Floatplane catalogue".
+
+
+ +
+
+ +
+

Playback

+
+ + +
Listed first as the default version; other qualities appear as extra versions in the player.
+
+
+ + +
+
+ + +
Floatplane rate-limits the stream-URL endpoint to about 2 per minute. Results are cached for the token's lifetime (6 h).
+
+
+ + +
How the server's own ffmpeg reaches this plugin. Leave empty to use Jellyfin's local address.
+
+
+ +
Uses Floatplane's download delivery. Untested — may not play.
+
+
+ +
+ +
+
+ +
+

Search test (Floatplane API)

+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj b/Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj new file mode 100644 index 0000000..f66a2e7 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj @@ -0,0 +1,37 @@ + + + + + net8.0 + Jellyfin.Plugin.Floatplane + enable + disable + true + false + latest + $(NoWarn);CS1591 + + + + + + runtime + + + runtime + + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs b/Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs new file mode 100644 index 0000000..00aa2b7 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Api; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Playback; + +/// +/// Calls /api/v3/delivery/info sparingly: results are cached until the signed +/// token expires and calls are throttled to the configured budget (Floatplane allows +/// roughly two per minute and answers 429 / Cloudflare 1015 beyond that). +/// +public class DeliveryResolver +{ + public const string ScenarioOnDemand = "onDemand"; + public const string ScenarioDownload = "download"; + public const string ScenarioLive = "live"; + + private readonly FloatplaneApiClient _api; + private readonly ILogger _logger; + private readonly Func _config; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _throttle = new(1, 1); + private DateTimeOffset _lastCall = DateTimeOffset.MinValue; + private DateTimeOffset _blockedUntil = DateTimeOffset.MinValue; + + public DeliveryResolver(FloatplaneApiClient api, ILogger logger) + : this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) + { + } + + public DeliveryResolver(FloatplaneApiClient api, ILogger logger, Func config) + { + _api = api; + _logger = logger; + _config = config; + } + + public int CachedEntries => _cache.Count; + + public DateTimeOffset LastCall => _lastCall; + + /// Variants for an attachment, from cache when the token is still valid. + public async Task> GetVariantsAsync(string scenario, string entityId, CancellationToken ct) + { + var key = scenario + "|" + entityId; + if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + { + return cached.Variants; + } + + await _throttle.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_cache.TryGetValue(key, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + { + return cached.Variants; + } + + var now = DateTimeOffset.UtcNow; + if (_blockedUntil > now) + { + throw new FloatplaneRateLimitException(_blockedUntil - now, "Floatplane delivery endpoint is rate-limited; retry later"); + } + + var perMinute = Math.Max(1, _config().DeliveryCallsPerMinute); + var minGap = TimeSpan.FromSeconds(60.0 / perMinute); + var wait = _lastCall + minGap - now; + if (wait > TimeSpan.Zero) + { + _logger.LogDebug("Throttling delivery/info for {Wait}", wait); + await Task.Delay(wait, ct).ConfigureAwait(false); + } + + DeliveryInfoModel info; + try + { + _lastCall = DateTimeOffset.UtcNow; + info = await _api.GetDeliveryInfoAsync(scenario, entityId, ct).ConfigureAwait(false); + } + catch (FloatplaneRateLimitException ex) + { + _blockedUntil = DateTimeOffset.UtcNow + ex.RetryAfter; + throw; + } + + var variants = info.Resolve().ToList(); + var expires = variants + .Select(v => TokenExpiry(v.Url)) + .Where(e => e.HasValue) + .Select(e => e!.Value) + .DefaultIfEmpty(DateTimeOffset.UtcNow.AddHours(1)) + .Min() + .AddMinutes(-5); + _cache[key] = new CachedDelivery(variants, expires); + _logger.LogInformation( + "delivery/info {Scenario} {Entity}: {Count} variants ({Usable} usable), valid until {Expires:u}", + scenario, + entityId, + variants.Count, + variants.Count(v => v.IsUsable), + expires); + return variants; + } + finally + { + _throttle.Release(); + } + } + + /// + /// Picks the variant for a requested level name ("1080"): exact level, else the best + /// usable one not taller than requested, else the best usable one at all. + /// + public static ResolvedVariant? SelectVariant(IEnumerable variants, string requestedLevel) + { + var usable = variants.Where(v => v.IsUsable).ToList(); + if (usable.Count == 0) + { + return null; + } + + var wanted = DeliveryVariant.ParseHeight(requestedLevel); + var exact = usable.FirstOrDefault(v => string.Equals(v.Name, requestedLevel, StringComparison.OrdinalIgnoreCase) + || DeliveryVariant.ParseHeight(v.Name) == wanted); + if (exact != null) + { + return exact; + } + + var notTaller = usable.Where(v => v.Height <= wanted).OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).FirstOrDefault(); + return notTaller ?? usable.OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).First(); + } + + /// Reads exp from the JWT in a ?token= query parameter, if any. + public static DateTimeOffset? TokenExpiry(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return null; + } + + var query = uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries); + var token = query.Select(p => p.Split('=', 2)).FirstOrDefault(p => p[0] == "token" && p.Length == 2)?[1]; + if (string.IsNullOrEmpty(token)) + { + return null; + } + + var parts = Uri.UnescapeDataString(token).Split('.'); + if (parts.Length < 2) + { + return null; + } + + try + { + var payload = Base64UrlDecode(parts[1]); + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var seconds)) + { + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + return null; + } + + return null; + } + + private static byte[] Base64UrlDecode(string s) + { + var padded = s.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return Convert.FromBase64String(padded); + } + + private sealed record CachedDelivery(IReadOnlyList Variants, DateTimeOffset ExpiresAt); +} diff --git a/Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs b/Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs new file mode 100644 index 0000000..5b5560c --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs @@ -0,0 +1,94 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace Jellyfin.Plugin.Floatplane.Playback; + +/// +/// Rewrites an HLS playlist so that (a) every media/segment URI is absolute and +/// (b) the AES-128 key URI (which needs the Floatplane session cookie) and any nested +/// playlist URIs point back at the plugin's proxy. +/// +public static partial class HlsRewriter +{ + [GeneratedRegex("URI=\"([^\"]*)\"", RegexOptions.CultureInvariant)] + private static partial Regex UriAttribute(); + + /// Playlist text as served by the CDN. + /// Absolute URL the playlist was fetched from (base for relative URIs). + /// Maps the absolute key URI to the proxy key URL. + /// Maps a nested playlist URI (master playlists) to a proxy URL; null = leave absolute. + /// Maps a segment URI; null = leave absolute (segments need no cookie). + public static string Rewrite( + string playlist, + Uri playlistUrl, + Func mapKeyUri, + Func? mapPlaylistUri = null, + Func? mapSegmentUri = null) + { + var sb = new StringBuilder(playlist.Length + 512); + var isMaster = playlist.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal); + var nextIsVariantPlaylist = false; + + foreach (var rawLine in playlist.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length == 0) + { + sb.Append('\n'); + continue; + } + + if (line.StartsWith('#')) + { + if (line.StartsWith("#EXT-X-KEY", StringComparison.Ordinal) || line.StartsWith("#EXT-X-SESSION-KEY", StringComparison.Ordinal)) + { + line = UriAttribute().Replace(line, m => "URI=\"" + mapKeyUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1); + } + else if (line.StartsWith("#EXT-X-MAP", StringComparison.Ordinal)) + { + line = UriAttribute().Replace(line, m => "URI=\"" + MapSegment(Absolute(playlistUrl, m.Groups[1].Value), mapSegmentUri) + "\"", 1); + } + else if (line.StartsWith("#EXT-X-MEDIA", StringComparison.Ordinal) && mapPlaylistUri != null) + { + line = UriAttribute().Replace(line, m => "URI=\"" + mapPlaylistUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1); + } + else if (line.StartsWith("#EXT-X-STREAM-INF", StringComparison.Ordinal)) + { + nextIsVariantPlaylist = true; + } + + sb.Append(line).Append('\n'); + continue; + } + + var absolute = Absolute(playlistUrl, line); + if (isMaster && nextIsVariantPlaylist) + { + nextIsVariantPlaylist = false; + sb.Append(mapPlaylistUri != null ? mapPlaylistUri(absolute) : absolute).Append('\n'); + } + else + { + sb.Append(MapSegment(absolute, mapSegmentUri)).Append('\n'); + } + } + + return sb.ToString().TrimEnd('\n') + "\n"; + } + + private static string MapSegment(string absolute, Func? mapSegmentUri) + => mapSegmentUri != null ? mapSegmentUri(absolute) : absolute; + + /// Resolves against (RFC 3986). + public static string Absolute(Uri baseUrl, string reference) + { + // Only http(s) is "absolute" here: on Linux Uri.TryCreate("/x.ts", Absolute) yields file:///x.ts. + if (Api.Models.DeliveryInfoModel.IsHttpUrl(reference)) + { + return reference; + } + + return new Uri(baseUrl, reference).ToString(); + } +} diff --git a/Jellyfin.Plugin.Floatplane/Plugin.cs b/Jellyfin.Plugin.Floatplane/Plugin.cs new file mode 100644 index 0000000..b75cf37 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Plugin.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.Floatplane.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.Floatplane; + +/// +/// Floatplane plugin — exposes Floatplane subscriptions as a Jellyfin channel. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// Plugin GUID; must match build.yaml / meta.json. + public const string PluginGuid = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b"; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "Floatplane"; + + /// + public override Guid Id => Guid.Parse(PluginGuid); + + /// + public override string Description => "Browse and play your Floatplane subscriptions inside Jellyfin."; + + /// Gets the current plugin instance. + public static Plugin? Instance { get; private set; } + + /// Gets the User-Agent the plugin identifies itself with towards Floatplane. + public static string UserAgent { get; } = string.Format( + CultureInfo.InvariantCulture, + "Jellyfin-Floatplane/{0} (+https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane)", + typeof(Plugin).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"); + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + } + ]; + } +} diff --git a/Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs b/Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs new file mode 100644 index 0000000..4e60650 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Http; +using Jellyfin.Plugin.Floatplane.Api; +using Jellyfin.Plugin.Floatplane.Channel; +using Jellyfin.Plugin.Floatplane.Playback; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.Floatplane; + +/// +/// Registers the plugin's services. Found by Jellyfin through type scanning; needs a +/// parameterless constructor. In 10.10 ChannelManager takes IEnumerable<IChannel> +/// from DI, so the channel must be registered here. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// Named HttpClient for Floatplane API + CDN traffic. + public const string HttpClientName = "Floatplane"; + + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection + .AddHttpClient(HttpClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(60); + client.DefaultRequestHeaders.UserAgent.ParseAdd(Plugin.UserAgent); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + // The session cookie is attached per request; no cookie jar, no redirects + // (a 302 from the CDN would otherwise be followed with the cookie attached). + UseCookies = false, + AllowAutoRedirect = false, + AutomaticDecompression = System.Net.DecompressionMethods.All + }); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + // One instance: the status endpoint and the sync task must see the same caches + // as the channel Jellyfin serves from. + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(sp => sp.GetRequiredService()); + } +} diff --git a/Jellyfin.Plugin.Floatplane/Resources/logo.png b/Jellyfin.Plugin.Floatplane/Resources/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..81352839fe859596ac3de1a10781f1fef5861514 GIT binary patch literal 8024 zcmbt&cU+TMx9%Id(nk>l=_)D$GKip*#6dxd!Y2wUAQ=%+T0nYBf;cE3Fd`^|2%>^g zMT8(NK}rNEgGw)fM0yFGkc8yy7ti^=IrrY*{m%W}zuvO;de*bn+I#J_cZ|(BGtup` z+aU-NH9vFeJOuH8R~|@606bQLzcV37QPKR=$qT{#b3-A?&jOxKF6QP{hSJaFK7u6A z^xj4FUVo^2Xzzi~BUu$bH9{)9+3ol{t^79(ubQ2d?DX}YABm-X3YIyA=#xxwRf#J| z`uy&$#lK$Ng@c@Ly-y^(=30P8Sl+YfK#eA_&>o zeicJNLJ)-7Vg^Y-5FWhoL68!7AfSIE`hOgb0+=8KK+iy`3lH)B7&d=qTsoH?Zg^5TAW=HnN6zW+57Ryog$JN!>` z_vZNtL8+5A#Y1`55 zxBz*(EW2xE8L$-D{H1tUZ}VdThaZBZ$}86@;gQk*H0azqieWiJkd1QwEp;3MLWzoU zZVPSWgYdvQ;C_Umev9F-2}mD8cX^J%EMNR5OV5QT?u-ErAR~a=U7RfH4z%M%HqUdt zvwwpi8MbTd%$Ac5i(uRqL=>e(5door^MF|taXOkf2TYkALC(dp%5G!Qe5#tk3JW=Qip;50u|B=&~i6>={`gFkzpFDM%Qicx@E3 z3fvaKg2fSyEh~m;M%##r;RgO@!uX#iFk>KUB3RyB$7q}e`>+szARj*oRII#;IT_M? zTkbz%E-QfuD5?Wr(F?ZbKYbNI!oHIJGE3eP=KmD-{S-zUZwW`p3&14-jsTw}0lEtV#KuIiUXc$(LWc92b6FC^H!%_(jXgA_ ztz-vt(lO`lazP*fax}3EI4*(}!Abm08>fQvhPm?cfD{yR{tG&+S_u#)FKA_w>NZQWLgNq-5# z@sMHL%x$@huM-F0Blp~$H&ua=dmyXjTz()&!y%4R%!>fCN)^zlgJ)H=@dehQzG+1S zP*Zp!1TF9h(=P7|^Hka5SDZ!>{plR};Ffb-i_n5Af7gyCQY7IK!EB(hL(Q&9pFWd;me#$ueMW{gj-9;{kt&M7(1P!iJ-f-~)Sv{bf4H6b|yJI1dQD z2o6rA08-|T63mtoxo{F1*(QGIwh+?+1!5ls+)o6a3c@)8!#G>R7K*>S-~o9-Fk~UB zYbz^J080Xnz)#Ot>K6WHHuc9SJs7`bVTTfAXnwM+I52;M-U4D`GpbPO6WaRpX@C-G z;oi2CK%xCTC!MeC#yJ`@Cb$Tg;4JjrhR<_QG}w!;SDv(>$F37Y#os06%!;8Ia)6Qh zQJw}U8kr!epFO+RWt{x%*cTn-Br15Jg3DlU` zFG7$cIta?gjRZLIUE+@oh(!NVBu4`4lPm`5zL!J0LG_BW5L%5i55cqcB5451_fbQ? z|Bd7zqRoDBJ9t}GtO@$pkhf6&WwP);q0y`2Jb2kysEXIFG#Z&KEsf~D-&~72vaLr{ zh7-;0P2Am!$jT1nE&rf{4g#F`Al;n^hwVHfII?K$mjTG*D9@qMeUUv)B-5*hj<691 z(D%>Yq{o2*z{Q`?cnyAp9TyJ@tqMv+hC`YRmQ%vn-Rj|g@Yq98gzIjAMGHbzh(JLR z912q6X~2USAnq{Xh4<9v$=lo)dDaDO3!p1VV$g3{0)6raH1cthe?Ta{`;oG8j_@oy zg*r_}6$(a9DQ4- zYSBM^=lFyouUGM=DDXy0T~O3Q*d$9Rg@}A_IepiTduKZWFO+r1h13mUJuD1 za20{NzF0z5mxOGXGBR{aemgF_UL+6~f8jNcg3}GDfVly}kdJdujDw!;1SZ7Ouj(C1 zQ+5SAiY67bT40(spR!m9&&<+c{5 z1VZ;3?Jqs{NeD0WrW2m12xwm-qHlpioyDZBuCc41e3Y5 zWojCjLJQr28Dj14g5m)x4}?#<5B}I5||B2{wN5wYKqdmi%&%W zkSseg7Rd)VfJSQP1H6SWVw4AzstcCcR|w5sT(E6Qg0?a1R~57huGZZ_11_KVJ0{)U zz&(;TK9f8R3ebHhY6sxILlV6zDg@`)mQwY#uMq`yof5=$9$=ix&j%f85lVd{ap2y_ zkuxVD5dH!Vc&GqZZR#a$VkHjbR=mp=KtR2s2RyFvBLFDSPuYPOlldj(-TQKoKIoAG zB9Ipl4dgu(y6U{G0BlyMMl-y!un_bnN$A~s>HRP%QYwcVt)T>ES*QXCFf}0qP!_VN zD(G(#Z(bcZ;=g76u8U0q78s%B#>hplY=V6n z)>5y6ErU0J{$HK`Pd5Mk9|}l$ww%C&xBoW$Um_(S+A;wfhf<2#mEU--KE1HVfS{Z( zSZKV~XDnB0pwl^=8r$9E|6S@J-5BQ=V3TTgF#SdoEc&Uf8k5^D@)2%6K0GYV6Q zwUJBuVqLef;?GjzcdTO`)Cavy94dbsx7Orep!k{GGCUeQ-ZfgWocOU~w1g8JGUwAt zTu-po@ojxB>N_&)+1lq_Pq_g&r*S4QrBz5ptz!9v#$u61UP}S$V>Q2$4}3_$M@MY0 zv|oN4B-8ROPrcl7C5?MCob|j`pWYuJ`H-zbDjD9cpB|$>*4F0RT|M6G#_FUHMjE^7 z7i(Bds>Aw1nN!e5CX2NFAwkH{x z9y7|6iysTDq7PPS*9Kp6m_6}wAq{<&a>nttrFHns^WlF8UeU(zJBm#NRTBp|2G?m{ z=(iH@&4>a|p7kJP?Q6NG@xPSEN74$aRZdqNH~|PM}A;Q7vMl&93V_;NU z#Xc>{XMI<9XSkLw8M`M&Gsc^353M#ceVcyZ0DX{cVuEosdn3T?yZ;MT!GQjiY;Ju@b zA0B_QzL|NvR>M2wSNm@hTuZul;(+wTHP?btiC#0* zrAJAdUc-!2IQC@F3~gOYtYLci;Z2Mcxz50q<^mG1;a9Hk+dz3P&6#U&R!twsH&WgX zxi{ONbGK$Q*(O>dO~j!gxpiY53H3^|YwnhZ#L_M*y0v*|GWM2sx&i_QfB-u9i@nLj zvZS~nQU)24rBxL63faBl6`{X6zSNm6moQ;3N|ulF^PMUjS{xD&XxPT`j7Sfs%$53* z3Oo(jRCMEWB3xhSTzhB368|Hh;%WO?oJhzRkeSfB89&!AEZ*Gy*dLBMSeNT}H(R@W zuynSIW>IyO9+pc*`>#*95OxRnjz4VUM{3-D88km+a|8m(tQjd2`S>C{oFV8zYf|I< znrFY7&`@YSPVH9niy0pC861qhbR<`Kp6$~9C>uf>US-+6d4)M>F!EJuk>V08_w7>K zK%ueN(7wK|1}-ywxPrkfA~M47AOpr|Imi|AS=^-c%HAE4CCe*T&-_s?Y;u$Xb}6hZ zY;OXmzeSsSwdP}Z_zy}?Qx-)`&f z+IfA=%g6U{*9L|eQ&BTCW@11SqI5g4Hmv{mvx~0>DhZ6f9gTLN6d4-(nFNK;+2c0W z`jLm_9{3esmbNCI*y$V^GOs=~Y$&nOPU^xXk*5?2xSL zw*T>wwKQks5dTIuLUpm0UXU=)`9wFs)hAn3M|8AQDqMTtP8Z*`225e5(}`2@xy~AU z7OUFc#N`wg%y;N#FRTiP*B+_pB9n4z|3u$&8}?r1kJ^qvNqxSdl8});Yq1oE?M#_$ z($1_YQNL~S2XW!gj2X2bMxV`}-=AnNsBZg^Vx+p*Wgtu$nX-0LU5s~DtCIX)t!FpP z{}7bDu-hAq+4w&Mr<|;%uAa_4U(y)H^Zm7I0;OtUgE9K)hvJ^{>tjBKRoM%RC%m80 zHBUk|h_xZ(^vN4X0IgKUynC^K{NDcGta4Sv20pH+)AthY&3T%e`9-+aC~~9|*UT`V z9GD!kNweL3!H%M8!kx1#8#SYgoJ%Pq%O{dJH6Qf6I1~MDEKmQv6~F&g(~&Umjnyj9 z43`@*30iwhvKWztn)XeF;pmQrl|6iP$7hx+3aguet-HeKZrg&cN1GnMT0&Xt>-9>n z?ET$+e@@w}mxbC=pgp?Y^soVxf|%!fzMM{y8qZNDO52H(XWI)gbMr)(V_)Bj+K*XB zTj&!B#!qJ#D|58^P7Z#JPhGQS5H>y|TMsZpdR-h|o`!5zZ>WVAVmg;Pols>rL*93F z6tsV$5J7`uJ3P2ES8>?!YkJDji@jo`M_UQANQO!#@T4yMPh<@guOrB@Jm?fN$& zuHAZ18-B8Y{LocD9vn6EhKX`IGfmxjhrdTCNW#=N5M6f8oLB zJy{9p?8Js%w${81eefJ(5hIGD8H-c4YO`e<7P4H`4VhIl=Kjr7O$p=v4S8RQJI@24 zy^1l#RWkH}gKEoZ$yTBDT#-aRSF0TDTD8Wz9oicQ+TuPrgxc4w$vd zni7BWG!HgZqpsp6^y(;0sNs|7{AxZ@MZuY1`nsOgZYQYKim_T8NaV1Bj^@%B5lJ=T z8!1H(8QnuQzCE@dg9FEW{MQq!y$6;WHsxsKTziVzaNDya^OfiG@#P-YYbRJ~tFPq^ z+c6I|FtoC0GnE_R%#_06^Ku9%ef76bJ)!GqPvbL@Y3#Ysuzl5wEph1~4&5bTx_g&EjKf(x9lXis|{J zum;SBaa+tyWeLL2V zc}-pVQ%S~jNmBTUi02wbddZ^a4Ar1eiej%PICcwb*QMEwz!My1mFdBBxT#i7xAG4OE(^op!qbjMwi;P9d0 zJN=^Bc#NLM?Psq$rHp)PkU#M&KXSr2BNp^3^0dl`+3~|p>9e{^ir8THYCn2YYUg83 zX2-iuZityjHJzkUJfC~?H7!lYfDlaFIIH2Tw}+rLt+hcb9PGdx-x!W)<{TsXS(sVf zk{yUVldE#BH&+|(UQ;U=0_AS=5-a{JyWKXEROySb?w zV=S+It-fpr9r;eXT|fJH;8X8pA;`U(zbBy4DKqW)Um&Pb`dG@zF>`KW(?OPKi0 z*mZ@!j*jDqIheh*5g7VicEK1H`=xE8rzkGW*r;aYi+fmGL7bpgnj|tTIAU-i7-zWj z6#L1ggr#lxj_MQ1!DiG+BU#e`-EeIYF*Ekci&ft>hbwvGZ0D7@qD!4d+UpS-AU@vk zxl9;0EaeF zW-~VW_eESTZX*Db9b}zs0nQ~zwv%&bdvhY)8TkT<`(hh)RhZzP$O?NNG*{W+(Ef6fIz6h-nl6jM zK1s^R_o$@iARo}PYsgm>9(?0OwB6;-!cpv;z|1Es7dh#@O+3yAT|Atb$QabBqBW@7 zlao{;R0i{gA2KGbyPGKNn{#ixFNgMd87+Cu<{6Plj9^&9$QERUvYkL8)%+rEQ(_GkOgnZoX@%!M(jh4ux9n>S^jJ09DK!#UZr zX{$pgU42BMeT(_XaVp8uuHo&yV~tPE${Pmt9&lc)eN7u>a3Ugn$BbMkn%vy97jGNpcX2&1-Cg;`>Ih!#lV*yQ5tDBth)_D#Ni za@2wl4Q<@H`cwrsa5QwW6r?3Ieq@Z%HWyjaf>A9=?#JDpT1RI62&9y2?rObtA3lw* zd(B*9C0KpX%gL_Ql!~g~m~0z!>8l(K_HOhsWPe4onQtB8XXjN5P#WNt8AB;m1|6&1 zK$%Q>mp);pup^-tkHsfhdW*-;zU6}Qp)h*S$ z5pd(0smFfV(zj<`tAsMmvh$IO9!yi}Qz?^@;0>F@SB!T8<5s@@&@%uk;?RrDMB&VK;9 CY&J>& literal 0 HcmV?d00001 diff --git a/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs b/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs new file mode 100644 index 0000000..6a01be2 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Floatplane.Channel; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Tasks; + +/// +/// Scheduled task: refresh creators/channels and warm the newest posts, then bump the +/// channel cache key so Jellyfin re-lists on the next browse. Found by type scanning. +/// +public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly CatalogService _catalog; + private readonly ILogger _logger; + + public FloatplaneSyncTask(CatalogService catalog, ILogger logger) + { + _catalog = catalog; + _logger = logger; + } + + /// + public string Name => "Refresh Floatplane catalogue"; + + /// + public string Key => "FloatplaneSync"; + + /// + public string Description => "Reloads subscribed creators, their channels and the newest videos from Floatplane."; + + /// + public string Category => "Floatplane"; + + /// + public bool IsHidden => false; + + /// + public bool IsEnabled => true; + + /// + public bool IsLogged => true; + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + _logger.LogInformation("Floatplane sync started"); + await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation); + } + + /// + public IEnumerable GetDefaultTriggers() + { + var hours = Math.Clamp(Plugin.Instance?.Configuration.SyncIntervalHours ?? 6, 1, 168); + return + [ + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerStartup }, + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(hours).Ticks } + ]; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..62e17c4 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# jellyfin-plugin-floatplane + +A Jellyfin server plugin that exposes your [Floatplane](https://www.floatplane.com) +subscriptions (creators → channels → videos, plus live streams) inside +Jellyfin and all its clients, so Floatplane content shows up next to the +rest of the library on the Pi5 server and plays through the normal +Jellyfin players. + +**Status (2026-10-25):** browsing works (verified against a local Jellyfin +10.10.7); playback proxy is implemented but still needs a real session +cookie to be verified. See [`doc/plan.md`](doc/plan.md) for milestones and +[`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) for the API. + +The plugin is a pure managed .NET assembly (AnyCPU IL), so one build runs +on both `linux-x64` (dev box) and `linux-arm64` (the Pi5). + +## Prerequisites + +| Target | What to install | +|--------|-----------------| +| Arch / Garuda (dev box) | `sudo pacman -S dotnet-sdk-8.0` (Jellyfin 10.10.x = .NET 8) — for Jellyfin 12.x use `dotnet-sdk` (.NET 10) | +| Debian / Ubuntu | `apt install dotnet-sdk-8.0` from the Microsoft repo | +| Pi5 (runtime only) | nothing — the plugin runs inside the existing `linuxserver/jellyfin` container | + +Optional, for local testing: Docker (to run a throw-away Jellyfin of the +same version as the Pi5 with the plugin folder mounted). + +## Build + +```bash +git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git +cd jellyfin-plugin-floatplane +scripts/package.sh # → build/Floatplane_/ (DLL + meta.json), build/floatplane_.zip (+ .md5) +``` + +The VS Code `build` task (`Ctrl+Shift+B`) runs the same script. Plain +`dotnet build` works too (`jellyfin-plugin-floatplane.sln`). + +## Install + +**From the plugin repository (recommended):** Dashboard → Plugins → +Repositories → add + +``` +https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json +``` + +then Catalog → Channels → Floatplane → Install → restart Jellyfin. + +**Manual, on the Pi5 (linuxserver image):** + +```bash +curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh -s -- --dry-run +# then without --dry-run; it installs into /srv/docker/jellyfin/data/plugins/ and restarts the container +``` + +**Manual, from a local build:** + +```bash +# linuxserver image keeps data under /config/data (container path); +# host path on the Pi5: /srv/docker/jellyfin/data/plugins/ (infra-Doc services/media-stack.md) +cp -r build/Floatplane_0.1.0.0 /srv/docker/jellyfin/data/plugins/ +docker restart jellyfin +``` + +> The Pi5 runs Jellyfin **10.10.7** but `lscr.io/linuxserver/jellyfin:latest` +> is already **12.3** (.NET 10). Pin the tag (`10.10.7ubu2404-ls80`) before +> any `docker pull`, or the plugin becomes `NotSupported` overnight. + +Then Dashboard → Plugins → Floatplane → paste your `sails.sid` cookie +(the page explains where to find it) → Validate → Save. A "Floatplane" +channel appears under My Media: creators → sub-channels → videos. +Without a cookie you can still browse creators listed under +"Extra creators" (public data); playback needs the cookie. + +## How it works (short) + +- `FloatplaneChannel` (`IChannel`) lists creators → sub-channels → posts + from Floatplane's public listing API; ids are `creator:…`, `channel:…`, + `post:…`. +- On play, `IRequiresMediaInfoCallback` fetches the post (login) and hands + Jellyfin signed URLs to the plugin's own `Floatplane/Stream/…` endpoints. +- The stream endpoint calls `delivery/info` (cached, throttled to ~2/min), + fetches the HLS playlist, and rewrites it: the AES-128 key URI points + back at the plugin (which fetches the key with the cookie), segments go + straight to the CDN. ffmpeg then remuxes (stream copy) for the client. +- A scheduled task refreshes the catalogue and bumps the channel cache key. + +## Test + +```bash +dotnet test # unit tests (models, HLS rewriter, API client with fixtures from the HAR) +``` + +Local end-to-end test with a disposable Jellyfin matching the Pi5 version: + +```bash +mkdir -p .jellyfin-test/config/data/plugins +cp -r build .jellyfin-test/config/data/plugins/Floatplane_0.1.0.0 # writable copy, not :ro +docker run --rm -p 8096:8096 -v $PWD/.jellyfin-test/config:/config \ + lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80 +# logs: .jellyfin-test/config/log/log_*.log ; plugin status: Dashboard → Plugins +``` + +## Docs + +- `doc/plan.md` — goal, stack, milestones, roadmap +- `doc/floatplane-api-notes.md` — endpoints, auth, HLS/AES-128 playback flow +- `doc/research/jellyfin-plugin-research.md` — plugin API research (10.10 vs 12.x, channels, proxy pattern, packaging, paths) +- `doc/research/floatplane-api-research.md` — community API docs, OAuth device flow, DPoP, rate limits, other clients +- `doc/research/reference-sources/` — snapshots of other clients' source for study (chaosflix is the template to follow) +- `doc/har/` — the captured browser session (**gitignored, contains credentials**) +- Gitea wiki (once the repo exists) — architecture and design decisions diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..88a79cc --- /dev/null +++ b/build.yaml @@ -0,0 +1,18 @@ +--- +name: "Floatplane" +guid: "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b" +version: "0.1.0.0" +targetAbi: "10.10.0.0" +framework: "net8.0" +overview: "Browse and play your Floatplane subscriptions inside Jellyfin" +description: > + Adds a "Floatplane" channel: subscribed creators, their sub-channels and the newest + videos, played through Jellyfin's normal player. Log in by pasting the sails.sid + session cookie in the plugin settings. Built for Jellyfin 10.10.x (.NET 8), AnyCPU + (runs on x64 and arm64). +category: "Channels" +owner: "brasse" +artifacts: +- "Jellyfin.Plugin.Floatplane.dll" +changelog: > + Initial release: channel browsing, HLS playback proxy, cookie session, search test. diff --git a/doc/floatplane-api-notes.md b/doc/floatplane-api-notes.md new file mode 100644 index 0000000..54160d1 --- /dev/null +++ b/doc/floatplane-api-notes.md @@ -0,0 +1,239 @@ +# Floatplane API — what the captured session shows + +Source: `doc/har/www.floatplane.com.har` (Chrome export, 2026-10-25, web +client `4.5.25`). The HAR is **gitignored** because it contains the login +POST body (plain-text password + captcha token), the account e-mail and +user-bound signed CDN tokens. Everything below is sanitised. + +The capture covers: a video already playing → logout → login (with +Cloudflare Turnstile) → load the LinusTechTips creator page → open a post +→ play its 1080p video. + +## Hosts + +| Host | Role | Needs session? | +|------|------|----------------| +| `www.floatplane.com` | REST API (`/api/v3/...`) + HLS **key server** (`/api/video/watchKey`) | yes for most | +| `cdn-vod-drm2.floatplane.com` | VOD HLS playlists + `.ts` segments (Cloudflare-cached) | no — signed URL token | +| `pbs.floatplane.com` | thumbnails, cover images, timeline sprites | no | +| `*.r2.cloudflarestorage.com` | subtitle `.vtt` tracks (S3-presigned, 15 min) | no — presigned | +| `apm.floatplane.com`, `playback-slurp.floatplane.com` | telemetry (ignore) | — | + +Verified 2026-10-25 with plain `curl` (custom User-Agent, no cookie): + +- `GET /api/v3/creator/named`, `GET /api/v3/content/creator` → **200, public** + (post metadata, thumbnails, titles, durations — no login needed). +- `GET /api/v3/delivery/info` → `notLoggedInError` (needs `sails.sid`). +- `POST /api/v3/auth/login` **without** `captchaToken` → `invalidCaptchaTokenError`, + **regardless of User-Agent** (tested Hydravion/Wasserflug/CFNetwork/Android + style UAs). Server-side username+password login is therefore not possible + without a Turnstile token → the plugin must accept a pasted session + cookie (see "Auth" below). +- Cloudflare does **not** block plain non-browser requests to the API. + +## Auth + +``` +GET /api/v3/auth/captcha/info → site keys; the web client uses + turnstile.variants.managed.siteKey +POST /api/v3/auth/login {"username","password","captchaToken"} + 200 → {"user":{id,username,email,profileImage,...},"needs2FA":false} + (if needs2FA → POST /api/v3/auth/checkFor2faLogin {"token":"123456"}) +POST /api/v3/auth/logout → 200 "OK" +GET /api/v3/user/self → current user (good "is the cookie alive?" probe) +``` + +Session = cookie `sails.sid` (HttpOnly). Chrome strips cookies from HAR +exports, so the actual value is not in the capture. Plugin plan: the user +copies `sails.sid` from the browser (DevTools → Application → Cookies) into +the plugin settings; the plugin validates it with `/api/v3/user/self` and +reports "logged in as ". + +## Discovery + +``` +GET /api/v3/user/subscriptions?active=true + → [{plan:{id,title,price,...}, creator:"", startDate, endDate, interval}] +GET /api/v3/creator/list?ids[0]= → [Creator] +GET /api/v3/creator/named?creatorURL[0]=linustechtips → [Creator] +GET /api/v3/creator/info?id= → Creator +GET /api/v3/creator/stats?id= +``` + +`Creator` keys: `id, title, urlname, description, about, category, cover, +icon, card, channels[], defaultChannel, liveStream, subscriptionPlans[], +subscriberCountDisplay, socialLinks, discordServers, owner, visibility`. + +`channels[]` = sub-channels (`{id, title, urlname, icon, cover, card}`), +e.g. LTT has 11: `main`, `techlinked`, `shortcircuit`, `techquickie`, +`fpexclusive`, `tajm`, `gamelinked`, `channelsuperfun`, `macaddress`, +`psucircuit`, `ltxexpo`. Every post carries a `channel` object → natural +folder structure in Jellyfin: **Creator → Channel → Posts**. + +`liveStream` = `{id, title, streamPath, thumbnail, ...}`; +`streamPath` looks like `/api/video/v1/..channel..m3u8`. + +## Content feed + +``` +POST /api/v3/content/creator/list {"ids":["",...],"limit":20} + → {"blogPosts":[Post...], "lastElements":[{creatorId, blogPostId, moreFetchable}]} + (pagination: send fetchAfter=[{creatorId, blogPostId, moreFetchable}] back) +GET /api/v3/content/creator?id=&limit=20&fetchAfter=0&sort=DESC + &search=&hasVideo=false&hasAudio=false&hasPicture=false&hasText=false + → [Post...] (fetchAfter = offset; add &channel= to filter) +GET /api/v3/content/post?id= → Post (full, with videoAttachments[]) +GET /api/v3/content/video?id= → Video +GET /api/v3/content/related?id= +GET /api/v3/content/progress?ids[0]=&...&contentType=blogPost → [{id, progress}] +POST /api/v3/content/progress {"id":"","contentType":"video","progress":} +``` + +`Post` (trimmed): + +```json +{ + "id": "SSvhppI28t", "guid": "SSvhppI28t", + "title": "How Board Games Come ALIVE...- Board", + "text": "

…html…

", "textMarkdown": "…", + "type": "blogPost", "tags": [], + "attachmentOrder": ["dcm2jUIg1J"], + "releaseDate": "2026-10-24T18:24:00.036Z", + "likes": 59, "dislikes": 0, "score": 59, "comments": 6, + "isAccessible": true, + "metadata": { "hasVideo": true, "videoCount": 1, "videoDuration": 1104, + "hasAudio": false, "hasPicture": false, "hasGallery": false, + "isFeatured": false, "displayDuration": 1104 }, + "thumbnail": { "width": 1920, "height": 1080, + "path": "https://pbs.floatplane.com/video_thumbnails//_.jpeg", + "childImages": [ {400x225}, {1200x675}, {1920x1080} ] }, + "creator": { "id": "59f94c0bdd241b70349eb72b", "title": "LinusTechTips", "urlname": "linustechtips", "...": "…" }, + "channel": { "id": "64135da7ce81077a8480c679", "title": "ShortCircuit", "urlname": "shortcircuit", "...": "…" }, + "videoAttachments": [ Video ], // only in /content/post + "audioAttachments": [], "pictureAttachments": [], "galleryAttachments": [] +} +``` + +In the list endpoints `videoAttachments` are **ids only**; `/content/post` +expands them. `isAccessible:false` = locked behind a higher tier. + +`Video` (trimmed): + +```json +{ + "id": "dcm2jUIg1J", "title": "…", "type": "video", "duration": 1104, + "creator": "", "primaryBlogPost": "SSvhppI28t", "isProcessing": false, + "levels": [ {"name":"360","width":640,"height":360,"label":"360p","order":0}, + {"name":"1080",…}, {"name":"2160","label":"4K","order":7} ], + "textTracks": [ {"id","src":"https://.r2.cloudflarestorage.com/…//….vtt?X-Amz-…&X-Amz-Expires=900", + "kind":"captions","language":"en","generated":true} ], + "thumbnail": { … "content_thumbnails//…" }, + "timelineSprite": { "width": 3840, "height": 2070, "path": "https://pbs.floatplane.com/timeline_sprite//….jpeg" } +} +``` + +## Playback (VOD) + +``` +GET /api/v3/delivery/info?scenario=onDemand&entityId= +``` + +```json +{ "groups": [ { + "origins": [ { "url": "https://cdn-vod-drm2.floatplane.com" } ], + "variants": [ + { "name": "1080-avc1", "label": "1080p", "enabled": true, "hidden": false, + "url": "/Videos//1080.mp4/chunk.m3u8?token=", + "mimeType": "application/x-mpegURL", "order": 111745216, + "meta": { "video": { "codec": "avc1.640028", "width": 1920, "height": 1080, + "fps": 29.97, "isHdr": false, "bitrate": {"average": 1661360}, + "mimeType": "video/MP2T" }, + "audio": { "codec": "mp4a.40.2", "channelCount": 2, "samplerate": 48000, + "bitrate": {"average": 151115} } } }, + { "name": "2160-avc1", "label": "4K", "enabled": false, "url": "", + "meta": { "common": { "access": { "deniedReason": "isMissingPermission", + "deniedMessage": "You lack permission to access this media." } } } } + ] } ] } +``` + +- Final playlist URL = `origins[0].url + variants[i].url`. +- The `token` is a **JWT** (HS256) with `resourcePath`, `userId`, `iat`, + `exp` → **valid 6 h** (`exp - iat = 21600`). Variants above the user's + tier come back `enabled:false` with an empty url (4K here). +- `chunk.m3u8` (HLS v3, `#EXT-X-ENDLIST`, ~181 segments of 4–12 s for an + 18-minute video) lists segments as + `.ts?token=` and starts with + + ``` + #EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token=" + ``` + +- **The AES-128 key request is the only playback call that needs the + session cookie** (`cache-control: no-cache, private`, 16-byte body). + Segments and playlists are served by Cloudflare with + `access-control-allow-origin: *` and `cache-control: public, max-age=86400` + and need no cookie, only their token. +- Consequence for Jellyfin: whoever plays the stream (ffmpeg on the + server, or hls.js in the browser on direct play) must be able to fetch + the key **with** `sails.sid`. Browsers cannot attach a third-party + cookie, so the plugin should **serve a rewritten playlist** from its own + API controller where the `#EXT-X-KEY` URI points back at the plugin + (`/Floatplane/Key//`), which fetches the key server-side. + Segment URLs can stay on the CDN (CORS is open), or be proxied too. +- Other `scenario` values documented by the community: + `download` (progressive mp4, used by floatplane-downloader) and `live`. + Not in the capture — test once a cookie is available; a plain mp4 would + let Jellyfin direct-play with range requests and skip the HLS/key dance. + +## Search & filters on `GET /api/v3/content/creator` (mapped 2026-10-25, public) + +All verified with plain curl, no cookie: + +| Param | Effect | Note | +|-------|--------|------| +| `id` | creator id (required) | | +| `channel` | sub-channel id | combine freely with the rest | +| `search` | free-text, matches titles (and apparently body text) | `search=framework` → Framework laptop videos across channels | +| `hasVideo=true` | only posts with a video | `hasAudio`/`hasPicture`/`hasText` likewise (one at a time) | +| `tags[0]=wan` | tag filter | matched WAN-show VODs even though their `tags` array is empty — server-side tags differ from the exposed array | +| `fromDate`/`toDate` | ISO 8601 window on `releaseDate` | | +| `fromDuration`/`toDuration` | seconds | `0..300` returned shorts and 0-s text posts | +| `sort` | `DESC` (default) / `ASC` | | +| `limit` | **max 20** (`paramValidationError "limit" must be less than or equal to 20`) | | +| `fetchAfter` | offset in posts, multiples of `limit` | empty array = end | + +Related public endpoints: `GET /api/v3/content/tags?creatorIds[0]=` → `{tag: count}` (136 tags for LTT), +`GET /api/v3/creator/channels/list?ids[0]=`, `GET /api/v3/creator/named?creatorURL[0]=`. + +**Login required** (verified `notLoggedInError` without cookie): `creator/info?id`, `creator/list` (with or +without `ids[]`/`search`), `POST content/creator/list` (multi-creator feed), `content/post`, `content/video`, +`delivery/info`, `user/self`, `user/subscriptions`. + +**Anonymous listings report every post as `isAccessible: false`** and `videoAttachments: null` — the flag only +means something once logged in. The plugin therefore only applies its "locked" filter when a session exists. + +Jellyfin has no channel-search hook in 10.10, so Floatplane search is exposed as an admin endpoint +(`GET /Floatplane/Search?creatorId=&q=&channelId=`) plus the "Search test" box on the plugin settings page. +Items that have been listed once are searchable through Jellyfin's normal library search. + +## Live + +`creator.liveStream.streamPath` + `GET /api/v3/delivery/info?scenario=live&entityId=` +(not in capture). + +## Subtitles + +`video.textTracks[].src` is an S3-presigned URL that expires after 900 s — +fetch at play time and hand to Jellyfin as an external subtitle stream (or +cache the `.vtt` on disk). + +## Images + +All `pbs.floatplane.com` paths are public and cacheable (verified without +cookie). Use `thumbnail.childImages` `1200x675` for primary art, +`creator.cover`/`card` for the creator folder. + +## IDs seen (public) + +- LinusTechTips creator id `59f94c0bdd241b70349eb72b`, channel ShortCircuit + `64135da7ce81077a8480c679`, sample post `SSvhppI28t`, video `dcm2jUIg1J`. diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..552b9df --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,152 @@ +# jellyfin-plugin-floatplane — plan (2026-10-25) + +> Decisions taken 2026-10-25: Gitea repo `brasse/jellyfin-plugin-floatplane` +> (private), **build for Jellyfin 10.10.x** (Pi5 stays on 10.10.7 for now), +> **cookie session first** (OAuth client id to be requested from Floatplane +> later), **VOD first**, and map/test the search API. Remaining open points +> are listed at the bottom. + +## Goal + +Make Floatplane subscriptions browsable and playable inside Jellyfin on the +Pi5 (`film.brasse-pc.eu`) with **no changes to any client**: creators and +their sub-channels appear as a "Floatplane" channel under My Media in the +web client, Android/TV apps and Jellyfin Media Player; videos play through +the normal Jellyfin player with watched-state, resume and SyncPlay. + +## What the research established + +Full reports: [`research/floatplane-api-research.md`](research/floatplane-api-research.md), +[`research/jellyfin-plugin-research.md`](research/jellyfin-plugin-research.md), +HAR findings in [`floatplane-api-notes.md`](floatplane-api-notes.md). + +**Jellyfin side** + +- Pi5 runs **10.10.7** (image pulled 2025-08-12). `linuxserver/jellyfin:latest` + is now **12.3** (.NET 10). A 10.10 plugin is `NotSupported` on 12.x and + vice versa. Decision needed (Q2). +- 10.10.7 = .NET 8, NuGet `Jellyfin.Controller 10.10.7`, `targetAbi 10.10.0.0`. + Verified locally: the template builds against 10.10.7 with the installed + .NET 8 SDK in 8 s. +- Plugins are IL/AnyCPU → one build runs on x64 and the Pi5 (arm64). No + native deps allowed in the plugin. +- The **channel API (`IChannel` + `IRequiresMediaInfoCallback`) exists and + works** in 10.10.7 *and* 12.3 (checked both NuGet packages). It is the + only way to get a virtual, playable catalogue into every official client. + Reference implementation: chaosflix-jellyfin (copy in `research/reference-sources/chaosflix/`). +- Jellyfin forwards **only User-Agent/Referer** to ffmpeg and its relay — + never cookies or `Authorization`. Therefore the plugin must **proxy** + playback through its own ASP.NET controller with HMAC-signed anonymous + URLs (chaosflix pattern). +- linuxserver image paths: plugins in `/config/data/plugins/_/`, + config XML in `/config/data/plugins/configurations/`, logs `/config/log/`. +- Listing cache 3 h (bump `DataVersion`/cache key to refresh), media-info + cache 5 min, one parent per item id, channel `Name` must never change. + +**Floatplane side** + +- Public, no login: creator info, channel lists, post listings, thumbnails. +- Login needed: `delivery/info` (stream URLs) and the HLS **AES-128 key** + (`/api/video/watchKey`). Segments and playlists only need their 6-hour + signed token. +- Username/password login requires a Cloudflare Turnstile token — verified + today that no User-Agent bypasses it. Two real options: + 1. **Pasted `sails.sid` cookie** (what the web client used in the HAR; + works today, zero dependencies, lifetime unknown — must handle expiry). + 2. **OAuth2 device-code flow** against Keycloak `auth.floatplane.com` + (what Hydravion/Wasserflug/floatplane-downloader use; access token + 30 min, refresh token 90 days sliding). Needs a **client_id registered + by Floatplane** — verified `invalid_client` for an unregistered id. + Obtained by asking Floatplane staff (community Discord). +- `delivery/info` is rate-limited (~2/min) → cache per video until token + expiry, serialise calls. +- `scenario=download` gives plain MP4 URLs (seekable, no key dance) — not + in the capture; test once a session exists. If the token alone is enough + this is the preferred playback path. + +## Stack + +- C# / .NET 8 class library `Jellyfin.Plugin.Floatplane` (switch to .NET 10 + + Jellyfin 12.x packages if Q2 says upgrade). `System.Text.Json`, + `IHttpClientFactory`, no third-party NuGet packages that ship natives. +- Target OS/arch: linux-x64 (dev/test) + linux-arm64 (Pi5), single AnyCPU build. +- Tests: xUnit against recorded JSON fixtures (shapes from the HAR + spec). +- Packaging: `meta.json` + zip (+ md5) per release; `manifest.json` plugin + repository served from Gitea so Jellyfin's dashboard can install/update. + +## Deploy target + +Pattern A (playbook): Gitea Actions builds the zip on push to `master`, +publishes rolling `latest` + `vX.Y.Z` releases, and updates the repository +`manifest.json`. Install on the Pi5 by adding the manifest URL under +Dashboard → Plugins → Repositories (or scp into `/config/data/plugins/`). + +## Architecture + +``` +Jellyfin clients ──► Jellyfin server (Pi5) + │ IChannel "Floatplane" + │ root → creators → sub-channels → videos (Episode/Clip) + │ IRequiresMediaInfoCallback + │ → MediaSourceInfo { Path = /Floatplane/stream/?t=, MediaStreams = h264/aac from delivery meta } + │ FloatplaneStreamController (anonymous, HMAC-signed) + │ → delivery/info (cached) → rewritten HLS playlist (key URI → /Floatplane/key/…) + │ or plain MP4 relay with Range passthrough + │ FloatplaneClient (session: cookie or OAuth tokens, UA, 429 handling, delivery throttle) + │ SyncTask (IScheduledTask, every N h: subscriptions → creators → posts → cache; bumps cache key) + │ Config page: login (cookie paste now, device-code later), quality preference, which creators/channels, sync interval + └──► www.floatplane.com / cdn-vod-drm2 / pbs +``` + +## Milestones + +| # | Increment | Done when | +|---|-----------|-----------| +| M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) | +| M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **code done** — config page validates the cookie; unit tests in `Jellyfin.Plugin.Floatplane.Tests` | +| M2 | Channel browse: root → creators → sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done locally** (web API verified: folders, paging, sorting, latest, images); Android app not yet checked | +| M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, untested** — needs a real session cookie (Bjorn, on the Pi5 or locally) | +| M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` → releases; install on Pi5 from the repository URL or `install-pi5.sh` | **code done** — first CI run pending the initial push | +| M5 | Polish: watched/resume sync to Floatplane progress API, subtitles (VTT text tracks), locked-tier handling, cookie-expiry warning, Android/TV app check | next | + +## Roadmap / expansions (to agree) + +- **OAuth device-code login** once Floatplane issues a client id (QR code + on the config page, 90-day refresh token, no more cookie copying). +- **Live streams** (WAN Show): `scenario=live` IVS HLS as a live item or a + Live TV tuner; offline detection by polling the playlist. +- **Audio posts / podcasts** as an audio channel. +- **Archive to library**: optional scheduled download of selected channels + to `/media/Floatplane///` with NFO + thumbnails for + trickplay/offline (floatplane-downloader-style layout). +- **Multi-user**: per-Jellyfin-user Floatplane sessions (`IsEnabledFor`). +- **Home-screen "Latest from Floatplane"** via `ISupportsLatestMedia`. +- **Jellyseerr-style request/notification** hook when new posts appear + (notifyr on the Pi5). + +## First real-world test (Bjorn, when home) + +1. Pin the Pi5 image tag (`lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80`) + before anything else, or a `pull` lands on 12.3. +2. Install: Dashboard → Plugins → Repositories → add + `https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json`, + install "Floatplane", restart. (Or `install-pi5.sh` from the release.) +3. Settings → paste `sails.sid` → Validate → Save. Creators from the + subscription appear under My Media → Floatplane. +4. Play a 1080p video in the web client; watch `log/` for + `FloatplaneStreamController` lines and the ffmpeg transcode log — the goal + is a stream **copy** (remux), not a re-encode. +5. Try the MP4 experiment (settings → "progressive MP4 versions") and report + whether it plays; if it does it becomes the default (seekable, no key dance). + +## Open questions + +1. **OAuth client id**: ask Floatplane (community Discord + `https://discord.gg/4xKDGz5M5B`) for a device-flow client id for this + plugin — only Bjorn can make that request. Until then: cookie. +2. **Cookie lifetime**: unknown; the settings page shows "rejected" when it + expires. Measure in practice. +3. **Pi5 upgrade to 12.x**: separate task; the plugin needs a .NET 10 / + 12.x build then (`TaskTriggerInfo` enum and package versions are the + known deltas). +4. **Live streams** and the other roadmap items: priority after VOD works. diff --git a/doc/research/floatplane-api-research.md b/doc/research/floatplane-api-research.md new file mode 100644 index 0000000..beb432c --- /dev/null +++ b/doc/research/floatplane-api-research.md @@ -0,0 +1,221 @@ + + +# Floatplane unofficial API — research report for a Jellyfin plugin (as of 2026-10-25) + +Legend: **[V]** = verified from docs/spec/source code or a live fetch today; **[I]** = inferred / not directly verified. Local copies of everything I read are in `/tmp/claude-1000/-home-brasse/8a10aa19-62bd-490c-b689-b51638220e46/scratchpad/src/` (e.g. `spec_full.json`, `spec_dump.txt`, `oidc.json`, `fpts_*.ts`, `fpdl_*.ts`, `hydra_*.brs`, `wf_*.swift`, `floaty_*.dart`, `fn_*.kt`, `fn_ios_*.swift`, `ytdlp_floatplane.py`, `bz_*.mjs`). + +## 0. Executive summary — what changed and what it means for a Jellyfin plugin + +1. **Username/password login is legacy.** Every actively maintained third-party client (floatplane-downloader 5.19.x, Hydravion Roku, Wasserflug tvOS, Floaty, FloatNative) has migrated in 2025–2026 to **OAuth 2.0 / OIDC on a Keycloak server at `https://auth.floatplane.com/realms/floatplane`**, using the **device-code grant** (TV/CLI) or auth-code+PKCE (mobile/desktop), and then call the REST API with `Authorization: Bearer ` instead of the `sails.sid` cookie. **[V]** (sources: floatplaneAPI.ts `src/index.ts`, Hydravion `oauthDeviceTask.brs`, Wasserflug `Wasserflug_tvOSApp.swift`, Floaty `oauth2_service.dart`, FloatNative `OAuthApi.kt`, live OIDC discovery doc). +2. **You need an OAuth `client_id` registered by Floatplane.** The `floatplane` npm README says `clientId: "example-client", // Has to be registered on FloatPlane`. Known registered public clients: `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty`, `floatnative`, and Floatplane's own `fp-tv-app`. There is no public self-service registration page; the maintainers evidently obtained client IDs from Floatplane staff (the community Discord `https://discord.gg/4xKDGz5M5B` is where Hydravion/Wasserflug devs hang out). **[V]** for the IDs, **[I]** for the "ask staff" process. Using another app's client ID would work technically but is not yours to use; the bmlzootown canary code explicitly says "Do not invent alternate clients". +3. **Some clients require DPoP (RFC 9449).** `fp-tv-app` and `floatnative` are configured with `usesExtendedSecurity` → PKCE S256 on the device-auth request **and** a DPoP proof (ES256/P-256) on every token-endpoint and REST request (`Authorization: DPoP `). `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty` use plain `Bearer` without DPoP. So whether your plugin needs DPoP depends on how Floatplane configures your client. **[V]** +4. **The CDN/HLS side needs auth too (for VOD).** Hydravion sets `Authorization: Bearer` on the Roku video node for all playlist/segment/key requests; Floaty passes `Authorization` + `Referer`/`Origin` headers to mpv; Wasserflug intercepts AVPlayer loads and adds Bearer; FloatNative fetches the **m3u8 manifest and the AES-128 `#EXT-X-KEY` with DPoP**, but rewrites **segment** URIs to plain absolute https ("the backend is designed around this split: keys require session-bound auth, segments don't"). Live (IVS) streams carry the token only in the URL and are played natively without headers. **[V]** For Jellyfin this means you cannot just hand the HLS URL to the client; the plugin must proxy the playlist+key (or feed ffmpeg `-headers`), see §6.6. +5. **`/api/v3/delivery/info` is rate-limited hard** (community consensus ≈ 2 requests/minute; Floaty budgets 2 per 5 min; 429 + `Retry-After`, Cloudflare error 1015). Cache delivery responses per attachment until the embedded token expires. **[V]** +6. The community OpenAPI spec (jamamp/FloatplaneAPI, spec version `3.10.0-c`, frontend tracked to `4.0.13`) **lags the live site** (frontend is `4.5.25` today) and still documents only `CookieAuth`; the bmlzootown fork README states "OpenAPI may still list only CookieAuth — explorer REST auth is Bearer ahead of that docs lag" and that `sails.sid` is "chat/Socket.IO-only". **[V]** + +--- + +## 1. Community API documentation + +### 1.1 Repositories and docs sites +- Spec repo (renamed): `https://github.com/jamamp/FloatplaneAPI` (formerly `Jman012/FloatplaneAPI`; old URL redirects). Files: `src/floatplane-openapi-specification.json` (full, 606 KB, OpenAPI 3.0.3, title "Floatplane REST API", version `3.10.0-c`), trimmed spec generated by `tools/trim.js` (drops operations whose description starts with "TODO"), AsyncAPI specs for chat (`chat.floatplane.com`) and frontend sockets. MIT. **[V]** +- Rendered docs: `https://jamamp.github.io/FloatplaneAPIDocs/` (Swagger UI, Redoc, RapiDoc, ReSlate, Postman v2.1 collection; trimmed and full variants). The old `jman012.github.io/FloatplaneAPIDocs` now 404s. **[V]** +- Trimmed spec download: `https://jamamp.github.io/FloatplaneAPIDocs/floatplane-openapi-specification-trimmed.json` (raw GitHub path for the trimmed file 404s; the full one is `https://raw.githubusercontent.com/jamamp/FloatplaneAPI/main/src/floatplane-openapi-specification.json`). **[V]** +- Fork with extras: `https://github.com/bmlzootown/FloatplaneAPI` — static API explorer (`static/explorer/`, PR #6), frontend-change watcher, and an authenticated "API shape canary" (PR #7 Phase 3.0, PR #11/#12 Phase 3.1) that implements the device flow + DPoP in Node (`tools/fp-api-shape-canary/lib/{device-flow,dpop,auth-refresh,constants}.mjs`) and commits measured token lifetimes (`artifacts/api-shape/refresh-lifetime-evidence.json`). **[V]** +- README guidance (jamamp): "Please set a custom `User-Agent` header ... that uniquely identifies what app the requests are coming from, and what version ... This is very helpful to the Floatplane team". Integration tests authenticate with env var `sails.sid`. The spec's `info.description` has an "API Flow" section (as of FP 4.0.13): login → `/api/v3/user/subscriptions` → `/api/v3/creator/info` → `/api/v3/content/creator/list` (home) → `/api/v3/content/creator` (creator page) → `/api/v3/content/post` → `/api/v3/content/{video|audio|picture}` → `/api/v3/delivery/info`; livestream via `delivery/info` + `wss://chat.floatplane.com/sails.io/`. **[V]** +- Rate limiting text in the spec: "Rate-limited requests will respond with an HTTP 429 ... will also have a `Retry-After` header ... Clients are expected to 1) prevent too many requests ... particularly for the `/api/v2/cdn/delivery` and `/api/v3/delivery/info` endpoints, and 2) properly handle rate-limited responses". **[V]** +- LTT forum thread announcing the docs: `https://linustechtips.com/topic/1384044-floatplane-unofficial-floatplane-api-documentation/` (Cloudflare-blocked for fetch tools; not read). **[I]** + +### 1.2 Authentication + +#### A. Legacy cookie login (documented in the spec; still exists server-side; the website bundle still contains `/api/v3/auth/login`, `/api/v3/auth/checkFor2faLogin`, `/api/v3/auth/captcha/info`, `/api/v3/auth/logout`) **[V]** +- `POST /api/v2/auth/login` (also `/api/v3/auth/login`) body `{"username","password","captchaToken"?}`; 200 → `{"user": {id, username, profileImage}, "needs2FA": bool}`; sets cookie `sails.sid` (Sails.js session). `captchaToken` description: "The Google Recaptcha v2/v3 token ... On web browsers, this is required. For mobile or TV applications, this is not required only if the User-[Agent …]" (text truncated in my dump; the point is a non-browser User-Agent exempts you). 401 → `ErrorModel {id, errors[{id,name,message?,data?}], message?}`. +- `POST /api/v2/auth/checkFor2faLogin` (also v3) body `{"token": ""}` → same login response, completes the session; `POST /api/v2/auth/logout` → `"OK"`. +- `GET /api/v3/auth/captcha/info` → `{"v2":{"variants":{"android":{siteKey},"checkbox":{siteKey},"invisible":{siteKey}}},"v3":{"variants":{"invisible":{siteKey}}}}` (Google reCAPTCHA keys). +- Cookie lifetime: not documented in the spec; Wasserflug persisted it with the `Expires` from `Set-Cookie` and rewrote the domain to `.floatplane.com`. No verified number. **[I]** +- How clients handled captcha: Wasserflug's source comment: "Set custom user agent for network requests. This is particularly required in order to pass the login phase with bypassing the captcha" (UA `"Wasserflug tvOS App version X, CFNetwork"`); the spec's own test UA also ends in `, CFNetwork`; Hydravion's dev wrote (LTT forum, via search snippet) that the Roku store build logs in without a reCAPTCHA token via "a semi-shady workaround that won't be made public". **[V]** for the code comments, **[I]** for how robust that still is. +- yt-dlp (master) still uses this path: it requires a `sails.sid` cookie exported from a browser (`_real_initialize` raises login-required otherwise) and sends `Origin`/`Referer: https://www.floatplane.com/`. **[V]** + +#### B. Current OAuth2/OIDC login (what you should implement) **[V]** +Live discovery document fetched today: `https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration` +``` +issuer https://auth.floatplane.com/realms/floatplane +authorization_endpoint …/protocol/openid-connect/auth +token_endpoint …/protocol/openid-connect/token +device_authorization_endpoint …/protocol/openid-connect/auth/device +revocation_endpoint …/protocol/openid-connect/revoke +end_session_endpoint …/protocol/openid-connect/logout +grant_types_supported authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code, … (also password, client_credentials, token-exchange, ciba) +scopes_supported openid, profile, email, offline_access, videos:watch, videos:download, account:read, account:write, … +code_challenge_methods plain, S256 +dpop_signing_alg_values ES256, RS256, PS256, EdDSA, … +``` +Pre-prod realm/host used by devs: realm `floatplane-pp` with API base `https://pp.floatplane.com` (floatplaneAPI.ts `sanity.ts`, Hydravion `apiConfig.brs`). **[V]** + +Device-code flow as implemented by Hydravion (`oauthDeviceTask.brs`), floatplaneAPI.ts (`deviceLogin`, via `openid-client`) and the canary: +1. `POST https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device`, form `client_id=&scope=openid profile email offline_access` (+ `code_challenge_method=S256&code_challenge=…` when the client has extended security). Response: `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}`. Hydravion shows `floatplane.com/link` as the friendly URL (the website bundle has `linkShortUrl:"floatplane.com/link"`) and renders `verification_uri_complete` as a QR code. +2. Poll `POST …/openid-connect/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=…&device_code=…` (+ `code_verifier`, + `DPoP` header for DPoP clients) every `interval` s; 400 `{"error":"authorization_pending"}` → keep polling; `slow_down` → +5 s; `expired_token`/`access_denied` → abort. +3. Success: `{access_token, expires_in, refresh_token, refresh_expires_in, token_type ("Bearer" or "DPoP"), id_token?, scope, session_state, not-before-policy}`. +4. Refresh: `POST …/token` form `grant_type=refresh_token&client_id=…&refresh_token=…` (DPoP proof again for DPoP clients). Refresh **before** expiry (floatplaneAPI.ts refreshes when <60 s left; Hydravion when <300 s). Don't refresh on every request: floatplane-downloader 5.19.5 had a bug refreshing before every call and after ~40 refreshes in seconds `auth.floatplane.com` returned **Cloudflare 429 / error 1015 with a 600 s retry** (issue #290). +5. Call `https://www.floatplane.com/api/...` with `Authorization: Bearer ` (floatplaneAPI.ts adds it in a `got` `beforeRequest` hook only for URLs under the base URL; Hydravion/Floaty add it to every API request; Floaty keeps a cookie jar too but relies on Bearer). + +Measured lifetimes (bmlzootown canary, live 2026-10-11, client `fp-tv-app`, requested `openid offline_access`, granted `openid profile email offline_access`): **access token `expires_in=1800` s (30 min); refresh token `refresh_expires_in=7776000` s (90 days), rotated on every refresh, sliding window** ("Each refresh returned full refresh_expires_in=7776000 from that moment"). Hydravion also handles `refresh_expires_in=0` by assuming a 30-day idle limit. **[V]** + +DPoP details (FloatNative `DPoPManager.kt`/`.swift`, canary `dpop.mjs`): EC P-256 key stored per device; proof JWT header `{"typ":"dpop+jwt","alg":"ES256","jwk":{kty,crv,x,y}}`, payload `{iat, jti, htm, htu (URL without query), ath = b64url(sha256(access_token)) on resource requests}`; headers `DPoP: ` and `Authorization: DPoP `; honour `DPoP-Nonce` response header and the `use_dpop_nonce` / `invalid_dpop_proof` errors by retrying with the nonce; FloatNative auto-corrects clock skew from the `Date` header on a DPoP 400. **[V]** + +Website's own auth modes (from `frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js`): enum `cookie | token | oauth-basic | oauth-dpop`; TV config `{baseUrl:"https://auth.floatplane.com", realm:"floatplane", clientId:"fp-tv-app", usesExtendedSecurity:true, linkShortUrl:"floatplane.com/link"}`, `token_endpoint_auth_method:"none"` (public client). **[V]** + +#### C. `GET /api/v3/user/self` **[V]** +```json +{"id":"0123456789abcdef01234567","username":"my_username", + "profileImage":{"width":512,"height":512,"path":"https://pbs.floatplane.com/profile_images/default/user12.png","childImages":[{"width":250,"height":250,"path":"…_250x250.png"}]}, + "email":"…","displayName":"…","creators":[],"scheduledDeletionDate":null} +``` +Used by every client as the "am I logged in" probe (Hydravion uses `/api/v3/user/subscriptions` for that; a 401 → clear tokens). Wasserflug calls `GET /api/v3/status?platform=web&version=…` first (returns `selfUser` or 403). + +### 1.3 Subscriptions — `GET /api/v3/user/subscriptions` **[V]** +```json +[{"startDate":"2020-09-25T07:35:04.273Z","endDate":"2021-09-25T07:35:04.273Z","paymentID":12345,"interval":"year","paymentCancelled":false, + "plan":{"id":"5d48d0306825b5780db93d07","title":"LTT Supporter (1080p)","description":"…","price":"5.00","priceYearly":"50.00","currency":"usd","logo":null,"featured":true,"allowGrandfatheredAccess":false,"discordServers":[],"discordRoles":[]}, + "creator":"59f94c0bdd241b70349eb72b"}] +``` +`creator` is the creator GUID (24 hex). Duplicates are possible (Wasserflug de-dupes with a Set). Wasserflug shows an error if the list is empty. + +### 1.4 Creators **[V]** +- `GET /api/v3/creator/info?id=` → `CreatorModelV3` (single creator). +- `GET /api/v3/creator/named?creatorURL[0]=linustechtips` → `[CreatorModelV3]` (yt-dlp uses it to resolve `/channel//home/` URLs). +- `GET /api/v3/creator/list?search=` → `[CreatorModelV3]` (discovery; FloatNative also calls it with `ids[]=` to batch-fetch creators). +- `GET /api/v3/creator/channels/list?ids[0]=` → `[ChannelModel]` (floatplane-downloader uses this per subscription). +- v2: `GET /api/v2/creator/info?creatorGUID[0]=…&creatorGUID[1]=…` → `[CreatorModelV2]` (multi-creator in one call; Wasserflug still uses it for livestream refresh). +`CreatorModelV3` (trimmed): +```json +{"id":"59f94c0bdd241b70349eb72b","owner":{"id":"…","username":"Linus"},"title":"LinusTechTips","urlname":"linustechtips", + "description":"…","about":"# We're LinusTechTips\n…","category":{"id":"…","title":"Technology"}, + "cover":{"width":1990,"height":519,"path":"https://pbs.floatplane.com/cover_images/…jpeg","childImages":[…]}, + "icon":{"width":600,"height":600,"path":"https://pbs.floatplane.com/creator_icons/…jpeg","childImages":[…]}, + "liveStream":{"id":"5c13f3c006f1be15e08e05c0","title":"…","description":"

…

","thumbnail":{…},"owner":"…","channel":"","streamPath":"…","offline":{"title":null,"description":null,"thumbnail":null}}, + "subscriptionPlans":[…],"discoverable":true,"subscriberCountDisplay":"total","incomeDisplay":false, + "defaultChannel":"63fe42c309e691e4e36de93d", + "channels":[{"id":"63fe42c309e691e4e36de93d","creator":"59f94c0b…","title":"Linus Tech Tips","urlname":"main","about":"…","order":0,"cover":{…},"card":{…},"icon":{…},"socialLinks":{}}, …], + "socialLinks":{},"card":{…}} +``` + +### 1.5 Content listing **[V]** +`GET /api/v3/content/creator` — params: `id`* (creator GUID), `channel` (channel id), `limit` (clients use 20), `fetchAfter` (integer **offset** = number of posts to skip, "usually a multiple of `limit`"), `search`, `tags[0]=…`, `hasVideo`, `hasAudio`, `hasPicture`, `hasText` (mutually exclusive with the others), `sort=ASC|DESC`, `fromDuration`/`toDuration` (seconds, imply hasVideo), `fromDate`/`toDate` (ISO). Returns `[BlogPostModelV3]`; empty array = end. floatplaneAPI.ts `blogPostsIterable` simply loops `fetchAfter += 20` until an empty page; yt-dlp uses `fetchAfter = page * 20`. + +`BlogPostModelV3` (trimmed): +```json +{"id":"Dw2ms0AgL8","guid":"Dw2ms0AgL8","title":"…","text":"

…

","type":"blogPost", + "channel":{…ChannelModel…} /* or just a channel id string in some responses */, + "tags":["test"],"attachmentOrder":["TViGzkuIic"], + "metadata":{"hasVideo":true,"videoCount":1,"videoDuration":5689,"hasAudio":false,"audioCount":0,"audioDuration":0,"hasPicture":false,"pictureCount":0,"hasGallery":false,"galleryCount":0,"isFeatured":false}, + "releaseDate":"2021-10-09T09:29:00.039Z","likes":41,"dislikes":0,"score":41,"comments":28, + "creator":{"id":"…","owner":{…},"title":"…","urlname":"…","icon":{…},"liveStream":{…}|null,"defaultChannel":"…","channels":["…"]}, + "wasReleasedSilently":false, + "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/blogPost_thumbnails//_.jpeg","childImages":[{"width":400,"height":225,"path":"…_400x225.jpeg"},…]}, + "isAccessible":true, + "videoAttachments":["TViGzkuIic"],"audioAttachments":[],"pictureAttachments":[],"galleryAttachments":[]} +``` +Important recent change: in list responses the attachment arrays are now **omitted/empty** — Hydravion's current code comments: "List responses omit attachment ids. Keep them when an older payload still sends them." and "Delivery and playback use the attachment id. The creator list no longer includes it." So always fetch `GET /api/v3/content/post?id=` to get attachment IDs before playback. **[V]** + +`GET /api/v3/content/creator/list` (multi-creator home feed): `ids[0]=…&ids[1]=…&limit=20` plus pagination objects `fetchAfter[0][creatorId]=…&fetchAfter[0][blogPostId]=…&fetchAfter[0][moreFetchable]=true` (one per creator, taken verbatim from the previous response's `lastElements`). Response `{"blogPosts":[BlogPostModelV3…],"lastElements":[{"creatorId":"…","blogPostId":"…"|null,"moreFetchable":true}]}`. Wasserflug filters out `lastElements` entries with `blogPostId == null` because "Floatplane doesn't like when we send one of these without a blogPostId". **[V]** + +`GET /api/v3/content/post?id=` → `ContentPostV3Response`: same as above but `channel` is a full `ChannelModel`, `creator` is `CreatorModelV2`, `userInteraction[]`, and the attachments are **full objects**: +```json +"videoAttachments":[{"id":"TViGzkuIic","guid":"TViGzkuIic","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…","likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8","thumbnail":{…},"isAccessible":true}], +"audioAttachments":[{…,"duration":114,"waveform":{"dataSetLength":…,"highestValue":…,"lowestValue":…,"data":[…]}}] +``` +Order attachments by `attachmentOrder`; a post may contain several videos (yt-dlp returns a playlist then). 403 for posts behind a plan you don't have (`isAccessible:false`). + +`GET /api/v3/content/video?id=` → `ContentVideoV3Response`: +```json +{"id":"TViGzkuIic","guid":"…","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…", + "likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8", + "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/content_thumbnails/TViGzkuIic/…jpeg","childImages":[]}, + "isAccessible":true,"blogPosts":["Dw2ms0AgL8"], + "timelineSprite":{"width":4960,"height":2610,"path":"https://pbs.floatplane.com/timeline_sprite/…jpeg"}, + "progress":123 /* seconds, optional */,"userInteraction":[], + "levels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…], + "textTracks":[{"id":"…","src":"https://.r2.cloudflarestorage.com/prod-texttrack/text_tracks/…vtt?X-Amz-…&X-Amz-Expires=900…","kind":"caption","language":"en","generated":true,"processing":false}]} +``` +Caption URLs are pre-signed (900 s) — fetch promptly. floatplane-downloader skips videos whose `levels` is empty. **[V]** + +### 1.6 Delivery **[V]** +`GET /api/v3/delivery/info?scenario=onDemand|download|live&entityId=[&outputKind=hls.mpegts|hls.fmp4|dash.mpegts|dash.m4s|flat][&entityKind=video|audio|livestream|auto]` (`entityKind` is in the website's client but not in the community spec). Floaty always sends `outputKind=hls.mpegts` for streaming; Wasserflug sends none (gets HLS TS by default). Response: +```json +{"groups":[{"origins":[{"url":"https://cdn-vod-drm2.floatplane.com"}], + "variants":[ + {"name":"1080-avc1","label":"1080p","url":"/Videos/Lug14XXgLx/1080.mp4/chunk.m3u8?token=","mimeType":"application/x-mpegURL","order":99293376,"hidden":false,"enabled":true, + "meta":{"video":{"codec":"avc1.64002a","codecSimple":"avc1","bitrate":{"average":1448573},"width":1920,"height":960,"isHdr":false,"fps":29.97,"mimeType":"video/MP2T"}, + "audio":{"codec":"mp4a.40.2","bitrate":{"average":93340,"maximum":93340},"channelCount":2,"samplerate":48000,"mimeType":"video/MP2T"}}}, + {"name":"2160-avc1","label":"4K","url":"/Videos/Lug14XXgLx/2160.mp4/chunk.m3u8?token=", …}]}]} +``` +- `download` scenario: origins are edge servers (`https://edge01-na.floatplane.com`, `queryUrl`, `datacenter{latitude,longitude,countryCode,regionCode}`) and variant URLs are plain MP4s `/Videos//1080.mp4?token=` with `mimeType: video/mp4`. +- `live` scenario: `{"origins":[{"url":"https://.us-east-1.playback.live-video.net"}],"variants":[{"name":"live-abr","label":"Auto","url":"/api/video/v1/us-east-1..channel..m3u8?allow_source=false&token=","mimeType":"application/x-mpegURL","meta":{"live":{"lowLatencyExtension":"ivshls"}}}]}` (Amazon IVS). +- `meta.common.access.deniedReason` ∈ `isMissingPermission | isProcessing | isBroken` with `deniedMessage` when a variant is not available to you. +- **Building the final URL**: if `variant.url` is absolute use it; else `origin.url` (trailing slash trimmed) + `variant.url`; prefer `variant.origins` over `group.origins` (Wasserflug `DeliveryHelper.getBestUrl`, random origin); floatplane-downloader round-robins origins for downloads; Hydravion/yt-dlp just take `groups[0].origins[0].url + variant.url`. The spec says v3 URLs contain **no template placeholders** (that was v2). +- Quality selection in practice: filter `enabled && !hidden`; sort by `order` (bigger = better) or `meta.video.height`; match the user's preference on `label` ("1080p", "4K") or `name` ("1080-avc1"); Wasserflug drops variants taller than the screen (×1.15 tolerance; note LTT 2:1 videos are 2160×1080 and 4320×2160). +- Token: the `?token=` is a JWT. The spec's v2 example decodes to `{"ressourcePath":"/Videos/TViGzkuIic/360.mp4/chunk.m3u8","userId":"…","iat":1633797311,"exp":1633818911}` → **6-hour lifetime** in that example (older format); Floaty checks an `expires` epoch query param on download URLs and re-requests delivery info one minute before expiry. Treat tokens as short-lived (hours) and per-user/per-resource. **[V]** for the example, **[I]** for current lifetime. +- **Does the CDN need the cookie/bearer or just the signed URL?** Verified behaviour of clients: all current clients send `Authorization` (Bearer/DPoP) to the CDN as well; FloatNative documents that the **master playlist and the AES-128 key request require session-bound auth, media segments do not**; yt-dlp pulls the key URL from `#EXT-X-KEY:METHOD=AES-128,URI="https://…"` and fetches it with the same cookies/headers. Whether a `download`-scenario MP4 URL plays with the token alone is **not verified** (Floaty's downloader also goes through its Bearer-adding HTTP client). **[V]/[I]** as marked. Test both. +- Older endpoint `GET /api/v2/cdn/delivery?type=vod|aod|live|download&guid=` (or `&creator=` for live): `{"cdn":"https://cdn-vod-drm2.floatplane.com","strategy":"cdn","resource":{"uri":"/Videos/TViGzkuIic/{qualityLevels}.mp4/chunk.m3u8?token={qualityLevelParams.token}","data":{"qualityLevels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],"qualityLevelParams":{"360":{"token":""},…}}}}` — substitute `{qualityLevels}` with `qualityLevels[].name` and `{qualityLevelParams.X}` with `qualityLevelParams[name][X]`; later revisions used numeric keys (`{qualityLevelParams.1}` file, `{qualityLevelParams.2}`/`.4` token — Floaty and old yt-dlp). yt-dlp's old code: `path.replace(f'{{qualityLevelParams.{i}}}', val)` for each param then `urljoin(stream['cdn'], path)`. yt-dlp switched to v3 on 2025-11-10 (PR #14984) because the v2 shape broke. Treat v2 as deprecated. **[V]** +- Floaty's download trick: take the HLS variant URL, strip `/chunk.m3u8`, replace `/Videos/` with `/`, append `&attachment=true&filename=` to get a browser-style download. **[V]** (behaviour), **[I]** (stability). +- Rate limit evidence: spec test `test_Flow.py` sleeps 35 s between delivery calls ("Current rate limit is 2 req per min"); floatplane-downloader uses a semaphore of 2 with a 65 s hold ("only call the delivery endpoint twice a minute at most"); Floaty budgets 2 per 5 minutes and parses `Retry-After` (default 300 s) and body text `error code: 1015`. **[V]** + +### 1.7 Live streams **[V]** +- Find the stream via `creator.liveStream` (`CreatorModelV2/V3`, also on posts' `creator`). `liveStream.id` → `GET /api/v3/delivery/info?scenario=live&entityId=` → IVS HLS URL with `token` (see above). Old way: `/api/v2/cdn/delivery?type=live&creator=`. +- Live/offline detection: Hydravion (`streamCheckTask.brs`) and Wasserflug (`LivestreamViewModel`) simply `GET` the m3u8 URL every ~3–5 s; HTTP 200 = live, anything else = offline. `liveStream.offline{title,description,thumbnail}` holds the offline card. Channels (sub-channels) don't have their own livestreams ("Individual channels do not currently support livestreams" — Hydravion). FloatNative plays live URLs natively without DPoP/headers (token in URL). The AsyncAPI spec covers chat on `wss://chat.floatplane.com/sails.io/` (needs the `sails.sid` cookie — the one remaining use of the cookie per bmlzootown). + +### 1.8 Watch progress **[V]** +- Batch read: `POST /api/v3/content/get/progress` body `{"ids":["",…],"contentType":"blogPost"}` → `[{"id":"3qZrz5ugsa","progress":0},{"id":"mhhrOfndeF","progress":100}]` — **percentage 0–100** of the first video/audio attachment; don't use it to seek. +- Write: `POST /api/v3/content/progress` body `{"id":"