diff --git a/backend/package.json b/backend/package.json index 26041d9..2c5f441 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.87.6", "axios": "^1.6.2", + "axios-cookiejar-support": "^5.0.5", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -30,6 +31,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "socket.io": "^4.7.3", + "tough-cookie": "^5.1.2", "tweetnacl": "^1.0.3", "ws": "^8.15.1" }, diff --git a/backend/src/services/listenarr.ts b/backend/src/services/listenarr.ts index da7ecfe..00b5cdc 100644 --- a/backend/src/services/listenarr.ts +++ b/backend/src/services/listenarr.ts @@ -1,18 +1,25 @@ import axios from "axios"; +import { wrapper } from "axios-cookiejar-support"; +import { CookieJar } from "tough-cookie"; const LISTENARR_URL = process.env.LISTENARR_URL || ""; const LISTENARR_API_KEY = process.env.LISTENARR_API_KEY || ""; -const listenarrApi = axios.create({ - baseURL: LISTENARR_URL, - timeout: 15000, - headers: { - "Content-Type": "application/json", - ...(LISTENARR_API_KEY ? { "X-Api-Key": LISTENARR_API_KEY } : {}), - }, -}); +const jar = new CookieJar(); +const listenarrApi = wrapper( + axios.create({ + baseURL: LISTENARR_URL, + timeout: 15000, + headers: { + "Content-Type": "application/json", + ...(LISTENARR_API_KEY ? { "X-Api-Key": LISTENARR_API_KEY } : {}), + }, + withCredentials: true, + jar, + }), +); -// ponytail: fetch CSRF token from Listenarr's /antiforgery/token endpoint +// ponytail: fetch + cache CSRF token (cookie comes along via jar) let cachedCsrfToken: string | null = null; async function getCsrfToken(): Promise { if (cachedCsrfToken) return cachedCsrfToken; @@ -25,9 +32,7 @@ async function getCsrfToken(): Promise { } } -export async function searchAudiobooks( - query: string -): Promise { +export async function searchAudiobooks(query: string): Promise { const resp = await listenarrApi.get("/api/v1/search/audible", { params: { query }, }); @@ -55,7 +60,6 @@ export async function addAudiobook(options: { explicit?: boolean; abridged?: boolean; }): Promise { - // Defensive: ensure series/seriesNumber are always strings, not arrays const series = typeof options.series === "string" ? options.series : ""; const seriesNumber = typeof options.seriesNumber === "string" ? options.seriesNumber : ""; @@ -74,8 +78,8 @@ export async function addAudiobook(options: { releaseDate: options.releaseDate || "", runtime: options.runtimeLengthMin ? options.runtimeLengthMin * 60 : 0, genres: options.genres || [], - series: series, - seriesNumber: seriesNumber, + series, + seriesNumber, isbn: Array.isArray(options.isbn) ? options.isbn : (typeof options.isbn === "string" && options.isbn ? options.isbn.split(",") : []), explicit: options.explicit || false, abridged: options.abridged || false, @@ -93,13 +97,13 @@ export async function addAudiobook(options: { const resp = await listenarrApi.post("/api/v1/library/add", body, { headers }); return resp.data; } catch (err: any) { - // ponytail: retry once on CSRF failure after refreshing token + // ponytail: retry once on CSRF failure after refreshing token + cookie if (err?.response?.status === 400 && /csrf|xsrf|antiforgery/i.test(JSON.stringify(err?.response?.data))) { cachedCsrfToken = null; const freshToken = await getCsrfToken(); if (freshToken) { - const retryResp = await listenarrApi.post("/api/v1/library/add", body, { headers: { "X-XSRF-TOKEN": freshToken } }); - return retryResp.data; + const resp = await listenarrApi.post("/api/v1/library/add", body, { headers: { "X-XSRF-TOKEN": freshToken } }); + return resp.data; } } throw err; @@ -112,9 +116,7 @@ export async function getListenarrStatus(): Promise<{ error?: string; }> { try { - const resp = await listenarrApi.get("/api/v1/system/info", { - timeout: 5000, - }); + const resp = await listenarrApi.get("/api/v1/system/info", { timeout: 5000 }); return { connected: true, data: resp.data }; } catch (error: any) { return {