@@ -99,6 +99,8 @@ def __init__(self, hass) -> None:
9999 self ._hass = hass
100100 # Dedikovaná session pro qBit — potřebuje cookie jar pro session auth
101101 self ._qbit_session : aiohttp .ClientSession | None = None
102+ # Session pro admin Overseerr požadavky — potřebuje cookie jar pro CSRF token
103+ self ._seerr_admin_session : aiohttp .ClientSession | None = None
102104 # Session pro rodinný Overseerr účet
103105 self ._seerr_family_session : aiohttp .ClientSession | None = None
104106 # Cache pro auto-detekovanou Plex URL (když user nezadal manuální)
@@ -131,6 +133,65 @@ async def _qbit_login(self, session: aiohttp.ClientSession, ssl=None) -> None:
131133 }, ssl = ssl ) as r :
132134 await r .read ()
133135
136+ def _csrf_from_jar (self , session : aiohttp .ClientSession ) -> tuple [dict , dict ]:
137+ """Extrahuje XSRF-TOKEN a _csrf z cookie jaru — vrátí (extra_headers, cookies)."""
138+ csrf_token = ""
139+ _csrf_value = ""
140+ for cookie in session .cookie_jar :
141+ if cookie .key == "XSRF-TOKEN" :
142+ csrf_token = cookie .value
143+ if cookie .key == "_csrf" :
144+ _csrf_value = cookie .value
145+ hdrs = {** ({"X-XSRF-TOKEN" : csrf_token } if csrf_token else {})}
146+ cookies = {}
147+ if csrf_token :
148+ cookies ["XSRF-TOKEN" ] = csrf_token
149+ if _csrf_value :
150+ cookies ["_csrf" ] = _csrf_value
151+ return hdrs , cookies
152+
153+ async def _seerr_admin_sess (self ) -> aiohttp .ClientSession :
154+ """Vrátí (nebo vytvoří) aiohttp session s cookie jar pro admin Overseerr mutace."""
155+ if self ._seerr_admin_session is None or self ._seerr_admin_session .closed :
156+ self ._seerr_admin_session = aiohttp .ClientSession (
157+ cookie_jar = aiohttp .CookieJar (unsafe = True )
158+ )
159+ return self ._seerr_admin_session
160+
161+ async def _seerr_csrf_hdrs (self , base : str , api_key : str , ssl ) -> tuple [dict , dict ]:
162+ """Vrátí (headers, cookies) pro Overseerr POST/PUT/DELETE s CSRF ochranou.
163+
164+ Overseerr double-submit cookie pattern: GET nastaví XSRF-TOKEN + _csrf cookies,
165+ POST musí posílat oba cookies explicitně + X-XSRF-TOKEN header (aiohttp neposílá
166+ Secure-flagged cookies automaticky přes HTTP).
167+ """
168+ sess = await self ._seerr_admin_sess ()
169+ hdrs_get = {"X-Api-Key" : api_key , "Accept" : "application/json" }
170+ try :
171+ async with sess .get (f"{ base } /api/v1/auth/me" , headers = hdrs_get , ssl = ssl ) as _ :
172+ pass
173+ except Exception :
174+ pass
175+ csrf_token = ""
176+ _csrf_value = ""
177+ for cookie in sess .cookie_jar :
178+ if cookie .key == "XSRF-TOKEN" :
179+ csrf_token = cookie .value
180+ if cookie .key == "_csrf" :
181+ _csrf_value = cookie .value
182+ hdrs = {
183+ "X-Api-Key" : api_key ,
184+ "Accept" : "application/json" ,
185+ "Content-Type" : "application/json" ,
186+ ** ({"X-XSRF-TOKEN" : csrf_token } if csrf_token else {}),
187+ }
188+ cookies = {}
189+ if csrf_token :
190+ cookies ["XSRF-TOKEN" ] = csrf_token
191+ if _csrf_value :
192+ cookies ["_csrf" ] = _csrf_value
193+ return hdrs , cookies
194+
134195 async def _seerr_family_sess (self , ssl = None ) -> aiohttp .ClientSession :
135196 """Vrátí (nebo vytvoří) aiohttp session s cookie jar pro rodinný Overseerr účet."""
136197 if self ._seerr_family_session is None or self ._seerr_family_session .closed :
@@ -142,11 +203,31 @@ async def _seerr_family_sess(self, ssl=None) -> aiohttp.ClientSession:
142203
143204 async def _seerr_family_login (self , session : aiohttp .ClientSession , ssl = None ) -> None :
144205 """Přihlásí se do Overseerr jako rodinný účet (nastaví session cookie)."""
145- url = f"{ self ._cfg [CONF_SEERR_URL ]} /api/v1/auth/local"
146- async with session .post (url , json = {
206+ base = self ._cfg .get (CONF_SEERR_URL , "" )
207+ # GET first to get CSRF cookies — Overseerr double-submit cookie pattern
208+ csrf_token = ""
209+ _csrf_value = ""
210+ try :
211+ async with session .get (f"{ base } /api/v1/auth/me" , ssl = ssl ) as _ :
212+ pass
213+ for cookie in session .cookie_jar :
214+ if cookie .key == "XSRF-TOKEN" :
215+ csrf_token = cookie .value
216+ if cookie .key == "_csrf" :
217+ _csrf_value = cookie .value
218+ except Exception :
219+ pass
220+ login_hdrs = {"Accept" : "application/json" , "Content-Type" : "application/json" }
221+ req_cookies = {}
222+ if csrf_token :
223+ login_hdrs ["X-XSRF-TOKEN" ] = csrf_token
224+ req_cookies ["XSRF-TOKEN" ] = csrf_token
225+ if _csrf_value :
226+ req_cookies ["_csrf" ] = _csrf_value
227+ async with session .post (f"{ base } /api/v1/auth/local" , json = {
147228 "email" : self ._cfg [CONF_SEERR_FAMILY_EMAIL ],
148229 "password" : self ._cfg [CONF_SEERR_FAMILY_PASS ],
149- }, headers = { "Accept" : "application/json" } , ssl = ssl ) as r :
230+ }, headers = login_hdrs , cookies = req_cookies if req_cookies else None , ssl = ssl ) as r :
150231 await r .read ()
151232
152233 # ── Router ───────────────────────────────────────────────────────────
@@ -1333,44 +1414,47 @@ async def _enrich(req: dict) -> None:
13331414 if path == "request_delete" and method == "POST" :
13341415 body = await request .json ()
13351416 req_id = body .get ("requestId" )
1336- async with http .delete (
1337- f"{ base } /api/v1/request/{ req_id } " , headers = hdrs ,
1338- ssl = ssl ,
1417+ adsess = await self ._seerr_admin_sess ()
1418+ csrf_hdrs , csrf_cookies = await self ._seerr_csrf_hdrs (base , cfg .get (CONF_SEERR_KEY , "" ), ssl )
1419+ async with adsess .delete (
1420+ f"{ base } /api/v1/request/{ req_id } " , headers = csrf_hdrs ,
1421+ cookies = csrf_cookies or None , ssl = ssl ,
13391422 ) as r :
13401423 return web .json_response ({"ok" : r .status in (200 , 204 )})
13411424
13421425 if path .startswith ("request/" ) and method == "PUT" :
13431426 req_id = path .split ("/" , 1 )[1 ]
13441427 body = await request .json ()
1345- async with http .put (
1428+ adsess = await self ._seerr_admin_sess ()
1429+ csrf_hdrs , csrf_cookies = await self ._seerr_csrf_hdrs (base , cfg .get (CONF_SEERR_KEY , "" ), ssl )
1430+ async with adsess .put (
13461431 f"{ base } /api/v1/request/{ req_id } " ,
1347- headers = {** hdrs , "Content-Type" : "application/json" },
1348- json = body ,
1349- ssl = ssl ,
1432+ headers = csrf_hdrs , cookies = csrf_cookies or None ,
1433+ json = body , ssl = ssl ,
13501434 ) as r :
13511435 return web .Response (body = await r .read (), content_type = "application/json" , status = r .status )
13521436
13531437 if path == "approve" and method == "POST" :
13541438 body = await request .json ()
13551439 req_id = body .get ("requestId" )
13561440 server_settings = {k : v for k , v in body .items () if k != "requestId" and v is not None }
1441+ adsess = await self ._seerr_admin_sess ()
1442+ csrf_hdrs , csrf_cookies = await self ._seerr_csrf_hdrs (base , cfg .get (CONF_SEERR_KEY , "" ), ssl )
13571443 # Step 1: update request with server settings if provided
13581444 if server_settings :
13591445 _LOGGER .debug ("arr_stack approve → updating requestId=%s settings=%s" , req_id , server_settings )
1360- async with http .put (
1446+ async with adsess .put (
13611447 f"{ base } /api/v1/request/{ req_id } " ,
1362- headers = {** hdrs , "Content-Type" : "application/json" },
1363- json = server_settings ,
1364- ssl = ssl ,
1448+ headers = csrf_hdrs , cookies = csrf_cookies or None ,
1449+ json = server_settings , ssl = ssl ,
13651450 ) as r_put :
13661451 put_status = r_put .status
13671452 _LOGGER .debug ("arr_stack approve → PUT status=%s" , put_status )
13681453 # Step 2: approve
1369- async with http .post (
1454+ async with adsess .post (
13701455 f"{ base } /api/v1/request/{ req_id } /approve" ,
1371- headers = {** hdrs , "Content-Type" : "application/json" },
1372- json = {},
1373- ssl = ssl ,
1456+ headers = csrf_hdrs , cookies = csrf_cookies or None ,
1457+ json = {}, ssl = ssl ,
13741458 ) as r :
13751459 resp_body = await r .read ()
13761460 _LOGGER .debug ("arr_stack approve ← status=%s body=%s" , r .status , resp_body [:300 ])
@@ -1383,9 +1467,11 @@ async def _enrich(req: dict) -> None:
13831467 if path == "decline" and method == "POST" :
13841468 body = await request .json ()
13851469 req_id = body .get ("requestId" )
1386- async with http .post (
1470+ adsess = await self ._seerr_admin_sess ()
1471+ csrf_hdrs , csrf_cookies = await self ._seerr_csrf_hdrs (base , cfg .get (CONF_SEERR_KEY , "" ), ssl )
1472+ async with adsess .post (
13871473 f"{ base } /api/v1/request/{ req_id } /decline" ,
1388- headers = hdrs ,
1474+ headers = csrf_hdrs , cookies = csrf_cookies or None ,
13891475 ssl = ssl ,
13901476 ) as r :
13911477 return web .Response (
@@ -1410,20 +1496,24 @@ async def _enrich(req: dict) -> None:
14101496 # Rodinný účet — použij session cookie místo admin API klíče
14111497 if user_mode == "family" and self ._cfg .get (CONF_SEERR_FAMILY_EMAIL ):
14121498 fs = await self ._seerr_family_sess (ssl = ssl )
1499+ fam_hdrs = {"Accept" : "application/json" , "Content-Type" : "application/json" }
1500+ csrf_xtra , csrf_ck = self ._csrf_from_jar (fs )
1501+ fam_hdrs .update (csrf_xtra )
14131502 async with fs .post (
14141503 f"{ base } /api/v1/request" ,
1415- json = body ,
1416- headers = {"Accept" : "application/json" , "Content-Type" : "application/json" },
1417- ssl = ssl ,
1504+ json = body , headers = fam_hdrs ,
1505+ cookies = csrf_ck or None , ssl = ssl ,
14181506 ) as r :
1419- if r .status == 401 :
1420- # Session vypršela — znovu se přihlásit a zkusit
1507+ if r .status in ( 401 , 403 ) :
1508+ # Session vypršela nebo CSRF mismatch — znovu přihlásit a zkusit
14211509 await self ._seerr_family_login (fs , ssl = ssl )
1510+ csrf_xtra2 , csrf_ck2 = self ._csrf_from_jar (fs )
1511+ fam_hdrs2 = {"Accept" : "application/json" , "Content-Type" : "application/json" }
1512+ fam_hdrs2 .update (csrf_xtra2 )
14221513 async with fs .post (
14231514 f"{ base } /api/v1/request" ,
1424- json = body ,
1425- headers = {"Accept" : "application/json" , "Content-Type" : "application/json" },
1426- ssl = ssl ,
1515+ json = body , headers = fam_hdrs2 ,
1516+ cookies = csrf_ck2 or None , ssl = ssl ,
14271517 ) as r2 :
14281518 return web .Response (
14291519 body = await r2 .read (),
@@ -1437,11 +1527,12 @@ async def _enrich(req: dict) -> None:
14371527 )
14381528
14391529 # Admin — použij API klíč (auto-approve)
1440- async with http .post (
1530+ adsess = await self ._seerr_admin_sess ()
1531+ csrf_hdrs , csrf_cookies = await self ._seerr_csrf_hdrs (base , cfg .get (CONF_SEERR_KEY , "" ), ssl )
1532+ async with adsess .post (
14411533 f"{ base } /api/v1/request" ,
1442- headers = {** hdrs , "Content-Type" : "application/json" },
1443- json = body ,
1444- ssl = ssl ,
1534+ headers = csrf_hdrs , cookies = csrf_cookies or None ,
1535+ json = body , ssl = ssl ,
14451536 ) as r :
14461537 return web .Response (
14471538 body = await r .read (),
0 commit comments