diff --git a/README.md b/README.md index d4859f7..11ec0c2 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,10 @@ Se añadió un flow en `data/flows.json` que usa Node-RED Dashboard para mostrar - Puedes automatizar la subida llamando directamente al endpoint; el servidor responde con el mismo JSON que consume el dashboard (o `{ error: \"...\" }` en caso de fallo). > Tip: si cambias el flujo desde el editor de Node-RED, recuerda exportarlo o guardar el nuevo `flows.json` para versionarlo aquí. + +### Biblioteca y deduplicación + +- Cada análisis queda persistido en `data/als-library.json` junto con su hash MD5 y todos los metadatos. +- Si intentas subir el mismo `.als`, el flujo detecta el hash duplicado, responde `409` y el dashboard avisa sin sobrescribir la biblioteca. +- Para consultar el inventario (por ejemplo, desde una IA) usa `GET /als/library`; el endpoint devuelve el arreglo completo de proyectos almacenados. +- Como el archivo vive dentro de `/data`, puedes versionarlo o respaldarlo fácilmente según tu flujo de trabajo. diff --git a/data/als-library.json b/data/als-library.json new file mode 100644 index 0000000..ae1444e --- /dev/null +++ b/data/als-library.json @@ -0,0 +1,173 @@ +[ + { + "hash": "9dbed77293415a951c43101ef30873d4", + "projectName": "GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL).als", + "meta": { + "creator": "Ableton Live 12.2", + "version": "5.12.0_12203", + "revision": "1c7a2c5dacd710ba28150f2c1534c22b1c158263", + "fileName": "GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL).als", + "sizeBytes": 6685879, + "sizeHuman": "6.4 MB" + }, + "liveSet": { + "tempo": 124, + "loopLengthBeats": 272, + "durationSeconds": 131.61290322580646, + "durationHuman": "2:12", + "loopStart": 0, + "scenes": [] + }, + "tracks": [ + { + "name": "VOCAL", + "type": "Audio", + "deviceCount": 3, + "color": "22" + }, + { + "name": "KICK #1", + "type": "Audio", + "deviceCount": 0, + "color": "22" + }, + { + "name": "5-Audio", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "6-Audio", + "type": "Audio", + "deviceCount": 2, + "color": "22" + }, + { + "name": "KSMR FX", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Clap", + "type": "MIDI", + "deviceCount": 1, + "color": "22" + }, + { + "name": "9-Serum 2", + "type": "MIDI", + "deviceCount": 4, + "color": "22" + }, + { + "name": "10-Serum 2", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "12-Serum 2", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "13-Serum 2", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "14-Serum 2", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "15-Serum 2", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "16-Serum 2", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "17-Serum 2", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "18-Serum 2", + "type": "MIDI", + "deviceCount": 5, + "color": "22" + }, + { + "name": "19-Serum 2", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Drums", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + }, + { + "name": "LEAD", + "type": "Grupo", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Bass", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + } + ], + "stats": { + "audio": 5, + "midi": 11, + "group": 3, + "totalTracks": 19, + "devices": 42, + "scenes": 0, + "samples": 8 + }, + "samples": { + "total": 8, + "relative": [ + "Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "Samples/Imported/Clap Fx.wav", + "Samples/Imported/Clap.wav", + "Swing and Groove/Swing/Swing 16-99.agr" + ], + "absolute": [ + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "C:/VST2/Proximity-x64.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "C:/Users/novik/OneDrive/Documenten/Ableton/User Library/Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "C:/VST2/Fabfilter/FabFilter Pro-Q 4.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap Fx.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap.wav", + "C:/VST2/Fabfilter/FabFilter Pro-C 2.dll" + ] + }, + "storedAt": "2025-12-01T02:46:38.916Z" + } +] \ No newline at end of file diff --git a/data/flows.json b/data/flows.json index 8c00270..4db4aaa 100644 --- a/data/flows.json +++ b/data/flows.json @@ -45,14 +45,14 @@ "order": 0, "width": "12", "height": "7", - "format": "\n \n Inspector de proyectos ALS\n \n \n

Selecciona un archivo .als de Ableton Live. El an\u00e1lisis se realiza en el servidor a trav\u00e9s de un endpoint HTTP dedicado.

\n \n

{{statusText}}

