{ "id": "xXUnt2hL2FKxzOhBnkd3Z", "name": "MAM Series Checker", "nodes": [ { "parameters": { "rule": { "interval": [ {} ] } }, "typeVersion": 1.3, "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "position": [ -832, 496 ], "id": "279ca2c5-3c3e-4cd6-bc97-e43010fb693c" }, { "parameters": { "options": {} }, "typeVersion": 3, "id": "2412dfa0-8362-4cc4-8a18-3f233d5338b2", "type": "n8n-nodes-base.splitInBatches", "name": "Loop Over Items", "position": [ -384, 496 ] }, { "parameters": { "method": "POST", "url": "https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "= {\n \"dlLink\": \"1\",\n \"tor\": {\n \"text\": \"{{ $json.series_name }}\",\n \"srchIn\": [\"series\"],\n \"main_cat\": [\"13\"],\n \"searchType\": \"all\",\n \"sortType\": \"seedersDesc\"\n }\n }\n\n", "options": {} }, "typeVersion": 4.3, "name": "HTTP Request", "position": [ -160, 416 ], "type": "n8n-nodes-base.httpRequest", "id": "f387a918-881e-492c-b41a-1dc621e8e822", "credentials": { "httpHeaderAuth": { "id": "G8eA8XeS9P5axwJd", "name": "mam cookie auth header" } } }, { "parameters": { "fieldsToAggregate": { "fieldToAggregate": [ { "fieldToAggregate": "book_name" } ] }, "options": {} }, "id": "019177aa-2c4f-4291-90f7-c58750033e77", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "name": "Aggregate", "position": [ 736, 272 ] }, { "parameters": { "promptType": "define", "text": "=You are a Librarian checking for missing audiobooks.\n\n Series: {{ $('Loop Over Items').item.json.series_name }}\n\n Our library contains:\n {{ $json.book_name }}\n\n Available from provider (ID | Title):\n {{ $('Format for LLM').item.json.llm_available }}\n\n IMPORTANT MATCHING RULES:\n - Match by book NUMBER, not exact title. \"Book 7\", \"Vol. 7\", \"Volume 7\", and just \"7\" are the same.\n - Ignore subtitles after the number. \"The Primal Hunter 7\" matches \"The Primal Hunter 7 - A LitRPG Adventure\".\n - Ignore format differences like \"(Light Novel)\", \"[Audiobook]\", \"A LitRPG Saga\", etc.\n - If unsure, assume we already own it (don't include in missing).\n\n Return ONLY a JSON object with IDs of books we are CERTAIN we don't have:\n {\"missing_ids\": [123, 456]}\n\n If we have all books or are unsure, return: {\"missing_ids\": []}", "batching": {} }, "id": "aef43627-3228-42ab-a2fe-9f4b50d1a855", "typeVersion": 1.9, "position": [ 960, 160 ], "type": "@n8n/n8n-nodes-langchain.chainLlm", "name": "Basic LLM Chain", "retryOnFail": true, "onError": "continueRegularOutput" }, { "parameters": { "operation": "executeQuery", "query": "SELECT * FROM followed_series\n WHERE active = true\n AND (last_checked_at IS NULL OR last_checked_at < NOW() - INTERVAL '24 hours')\n ORDER BY last_checked_at ASC NULLS FIRST\n", "options": {} }, "name": "Select Series", "type": "n8n-nodes-base.postgres", "id": "e7bc495c-a3e1-46b8-b0f1-ff49286344e7", "typeVersion": 2.6, "position": [ -608, 496 ], "credentials": { "postgres": { "id": "9grzZwW7Br6SzdV8", "name": "n8n-media" } } }, { "parameters": { "operation": "select", "schema": { "mode": "list", "value": "public", "__rl": true }, "table": { "__rl": true, "cachedResultName": "smb_general_books", "value": "smb_general_books", "mode": "list" }, "returnAll": true, "where": { "values": [ { "column": "followed_series_id", "value": "={{ $('Loop Over Items').item.json.id }}" } ] }, "options": {} }, "typeVersion": 2.6, "name": "Select Book Titles", "type": "n8n-nodes-base.postgres", "position": [ 512, 272 ], "id": "62be829f-6d74-4142-9420-eb4f6947df09", "executeOnce": true, "credentials": { "postgres": { "id": "9grzZwW7Br6SzdV8", "name": "n8n-media" } } }, { "parameters": { "workflowId": { "__rl": true, "value": "kRZyX9H2uDHHncpE", "mode": "list", "cachedResultUrl": "/workflow/kRZyX9H2uDHHncpE", "cachedResultName": "MAM Transmission Manager" }, "workflowInputs": { "schema": [], "matchingColumns": [], "value": {}, "mappingMode": "defineBelow", "attemptToConvertTypes": false, "convertFieldsToString": true }, "mode": "each", "options": {} }, "position": [ 1984, 272 ], "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "id": "b6ec81b3-4f62-494e-9437-1a31c82a0299", "name": "Call 'MAM Transmission Manager'" }, { "parameters": {}, "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ 1312, 416 ], "id": "0e6b9d38-bf8b-4f52-83a7-55256eec306a", "name": "Merge" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "d8fe3601-6f8f-4502-8231-a533dad5e5e5", "leftValue": "={{ $json.error }}", "rightValue": "", "operator": { "type": "string", "operation": "notExists", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 64, 416 ], "id": "2291aa2e-fb6b-42ec-892b-ee2b015514f7", "name": "Search Error Check" }, { "parameters": { "operation": "update", "schema": { "__rl": true, "mode": "list", "value": "public" }, "table": { "__rl": true, "value": "followed_series", "mode": "list", "cachedResultName": "followed_series" }, "columns": { "mappingMode": "defineBelow", "value": { "series_name": "={{ $('Loop Over Items').last().json.series_name }}", "last_checked_at": "={{ $now }}" }, "matchingColumns": [ "series_name" ], "schema": [ { "id": "id", "displayName": "id", "required": false, "defaultMatch": true, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "series_name", "displayName": "series_name", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": false }, { "id": "author", "displayName": "author", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "category", "displayName": "category", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "smb_path", "displayName": "smb_path", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "active", "displayName": "active", "required": false, "defaultMatch": false, "display": true, "type": "boolean", "canBeUsedToMatch": true, "removed": true }, { "id": "created_at", "displayName": "created_at", "required": false, "defaultMatch": false, "display": true, "type": "dateTime", "canBeUsedToMatch": true, "removed": true }, { "id": "updated_at", "displayName": "updated_at", "required": false, "defaultMatch": false, "display": true, "type": "dateTime", "canBeUsedToMatch": true, "removed": true }, { "id": "mam_series_id", "displayName": "mam_series_id", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "enrichment_status", "displayName": "enrichment_status", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "last_checked_at", "displayName": "last_checked_at", "required": false, "defaultMatch": false, "display": true, "type": "dateTime", "canBeUsedToMatch": true } ], "attemptToConvertTypes": false, "convertFieldsToString": false }, "options": {} }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 2432, 624 ], "id": "62ec7f39-4f13-4d8e-8676-076e281677a0", "name": "Update rows in a table", "credentials": { "postgres": { "id": "9grzZwW7Br6SzdV8", "name": "n8n-media" } } }, { "parameters": { "model": { "__rl": true, "mode": "id", "value": "gemini_cli/gemini-3-flash-preview" }, "responsesApiEnabled": false, "options": {} }, "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.3, "position": [ 960, 352 ], "id": "c6a2245c-ad50-465c-813a-a8ec206110e1", "name": "OpenAI Chat Model", "credentials": { "openAiApi": { "id": "sxSUdecXdMfKPuTu", "name": "llm-proxy.ext.ben.io" } } }, { "parameters": { "language": "pythonNative", "pythonCode": "import json\n\n# Format search results for LLM consumption\n# Filters by m4b, English, and outputs ID|Title pairs\n\ninput_json = _items[0].get('json', {})\n\n# Handle both response structures\nbody = input_json.get('body', {})\nsearch_data = []\n\nif isinstance(body, dict):\n search_data = body.get('data', [])\n\nif not search_data and isinstance(input_json, dict):\n search_data = input_json.get('data', [])\n\nif not isinstance(search_data, list):\n search_data = []\n\n# Filter and format\nfiltered = []\nllm_lines = []\n\nfor x in search_data:\n filetype = str(x.get('filetype', '')).lower()\n lang_code = str(x.get('lang_code', '')).upper()\n\n # Must be m4b and English\n if 'm4b' not in filetype or lang_code != 'ENG':\n continue\n\n book_id = x.get('id')\n title = str(x.get('title', '')).strip()\n\n if book_id and title:\n filtered.append(x)\n llm_lines.append(f\"{book_id} | {title}\")\n\n# Output: full data for later lookup + formatted string for LLM\nresults = [{\n \"json\": {\n \"search_data\": filtered, # Full objects for ID lookup later\n \"llm_available\": \"\\n\".join(llm_lines), # Formatted for LLM prompt\n \"count\": len(filtered)\n }\n}]\n\nreturn results" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 272, 400 ], "id": "41482579-dd45-4e60-be7b-2e9f7d08f8b3", "name": "Format for LLM" }, { "parameters": { "language": "pythonNative", "pythonCode": "import json\nimport re\n\n# Build download links from LLM output\n# LLM returns {\"missing_ids\": [123, 456]}\n# We match IDs to the search_data stored earlier\n\n# Collect inputs\nsearch_data = []\nmissing_ids = []\n\nfor item in _items:\n data = item.get('json', {})\n\n # From Format for LLM - the full search data\n if 'search_data' in data:\n search_data = data['search_data']\n\n # From LLM - parse the text field\n if 'text' in data:\n try:\n parsed = json.loads(data['text'])\n # Handle various formats\n if 'missing_ids' in parsed:\n missing_ids = parsed['missing_ids']\n elif 'missing_books' in parsed:\n # Fallback if LLM uses old format\n missing_ids = parsed['missing_books']\n except:\n pass\n\n # Direct missing_ids field\n if 'missing_ids' in data:\n missing_ids = data['missing_ids']\n\n# Ensure missing_ids is a list\nif not isinstance(missing_ids, list):\n missing_ids = []\n\n# Convert to set of ints for lookup\ntry:\n missing_id_set = set(int(x) for x in missing_ids if x)\nexcept:\n missing_id_set = set()\n\nif not missing_id_set:\n return [{\"json\": {\"found\": False, \"reason\": \"No missing books identified\"}}]\n\n# Build search_data lookup by ID\nid_to_item = {}\nfor item in search_data:\n book_id = item.get('id')\n if book_id:\n id_to_item[int(book_id)] = item\n\n# Patterns to filter out (box sets, collections, etc.)\nSKIP_PATTERNS = [\n 'box set',\n 'boxset',\n 'collection',\n \"publisher's pack\",\n 'publishers pack',\n 'omnibus',\n 'complete series',\n 'books 1',\n 'books 2',\n 'books 3',\n ', books ',\n 'audio immersion tunnel',\n]\n\ndef should_skip_title(title):\n \"\"\"Check if title matches skip patterns.\"\"\"\n title_lower = title.lower()\n for pattern in SKIP_PATTERNS:\n if pattern in title_lower:\n return True\n # Skip if title ends with \"series\" (e.g., \"The Land: Chaos Seeds Series\")\n if title_lower.rstrip().endswith(' series'):\n return True\n return False\n\ndef normalize_title(title):\n \"\"\"Normalize title for deduplication - extracts core title without subtitles.\"\"\"\n t = title.lower().strip()\n # Remove common suffixes/subtitles\n t = re.sub(r'\\s*[-:]\\s*a\\s+litrpg.*$', '', t, flags=re.IGNORECASE)\n t = re.sub(r'\\s*\\(light novel\\).*$', '', t, flags=re.IGNORECASE)\n t = re.sub(r'\\s*\\(audiobook\\).*$', '', t, flags=re.IGNORECASE)\n t = re.sub(r'\\s*\\[.*?\\]', '', t) # Remove bracketed text like [B00I2VWW5U]\n # Remove trailing subtitle after number (e.g., \"Book 7 - The Subtitle\" -> \"Book 7\")\n t = re.sub(r'(\\d+)\\s*[-:]\\s*.+$', r'\\1', t)\n # Normalize \"Vol.\" variations\n t = re.sub(r'\\bvol\\.?\\s*', 'vol ', t, flags=re.IGNORECASE)\n t = re.sub(r'\\bvolume\\s*', 'vol ', t, flags=re.IGNORECASE)\n # Normalize \"Book\" to just number\n t = re.sub(r'\\bbook\\s+(\\d+)', r'\\1', t, flags=re.IGNORECASE)\n # Normalize spacing and punctuation\n t = re.sub(r'[,:]', '', t)\n t = re.sub(r'\\s+', ' ', t).strip()\n return t\n\nresults = []\nseen_ids = set() # For deduplication by ID\nseen_titles = set() # For deduplication by normalized title\n\nfor book_id in missing_id_set:\n # Dedupe by ID check\n if book_id in seen_ids:\n continue\n seen_ids.add(book_id)\n\n if book_id not in id_to_item:\n continue\n\n item = id_to_item[book_id]\n title = str(item.get('title', '')).strip()\n\n # Skip box sets and collections\n if should_skip_title(title):\n continue\n\n # Dedupe by normalized title (prevents multiple uploads of same book)\n normalized = normalize_title(title)\n if normalized in seen_titles:\n continue\n seen_titles.add(normalized)\n\n # Parse series info\n meta_series = \"Unknown\"\n meta_book_num = \"0\"\n try:\n series_raw = item.get('series_info', '{}')\n s_data = json.loads(series_raw) if isinstance(series_raw, str) else series_raw\n if isinstance(s_data, dict) and s_data:\n for key, val in s_data.items():\n if isinstance(val, list) and len(val) >= 2:\n meta_series = str(val[0]).replace(''', \"'\")\n meta_book_num = str(val[1])\n break\n except:\n pass\n\n # Skip items with Unknown series\n if meta_series == \"Unknown\":\n continue\n\n # Parse author info\n meta_author = \"Unknown\"\n try:\n author_raw = item.get('author_info', '{}')\n a_data = json.loads(author_raw) if isinstance(author_raw, str) else author_raw\n if isinstance(a_data, dict) and a_data:\n meta_author = str(list(a_data.values())[0])\n except:\n pass\n\n results.append({\n \"json\": {\n \"meta_title\": title,\n \"meta_series\": meta_series,\n \"meta_book_number\": meta_book_num,\n \"meta_author\": meta_author,\n \"mam_id\": book_id,\n \"dl_link\": f\"https://www.myanonamouse.net/tor/download.php?tid={book_id}\",\n \"found\": True\n }\n })\n\nif not results:\n return [{\"json\": {\"found\": False, \"reason\": \"No matching IDs found after filtering\"}}]\n\nreturn results\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1536, 416 ], "id": "fb4fed9b-1d34-4d07-85cf-ad3c50a52218", "name": "Build Downloads from LLM" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "ab236a62-6482-49d4-b3b4-00d8a692cf61", "leftValue": "={{ $json.dl_link }}", "rightValue": "", "operator": { "type": "string", "operation": "exists", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 1760, 416 ], "id": "a5baf7a6-b47f-4628-81f8-c5bf0c06f21a", "name": "If" }, { "parameters": {}, "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ 1024, 560 ], "id": "356d68c5-4ab8-4bbf-a91e-29567501d6e2", "name": "do nothing" }, { "parameters": {}, "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ 2208, 416 ], "id": "48de6e72-1427-46aa-87ce-0225323b78b4", "name": "do nothing 2" }, { "parameters": {}, "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [ -176, 160 ], "id": "4b6e7bf3-ed30-4518-883c-b9e39bdbf982", "name": "No Operation, do nothing" } ], "connections": { "Schedule Trigger": { "main": [ [ { "type": "main", "node": "Select Series", "index": 0 } ] ] }, "Loop Over Items": { "main": [ [ { "node": "No Operation, do nothing", "type": "main", "index": 0 } ], [ { "type": "main", "index": 0, "node": "HTTP Request" } ] ] }, "HTTP Request": { "main": [ [ { "node": "Search Error Check", "type": "main", "index": 0 } ] ] }, "Aggregate": { "main": [ [ { "index": 0, "type": "main", "node": "Basic LLM Chain" } ] ] }, "Select Book Titles": { "main": [ [ { "node": "Aggregate", "type": "main", "index": 0 } ] ] }, "Select Series": { "main": [ [ { "node": "Loop Over Items", "type": "main", "index": 0 } ] ] }, "Call 'MAM Transmission Manager'": { "main": [ [ { "node": "do nothing 2", "type": "main", "index": 0 } ] ] }, "Basic LLM Chain": { "main": [ [ { "node": "Merge", "type": "main", "index": 0 } ] ] }, "Merge": { "main": [ [ { "node": "Build Downloads from LLM", "type": "main", "index": 0 } ] ] }, "Search Error Check": { "main": [ [ { "node": "Format for LLM", "type": "main", "index": 0 } ], [ { "node": "Update rows in a table", "type": "main", "index": 0 } ] ] }, "Update rows in a table": { "main": [ [ { "node": "Loop Over Items", "type": "main", "index": 0 } ] ] }, "OpenAI Chat Model": { "ai_languageModel": [ [ { "node": "Basic LLM Chain", "type": "ai_languageModel", "index": 0 } ] ] }, "Format for LLM": { "main": [ [ { "node": "Select Book Titles", "type": "main", "index": 0 }, { "node": "do nothing", "type": "main", "index": 0 } ] ] }, "Build Downloads from LLM": { "main": [ [ { "node": "If", "type": "main", "index": 0 } ] ] }, "If": { "main": [ [ { "node": "Call 'MAM Transmission Manager'", "type": "main", "index": 0 } ], [ { "node": "do nothing 2", "type": "main", "index": 0 } ] ] }, "do nothing": { "main": [ [ { "node": "Merge", "type": "main", "index": 1 } ] ] }, "do nothing 2": { "main": [ [ { "node": "Update rows in a table", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1", "availableInMCP": false, "callerPolicy": "workflowsFromSameOwner" }, "triggerCount": 1, "versionId": "1b02aafb-2cc4-4541-a26f-be001d7e7741", "owner": { "type": "personal", "projectId": "FeLO36wNUAcn61Wj", "projectName": "Ben W ", "personalEmail": "admin@ben.io" }, "parentFolderId": "6tDyZCwqELStb6Ik", "isArchived": false }