This workflow corresponds to n8n.io template #17744 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "9tTwviSzHSiUZPFm",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "PlexPlayListerV1-3",
"tags": [],
"nodes": [
{
"id": "205b82dc-81f0-4398-bdb2-19f12da3e862",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-1296,
16
],
"parameters": {
"rule": {
"interval": [
{
"field": "weeks",
"triggerAtHour": 22,
"triggerAtMinute": 45
}
]
}
},
"typeVersion": 1.3
},
{
"id": "787f6f90-35be-46ed-a194-cd2b4310c461",
"name": "Code in JavaScript",
"type": "n8n-nodes-base.code",
"position": [
-560,
16
],
"parameters": {
"jsCode": "const sections = $('HTTP Request').first().json.MediaContainer.Directory;\nconst musicSection = sections.find(s => s.type === 'artist');\nconst machineIdentifier = $('HTTP Request Identity').first().json.MediaContainer.machineIdentifier;\n\nconst store = $getWorkflowStaticData('global');\nstore.machineIdentifier = machineIdentifier;\n\nreturn [{ json: { sectionId: musicSection.key } }];"
},
"typeVersion": 2
},
{
"id": "220c477c-f813-44e5-885f-2749d1ae71ff",
"name": "Set Offset",
"type": "n8n-nodes-base.set",
"position": [
-112,
16
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "1427b2cb-031c-4647-8f52-1f96a6a9cd70",
"name": "offset",
"type": "number",
"value": 0
},
{
"id": "c040b42c-27b6-48f6-98df-e44a4601f243",
"name": "sectionId",
"type": "string",
"value": "={{ $json.sectionId }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "0c7be6bc-2f72-45cc-8ddf-ef66ccb8c3e6",
"name": "Code Accumulate",
"type": "n8n-nodes-base.code",
"position": [
336,
-48
],
"parameters": {
"jsCode": "const container = $input.first().json.MediaContainer;\nconst totalSize = parseInt(container.totalSize);\nconst tracks = container.Metadata || [];\nconst currentOffset = parseInt(container.offset) || 0;\n\nconst store = $getWorkflowStaticData('global');\nif (!store.tracks) store.tracks = [];\nif (!store.sectionId) store.sectionId = $('Set Offset').first().json.sectionId; // read from the node that actually has it\n\nconst normalized = tracks.map(t => ({\n ratingKey : t.ratingKey,\n title : t.title || 'Unknown Title',\n artist : t.grandparentTitle || t.originalTitle || 'Unknown Artist',\n album : t.parentTitle || 'Unknown Album',\n genre : Array.isArray(t.Genre) ? t.Genre.map(g => g.tag).join(', ') : (t.Genre?.tag || ''),\n mood : Array.isArray(t.Mood) ? t.Mood.map(m => m.tag).join(', ') : '',\n year : t.parentYear || t.year || null,\n duration : t.duration || null,\n}));\n\nstore.tracks.push(...normalized);\n\nconst nextOffset = currentOffset + 300;\nconst hasMore = nextOffset < totalSize;\n\nreturn [{ json: {\n sectionId : store.sectionId,\n offset : nextOffset,\n hasMore,\n totalSize,\n totalCollected : store.tracks.length,\n}}];"
},
"typeVersion": 2
},
{
"id": "e7c46331-a7fc-4d39-8071-3349424d0770",
"name": "If hasMore?",
"type": "n8n-nodes-base.if",
"position": [
560,
16
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "625edc38-6509-433e-9574-55ef376413cc",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.hasMore }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "4b34097d-75c7-41b5-9472-ddc6c1048812",
"name": "Loop Over Items",
"type": "n8n-nodes-base.splitInBatches",
"position": [
1056,
224
],
"parameters": {
"options": {
"reset": false
},
"batchSize": 13
},
"typeVersion": 3
},
{
"id": "03abe4d5-7d80-4500-9e8d-b106a06e6acc",
"name": "Build Prompt",
"type": "n8n-nodes-base.code",
"position": [
1248,
240
],
"parameters": {
"jsCode": "const batch = $('Loop Over Items').all().map(item => item.json).slice(0, 20);\n\nconst trackList = batch.map((t, i) =>\n `${i+1}. id:${t.ratingKey} | \"${t.title}\" by ${t.artist} | Genre: ${t.genre || 'unknown'} | Year: ${t.year || 'unknown'}`\n).join('\\n');\n\nconst expectedIds = batch.map(t => t.ratingKey).join(', ');\n\nconst prompt = `You are a music classifier. Classify each track below into exactly one playlist.\n\nVALID CATEGORIES (use ONLY these exact strings):\n- Deep Focus\n- Morning Energy\n- Late Night Drive\n- Workout Power\n- Chill & Unwind\n- Nostalgia Trip\n- Party Mode\n- Discover Gems\n- SKIP_NON_MUSIC\n\nCRITICAL DEFINITIONS & RULES:\n1. \"Discover Gems\": Use this ONLY for actual music tracks that are eclectic, indie, obscure, or don't fit a specific mood. It must be music.\n2. \"SKIP_NON_MUSIC\": You MUST use this category for any track that is a podcast, audiobook, news broadcast, interview, comedy routine, or spoken-word audio. \n3. Clues for NON-MUSIC: If the genre is missing, look at the title and artist. If the title contains words like \"Episode\", \"Chapter\", \"Interview\", \"Talk\", \"Live at\", or looks like a conversational topic, classify it as \"SKIP_NON_MUSIC\".\n\nTRACKS:\n${trackList}\n\nYou MUST output an entry for every one of these ${batch.length} track IDs, with no exceptions: ${expectedIds}\nDo not stop until all ${batch.length} tracks have an entry.\n\nOUTPUT RULES:\n- Return ONLY a JSON array\n- Each element must be: {\"id\":\"<the id number>\",\"playlist\":\"<category name>\"}\n- Do NOT group by playlist\n- Do NOT return track titles\n- One object per track\n- No explanation, no markdown\n\nEXAMPLE OUTPUT:\n[{\"id\":\"1193\",\"playlist\":\"Chill & Unwind\"},{\"id\":\"1194\",\"playlist\":\"Deep Focus\"}]\n\nYOUR OUTPUT:`;\n\nreturn [{\n json: {\n skip: false,\n ollamaBody: {\n //model: \"qwen2.5:7b\",\n //model: \"mistral:7b\",\n model: \"gemma2:9b\",\n //model: \"llama3.2:1b\",\n prompt,\n stream: false,\n options: { temperature: 0.1, num_predict: 900, num_ctx: 2048 }\n }\n }\n}];"
},
"typeVersion": 2
},
{
"id": "29dfce30-cf64-4fde-8375-ff42c8290d2c",
"name": "[ Clear Store ]2",
"type": "n8n-nodes-base.code",
"position": [
-336,
16
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\n// Increased STALE_MS to 30 hours to safely accommodate Gemma 2 9B's ~24h run time\nconst STALE_MS = 30 * 60 * 60 * 1000;\nconst lockAge = store.runStartedAt ? Date.now() - store.runStartedAt : Infinity;\n\nif (store.runInProgress && lockAge < STALE_MS) {\n throw new Error(`Another PlexPlayLister run is already in progress (started ${Math.round(lockAge/60000)} min ago) \u2014 aborting to avoid clobbering shared state.`);\n}\n\nstore.runInProgress = true;\nstore.runStartedAt = Date.now();\nstore.tracks = [];\nstore.classificationMap = {};\nstore.emptyParseBatches = 0;\nstore.doneReasons = {};\nstore.errorBatches = 0;\nstore.createdPlaylists = []; // <-- missing reset, causes stale playlist keys to leak across runs\n\nconst sectionId = $input.first().json.sectionId;\nreturn [{ json: { cleared: true, sectionId } }];"
},
"typeVersion": 2
},
{
"id": "54acec42-e75c-4331-a8af-38b7b56cab14",
"name": "Group by Playlist",
"type": "n8n-nodes-base.code",
"position": [
1168,
-224
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\nconst allTracks = store.tracks || [];\nconst classificationMap = store.classificationMap || {};\nconst FALLBACK = 'Discover Gems';\n\nif (allTracks.length === 0) {\n throw new Error('store.tracks is empty when Group by Playlist ran.');\n}\n\nlet trueGaps = 0; // tracks with NO classificationMap entry at all\nconst playlistGroups = {};\n\nfor (const track of allTracks) {\n const key = String(track.ratingKey);\n const hasEntry = Object.prototype.hasOwnProperty.call(classificationMap, key);\n if (!hasEntry) trueGaps++;\n const playlist = classificationMap[key] || FALLBACK;\n // NEW: Drop the track entirely if the LLM flagged it as non-music!\n if (playlist === 'SKIP_NON_MUSIC') continue;\n if (!playlistGroups[playlist]) playlistGroups[playlist] = new Set();\n playlistGroups[playlist].add(track.ratingKey);\n}\n\nconst result = Object.entries(playlistGroups)\n .map(([name, keySet]) => ({\n json: { playlist: name, count: keySet.size, ratingKeys: Array.from(keySet) }\n }))\n .sort((a, b) => b.json.count - a.json.count);\n\nresult.push({ json: { playlist: '__DIAGNOSTIC__', trueGaps, totalTracks: allTracks.length, gapPercent: Math.round((trueGaps / allTracks.length) * 100) } });\n\nresult.push({ json: {\n playlist: '__DIAGNOSTIC__',\n trueGaps,\n totalTracks: allTracks.length,\n gapPercent: Math.round((trueGaps / allTracks.length) * 100),\n errorBatches: store.errorBatches || 0,\n emptyParseBatches: store.emptyParseBatches || 0,\n totalBatches: 1239\n}});\n\nreturn result;"
},
"typeVersion": 2
},
{
"id": "83fc27e8-48ba-42e0-af62-804097db0b7f",
"name": "If",
"type": "n8n-nodes-base.if",
"position": [
1408,
400
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "a993e1a1-aa4b-4194-98f6-fe324d7b2e3c",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.skip }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "25b6ccb7-009f-45e0-b02c-6ec79d0d8994",
"name": "HTTP Fetch Plex Lib Data",
"type": "n8n-nodes-base.httpRequest",
"position": [
112,
16
],
"parameters": {
"url": "=http://MY_HOST_OR_IP:32400/library/sections/{{ $json.sectionId }}/all",
"options": {},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "type",
"value": "10"
},
{
"name": "X-Plex-Container-Start",
"value": "={{ $json.offset }}"
},
{
"name": "X-Plex-Container-Size",
"value": "300"
},
{
"name": "includeGuids",
"value": "0"
},
{
"name": "X-Plex-Token",
"value": "Y4va75DCXFtfjAqenQMr"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": true,
"typeVersion": 4.4,
"alwaysOutputData": false
},
{
"id": "14d6f44f-6d09-46a4-93a4-8088d87ff3aa",
"name": "Return Track List",
"type": "n8n-nodes-base.code",
"position": [
784,
16
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\nconst tracks = store.tracks || [];\nif (tracks.length === 0) {\n throw new Error('No tracks in store. Check pagination loop.');\n}\nreturn tracks.map(t => ({ json: t }));"
},
"typeVersion": 2
},
{
"id": "63fe5638-9bfd-49e6-ac91-7b5bd181081b",
"name": "Classify Tracks Using AI",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueErrorOutput",
"position": [
1760,
272
],
"parameters": {
"url": "http://MY_OLLAMA_HOST_OR_IP:11434/api/generate",
"method": "POST",
"options": {},
"jsonBody": "={{ JSON.stringify($json.ollamaBody) }}",
"sendBody": true,
"specifyBody": "json"
},
"retryOnFail": true,
"typeVersion": 4.4,
"waitBetweenTries": 5000
},
{
"id": "f7f96e99-6b57-4f07-af5d-69ae03bb8ca4",
"name": "Present Classified List",
"type": "n8n-nodes-base.code",
"position": [
2000,
432
],
"parameters": {
"jsCode": "if ($input.first().json.skip === true) {\n return [{ json: { accepted: 0, rejected: 0, skipped: true } }];\n}\n\nconst response = $input.first().json.response || '';\nconst cleaned = response\n .replace(/```json/gi, '')\n .replace(/```/g, '')\n .trim();\n\nlet classifications = [];\nlet recovered = false;\n\ntry {\n const parsed = JSON.parse(cleaned);\n if (Array.isArray(parsed)) {\n classifications = parsed;\n } else if (typeof parsed === 'object' && parsed !== null) {\n classifications = Object.values(parsed)\n .map(v => (typeof v === 'object' ? v : null))\n .filter(Boolean);\n }\n} catch (e) {\n recovered = true;\n const pairRegex = /\"id\"\\s*:\\s*\"?(\\d+)\"?[^}]*?\"playlist\"\\s*:\\s*\"([^\"]+)\"/g;\n let match;\n while ((match = pairRegex.exec(cleaned)) !== null) {\n classifications.push({ id: match[1], playlist: match[2] });\n }\n}\n\nconst VALID = new Set([\n \"Deep Focus\", \"Morning Energy\", \"Late Night Drive\", \"Workout Power\",\n \"Chill & Unwind\", \"Nostalgia Trip\", \"Discover Gems\", \"Party Mode\", \"SKIP_NON_MUSIC\"\n]);\n\nconst store = $getWorkflowStaticData('global'); // \u2190 declared ONCE, here\nif (!store.classificationMap) store.classificationMap = {};\n\nif (classifications.length === 0) {\n store.emptyParseBatches = (store.emptyParseBatches || 0) + 1;\n}\n\nlet accepted = 0, rejected = 0;\nfor (const item of classifications) {\n const id = String(item.id || '');\n const playlist = item.playlist || '';\n if (id && VALID.has(playlist)) {\n store.classificationMap[id] = playlist;\n accepted++;\n } else if (id) {\n store.classificationMap[id] = 'Discover Gems';\n rejected++;\n }\n}\n\nconst total = Object.keys(store.classificationMap).length;\nreturn [{ json: { accepted, rejected, totalClassified: total, recovered } }];"
},
"typeVersion": 2
},
{
"id": "5ec03aa5-bcea-4e38-9ac1-fc2b185bd40d",
"name": "Code in JavaScript1",
"type": "n8n-nodes-base.code",
"position": [
2000,
640
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\nstore.errorBatches = (store.errorBatches || 0) + 1;\nreturn [{ json: { accepted: 0, rejected: 0, error: true } }];"
},
"typeVersion": 2
},
{
"id": "db0742f7-2650-4610-9001-d5045b7e46c0",
"name": "Code in JavaScript2",
"type": "n8n-nodes-base.code",
"position": [
1392,
-224
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\nstore.runInProgress = false;\nreturn $input.all();"
},
"typeVersion": 2
},
{
"id": "357605f7-0192-46cb-a9c8-5c2e4d1400a1",
"name": "Code in JavaScript3",
"type": "n8n-nodes-base.code",
"position": [
1616,
-224
],
"parameters": {
"jsCode": "return $input.all().filter(item => item.json.playlist !== '__DIAGNOSTIC__');"
},
"typeVersion": 2
},
{
"id": "6cf405d1-143e-40e4-b799-a3ca5a7be0a9",
"name": "Loop Over Items1",
"type": "n8n-nodes-base.splitInBatches",
"position": [
1920,
-224
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "f0475434-3408-4cff-a704-2223db520909",
"name": "HTTP Request1",
"type": "n8n-nodes-base.httpRequest",
"position": [
2304,
-224
],
"parameters": {
"url": "http://MY_HOST_OR_IP:32400/playlists?type=15",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "3e2c6e69-9d7c-4ea7-a915-1520e20bab89",
"name": "Find Match",
"type": "n8n-nodes-base.code",
"position": [
2496,
-224
],
"parameters": {
"jsCode": "const targetTitle = $('Loop Over Items1').first().json.playlist;\nconst container = $input.first().json.MediaContainer;\nconst existing = (container.Metadata || []).find(p => p.title === targetTitle);\n\nreturn [{ json: {\n playlist: targetTitle,\n ratingKeys: $('Loop Over Items1').first().json.ratingKeys,\n existingPlaylistKey: existing ? existing.ratingKey : null,\n machineIdentifier: $getWorkflowStaticData('global').machineIdentifier\n}}];"
},
"typeVersion": 2
},
{
"id": "6ef4c76e-7b0f-47b5-bd35-eff839d3375c",
"name": "Create Playlist",
"type": "n8n-nodes-base.code",
"position": [
3168,
-224
],
"parameters": {
"jsCode": "// Replace the code in 'Create Playlist' with:\nconst matchNode = $('Find Match').first().json;\n\nconst ratingKeys = matchNode.ratingKeys || [];\nconst first50 = ratingKeys.slice(0, 50);\nconst remaining = ratingKeys.slice(50);\nconst uri = `server://${matchNode.machineIdentifier}/com.plexapp.plugins.library/library/metadata/${first50.join(',')}`;\n\nreturn [{ json: {\n playlist: matchNode.playlist,\n createUri: uri,\n remaining,\n machineIdentifier: matchNode.machineIdentifier\n}}];"
},
"typeVersion": 2
},
{
"id": "48b75dee-da83-4ca9-8076-3f059267004c",
"name": "HTTP Request3",
"type": "n8n-nodes-base.httpRequest",
"position": [
3328,
-224
],
"parameters": {
"url": "=http://MY_HOST_OR_IP:32400/playlists?type=audio&title={{ encodeURIComponent($json.playlist) }}&smart=0&uri={{ encodeURIComponent($json.createUri) }}",
"method": "POST",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "ae212192-f5fc-4364-965f-cb6ca4e3c0a2",
"name": "IfExists",
"type": "n8n-nodes-base.if",
"position": [
2720,
-224
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "c94d05d4-5661-4e85-bc58-7dc779186d33",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"leftValue": "={{ $json.existingPlaylistKey }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "cbf24201-fdb5-43c2-9cd6-c1db05e1d1a1",
"name": "If1",
"type": "n8n-nodes-base.if",
"position": [
3728,
-144
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "d51fc114-d200-4a12-ab29-b492c467b4c3",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.remaining.length }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.3
},
{
"id": "aea18b5a-40fe-49f1-bdf9-5f19d8e6e4aa",
"name": "Capture ratingKey",
"type": "n8n-nodes-base.code",
"position": [
3504,
-224
],
"parameters": {
"jsCode": "const data = $('Create Playlist').first().json;\nconst container = $input.first().json?.MediaContainer;\nconst metadata = container?.Metadata?.[0] || container?.Directory?.[0];\n\nif (!metadata?.ratingKey) {\n throw new Error(`Failed to extract ratingKey for created playlist \"${data.playlist}\"`);\n}\n\n// Store the created playlist info in global memory so the final chunker can use it\nconst store = $getWorkflowStaticData('global');\nif (!store.createdPlaylists) store.createdPlaylists = [];\n\nstore.createdPlaylists.push({\n playlist: data.playlist,\n newPlaylistKey: metadata.ratingKey,\n remaining: data.remaining,\n machineIdentifier: data.machineIdentifier\n});\n\nreturn [{ json: {\n playlist: data.playlist,\n newPlaylistKey: metadata.ratingKey,\n remaining: data.remaining,\n machineIdentifier: data.machineIdentifier\n}}];"
},
"typeVersion": 2
},
{
"id": "585afada-8951-414c-91e7-4848bc68eb4f",
"name": "Loop Over Items2",
"type": "n8n-nodes-base.splitInBatches",
"position": [
2864,
432
],
"parameters": {
"options": {},
"batchSize": 50
},
"typeVersion": 3
},
{
"id": "e89831da-a43c-45b2-8d84-7a9e1702b926",
"name": "HTTP Request4",
"type": "n8n-nodes-base.httpRequest",
"position": [
3216,
496
],
"parameters": {
"url": "=http://MY_HOST_OR_IP:32400/playlists/{{ $json.newPlaylistKey }}/items?uri={{ encodeURIComponent('server://' + $json.machineIdentifier + '/com.plexapp.plugins.library/library/metadata/' + $json.chunk.join(',')) }}",
"method": "PUT",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "8c570dd7-7e7d-4d53-a321-401a1d933d1a",
"name": "Chunk Remaining",
"type": "n8n-nodes-base.code",
"position": [
2704,
352
],
"parameters": {
"jsCode": "const store = $getWorkflowStaticData('global');\nconst allPlaylists = store.createdPlaylists || [];\nconst allAppendJobs = [];\n\nfor (const data of allPlaylists) {\n // Only process if there are tracks remaining after the initial 50\n if (data.remaining && data.remaining.length > 0) {\n for (let i = 0; i < data.remaining.length; i += 50) {\n const chunk = data.remaining.slice(i, i + 50);\n \n allAppendJobs.push({\n json: {\n playlist: data.playlist,\n newPlaylistKey: data.newPlaylistKey,\n machineIdentifier: data.machineIdentifier,\n chunk: chunk\n }\n });\n }\n }\n}\n\n// Returns a flat list of every single append HTTP request needed across all 8 playlists!\nreturn allAppendJobs;"
},
"typeVersion": 2
},
{
"id": "989cb3cb-674b-4d42-af21-5c9a618f0cfe",
"name": "HTTP Request2",
"type": "n8n-nodes-base.httpRequest",
"position": [
2944,
-288
],
"parameters": {
"url": "=http://MY_HOST_OR_IP:32400/playlists/{{ $json.existingPlaylistKey }}",
"method": "DELETE",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "27892f6f-35a2-43be-9fa3-dbcaea79c536",
"name": "HTTP Request Identity",
"type": "n8n-nodes-base.httpRequest",
"position": [
-784,
16
],
"parameters": {
"url": "http://MY_HOST_OR_IP:32400/identity",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "313675ec-1e44-4968-a328-62d378f09d94",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2544,
-336
],
"parameters": {
"width": 1104,
"height": 752,
"content": "## PlexPlaylister Version 1.3\n\n## HOW IT WORKS\n\nBuilding Playlist can become quite a hassle as your library grows. With this flow you can make new Playlists allowing you to find hidden gems and listen to music again that you have not heard in times. \n\n## HOW TO SETUP\n\nThis flow has been developed for people that have their own instances of OLLAMA, n8n on a home NAS / media server. \n\n1. From the n8n Overview Page, go to Credentials \n- Setup global credentials to be used to authenticate for Plex \n - Header 1\n - Name : X-Plex-Token; Value \"MY_PLEX_TOKEN\" see (https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)\";\n - Allowed HTTP Request Domains : All\n- Setup OLLAMA Base URL : test whether connectivity with OLLAMA is possible from the n8n instance\n\n2. In the PlaxPlaylister flow : \nFollow the STEPS adjusting the flow \n- Section 1 Change the Schedule Trigger : set the timing of when the flow should kick off. Take into account that a full run could take 24 hours.\n- Section 2,3,6&7 Change ALL the HTTP Request nodes : change \"MY_HOST_OR_IP\" to your local IP of the Plex server \n- Section 4 Change the HTTP Request \"Classify Tracks Using AI\" node \"MY_OLLAMA_HOST_OR_IP\" to your local IP of the OLLAMA server\n\n\n## CUSTOMIZATION\n\n* While this template provides an out-of-the-box architecture for exhaustive, 100% coverage music classification using local LLMs (like gemma2:9b or mistral:7b), your final playlist accuracy depends heavily on two factors: ID3 Metadata Quality and Personalized Prompt Tuning."
},
"typeVersion": 1
},
{
"id": "5d9d8dc6-0449-4a38-aaea-9059bb7822a2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"position": [
-1008,
16
],
"parameters": {
"url": "http://MY_HOST_OR_IP:32400/library/sections",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpMultipleHeadersAuth"
},
"credentials": {
"httpMultipleHeadersAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "3e003bd4-bfaa-4c62-81fe-07d1b632186d",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1408,
-336
],
"parameters": {
"color": "#ECE4DE",
"width": 352,
"height": 752,
"content": "## 1. Set time/schedule your run \n\nThis will allow scheduling the batch run depending on how often you music collection is changing.\n"
},
"typeVersion": 1
},
{
"id": "19391205-777b-438f-bcd8-99270f695702",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1040,
-336
],
"parameters": {
"color": "#ECE4DE",
"width": 1056,
"height": 752,
"content": "## 2 Collect Plex Library ID\nGet the Library ID and prep for the further steps"
},
"typeVersion": 1
},
{
"id": "5c508a08-f1e6-4fff-b6ae-7ddea78e641a",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
48,
-336
],
"parameters": {
"color": "#ECE4DE",
"width": 896,
"height": 752,
"content": "## 3 Collect Music Library Info \nFetch the track data untill all tracks have been collected"
},
"typeVersion": 1
},
{
"id": "464b91e4-8071-44d6-ba23-a12756b8cdab",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
976,
80
],
"parameters": {
"color": "#ECE4DE",
"width": 1184,
"height": 784,
"content": "## 4 Classify Tracks into predefined Playlists \nIn chuncks of data tracks will be offered to your local LLM for classification until all Playlist have been defined."
},
"typeVersion": 1
},
{
"id": "29481339-97f5-415e-84b9-f53f2eb10079",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
976,
-400
],
"parameters": {
"color": "#ECE4DE",
"width": 1184,
"height": 464,
"content": "## 5 Collect the complete Playlists + Track lists\nSee the results of the Playlist creation and track coverage as output from STEP3."
},
"typeVersion": 1
},
{
"id": "234a866f-fd7e-46e4-ac76-5d3963af0828",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
2192,
-400
],
"parameters": {
"color": "#ECE4DE",
"width": 1696,
"height": 464,
"content": "## 6 Create the Playlists in Plex\nBased on the output of STEP4 check the Plex Library and create the Playlist."
},
"typeVersion": 1
},
{
"id": "cd2be4e9-2022-4005-8e4f-1051891b165c",
"name": "Sticky Note14",
"type": "n8n-nodes-base.stickyNote",
"position": [
2192,
80
],
"parameters": {
"color": "#ECE4DE",
"width": 1696,
"height": 784,
"content": "## 6 Populate the Playlists in Plex\nBased on the output of STEP4 and STEP 5 populate the Plex Playlist with the tracks classified."
},
"typeVersion": 1
},
{
"id": "02366ba0-adba-44f1-8fd8-318c2ff8509b",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
1632,
176
],
"parameters": {
"color": "#F80103",
"width": 352,
"height": 256,
"content": "Change the \"MY_OLLAMA_HOST_OR_IP\" HTTP Request in this node to your local IP of the OLLAMA server\n"
},
"typeVersion": 1
}
],
"active": true,
"settings": {
"timezone": "Europe/Amsterdam",
"binaryMode": "separate",
"callerPolicy": "workflowsFromSameOwner",
"timeSavedMode": "fixed",
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "96602ac4-8c3a-46fe-8b29-c937cf6e91d1",
"nodeGroups": [],
"connections": {
"If": {
"main": [
[
{
"node": "Present Classified List",
"type": "main",
"index": 0
}
],
[
{
"node": "Classify Tracks Using AI",
"type": "main",
"index": 0
}
]
]
},
"If1": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
],
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"IfExists": {
"main": [
[
{
"node": "HTTP Request2",
"type": "main",
"index": 0
}
],
[
{
"node": "Create Playlist",
"type": "main",
"index": 0
}
]
]
},
"Find Match": {
"main": [
[
{
"node": "IfExists",
"type": "main",
"index": 0
}
]
]
},
"Set Offset": {
"main": [
[
{
"node": "HTTP Fetch Plex Lib Data",
"type": "main",
"index": 0
}
]
]
},
"If hasMore?": {
"main": [
[
{
"node": "HTTP Fetch Plex Lib Data",
"type": "main",
"index": 0
}
],
[
{
"node": "Return Track List",
"type": "main",
"index": 0
}
]
]
},
"Build Prompt": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request": {
"main": [
[
{
"node": "HTTP Request Identity",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request1": {
"main": [
[
{
"node": "Find Match",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request2": {
"main": [
[
{
"node": "Create Playlist",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request3": {
"main": [
[
{
"node": "Capture ratingKey",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request4": {
"main": [
[
{
"node": "Loop Over Items2",
"type": "main",
"index": 0
}
]
]
},
"Chunk Remaining": {
"main": [
[
{
"node": "Loop Over Items2",
"type": "main",
"index": 0
}
]
]
},
"Code Accumulate": {
"main": [
[
{
"node": "If hasMore?",
"type": "main",
"index": 0
}
]
]
},
"Create Playlist": {
"main": [
[
{
"node": "HTTP Request3",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "Group by Playlist",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Prompt",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items1": {
"main": [
[
{
"node": "Chunk Remaining",
"type": "main",
"index": 0
}
],
[
{
"node": "HTTP Request1",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items2": {
"main": [
[],
[
{
"node": "HTTP Request4",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
},
"[ Clear Store ]2": {
"main": [
[
{
"node": "Set Offset",
"type": "main",
"index": 0
}
]
]
},
"Capture ratingKey": {
"main": [
[
{
"node": "If1",
"type": "main",
"index": 0
}
]
]
},
"Group by Playlist": {
"main": [
[
{
"node": "Code in JavaScript2",
"type": "main",
"index": 0
}
]
]
},
"Return Track List": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "[ Clear Store ]2",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript1": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript2": {
"main": [
[
{
"node": "Code in JavaScript3",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript3": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request Identity": {
"main": [
[
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"Present Classified List": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Classify Tracks Using AI": {
"main": [
[
{
"node": "Present Classified List",
"type": "main",
"index": 0
}
],
[
{
"node": "Code in JavaScript1",
"type": "main",
"index": 0
}
]
]
},
"HTTP Fetch Plex Lib Data": {
"main": [
[
{
"node": "Code Accumulate",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpMultipleHeadersAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This scheduled workflow fetches all tracks from a Plex Music library, sends them in batches to a local Ollama LLM for playlist classification, then recreates and populates matching Plex playlists while skipping spoken-word content. Runs weekly on a schedule and queries the Plex…
Source: https://n8n.io/workflows/17744/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Version : 1.0
The News Site from Colt, a telecom company, does not offer an RSS feed, therefore web scraping is the choice to extract and process the news.
This workflow helps you monitor domain expiration dates and send automated reminders via Telegram when a domain is about to expire or has already expired, using WHOIS data and AI-powered information e
This template automates invoice processing for teams that currently copy data from PDFs into spreadsheets by hand. It is ideal for small businesses, back-office teams, accounting, and operations who w
This n8n workflow automates the extraction of actionable tasks from unread Gmail messages using OpenAI's GPT API, stores the resulting task metadata in Supabase, and avoids re-processing previously ha