{
  "name": "list_songs_webhook",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "b91a0495-0e7d-4715-ab08-241eb48313a0",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ],
      "id": "7d5fe275-7a3f-4e12-b411-dc0683f1e109",
      "name": "Webhook - List Songs"
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nconst rawQuery =\n  typeof item.body?.query === \"string\" ? item.body.query :\n  typeof item.query === \"string\" ? item.query :\n  typeof item.song === \"string\" ? item.song :\n  typeof item.text === \"string\" ? item.text :\n  \"\";\n\nconst rawLimit =\n  typeof item.body?.limit === \"number\" ? item.body.limit :\n  typeof item.limit === \"number\" ? item.limit :\n  25;\n\nconst limit = Math.max(1, Math.min(100, Number(rawLimit) || 25));\n\nreturn [\n  {\n    json: {\n      originalQuery: rawQuery.trim(),\n      normalizedQuery: rawQuery.trim().toLowerCase(),\n      limit\n    }\n  }\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        208,
        0
      ],
      "id": "705bc39c-d545-493d-9dee-3f08bd641e1c",
      "name": "Prepare List Query"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://host.docker.internal:7474/db/neo4j/query/v2",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "=application/json",
        "body": "={{$json.rawBody}}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        640,
        0
      ],
      "id": "7c8afa31-1932-4904-b7d1-f2fac66954c1",
      "name": "Query Neo4j List",
      "credentials": {
        "httpBasicAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst fields = item.data?.fields || [];\nconst values = item.data?.values || [];\nconst originalQuery = $('Prepare List Query').first().json.originalQuery || \"\";\nconst normalizedQuery = $('Prepare List Query').first().json.normalizedQuery || \"\";\nconst limit = $('Prepare List Query').first().json.limit || 25;\n\nfunction mapRow(row) {\n  const mapped = {};\n  fields.forEach((field, index) => {\n    mapped[field] = row[index];\n  });\n  return mapped;\n}\n\nfunction normalizeCatalogQuery(query) {\n  return String(query || \"\")\n    .toLowerCase()\n    .replace(/rocksange/g, \"rock\")\n    .replace(/metalsange/g, \"metal\")\n    .replace(/popsange/g, \"pop\")\n    .replace(/technosange/g, \"techno\")\n    .replace(/[.,!?()]/g, \" \")\n    .replace(/\\b(find|show|list|give|me|all|stored|songs|song|chords|for|please|vis|finde|alle|sange|sang|sorteret|alfabetisk|kunstner|kunstnere|artist|artists|efter|med|noget|mere|specifikke|specifik|lookup|opslagsvaerk|opslagsv\u00e6rk|by)\\b/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction inferGenres(row) {\n  const genres = new Set();\n  const artist = String(row.artist || \"\").toLowerCase();\n  const song = String(row.song || \"\").toLowerCase();\n  const tags = Array.isArray(row.tags) ? row.tags.map(tag => String(tag).toLowerCase()) : [];\n  const summary = String(row.summary || \"\").toLowerCase();\n\n  for (const tag of tags) {\n    if (tag) genres.add(tag);\n  }\n\n  const addGenres = (...values) => values.forEach(value => genres.add(value));\n\n  if (artist.includes(\"oasis\")) addGenres(\"rock\", \"britpop\");\n  if (artist.includes(\"coldplay\")) addGenres(\"rock\", \"alternative rock\", \"pop rock\");\n  if (artist.includes(\"courtney barnett\")) addGenres(\"rock\", \"indie rock\");\n  if (artist.includes(\"bruce springsteen\")) addGenres(\"rock\", \"heartland rock\");\n  if (artist.includes(\"twenty one pilots\")) addGenres(\"rock\", \"alternative rock\", \"pop rock\");\n  if (artist.includes(\"finn wolfhard\")) addGenres(\"rock\", \"indie rock\");\n  if (artist.includes(\"rend collective\")) addGenres(\"rock\", \"folk rock\", \"christian rock\");\n  if (artist.includes(\"olivia rodrigo\")) addGenres(\"pop\", \"pop rock\");\n  if (artist.includes(\"taylor swift\")) addGenres(\"pop\", \"country pop\", \"pop rock\");\n  if (artist.includes(\"ed sheeran\")) addGenres(\"pop\");\n  if (artist.includes(\"charli xcx\")) addGenres(\"pop\", \"electropop\");\n  if (artist.includes(\"electric callboy\")) addGenres(\"metal\", \"metalcore\", \"electronicore\", \"techno\");\n\n  if (song.includes(\"easy version\")) {\n    genres.add(\"accessible\");\n  }\n\n  if (summary.includes(\"metal\")) addGenres(\"metal\");\n  if (summary.includes(\"rock\")) addGenres(\"rock\");\n  if (summary.includes(\"pop\")) addGenres(\"pop\");\n  if (summary.includes(\"techno\")) addGenres(\"techno\");\n\n  return Array.from(genres);\n}\n\nfunction detectRequestedGenre(normalizedQuery) {\n  if (!normalizedQuery) return \"\";\n  if (/(^|\\s)rock($|\\s)/.test(normalizedQuery)) return \"rock\";\n  if (/(^|\\s)metal($|\\s)/.test(normalizedQuery)) return \"metal\";\n  if (/(^|\\s)pop($|\\s)/.test(normalizedQuery)) return \"pop\";\n  if (/(^|\\s)techno($|\\s)/.test(normalizedQuery)) return \"techno\";\n  return \"\";\n}\n\nfunction removeGenreTokens(normalizedQuery, genre) {\n  if (!genre) return normalizedQuery;\n  return normalizedQuery\n    .replace(new RegExp(`(^|\\\\s)${genre}($|\\\\s)`, \"g\"), \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction matchesTextQuery(row, searchText) {\n  if (!searchText) return true;\n\n  const haystack = [\n    row.artist,\n    row.song,\n    row.summary,\n    ...(Array.isArray(row.tags) ? row.tags : []),\n    ...(Array.isArray(row.inferredGenres) ? row.inferredGenres : [])\n  ]\n    .filter(Boolean)\n    .join(\" \")\n    .toLowerCase();\n\n  const terms = searchText.split(\" \").filter(Boolean);\n  return terms.every(term => haystack.includes(term));\n}\n\nfunction matchGenreBucket(row, bucket) {\n  const inferredGenres = Array.isArray(row.inferredGenres) ? row.inferredGenres : [];\n  return inferredGenres.some(genre => bucket.includes(genre));\n}\n\nconst genreProfiles = {\n  rock: {\n    aliases: [\"rock\", \"alternative rock\", \"indie rock\", \"hard rock\", \"britpop\", \"folk rock\", \"heartland rock\", \"christian rock\"],\n    fallback: [\"metal\", \"metalcore\", \"electronicore\"]\n  },\n  metal: {\n    aliases: [\"metal\", \"metalcore\", \"electronicore\", \"hard rock\"],\n    fallback: [\"rock\", \"alternative rock\"]\n  },\n  pop: {\n    aliases: [\"pop\", \"electropop\", \"dance pop\", \"country pop\", \"synthpop\", \"pop rock\"],\n    fallback: [\"rock\", \"indie rock\"]\n  },\n  techno: {\n    aliases: [\"techno\", \"electronic\", \"electropop\", \"electronicore\"],\n    fallback: []\n  }\n};\n\nconst rows = values.map(row => ({\n  ...mapRow(row),\n  inferredGenres: inferGenres(mapRow(row))\n}));\n\nconst effectiveNormalizedQuery = normalizeCatalogQuery(normalizedQuery || originalQuery);\nconst requestedGenre = detectRequestedGenre(effectiveNormalizedQuery);\nconst textSearch = removeGenreTokens(effectiveNormalizedQuery, requestedGenre);\n\nconst textMatchedRows = rows.filter(row => matchesTextQuery(row, textSearch));\n\nlet filteredRows = textMatchedRows;\nlet genreFallbackUsed = false;\n\nif (requestedGenre && genreProfiles[requestedGenre]) {\n  const primaryRows = textMatchedRows.filter(row =>\n    matchGenreBucket(row, genreProfiles[requestedGenre].aliases)\n  );\n\n  if (primaryRows.length > 0) {\n    filteredRows = primaryRows;\n  } else if (genreProfiles[requestedGenre].fallback.length > 0) {\n    const fallbackRows = textMatchedRows.filter(row =>\n      matchGenreBucket(row, genreProfiles[requestedGenre].fallback)\n    );\n\n    if (fallbackRows.length > 0) {\n      filteredRows = fallbackRows;\n      genreFallbackUsed = true;\n    } else {\n      filteredRows = [];\n    }\n  } else {\n    filteredRows = [];\n  }\n}\n\nfilteredRows = filteredRows.slice(0, limit);\n\nif (!filteredRows.length) {\n  return [\n    {\n      json: {\n        found: false,\n        response: originalQuery\n          ? `I couldn't find any stored songs matching \"${originalQuery}\".`\n          : \"I couldn't find any stored songs in Neo4j.\"\n      }\n    }\n  ];\n}\n\nconst lines = [];\nconst heading = originalQuery\n  ? `Stored songs matching \"${originalQuery}\" (sorted by artist):`\n  : \"Stored songs (sorted by artist):\";\n\nlines.push(heading);\n\nif (genreFallbackUsed && requestedGenre === \"rock\") {\n  lines.push(\"\");\n  lines.push(\"No direct rock-labelled matches were found, so I included the closest metal-adjacent matches instead.\");\n}\n\nlines.push(\"\");\n\nfor (const row of filteredRows) {\n  const extras = [];\n\n  if (row.capo) {\n    extras.push(`capo ${row.capo}`);\n  }\n\n  if (row.songKey) {\n    extras.push(`key ${row.songKey}`);\n  }\n\n  const displayGenres = Array.isArray(row.inferredGenres)\n    ? row.inferredGenres.filter(genre => genre !== \"accessible\").slice(0, 3)\n    : [];\n\n  if (displayGenres.length > 0) {\n    extras.push(`genres: ${displayGenres.join(\", \")}`);\n  }\n\n  const suffix = extras.length ? ` [${extras.join(\" \u2022 \")}]` : \"\";\n  lines.push(`- ${row.artist} \u2014 ${row.song}${suffix}`);\n}\n\nif (filteredRows.length === limit) {\n  lines.push(\"\");\n  lines.push(`Showing the first ${limit} matches.`);\n}\n\nreturn [\n  {\n    json: {\n      found: true,\n      response: lines.join(\"\\n\"),\n      matches: filteredRows\n    }\n  }\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        848,
        0
      ],
      "id": "dcaec54f-cc6c-4610-ba36-84bd0c48c003",
      "name": "Format Song List Results"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{$json}}",
        "options": {}
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1056,
        0
      ],
      "id": "4cc0a176-aba5-4494-a735-5e810f3deef9",
      "name": "Respond - Song List Results"
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nconst originalQuery = item.originalQuery || \"\";\nconst normalizedQuery = item.normalizedQuery || \"\";\nconst limit = item.limit || 25;\n\nconst rawBody = JSON.stringify({\n  statement: `\n    MATCH (s:Song)-[:BY_ARTIST]->(a:Artist)\n    OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag)\n    RETURN\n      a.name AS artist,\n      s.title AS song,\n      coalesce(s.capo, \"\") AS capo,\n      coalesce(s.key, \"\") AS songKey,\n      coalesce(s.url, \"\") AS url,\n      coalesce(s.summary, \"\") AS summary,\n      [tag IN collect(DISTINCT t.name) WHERE tag IS NOT NULL] AS tags\n    ORDER BY toLower(a.name), toLower(s.title)\n    LIMIT $limit\n  `.replace(/\\s+/g, \" \").trim(),\n  parameters: {\n    originalQuery,\n    normalizedQuery,\n    limit\n  }\n});\n\nreturn [\n  {\n    json: {\n      originalQuery,\n      normalizedQuery,\n      limit,\n      rawBody\n    }\n  }\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        432,
        0
      ],
      "id": "b103bf0c-149b-416a-9d42-753654487a98",
      "name": "Build Neo4j List Request Body"
    }
  ],
  "connections": {
    "Webhook - List Songs": {
      "main": [
        [
          {
            "node": "Prepare List Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare List Query": {
      "main": [
        [
          {
            "node": "Build Neo4j List Request Body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query Neo4j List": {
      "main": [
        [
          {
            "node": "Format Song List Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Song List Results": {
      "main": [
        [
          {
            "node": "Respond - Song List Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Neo4j List Request Body": {
      "main": [
        [
          {
            "node": "Query Neo4j List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "versionId": "ba3430ed-ab47-4c67-8377-b8400724afe6",
  "id": "QC0y6MMYaFEL01usFxOZb",
  "tags": []
}