\n
\n
\n", + "format": "\n \n Inspector de proyectos ALS\n \n \n

Selecciona un archivo .als. Se enviar\u00e1 al endpoint /als/upload y, si es nuevo, se almacenar\u00e1 en la biblioteca.

\n \n

{{statusText}}

\n
\n
\n", "storeOutMessages": false, "fwdInMessages": false, "resendOnRefresh": true, "templateScope": "local", "className": "", - "x": 250, - "y": 120, + "x": 230, + "y": 100, "wires": [] }, { @@ -64,13 +64,13 @@ "order": 0, "width": "12", "height": "14", - "format": "\n \n Resumen del proyecto\n \n \n
\n
\n
Proyecto
\n
{{msg.payload.projectName}}
\n
\n
\n
Tempo
\n
{{msg.payload.liveSet.tempo || 'N/D'}} BPM
\n
\n
\n
Duraci\u00f3n aprox.
\n
{{msg.payload.liveSet.durationHuman || 'N/D'}}
\n
\n
\n
Escenas
\n
{{msg.payload.stats.scenes}}
\n
\n
\n
Samples
\n
{{msg.payload.samples.total}}
\n
\n
\n
Dispositivos
\n
{{msg.payload.stats.devices}}
\n
\n
\n
\n

Versi\u00f3n: {{msg.payload.meta.creator || 'Desconocido'}} {{msg.payload.meta.version}}

\n

Archivo: {{msg.payload.meta.fileName || '\u2014'}} \u00b7 {{msg.payload.meta.sizeHuman}}

\n
\n

Tracks ({{msg.payload.stats.totalTracks}})

\n
\n
\n
{{track.name || '(sin nombre)'}}
\n
{{track.type}} \u00b7 {{track.deviceCount}} dispositivos
\n
\n
\n

Escenas

\n \n \n

{{scene || '(sin nombre)'}}

\n
\n
\n

Muestras referenciadas

\n \n \n

{{path}}

\n
\n
\n
\n \n

Sube un archivo .als para ver el an\u00e1lisis.

\n
\n \n

{{msg.error}}

\n
\n
\n", + "format": "\n \n Resumen del proyecto\n \n \n
\n
\n
Proyecto
\n
{{msg.payload.projectName}}
\n
\n
\n
Tempo
\n
{{msg.payload.liveSet.tempo || 'N/D'}} BPM
\n
\n
\n
Duraci\u00f3n aprox.
\n
{{msg.payload.liveSet.durationHuman || 'N/D'}}
\n
\n
\n
Escenas
\n
{{msg.payload.stats.scenes}}
\n
\n
\n
Samples
\n
{{msg.payload.samples.total}}
\n
\n
\n
Dispositivos
\n
{{msg.payload.stats.devices}}
\n
\n
\n
Hash
\n
{{msg.payload.hash | limitTo:12}}\u2026
\n
\n
\n
Guardado
\n
{{msg.payload.storedAt | date:'medium'}}
\n
\n
\n
\n

Versi\u00f3n: {{msg.payload.meta.creator || 'Desconocido'}} {{msg.payload.meta.version}}

\n

Archivo: {{msg.payload.meta.fileName || '\u2014'}} \u00b7 {{msg.payload.meta.sizeHuman}}

\n
\n

Tracks ({{msg.payload.stats.totalTracks}})

\n
\n
\n
{{track.name || '(sin nombre)'}}
\n
{{track.type}} \u00b7 {{track.deviceCount}} dispositivos
\n
\n
\n

Escenas

\n \n \n

{{scene || '(sin nombre)'}}

\n
\n
\n

Muestras referenciadas

\n \n \n

{{path}}

\n
\n
\n
\n \n

Sube un archivo .als para ver el an\u00e1lisis y guardarlo.

\n
\n \n

{{msg.error}}

