{ "id": "aEtoKG8mIocULX5W", "name": "Audiobook Torrents", "nodes": [ { "parameters": {}, "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ -736, -48 ], "id": "286acfc0-180f-407a-aa4a-f76683e5e061", "name": "When clicking 'Execute workflow'" }, { "parameters": { "url": "https://nyaa.si/?page=rss&q=%5BAudiobook%5D&c=3_1&f=0", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -512, 48 ], "id": "c3a1b112-cd01-4a0e-b843-bd6c635202c6", "name": "HTTP Request" }, { "parameters": { "options": { "trim": true } }, "type": "n8n-nodes-base.xml", "typeVersion": 1, "position": [ -288, 48 ], "id": "0239b8cc-c0b0-4bce-9892-176bf69ab8aa", "name": "XML" }, { "parameters": { "fieldToSplitOut": "rss.channel.item", "options": {} }, "id": "split-items-node", "name": "Split Feed Items", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [ -64, 48 ] }, { "parameters": { "jsCode": "// Process all input items and return them\nreturn $input.all().map(item => ({\n json: {\n info_hash: item.json['nyaa:infoHash'],\n title: item.json.title,\n link: item.json.link,\n view_link: item.json.guid._ || item.json.guid,\n pub_date: item.json.pubDate,\n seeders: parseInt(item.json['nyaa:seeders']) || 0,\n size: item.json['nyaa:size']\n }\n}));" }, "id": "extract-torrent-data", "name": "Extract Torrent Data", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 48 ], "executeOnce": false }, { "parameters": { "conditions": { "conditions": [ { "leftValue": "={{ $json.seeders }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } } ] }, "options": {} }, "id": "filter-has-seeders", "name": "Has Seeders?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ -592, 624 ] }, { "parameters": { "rule": { "interval": [ { "field": "minutes" } ] } }, "id": "schedule-trigger-5min", "name": "Every 5 Minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ -736, 144 ] }, { "parameters": { "sendTo": "admin@ben.io", "subject": "={{ $json.torrentCount }} New Audiobook Torrent(s) Found", "message": "={{ $json.emailHtmlBody }}", "options": {} }, "id": "send-gmail-notification", "name": "Send Gmail Notification", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 896, 528 ], "webhookId": "8da5c0a8-d147-46e8-bcc5-85d5cd2c87d5", "credentials": { "gmailOAuth2": { "id": "Os1ux3h3zFlC2XkG", "name": "Gmail account" } } }, { "parameters": { "operation": "getAll", "tableId": "nyaa_audiobooks", "returnAll": true, "filterType": "none" }, "type": "n8n-nodes-base.supabase", "typeVersion": 1, "position": [ -368, 704 ], "id": "ea585083-0b0a-410b-b0ac-768badf335fa", "name": "Get many rows", "executeOnce": true, "credentials": { "supabaseApi": { "id": "lWyf2ikOGHTTwnSU", "name": "Supabase account" } } }, { "parameters": { "mergeByFields": { "values": [ { "field1": "info_hash", "field2": "info_hash" } ] }, "options": { "skipFields": "seeders,discovered_at,updated_at,mam_torrent_id,mam_checked_at,view_link,id,created_at,search_title,volume_range" } }, "type": "n8n-nodes-base.compareDatasets", "typeVersion": 2.3, "position": [ -144, 592 ], "id": "13fffea6-eb23-4746-9c21-e9695bd8c377", "name": "Compare Datasets" }, { "parameters": { "jsCode": "// Get all incoming items using $input.all()\nconst torrents = $input.all();\nconst count = torrents.length;\n\n// If no torrents, return empty to stop workflow\nif (count === 0) {\n return [];\n}\n\n// Helper function to get MAM status display\nfunction getMAMStatus(torrent) {\n if (torrent.mam_torrent_id) {\n return `✅ Yes`;\n } else {\n return '❌ No';\n }\n}\n\n// Build HTML table rows\nlet tableRows = \"\";\n\nfor (const item of torrents) {\n const torrent = item.json;\n \n tableRows += `\n \n ${torrent.title}\n ${torrent.seeders}\n ${torrent.size}\n ${getMAMStatus(torrent)}\n \n Download | \n View\n \n \n `;\n}\n\n// Build complete HTML email body\nconst emailBody = `\n

${count} New Audiobook Torrent(s) Found

\n\n \n \n \n \n \n \n \n \n \n \n ${tableRows}\n \n
TitleSeedersSizeOn MAM?Links
\n`;\n\nreturn [{\n json: {\n torrentCount: count,\n emailHtmlBody: emailBody\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 672, 528 ], "id": "99f5e0ba-0c7e-4c37-9692-ff0c01ab48ae", "name": "Code in JavaScript", "executeOnce": true }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "condition1", "leftValue": "={{ $input.all().length }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } } ] }, "options": {} }, "id": "check-has-new-torrents", "name": "Has New Torrents?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 224, 528 ] }, { "parameters": { "tableId": "nyaa_audiobooks", "dataToSend": "autoMapInputData", "inputsToIgnore": "view_link" }, "id": "insert-new-torrents", "name": "Insert New Torrents", "type": "n8n-nodes-base.supabase", "typeVersion": 1, "position": [ 448, 528 ], "credentials": { "supabaseApi": { "id": "lWyf2ikOGHTTwnSU", "name": "Supabase account" } } }, { "parameters": { "url": "https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "tor[text]", "value": "={{ $json.search_title }}" }, { "name": "tor[srchIn][title]", "value": "true" }, { "name": "tor[searchType]", "value": "all" }, { "name": "tor[main_cat][]", "value": "13" }, { "name": "tor[sortType]", "value": "default" }, { "name": "tor[startNumber]", "value": "0" }, { "name": "perpage", "value": "10" } ] }, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Type", "value": "application/json; charset=utf-8" } ] }, "options": { "response": { "response": { "neverError": true } } } }, "id": "search-mam-by-title", "name": "Search MAM by Title", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1056, 48 ], "credentials": { "httpHeaderAuth": { "id": "sxQYMPEq6GLbc9Av", "name": "n8n_auth_cookie" } } }, { "parameters": { "jsCode": "// Parse MAM title search results with volume and format matching\n// Get the filtered torrents that actually went through MAM search\nconst searchedTorrents = $('Filter Unchecked Torrents').all();\nconst mamResults = $input.all();\n\n// Helper function to extract volume number from MAM title\nfunction extractVolume(title) {\n const patterns = [\n /[Vv]ol\\.?\\s*(\\d+)/,\n /[Vv]olume\\s*(\\d+)/,\n /\\bv(\\d+)\\b/,\n /[Bb]ook\\s*(\\d+)/,\n /,\\s*(\\d+)$/ // Match \", 1\" at end of title\n ];\n \n for (const pattern of patterns) {\n const match = title.match(pattern);\n if (match) {\n return parseInt(match[1]);\n }\n }\n return null;\n}\n\n// Helper function to check if volume is in range\nfunction isVolumeInRange(vol, range) {\n if (!range || vol === null) return false;\n return vol >= range.start && vol <= range.end;\n}\n\n// Helper function to check if it's m4b format\nfunction isM4bFormat(filetype) {\n if (!filetype) return false;\n return filetype.toLowerCase().includes('m4b');\n}\n\n// Process each item - match MAM results with the torrents that were searched\nreturn searchedTorrents.map((item, index) => {\n const mamResult = mamResults[index]?.json;\n const torrentData = item.json;\n \n let mamTorrentId = null;\n let mamFound = false;\n \n if (mamResult && mamResult.data && Array.isArray(mamResult.data) && mamResult.data.length > 0) {\n // Search through results for a matching volume and format\n for (const result of mamResult.data) {\n // Only match m4b format\n if (!isM4bFormat(result.filetype)) {\n continue;\n }\n \n // Extract volume from MAM title\n const mamVolume = extractVolume(result.title || '');\n \n // If Nyaa has volume info, match it\n if (torrentData.volume_range) {\n if (isVolumeInRange(mamVolume, torrentData.volume_range)) {\n mamTorrentId = result.id;\n mamFound = true;\n break;\n }\n } else {\n // No volume info in Nyaa title, take first m4b match\n mamTorrentId = result.id;\n mamFound = true;\n break;\n }\n }\n }\n \n return {\n json: {\n ...torrentData,\n mam_torrent_id: mamTorrentId,\n mam_found_by_title: mamFound,\n mam_checked_at: new Date().toISOString()\n }\n };\n});" }, "id": "parse-mam-title-results", "name": "Parse MAM Title Results", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, 48 ], "executeOnce": false }, { "parameters": { "conditions": { "conditions": [ { "leftValue": "={{ $json.mam_found_by_title }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ] }, "options": {} }, "id": "title-match-found", "name": "Title Match Found?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ -496, 336 ] }, { "parameters": { "jsCode": "// Extract author/narrator from Nyaa title for fallback search\nreturn $input.all().map(item => {\n const title = item.json.title || '';\n let searchTerm = '';\n \n // Try to extract author/narrator from common patterns\n // Pattern 1: \"Author Name - Book Title\"\n const dashPattern = /^([^-]+)\\s*-\\s*.+$/;\n const dashMatch = title.match(dashPattern);\n if (dashMatch) {\n searchTerm = dashMatch[1].trim();\n }\n \n // Pattern 2: [Author Name]\n if (!searchTerm) {\n const bracketPattern = /\\[([^\\]]+)\\]/;\n const bracketMatch = title.match(bracketPattern);\n if (bracketMatch) {\n searchTerm = bracketMatch[1].trim();\n }\n }\n \n // Pattern 3: \"by Author Name\"\n if (!searchTerm) {\n const byPattern = /\\bby\\s+([^-,(]+)/i;\n const byMatch = title.match(byPattern);\n if (byMatch) {\n searchTerm = byMatch[1].trim();\n }\n }\n \n // Fallback: use first few words of title\n if (!searchTerm) {\n const words = title.split(/\\s+/).slice(0, 3);\n searchTerm = words.join(' ');\n }\n \n return {\n json: {\n ...item.json,\n search_term: searchTerm\n }\n };\n});" }, "id": "extract-author-narrator", "name": "Extract Author/Narrator", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -256, 320 ], "executeOnce": false }, { "parameters": { "url": "https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "tor[text]", "value": "={{ $json.search_term }}" }, { "name": "tor[srchIn][author]", "value": "true" }, { "name": "tor[srchIn][narrator]", "value": "true" }, { "name": "tor[searchType]", "value": "all" }, { "name": "tor[main_cat][]", "value": "13" }, { "name": "tor[sortType]", "value": "default" }, { "name": "tor[startNumber]", "value": "0" }, { "name": "perpage", "value": "10" } ] }, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Type", "value": "application/json; charset=utf-8" } ] }, "options": { "response": { "response": { "neverError": true } } } }, "id": "search-mam-by-author", "name": "Search MAM by Author/Narrator", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -48, 320 ], "credentials": { "httpHeaderAuth": { "id": "sxQYMPEq6GLbc9Av", "name": "n8n_auth_cookie" } } }, { "parameters": { "jsCode": "// Parse MAM author/narrator search results\n// Get the torrents that went through author/narrator search (filtered by title-not-found)\nconst searchedTorrents = $('Extract Author/Narrator').all();\nconst mamResults = $input.all();\n\n// Process each item\nreturn searchedTorrents.map((item, index) => {\n const mamResult = mamResults[index]?.json;\n const torrentData = item.json;\n \n // Check if MAM search returned results\n let mamTorrentId = torrentData.mam_torrent_id;\n \n // Only update if we didn't find by title\n if (!torrentData.mam_found_by_title && mamResult && mamResult.data && Array.isArray(mamResult.data) && mamResult.data.length > 0) {\n // Found at least one match - take the first result\n mamTorrentId = mamResult.data[0].id;\n }\n \n return {\n json: {\n ...torrentData,\n mam_torrent_id: mamTorrentId\n }\n };\n});" }, "id": "parse-mam-author-results", "name": "Parse MAM Author Results", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 320 ], "executeOnce": false }, { "parameters": { "jsCode": "// Merge data from both paths (title match and author/narrator search)\n// Note: Only one of these paths will execute depending on title match result\n\n// Get all input items from the current execution\nconst allResults = $input.all();\n\n// Clean up temporary fields used during MAM search\nreturn allResults.map(item => {\n const { mam_found_by_title, search_term, ...cleanData } = item.json;\n return {\n json: cleanData\n };\n});" }, "id": "merge-mam-data", "name": "Merge MAM Data", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 400, 320 ], "executeOnce": false }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 1 }, "conditions": [ { "leftValue": "={{ $json.mam_torrent_id }}", "rightValue": "", "operator": { "type": "number", "operation": "notEmpty", "singleValue": true }, "id": "41cd060f-08bc-4b5c-8396-ac604893a67a" } ], "combinator": "and" }, "options": {} }, "id": "filter-needs-mam-update", "name": "Needs MAM Update?", "type": "n8n-nodes-base.filter", "typeVersion": 2, "position": [ 224, 720 ] }, { "parameters": { "jsCode": "// Extract base title AND volume numbers for matching\nreturn $input.all().map(item => {\n let fullTitle = item.json.title || '';\n let baseTitle = fullTitle;\n let volumeInfo = null;\n \n // Extract volume information first (we'll need it for matching later)\n // Match patterns: Vol. 01-10, Vol 1-5, Volume 1, v01, Book 1, etc.\n const volPatterns = [\n /[Vv]ol\\.?\\s*(\\d+)(?:\\s*-\\s*(\\d+))?/,\n /[Vv]olume\\s*(\\d+)(?:\\s*-\\s*(\\d+))?/,\n /\\bv(\\d+)(?:-v?(\\d+))?\\b/,\n /[Bb]ook\\s*(\\d+)(?:\\s*-\\s*(\\d+))?/\n ];\n \n for (const pattern of volPatterns) {\n const match = fullTitle.match(pattern);\n if (match) {\n const startVol = parseInt(match[1]);\n const endVol = match[2] ? parseInt(match[2]) : startVol;\n volumeInfo = { start: startVol, end: endVol };\n break;\n }\n }\n \n // Remove all metadata to get base title\n baseTitle = baseTitle.replace(/\\s*[Vv]ol\\.?\\s*\\d+(-\\d+)?/gi, '');\n baseTitle = baseTitle.replace(/\\s*[Vv]olume\\s*\\d+(-\\d+)?/gi, '');\n baseTitle = baseTitle.replace(/\\s*\\bv\\d+(-v?\\d+)?\\b/gi, '');\n baseTitle = baseTitle.replace(/\\s*\\[[^\\]]+\\]/g, '');\n baseTitle = baseTitle.replace(/\\s*\\([^)]+\\)/g, '');\n baseTitle = baseTitle.replace(/\\s*[Bb]ook\\s*\\d+(-\\d+)?/gi, '');\n baseTitle = baseTitle.replace(/\\s*[Pp]art\\s*\\d+(-\\d+)?/gi, '');\n baseTitle = baseTitle.replace(/[,\\s-]+$/, '').trim();\n \n return {\n json: {\n ...item.json,\n search_title: baseTitle,\n volume_range: volumeInfo\n }\n };\n});" }, "id": "extract-base-title", "name": "Extract Base Title", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 384, 48 ] }, { "parameters": { "jsCode": "// Filter out torrents that already have MAM data\nconst nyaaTorrents = $('Extract Base Title').all();\nconst dbTorrents = $('Get DB Torrents for Filtering').all();\n\n// Create a map of info_hash -> mam_torrent_id from database\nconst checkedHashes = new Map();\nfor (const item of dbTorrents) {\n if (item.json.mam_torrent_id) {\n checkedHashes.set(item.json.info_hash, item.json.mam_torrent_id);\n }\n}\n\n// Filter Nyaa torrents to only include unchecked ones\nconst uncheckedTorrents = nyaaTorrents.filter(item => {\n return !checkedHashes.has(item.json.info_hash);\n});\n\nconsole.log(`Total torrents: ${nyaaTorrents.length}, Already checked: ${checkedHashes.size}, Need MAM search: ${uncheckedTorrents.length}`);\n\nreturn uncheckedTorrents;" }, "id": "filter-unchecked-torrents", "name": "Filter Unchecked Torrents", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 832, 48 ] }, { "id": "Get DB Torrents for Filtering", "name": "Get DB Torrents for Filtering (Postgres)", "type": "n8n-nodes-base.postgres", "typeVersion": 1, "position": [ 500, 100 ], "parameters": { "operation": "executeQuery", "query": "SELECT * FROM nyaa_audiobooks;" }, "credentials": { "postgres": { "id": "9grzZwW7Br6SzdV8", "name": "n8n-media" } } }, { "id": "Update Existing Torrents MAM Data", "name": "Update Existing Torrents MAM Data (Postgres)", "type": "n8n-nodes-base.postgres", "typeVersion": 1, "position": [ 1500, 500 ], "parameters": { "operation": "executeQuery", "options": { "queryParameterValues": "{{$json.mam_torrent_id}},{{$json.id}}" }, "query": "UPDATE nyaa_audiobooks SET mam_torrent_id = $1, mam_checked_at = NOW(), updated_at = NOW() WHERE id = $2;" }, "credentials": { "postgres": { "id": "9grzZwW7Br6SzdV8", "name": "n8n-media" } } } ], "connections": { "When clicking 'Execute workflow'": { "main": [ [ { "node": "HTTP Request", "type": "main", "index": 0 } ] ] }, "HTTP Request": { "main": [ [ { "node": "XML", "type": "main", "index": 0 } ] ] }, "XML": { "main": [ [ { "node": "Split Feed Items", "type": "main", "index": 0 } ] ] }, "Split Feed Items": { "main": [ [ { "node": "Extract Torrent Data", "type": "main", "index": 0 } ] ] }, "Every 5 Minutes": { "main": [ [ { "node": "HTTP Request", "type": "main", "index": 0 } ] ] }, "Has Seeders?": { "main": [ [ { "node": "Get many rows", "type": "main", "index": 0 }, { "node": "Compare Datasets", "type": "main", "index": 0 } ] ] }, "Get many rows": { "main": [ [ { "node": "Compare Datasets", "type": "main", "index": 1 } ] ] }, "Code in JavaScript": { "main": [ [ { "node": "Send Gmail Notification", "type": "main", "index": 0 } ] ] }, "Compare Datasets": { "main": [ [ { "node": "Has New Torrents?", "type": "main", "index": 0 } ], [ { "node": "Needs MAM Update?", "type": "main", "index": 0 } ] ] }, "Has New Torrents?": { "main": [ [ { "node": "Insert New Torrents", "type": "main", "index": 0 } ] ] }, "Insert New Torrents": { "main": [ [ { "node": "Code in JavaScript", "type": "main", "index": 0 } ] ] }, "Search MAM by Title": { "main": [ [ { "node": "Parse MAM Title Results", "type": "main", "index": 0 } ] ] }, "Parse MAM Title Results": { "main": [ [ { "node": "Title Match Found?", "type": "main", "index": 0 } ] ] }, "Title Match Found?": { "main": [ [ { "node": "Extract Author/Narrator", "type": "main", "index": 0 }, { "node": "Merge MAM Data", "type": "main", "index": 0 } ] ] }, "Extract Author/Narrator": { "main": [ [ { "node": "Search MAM by Author/Narrator", "type": "main", "index": 0 } ] ] }, "Search MAM by Author/Narrator": { "main": [ [ { "node": "Parse MAM Author Results", "type": "main", "index": 0 } ] ] }, "Parse MAM Author Results": { "main": [ [ { "node": "Merge MAM Data", "type": "main", "index": 0 } ] ] }, "Merge MAM Data": { "main": [ [ { "node": "Has Seeders?", "type": "main", "index": 0 } ] ] }, "Extract Torrent Data": { "main": [ [ { "node": "Extract Base Title", "type": "main", "index": 0 } ] ] }, "Filter Unchecked Torrents": { "main": [ [ { "node": "Search MAM by Title", "type": "main", "index": 0 } ] ] }, "Extract Base Title": { "main": [ [ { "node": "Get DB Torrents for Filtering (Postgres)", "type": "main", "index": 0 } ] ] }, "Get DB Torrents for Filtering (Postgres)": { "main": [ [ { "node": "Filter Unchecked Torrents", "type": "main", "index": 0 } ] ] }, "Needs MAM Update?": { "main": [ [ { "node": "Update Existing Torrents MAM Data (Postgres)", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1", "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, "triggerCount": 1, "versionId": "5a1020ae-9654-4801-8ddc-48d87af2b558", "owner": { "type": "personal", "projectId": "FeLO36wNUAcn61Wj", "projectName": "Ben W ", "personalEmail": "admin@ben.io" }, "parentFolderId": "kUg4HIPXraph3M0E", "isArchived": false }