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
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:
220
doc/research/reference-sources/hydra_oauthDeviceTask.brs
Normal file
220
doc/research/reference-sources/hydra_oauthDeviceTask.brs
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user