\n
\n
\n", "storeOutMessages": false, "fwdInMessages": false, "resendOnRefresh": true, "templateScope": "local", "className": "", - "x": 1170, + "x": 1240, "y": 320, "wires": [] }, @@ -83,7 +83,7 @@ "method": "post", "upload": false, "swaggerDoc": "", - "x": 210, + "x": 180, "y": 320, "wires": [ [ @@ -102,7 +102,7 @@ "initialize": "", "finalize": "", "libs": [], - "x": 480, + "x": 460, "y": 320, "wires": [ [ @@ -118,7 +118,7 @@ "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Inspector ALS", - "func": "function toArray(value) {\n if (!value) { return []; }\n return Array.isArray(value) ? value : [value];\n}\nfunction readName(entity) {\n if (!entity || !entity.Name) { return ''; }\n const name = entity.Name;\n if (name.EffectiveName && name.EffectiveName.Value) { return name.EffectiveName.Value; }\n if (name.UserName && name.UserName.Value) { return name.UserName.Value; }\n if (name.Value) { return name.Value; }\n return '';\n}\nfunction countDevices(node) {\n if (!node || typeof node !== 'object') { return 0; }\n let total = 0;\n Object.values(node).forEach((value) => {\n if (!value) { return; }\n if (Array.isArray(value)) {\n total += value.length;\n } else if (typeof value === 'object') {\n total += 1;\n }\n });\n return total;\n}\nfunction humanFileSize(bytes) {\n if (!bytes && bytes !== 0) { return 'N/D'; }\n if (bytes < 1024) { return bytes + ' B'; }\n const units = ['KB', 'MB', 'GB'];\n let value = bytes / 1024;\n let unit = units[0];\n for (let i = 0; i < units.length; i += 1) {\n unit = units[i];\n if (value < 1024 || i === units.length - 1) { break; }\n value /= 1024;\n }\n return value.toFixed(1) + ' ' + unit;\n}\nfunction secondsToHuman(seconds) {\n if (!seconds && seconds !== 0) { return null; }\n const total = Math.round(seconds);\n const mins = Math.floor(total / 60);\n const secs = String(total % 60).padStart(2, '0');\n return mins + ':' + secs;\n}\nfunction handlePathField(field, bucket) {\n if (!field) { return; }\n if (typeof field === 'string') {\n bucket.add(field);\n } else if (Array.isArray(field)) {\n field.forEach((item) => handlePathField(item, bucket));\n } else if (typeof field === 'object' && field.Value) {\n bucket.add(field.Value);\n }\n}\nfunction collectRefs(node, bag) {\n if (!node || typeof node !== 'object') { return; }\n handlePathField(node.RelativePath, bag.relative);\n handlePathField(node.Path, bag.absolute);\n Object.values(node).forEach((value) => {\n if (Array.isArray(value)) {\n value.forEach((item) => collectRefs(item, bag));\n } else if (value && typeof value === 'object') {\n collectRefs(value, bag);\n }\n });\n}\nif (!Buffer.isBuffer(msg.payload)) {\n msg.error = 'No se recibi\u00f3 un Buffer con el ALS.';\n msg.analysis = null;\n msg.statusCode = 400;\n return msg;\n}\nconst originalSize = msg.size || msg.payload.length;\nlet xmlBuffer;\ntry {\n xmlBuffer = zlib.gunzipSync(msg.payload);\n} catch (err) {\n msg.error = 'El archivo no parece ser un ALS v\u00e1lido (fall\u00f3 la descompresi\u00f3n).';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nlet doc;\ntry {\n const ParserClass = fastXmlParser.XMLParser || fastXmlParser;\n const parser = new ParserClass({\n ignoreAttributes: false,\n attributeNamePrefix: '',\n parseAttributeValue: false,\n allowBooleanAttributes: true\n });\n doc = parser.parse(xmlBuffer.toString('utf8'));\n} catch (err) {\n msg.error = 'No se pudo parsear el XML del proyecto.';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nconst ableton = doc?.Ableton || {};\nconst liveSet = ableton.LiveSet || {};\nconst projectName = readName(liveSet) || msg.filename || 'Proyecto sin nombre';\nconst tempoValue = liveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? liveSet?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? null;\nconst tempo = tempoValue ? Number(tempoValue) : null;\nconst loopLengthBeats = Number(liveSet?.Transport?.LoopLength?.Value) || null;\nconst durationSeconds = tempo && loopLengthBeats ? (loopLengthBeats * 60) / tempo : null;\nconst trackBuckets = [\n { key: 'AudioTrack', label: 'Audio', stat: 'audio' },\n { key: 'MidiTrack', label: 'MIDI', stat: 'midi' },\n { key: 'GroupTrack', label: 'Grupo', stat: 'group' }\n];\nconst stats = { audio: 0, midi: 0, group: 0, devices: 0 };\nconst tracks = [];\nconst trackNode = liveSet?.Tracks || {};\ntrackBuckets.forEach((bucket) => {\n toArray(trackNode[bucket.key]).forEach((track) => {\n stats[bucket.stat] += 1;\n const deviceCount = countDevices(track?.DeviceChain?.DeviceChain?.Devices);\n stats.devices += deviceCount;\n tracks.push({\n name: readName(track) || bucket.label + ' ' + stats[bucket.stat],\n type: bucket.label,\n deviceCount,\n color: track?.Color?.Value || null\n });\n });\n});\nconst scenes = toArray(liveSet?.Scenes?.Scene)\n .map((scene) => readName(scene))\n .filter((name) => name !== '');\nconst sampleRefs = { relative: new Set(), absolute: new Set() };\ncollectRefs(liveSet, sampleRefs);\nconst sampleSummary = {\n total: sampleRefs.relative.size || sampleRefs.absolute.size,\n relative: Array.from(sampleRefs.relative).slice(0, 15),\n absolute: Array.from(sampleRefs.absolute).slice(0, 10)\n};\nmsg.analysis = {\n projectName,\n meta: {\n creator: ableton.Creator || null,\n version: [ableton.MajorVersion, ableton.MinorVersion].filter(Boolean).join('.'),\n revision: ableton.Revision || null,\n fileName: msg.filename || null,\n sizeBytes: originalSize,\n sizeHuman: humanFileSize(originalSize)\n },\n liveSet: {\n tempo,\n loopLengthBeats,\n durationSeconds,\n durationHuman: secondsToHuman(durationSeconds),\n loopStart: Number(liveSet?.Transport?.LoopStart?.Value) || 0,\n scenes\n },\n tracks,\n stats: {\n audio: stats.audio,\n midi: stats.midi,\n group: stats.group,\n totalTracks: tracks.length,\n devices: stats.devices,\n scenes: scenes.length,\n samples: sampleSummary.total\n },\n samples: sampleSummary\n};\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis actualizado';\nreturn msg;", + "func": "function toArray(value) {\n if (!value) { return []; }\n return Array.isArray(value) ? value : [value];\n}\nfunction readName(entity) {\n if (!entity || !entity.Name) { return ''; }\n const name = entity.Name;\n if (name.EffectiveName && name.EffectiveName.Value) { return name.EffectiveName.Value; }\n if (name.UserName && name.UserName.Value) { return name.UserName.Value; }\n if (name.Value) { return name.Value; }\n return '';\n}\nfunction countDevices(node) {\n if (!node || typeof node !== 'object') { return 0; }\n let total = 0;\n Object.values(node).forEach((value) => {\n if (!value) { return; }\n if (Array.isArray(value)) {\n total += value.length;\n } else if (typeof value === 'object') {\n total += 1;\n }\n });\n return total;\n}\nfunction humanFileSize(bytes) {\n if (!bytes && bytes !== 0) { return 'N/D'; }\n if (bytes < 1024) { return bytes + ' B'; }\n const units = ['KB', 'MB', 'GB', 'TB'];\n let value = bytes / 1024;\n let unit = units[0];\n for (let i = 0; i < units.length; i += 1) {\n unit = units[i];\n if (value < 1024 || i === units.length - 1) { break; }\n value /= 1024;\n }\n return value.toFixed(1) + ' ' + unit;\n}\nfunction secondsToHuman(seconds) {\n if (!seconds && seconds !== 0) { return null; }\n const total = Math.round(seconds);\n const mins = Math.floor(total / 60);\n const secs = String(total % 60).padStart(2, '0');\n return mins + ':' + secs;\n}\nfunction handlePathField(field, bucket) {\n if (!field) { return; }\n if (typeof field === 'string') {\n bucket.add(field);\n } else if (Array.isArray(field)) {\n field.forEach((item) => handlePathField(item, bucket));\n } else if (typeof field === 'object' && field.Value) {\n bucket.add(field.Value);\n }\n}\nfunction collectRefs(node, bag) {\n if (!node || typeof node !== 'object') { return; }\n handlePathField(node.RelativePath, bag.relative);\n handlePathField(node.Path, bag.absolute);\n Object.values(node).forEach((value) => {\n if (Array.isArray(value)) {\n value.forEach((item) => collectRefs(item, bag));\n } else if (value && typeof value === 'object') {\n collectRefs(value, bag);\n }\n });\n}\nif (!Buffer.isBuffer(msg.payload)) {\n msg.error = 'No se recibi\u00f3 un Buffer con el ALS.';\n msg.analysis = null;\n msg.statusCode = 400;\n return msg;\n}\nconst originalSize = msg.size || msg.payload.length;\nlet xmlBuffer;\ntry {\n xmlBuffer = zlib.gunzipSync(msg.payload);\n} catch (err) {\n msg.error = 'El archivo no parece ser un ALS v\u00e1lido (fall\u00f3 la descompresi\u00f3n).';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nlet doc;\ntry {\n const ParserClass = fastXmlParser.XMLParser || fastXmlParser;\n const parser = new ParserClass({\n ignoreAttributes: false,\n attributeNamePrefix: '',\n parseAttributeValue: false,\n allowBooleanAttributes: true\n });\n doc = parser.parse(xmlBuffer.toString('utf8'));\n} catch (err) {\n msg.error = 'No se pudo parsear el XML del proyecto.';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nconst ableton = doc?.Ableton || {};\nconst liveSet = ableton.LiveSet || {};\nconst projectName = readName(liveSet) || msg.filename || 'Proyecto sin nombre';\nconst tempoValue = liveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? liveSet?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? null;\nconst tempo = tempoValue ? Number(tempoValue) : null;\nconst loopLengthBeats = Number(liveSet?.Transport?.LoopLength?.Value) || null;\nconst durationSeconds = tempo && loopLengthBeats ? (loopLengthBeats * 60) / tempo : null;\nconst trackBuckets = [\n { key: 'AudioTrack', label: 'Audio', stat: 'audio' },\n { key: 'MidiTrack', label: 'MIDI', stat: 'midi' },\n { key: 'GroupTrack', label: 'Grupo', stat: 'group' }\n];\nconst stats = { audio: 0, midi: 0, group: 0, devices: 0 };\nconst tracks = [];\nconst trackNode = liveSet?.Tracks || {};\ntrackBuckets.forEach((bucket) => {\n toArray(trackNode[bucket.key]).forEach((track) => {\n stats[bucket.stat] += 1;\n const deviceCount = countDevices(track?.DeviceChain?.DeviceChain?.Devices);\n stats.devices += deviceCount;\n tracks.push({\n name: readName(track) || bucket.label + ' ' + stats[bucket.stat],\n type: bucket.label,\n deviceCount,\n color: track?.Color?.Value || null\n });\n });\n});\nconst scenes = toArray(liveSet?.Scenes?.Scene)\n .map((scene) => readName(scene))\n .filter((name) => name !== '');\nconst sampleRefs = { relative: new Set(), absolute: new Set() };\ncollectRefs(liveSet, sampleRefs);\nconst sampleSummary = {\n total: sampleRefs.relative.size || sampleRefs.absolute.size,\n relative: Array.from(sampleRefs.relative).slice(0, 15),\n absolute: Array.from(sampleRefs.absolute).slice(0, 10)\n};\nconst hash = crypto.createHash('md5').update(msg.payload).digest('hex');\nmsg.analysis = {\n hash,\n projectName,\n meta: {\n creator: ableton.Creator || null,\n version: [ableton.MajorVersion, ableton.MinorVersion].filter(Boolean).join('.'),\n revision: ableton.Revision || null,\n fileName: msg.filename || null,\n sizeBytes: originalSize,\n sizeHuman: humanFileSize(originalSize)\n },\n liveSet: {\n tempo,\n loopLengthBeats,\n durationSeconds,\n durationHuman: secondsToHuman(durationSeconds),\n loopStart: Number(liveSet?.Transport?.LoopStart?.Value) || 0,\n scenes\n },\n tracks,\n stats: {\n audio: stats.audio,\n midi: stats.midi,\n group: stats.group,\n totalTracks: tracks.length,\n devices: stats.devices,\n scenes: scenes.length,\n samples: sampleSummary.total\n },\n samples: sampleSummary\n};\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis generado';\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", @@ -131,9 +131,37 @@ { "var": "zlib", "module": "zlib" + }, + { + "var": "crypto", + "module": "crypto" } ], - "x": 760, + "x": 750, + "y": 300, + "wires": [ + [ + "b0b4475c1cd1a2f1" + ] + ] + }, + { + "id": "b0b4475c1cd1a2f1", + "type": "function", + "z": "f68df1c4d2e4e1a9", + "name": "Registrar ALS", + "func": "const DB_PATH = '/data/als-library.json';\nfunction readLibrary() {\n try {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n return raw ? JSON.parse(raw) : [];\n } catch (err) {\n if (err.code === 'ENOENT') { return []; }\n node.error('No se pudo leer la biblioteca ALS', err);\n throw err;\n }\n}\nfunction writeLibrary(data) {\n fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));\n}\nif (!msg.analysis || !msg.analysis.hash) {\n msg.error = 'No se pudo guardar el an\u00e1lisis (hash faltante).';\n msg.statusCode = msg.statusCode || 400;\n msg.analysis = null;\n return msg;\n}\nconst library = readLibrary();\nconst existing = library.find((entry) => entry.hash === msg.analysis.hash);\nif (existing) {\n msg.error = 'Este archivo ALS ya fue registrado anteriormente.';\n msg.statusCode = 409;\n msg.statusText = 'Archivo duplicado';\n msg.analysis = null;\n return msg;\n}\nconst record = {\n ...msg.analysis,\n storedAt: new Date().toISOString()\n};\nlibrary.push(record);\nwriteLibrary(library);\nmsg.analysis = record;\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis almacenado';\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [ + { + "var": "fs", + "module": "fs" + } + ], + "x": 980, "y": 300, "wires": [ [ @@ -153,8 +181,8 @@ "initialize": "", "finalize": "", "libs": [], - "x": 780, - "y": 380, + "x": 760, + "y": 400, "wires": [ [ "d2bc9fd9a04fde13", @@ -173,8 +201,8 @@ "initialize": "", "finalize": "", "libs": [], - "x": 1050, - "y": 260, + "x": 1240, + "y": 240, "wires": [ [ "3c1fe83bb7f4a173" @@ -188,8 +216,8 @@ "name": "", "statusCode": "", "headers": {}, - "x": 1250, - "y": 260, + "x": 1440, + "y": 240, "wires": [] }, { @@ -203,8 +231,8 @@ "initialize": "", "finalize": "", "libs": [], - "x": 1050, - "y": 360, + "x": 1240, + "y": 380, "wires": [ [ "f9c6054bed66e56e", @@ -225,8 +253,60 @@ "targetType": "full", "statusVal": "", "statusType": "auto", - "x": 1270, - "y": 420, + "x": 1450, + "y": 440, + "wires": [] + }, + { + "id": "5d10419c2babf6bc", + "type": "http in", + "z": "f68df1c4d2e4e1a9", + "name": "GET /als/library", + "url": "/als/library", + "method": "get", + "upload": false, + "swaggerDoc": "", + "x": 200, + "y": 500, + "wires": [ + [ + "8c33f8cbb1c2669c" + ] + ] + }, + { + "id": "8c33f8cbb1c2669c", + "type": "function", + "z": "f68df1c4d2e4e1a9", + "name": "Listar biblioteca ALS", + "func": "const DB_PATH = '/data/als-library.json';\ntry {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n const data = raw ? JSON.parse(raw) : [];\n msg.payload = data;\n msg.statusCode = 200;\n} catch (err) {\n if (err.code === 'ENOENT') {\n msg.payload = [];\n msg.statusCode = 200;\n } else {\n node.error('No se pudo leer la biblioteca ALS', err);\n msg.statusCode = 500;\n msg.payload = { error: 'No se pudo leer la biblioteca ALS' };\n }\n}\nreturn msg;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [ + { + "var": "fs", + "module": "fs" + } + ], + "x": 480, + "y": 500, + "wires": [ + [ + "0f16a9fca5dce956" + ] + ] + }, + { + "id": "0f16a9fca5dce956", + "type": "http response", + "z": "f68df1c4d2e4e1a9", + "name": "", + "statusCode": "", + "headers": {}, + "x": 700, + "y": 500, "wires": [] } ] \ No newline at end of file