Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
sub init()
m.top.functionName = "request"
end sub
sub request()
' Request device authorization
deviceAuthResponse = requestDeviceAuthorization()
if deviceAuthResponse = invalid
' Error details are already logged in requestDeviceAuthorization
' Set a generic error - the actual error message is in the logs
m.top.error = "Failed to request device authorization"
return
end if
deviceCode = deviceAuthResponse.device_code
userCode = deviceAuthResponse.user_code
verificationUriComplete = deviceAuthResponse.verification_uri_complete
expiresIn = deviceAuthResponse.expires_in
interval = deviceAuthResponse.interval
' Get verification URI for display - use user-friendly URL for prod only
verificationUri = deviceAuthResponse.verification_uri
apiConfigObj = ApiConfig()
if apiConfigObj.environment = "prod"
verificationUri = "floatplane.com/link"
end if
' Pass verification URI to main thread for QR code generation
' Note: verificationUriComplete is unchanged - QR code uses original API response
m.top.verificationUriComplete = verificationUriComplete
m.top.userCode = userCode
m.top.verificationUri = verificationUri
m.top.status = "QR_CODE_READY"
print "[OAUTH] OAuth device flow started"
print "[OAUTH] User code: " + userCode
' Poll for token
pollForToken(deviceCode, interval, expiresIn)
end sub
function requestDeviceAuthorization() as Object
appInfo = createObject("roAppInfo")
version = appInfo.getVersion()
useragent = "Hydravion (Roku) v" + version
apiConfigObj = ApiConfig()
url = apiConfigObj.buildRealmAuthUrl("openid-connect/auth/device")
https = CreateObject("roUrlTransfer")
https.RetainBodyOnError(true)
port = CreateObject("roMessagePort")
https.SetMessagePort(port)
https.SetUrl(url)
https.setCertificatesFile("common:/certs/ca-bundle.crt")
https.AddHeader("Content-Type", "application/x-www-form-urlencoded")
https.AddHeader("Accept", "application/json")
https.AddHeader("User-Agent", useragent)
https.initClientCertificates()
' Send client_id as form data
postData = "client_id=hydravion"
if https.AsyncPostFromString(postData)
while (true)
event = wait(10000, port)
if type(event) = "roUrlEvent"
code = event.GetResponseCode()
if code = 200
response = ParseJSON(event.GetString())
return response
else
responseBody = event.GetString()
print "[OAUTH] Device authorization error: " + code.ToStr() + " - " + responseBody
' Parse error to pass more specific information
errorResponse = ParseJSON(responseBody)
if errorResponse <> invalid and errorResponse.error <> invalid
' Pass the specific error type to the main thread
m.top.error = errorResponse.error
else
m.top.error = "Failed to request device authorization"
end if
return invalid
end if
else if event = invalid
https.AsyncCancel()
return invalid
end if
end while
end if
return invalid
end function
sub pollForToken(deviceCode as String, intervalSeconds as Integer, expiresInSeconds as Integer)
' Convert interval to milliseconds for sleep
intervalMs = intervalSeconds * 1000
if intervalMs < 1000
intervalMs = 1000 ' Minimum 1 second
end if
' Calculate expiration time
startTime = CreateObject("roDateTime")
startTimeSeconds = startTime.AsSeconds()
expirationTime = startTimeSeconds + expiresInSeconds
' Poll until token is received or expired
while (true)
' Check if expired
currentTime = CreateObject("roDateTime")
currentTimeSeconds = currentTime.AsSeconds()
if currentTimeSeconds >= expirationTime
m.top.error = "TIMEOUT"
m.top.status = "TIMEOUT"
print "[OAUTH] OAuth device flow expired"
return
end if
' Calculate remaining time
remainingSeconds = expirationTime - currentTimeSeconds
m.top.expiresIn = remainingSeconds.ToStr()
' Request token
tokenResponse = requestToken(deviceCode)
if tokenResponse <> invalid
' Success! Store tokens
registry = RegistryUtil()
registry.write("access_token", tokenResponse.access_token, "hydravion")
registry.write("refresh_token", tokenResponse.refresh_token, "hydravion")
' Store expiration time (current time + expires_in)
tokenExpirationTime = currentTimeSeconds + tokenResponse.expires_in
registry.write("token_expires_at", tokenExpirationTime.ToStr(), "hydravion")
' Store refresh token expiration
' If refresh_expires_in is 0, use 30-day idle limit (will be extended on each refresh)
if tokenResponse.refresh_expires_in <> invalid and tokenResponse.refresh_expires_in > 0
refreshExpirationTime = currentTimeSeconds + tokenResponse.refresh_expires_in
registry.write("refresh_token_expires_at", refreshExpirationTime.ToStr(), "hydravion")
refreshDays = tokenResponse.refresh_expires_in \ 86400
print "[OAUTH] Refresh token expires in " + refreshDays.ToStr() + " days (from API)"
else
refreshExpirationTime = currentTimeSeconds + (30 * 24 * 60 * 60) ' 30 days in seconds
registry.write("refresh_token_expires_at", refreshExpirationTime.ToStr(), "hydravion")
print "[OAUTH] Refresh token has 30-day idle limit (refresh_expires_in=0), expiration set to 30 days from now"
end if
m.top.status = "AUTHENTICATED"
print "[OAUTH] OAuth authentication successful"
return
end if
' Wait before next poll
sleep(intervalMs)
end while
end sub
function requestToken(deviceCode as String) as Object
appInfo = createObject("roAppInfo")
version = appInfo.getVersion()
useragent = "Hydravion (Roku) v" + version
apiConfigObj = ApiConfig()
url = apiConfigObj.buildRealmAuthUrl("openid-connect/token")
https = CreateObject("roUrlTransfer")
https.RetainBodyOnError(true)
port = CreateObject("roMessagePort")
https.SetMessagePort(port)
https.SetUrl(url)
https.setCertificatesFile("common:/certs/ca-bundle.crt")
https.AddHeader("Content-Type", "application/x-www-form-urlencoded")
https.AddHeader("Accept", "application/json")
https.AddHeader("User-Agent", useragent)
https.initClientCertificates()
' Send grant_type and device_code as form data
postData = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&client_id=hydravion&device_code=" + https.Escape(deviceCode)
if https.AsyncPostFromString(postData)
while (true)
event = wait(10000, port)
if type(event) = "roUrlEvent"
code = event.GetResponseCode()
if code = 200
response = ParseJSON(event.GetString())
return response
else if code = 400
' 400 might mean authorization_pending (user hasn't authorized yet)
' or expired_token, or other errors
responseBody = event.GetString()
errorResponse = ParseJSON(responseBody)
if errorResponse <> invalid and errorResponse.error <> invalid
if errorResponse.error = "authorization_pending"
' User hasn't authorized yet, continue polling
return invalid
else if errorResponse.error = "expired_token"
m.top.error = "EXPIRED"
m.top.status = "EXPIRED"
return invalid
else
' Other error
print "[OAUTH] Token request error: " + errorResponse.error
return invalid
end if
end if
return invalid
else
responseBody = event.GetString()
print "[OAUTH] Token request error: " + code.ToStr() + " - " + responseBody
return invalid
end if
else if event = invalid
https.AsyncCancel()
return invalid
end if
end while
end if
return invalid
end function