luna-clasificador 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BD/luna_db.json CHANGED
@@ -1238,6 +1238,7 @@
1238
1238
  "contra el destino": { "nombreOficial": "Contra el Destino", "confianza": 1.0 },
1239
1239
  "destino": { "nombreOficial": "Destino", "confianza": 0.8 },
1240
1240
  "dueña de mi": { "nombreOficial": "Dueña de Mí", "confianza": 1.0 },
1241
+ "dueñ de mi": { "nombreOficial": "Dueña de Mí", "confianza": 1.0 },
1241
1242
  "Dueñ De Mi": { "nombreOficial": "Dueña de Mí", "confianza": 1.0 },
1242
1243
  "el arbol de los secretos": { "nombreOficial": "El Árbol de los Secretos", "confianza": 1.0 },
1243
1244
  "el extraño en el espejo": { "nombreOficial": "El Extraño en el Espejo", "confianza": 1.0 },
@@ -14,7 +14,7 @@ const path = require('path');
14
14
  const { setBinariosFromFolder, procesarDuracionMediainfo } = require('./Servicios/ProcesosCore.js');
15
15
  const Clasificador = require('./Clasificacion.js');
16
16
  const { guardarLogDebug } = require('./Debug/LUNA_Logger.js');
17
- const VERSION_LUNA = '1.2.0';
17
+ const VERSION_LUNA = '1.2.1';
18
18
  var seEjecuto = false;
19
19
  var seEjecutoIPC = false;
20
20
 
@@ -665,6 +665,24 @@ function normalizarClaveDB(nombre) {
665
665
  .toLowerCase();
666
666
  }
667
667
 
668
+ function variantesClaveDB(nombre) {
669
+ const base = normalizarClaveDB(nombre);
670
+ if (!base) return [];
671
+
672
+ const variantes = new Set([base]);
673
+
674
+ // Variante útil para títulos con la 'a' faltante tras quitar tildes
675
+ // (ej. "Dueñ De Mi" -> "Duena De Mi")
676
+ const conA = base
677
+ .split(/\s+/)
678
+ .map(palabra => /^[a-z]+n$/i.test(palabra) ? palabra + 'a' : palabra)
679
+ .join(' ');
680
+
681
+ if (conA && conA !== base) variantes.add(conA);
682
+
683
+ return [...variantes];
684
+ }
685
+
668
686
  // Busca un nombre en una categoria concreta.
669
687
  // Estrategia:
670
688
  // 1. aliases (global, fuera de categoria) -> nombre canonico
@@ -675,46 +693,48 @@ function buscarEnCategoriaDB(db, nombreLimpio, categoria) {
675
693
  const bucket = db.categorias[categoria];
676
694
  if (!bucket || !bucket.nombres) return null;
677
695
 
678
- const claveOriginal = normalizarClaveDB(nombreLimpio);
679
- if (!claveOriginal) return null;
680
-
681
- // 1. Resolver aliases (pueden encadenarse, max 3 saltos)
682
- let clave = claveOriginal;
683
- if (db.aliases) {
684
- for (let salto = 0; salto < 3; salto++) {
685
- const destino = db.aliases[clave];
686
- if (!destino) break;
687
- const destinoNorm = normalizarClaveDB(destino);
688
- if (!destinoNorm || destinoNorm === clave) break;
689
- clave = destinoNorm;
696
+ const claves = variantesClaveDB(nombreLimpio);
697
+ if (!claves.length) return null;
698
+
699
+ for (const claveOriginal of claves) {
700
+ // 1. Resolver aliases (pueden encadenarse, max 3 saltos)
701
+ let clave = claveOriginal;
702
+ if (db.aliases) {
703
+ for (let salto = 0; salto < 3; salto++) {
704
+ const destino = db.aliases[clave] || Object.entries(db.aliases).find(([k]) => normalizarClaveDB(k) === clave)?.[1];
705
+ if (!destino) break;
706
+ const destinoNorm = normalizarClaveDB(destino);
707
+ if (!destinoNorm || destinoNorm === clave) break;
708
+ clave = destinoNorm;
709
+ }
690
710
  }
691
- }
692
711
 
693
- // 2. Match exacto
694
- const entrada = bucket.nombres[clave];
695
- if (entrada) {
696
- return {
697
- categoria,
698
- nombreOficial: entrada.nombreOficial || clave,
699
- confianza: entrada.confianza || 1.0,
700
- viaAlias: clave !== claveOriginal
701
- };
702
- }
712
+ // 2. Match exacto por clave normalizada
713
+ for (const [keyCatalogo, entrada] of Object.entries(bucket.nombres)) {
714
+ const keyNorm = normalizarClaveDB(keyCatalogo);
715
+ if (keyNorm === clave) {
716
+ return {
717
+ categoria,
718
+ nombreOficial: entrada.nombreOficial || keyCatalogo,
719
+ confianza: entrada.confianza || 1.0,
720
+ viaAlias: clave !== claveOriginal
721
+ };
722
+ }
723
+ }
703
724
 
704
- // 3. Match por prefijo (solo si el nombre empieza por una clave de la DB
705
- // seguida de espacio). Evita falsos positivos tipo "el" que matchearia
706
- // media DB.
707
- for (const key of Object.keys(bucket.nombres)) {
708
- if (key.length < 4) continue;
709
- if (clave.startsWith(key + ' ')) {
710
- const e = bucket.nombres[key];
711
- return {
712
- categoria,
713
- nombreOficial: e.nombreOficial || key,
714
- confianza: e.confianza || 1.0,
715
- viaAlias: false,
716
- viaPrefijo: true
717
- };
725
+ // 3. Match por prefijo comparando versiones normalizadas
726
+ for (const [keyCatalogo, entrada] of Object.entries(bucket.nombres)) {
727
+ const keyNorm = normalizarClaveDB(keyCatalogo);
728
+ if (keyNorm.length < 4) continue;
729
+ if (clave.startsWith(keyNorm + ' ')) {
730
+ return {
731
+ categoria,
732
+ nombreOficial: entrada.nombreOficial || keyCatalogo,
733
+ confianza: entrada.confianza || 1.0,
734
+ viaAlias: false,
735
+ viaPrefijo: true
736
+ };
737
+ }
718
738
  }
719
739
  }
720
740
 
@@ -784,22 +804,55 @@ function tomarDecision(paquete) {
784
804
  const pPeli = (rep.pelicula && rep.pelicula.puntos) || 0;
785
805
  const pSerie = (rep.serie && rep.serie.puntos) || 0;
786
806
  let pVari = (rep.variado && rep.variado.puntos) || 0;
787
- let pPeliFinal = pPeli;
788
807
  let elegido = null;
789
808
 
790
- // === NUEVO: Chequeo de DB para VARIADO (350 puntos) ===
809
+ // === DB override primero: si el nombre limpio coincide con una categoria
810
+ // de la DB, gana esa categoria antes del desempate por puntuacion.
811
+ // Esto corrige casos como 'La Novia' / 'Dueña de Mi' que son novelas.
812
+ try {
813
+ const db = cargarDB();
814
+ if (db && archivo.info && archivo.info.nombreLimpio) {
815
+ const prioridad = ['novela', 'programa', 'pelicula', 'variado'];
816
+ for (const categoria of prioridad) {
817
+ const matchDB = buscarEnCategoriaDB(db, archivo.info.nombreLimpio, categoria);
818
+ if (!matchDB) continue;
819
+
820
+ const mapa = {
821
+ novela: 'esNOVELA',
822
+ programa: 'esPROGRAM',
823
+ pelicula: 'esPELI',
824
+ variado: 'esVARIADO'
825
+ };
826
+
827
+ elegido = mapa[categoria];
828
+ archivo.info.DBmetadata = {
829
+ fuente: db.fuente || null,
830
+ version: db.version || null,
831
+ categoriaDB: categoria,
832
+ nombreOficial: matchDB.nombreOficial,
833
+ viaAlias: !!matchDB.viaAlias,
834
+ viaPrefijo: !!matchDB.viaPrefijo
835
+ };
836
+ break;
837
+ }
838
+ }
839
+ } catch (e) {
840
+ paquete.errores.push({
841
+ etapa: 'tomarDecision.dbOverride',
842
+ ruta: archivo.ruta,
843
+ mensaje: e.message
844
+ });
845
+ }
846
+
847
+ // === NUEVO: Chequeo de DB para VARIADO (350 puntos) ===
791
848
  let esVariadoDB = false;
792
849
  try {
793
850
  const db = cargarDB();
794
851
  if (db && archivo.info && archivo.info.nombreLimpio) {
795
- // Usamos la misma función de búsqueda, pero en la categoría 'variado'
796
852
  const matchDB = buscarEnCategoriaDB(db, archivo.info.nombreLimpio, 'variado');
797
853
  if (matchDB) {
798
- // Si la DB dice que es variado, le damos 350 puntos.
799
- pVari += 350;
854
+ pVari += 350;
800
855
  esVariadoDB = true;
801
-
802
- // Guardamos la metadata para que el usuario sepa por qué
803
856
  archivo.info.DBmetadata = {
804
857
  fuente: db.fuente || null,
805
858
  version: db.version || null,
@@ -811,7 +864,6 @@ function tomarDecision(paquete) {
811
864
  }
812
865
  }
813
866
  } catch (e) {
814
- // Si falla la DB, no rompemos el pipeline
815
867
  paquete.errores.push({
816
868
  etapa: 'tomarDecision.dbVariado',
817
869
  ruta: archivo.ruta,
@@ -819,16 +871,15 @@ function tomarDecision(paquete) {
819
871
  });
820
872
  }
821
873
 
822
- // === NUEVO: Chequeo de DB para PELICULA (350 puntos) ===
874
+ // === NUEVO: Chequeo de DB para PELICULA (350 puntos) ===
823
875
  let esPeliDB = false;
824
876
  try {
825
877
  const db = cargarDB();
826
878
  if (db && archivo.info && archivo.info.nombreLimpio) {
827
879
  const matchDB = buscarEnCategoriaDB(db, archivo.info.nombreLimpio, 'pelicula');
828
880
  if (matchDB) {
829
- pPeliFinal += 350; // <-- Le sumamos a la variable nueva
881
+ pPeli += 350;
830
882
  esPeliDB = true;
831
-
832
883
  archivo.info.DBmetadata = {
833
884
  fuente: db.fuente || null,
834
885
  version: db.version || null,
@@ -847,10 +898,9 @@ function tomarDecision(paquete) {
847
898
  });
848
899
  }
849
900
 
850
-
851
- if (pPeli > pSerie && pPeli > pVari) elegido = 'esPELI';
852
- else if (pSerie > pPeli && pSerie > pVari) elegido = 'esSERIE';
853
- else if (pVari > pPeli && pVari > pSerie) elegido = 'esVARIADO';
901
+ if (elegido === null && pPeli > pSerie && pPeli > pVari) elegido = 'esPELI';
902
+ else if (elegido === null && pSerie > pPeli && pSerie > pVari) elegido = 'esSERIE';
903
+ else if (elegido === null && pVari > pPeli && pVari > pSerie) elegido = 'esVARIADO';
854
904
 
855
905
  // NUEVO: Si la DB lo marcó como variado, forzamos la decisión.
856
906
  if (esVariadoDB) {
Binary file
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "luna-clasificador",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
+ "icon": "assets/icons.png",
4
5
  "description": "Clasificador local de archivos multimedia (película, serie, variado, programa, novela)",
5
6
  "main": "LUNA.js",
6
7
  "files": [
@@ -8,6 +9,9 @@
8
9
  "Clasificacion.js",
9
10
  "Debug/LUNA_Logger.js",
10
11
  "tests/probe_LUNA.js",
12
+ "tests/luna_dashboard_server.js",
13
+ "tests/luna_dashboard.html",
14
+ "assets/icons.png",
11
15
  "LUNA.js",
12
16
  "Servicios/ProcesosCore.js",
13
17
  "Servicios/ResolverPathffmpeg.js",
@@ -16,11 +20,20 @@
16
20
  "optionalDependencies": {
17
21
  "ffmpeg-static": "^5.3.0"
18
22
  },
23
+ "keywords": [
24
+ "clasificador",
25
+ "multimedia",
26
+ "peliculas",
27
+ "series",
28
+ "ffmpeg",
29
+ "nodejs",
30
+ "file-organizer",
31
+ "media-classifier"
32
+ ],
19
33
  "engines": {
20
34
  "node": ">=18"
21
35
  },
22
36
  "author": "Abraham de Jesús Piñirí Megret",
23
- "keywords": ["multimedia", "clasificador", "video", "ffmpeg", "series", "peliculas"],
24
37
  "license": "MIT",
25
38
  "repository": {
26
39
  "type": "git",
package/readme.md CHANGED
@@ -1,6 +1,14 @@
1
1
 
2
+ <p align="center">
3
+ <img src="./assets/icons.png" width="120" alt="Logo del proyecto">
4
+ </p>
5
+
2
6
  # 📘 README — Proyecto LUNA
3
7
 
8
+ <!-- Banner -->
9
+ <p align="center">
10
+ <img src="./assets/banner.png" width="100%">
11
+ </p>
4
12
  > **Clasificador local de archivos multimedia** (película / serie / variado) con sistema de banderas puntuables, rescate por contexto de carpeta y reclasificación mediante base de datos de nombres conocidos.
5
13
 
6
14
  ---
@@ -25,12 +33,13 @@
25
33
  8. [Sistema de puntajes y decisión](#-sistema-de-puntajes-y-decisión)
26
34
  9. [Rescate y reclasificación](#-rescate-y-reclasificación)
27
35
  10. [API pública](#-api-pública)
28
- 11. [Ejemplos de uso](#-ejemplos-de-uso)
36
+ 11. [Ejemplos reales de uso](#-ejemplos-reales-de-uso)
29
37
  12. [Instalación y dependencias](#-instalación-y-dependencias)
30
38
  13. [Cómo ejecutar el probe](#-cómo-ejecutar-el-probe)
31
- 14. [Historial de fixes](#-historial-de-fixes)
32
- 15. [Limitaciones conocidas](#-limitaciones-conocidas)
33
- 16. [Autoría y licencia](#-autoría-y-licencia)
39
+ 14. [Novedades v1.2.1](#-novedades-en-v121)
40
+ 15. [Historial de fixes](#-historial-de-fixes)
41
+ 16. [Limitaciones conocidas](#-limitaciones-conocidas)
42
+ 17. [Autoría y licencia](#-autoría-y-licencia)
34
43
 
35
44
  ---
36
45
 
@@ -44,9 +53,28 @@
44
53
  - `esNOVELA` → telenovela
45
54
  - `esVARIADO` → video suelto, clip, personal, documental, deporte
46
55
 
47
- Además devuelve metadata enriquecida: **nombre limpio**, **temporada**, **capítulo**, **año**, **duración**, y **puntaje de confianza**.
56
+ Además devuelve metadata enriquecida: **nombre limpio**, **temporada**, **capítulo**, **año**, **duración**, **fecha extra**, **tipo de archivo** y **puntaje de confianza**.
57
+
58
+ Fue diseñado para ordenar discos duros llenos de series, películas, novelas y videos personales, respetando nombres "sucios" típicos de la piratería, releases, grabaciones de TV y nombres manuales de series extranjeras.
59
+
60
+ > Versión actual del proyecto: **1.2.1**
61
+ > Base de datos actual: **1.0.0**
62
+
63
+ ---
64
+
65
+ ## 🆕 Novedades en v1.2.1
66
+
67
+ Esta versión deja el sistema mucho más estable y útil en escenarios reales:
48
68
 
49
- Fue diseñado para ordenar discos duros llenos de series, películas y videos personales, respetando nombres "sucios" típicos de la piratería, releases, grabaciones de TV, etc.
69
+ - `infoDBMetadata()` cuenta correctamente el número total de registros de todas las categorías de `luna_db.json`.
70
+ - El banner muestra una línea con `DB registros: X`.
71
+ - El override de la DB ya se aplica antes del desempate, evitando que series/películas/novelas se queden en una categoría equivocada por heurísticas.
72
+ - La comparación normalizada contra la DB corrige nombres con tildes, puntos, espacios y variantes como `Dueñ De Mi`, `La Novia`, etc.
73
+ - La salida final expone `extension` y `tipoArchivo` de forma segura (`mkv`, `mp4`, `video`, `audio`).
74
+ - Se mantiene compatibilidad con `archivo.info.extension`, `archivo.info.tipoArchivo` y la salida final.
75
+ - La lógica de separación de título y nombre limpia siguió refinándose para evitar que capítulos y temporadas se queden pegados en `nombreLimpio`.
76
+
77
+ ---
50
78
 
51
79
  ---
52
80
 
@@ -61,6 +89,9 @@ Fue diseñado para ordenar discos duros llenos de series, películas y videos pe
61
89
  | **DB como último recurso** | `luna_db.json` no suma puntos: solo reclasifica `esSERIE → esPROGRAM / esNOVELA` si el nombre coincide. |
62
90
  | **No romper con entradas inválidas** | `null`, `{}`, `123`, `[]` deben devolver error controlado, no lanzar excepción. |
63
91
 
92
+ <p align="center">
93
+ <img src="./assets/1.png" width="100%">
94
+ </p>
64
95
  ---
65
96
 
66
97
  ## 🏗 Arquitectura general
@@ -391,6 +422,10 @@ Usado por Cine, convertidor y el IPC del render para no cargar LUNA hasta que re
391
422
  - Si el JSON está corrupto, se resetea a `[]`.
392
423
  - Nunca lanza excepción hacia arriba.
393
424
 
425
+ <p align="center">
426
+ <img src="./assets/2.png" width="100%">
427
+ </p>
428
+
394
429
  **Estructura de cada registro:**
395
430
 
396
431
  ```js
@@ -674,60 +709,189 @@ Igual que `infoDBMetadata` + `versionLUNA`, `Creador`, `Comentario`.
674
709
 
675
710
  ---
676
711
 
677
- ## 💡 Ejemplos de uso
712
+ ## 🧪 Ejemplos reales de uso
713
+
714
+ A continuación te dejo ejemplos reales de cómo yo lo uso en proyectos de Node y Electron. Están pensados para que veas exactamente qué entra y qué sale.
678
715
 
679
- ### Uso básico
716
+ ### 1) Uso básico en Node.js
680
717
 
681
718
  ```js
682
- const { LUNA } = require('./LUNA_clasificador');
719
+ const { getLUNA } = require('luna-clasificador');
720
+ const LUNA = getLUNA();
683
721
 
684
722
  (async () => {
685
- const res = await LUNA('D:\\ANIME\\Silo [Temp 1]\\Silo - S01E03.mkv');
686
- console.log(res.salida.esCC); // 'esSERIE'
687
- console.log(res.salida.info.nombreLimpio); // 'Silo'
688
- console.log(res.salida.info.temporada); // 1
689
- console.log(res.salida.info.capituloNumero);// 3
723
+ const resultado = await LUNA.LUNA('D:\\ANIME\\La Novia - 107 [15-10-2026].mp4');
724
+
725
+ console.log(resultado.exito);
726
+ console.log(resultado.salida.esCC); // esNOVELA
727
+ console.log(resultado.salida.nombreLimpio); // La Novia
728
+ console.log(resultado.salida.info.serie); // La Novia
729
+ console.log(resultado.salida.info.capituloNumero); // 107
730
+ console.log(resultado.salida.extension); // mp4
731
+ console.log(resultado.salida.tipoArchivo); // video
690
732
  })();
691
733
  ```
692
734
 
693
- ### Uso por lotes
735
+ ### 2) Uso con `getFFmpegPath()` y configurando la ruta del binario
736
+
737
+ Este es un patrón real que uso cuando el proyecto ya tiene ffmpeg en una ubicación custom.
694
738
 
695
739
  ```js
696
- const rutas = [
697
- 'D:\\Pelis\\Superman (2025).mkv',
698
- 'D:\\Novelas\\Ciudad Lejana 137.mp4',
699
- 'D:\\Varios\\VID_20240101.mp4'
700
- ];
701
- const res = await LUNA(rutas);
702
- res.salida.forEach(s => console.log(s.esCC, s.nombreLimpio));
703
- // esPELI Superman
704
- // esNOVELA Ciudad Lejana
705
- // esVARIADO VID 20240101
740
+ const path = require('path');
741
+ const { getLUNA } = require('luna-clasificador');
742
+ const { getFFmpegPath } = require('./ResolverPathFFmpeg.js');
743
+
744
+ const LUNA = getLUNA();
745
+ const ffmpegExe = getFFmpegPath();
746
+ const carpeta = path.dirname(ffmpegExe) + path.sep;
747
+
748
+ LUNA.setBinariosFromFolder(carpeta);
749
+
750
+ (async () => {
751
+ const res = await LUNA.LUNA('D:\\ANIME\\Peliculas\\John Wick 4.mkv');
752
+ console.log(JSON.stringify(res, null, 2));
753
+ })();
706
754
  ```
707
755
 
708
- ### Con singleton
756
+ ### 3) Uso clásico en una app Node / backend
709
757
 
710
758
  ```js
711
- const { getLUNA } = require('./luna_enchufle');
712
- const LUNA = getLUNA();
713
- const res = await LUNA.LUNA('D:\\archivo.mp4');
759
+ const { getLUNA, LUNA_PATH } = require('luna-clasificador');
760
+ const {
761
+ LUNA,
762
+ reclasificarDesdeDB,
763
+ cargarDB,
764
+ infoDBMetadata
765
+ } = getLUNA();
766
+
767
+ const ffmpegExe = getFFmpegPath();
768
+ const carpeta = path.dirname(ffmpegExe) + path.sep;
769
+ LUNA.setBinariosFromFolder(carpeta);
770
+
771
+ (async () => {
772
+ const meta = infoDBMetadata();
773
+ console.log('DB:', meta);
774
+
775
+ const salida = await LUNA('D:\\ANIME\\Thunder 3 [Temp 1] [2026] [Caps 12]\\Dueña de Mi - 139 [16-10-2026].mp4');
776
+ console.log(salida.salida.esCC); // esNOVELA
777
+ console.log(salida.salida.info.DBmetadata);
778
+ })();
714
779
  ```
715
780
 
716
- ### Solo clasificación (sin duración)
781
+ ### 4) Uso en Electron / proceso principal (`main`)
717
782
 
718
783
  ```js
719
- const Clasificacion = require('./Clasificacion');
784
+ const path = require('path');
785
+ const { ipcMain } = require('electron');
786
+ const { getLUNA } = require('luna-clasificador');
787
+ const { getFFmpegPath } = require('./ResolverPathFFmpeg.js');
788
+
789
+ const LUNA = getLUNA();
790
+ let lunaInicializado = false;
791
+
792
+ ipcMain.handle('luna:clasificar', async (event, entrada) => {
793
+ try {
794
+ if (!lunaInicializado) {
795
+ const ffmpegExe = getFFmpegPath();
796
+ const carpeta = path.dirname(ffmpegExe) + path.sep;
797
+ LUNA.setBinariosFromFolder(carpeta);
798
+ lunaInicializado = true;
799
+ await new Promise(resolve => setTimeout(resolve, 500));
800
+ }
720
801
 
721
- const reporte = await Clasificacion.clasificarArchivo({
722
- nombreOriginal: 'Silo - S01E03.mkv',
723
- ruta: 'D:\\Series\\Silo\\S01E03.mkv',
724
- duracion: 2700
802
+ if (entrada === 'info') {
803
+ return await LUNA.LUNAVersion();
804
+ }
805
+
806
+ return await LUNA.LUNA(entrada);
807
+ } catch (error) {
808
+ console.error('[LUNA:IPC] Error:', error);
809
+ return {
810
+ exito: false,
811
+ salida: null,
812
+ errores: [{ etapa: 'ipc', mensaje: error.message }]
813
+ };
814
+ }
725
815
  });
816
+ ```
817
+
818
+ ### 5) Uso real de ejemplo de mi app con cache de versión
819
+
820
+ ```js
821
+ const { getLUNA, LUNA_PATH } = require('luna-clasificador');
822
+
823
+ const LUNA = getLUNA();
824
+
825
+ const ffmpegExe = getFFmpegPath();
826
+ const carpeta = path.dirname(ffmpegExe) + path.sep;
827
+ LUNA.setBinariosFromFolder(carpeta);
828
+
829
+ let _lunaVersion = null;
830
+ function obtenerVersionLUNA() {
831
+ if (_lunaVersion !== null) return _lunaVersion;
832
+
833
+ try {
834
+ if (typeof LUNA.LUNAVersion === 'function') {
835
+ const v = LUNA.LUNAVersion();
836
+ _lunaVersion = (v && typeof v === 'object') ? (v.versionLUNA || null) : (typeof v === 'string' ? v : null);
837
+ }
838
+
839
+ if (!_lunaVersion) {
840
+ const meta = typeof LUNA.infoDBMetadata === 'function' ? LUNA.infoDBMetadata() : null;
841
+ if (meta && meta.versionLUNA) _lunaVersion = meta.versionLUNA;
842
+ }
843
+ } catch (e) {
844
+ console.warn('[CINE] No se pudo obtener la versión de LUNA:', e.message);
845
+ }
726
846
 
727
- console.log(reporte.serie.puntos); // ej. 130
728
- console.log(reporte.serie.banderasQueSumaron); // [{ nombre: 'serie_sxxexx', puntos: 50, ... }, ...]
847
+ if (!_lunaVersion) _lunaVersion = 'unknown';
848
+ return _lunaVersion;
849
+ }
729
850
  ```
730
851
 
852
+ ### 6) Qué devuelve LUNA
853
+
854
+ La salida típica es este formato:
855
+
856
+ ```js
857
+ {
858
+ exito: true,
859
+ salida: {
860
+ nombreOriginal: 'La Novia - 107 [15-10-2026].mp4',
861
+ nombreLimpio: 'La Novia',
862
+ tituloLimpio: null,
863
+ fechaExtra: '15-10-2026',
864
+ ruta: 'D:\\ANIME\\Thunder 3 [Temp 1] [2026] [Caps 12]\\La Novia - 107 [15-10-2026].mp4',
865
+ info: {
866
+ nombreLimpio: 'La Novia',
867
+ serie: 'La Novia',
868
+ temporada: 1,
869
+ capituloNumero: 107,
870
+ anio: null,
871
+ extension: 'mp4',
872
+ tipoArchivo: 'video',
873
+ categoriaGanadora: 'serie',
874
+ DBmetadata: {
875
+ categoriaDB: 'novela',
876
+ nombreOficial: 'La Novia'
877
+ }
878
+ },
879
+ extension: 'mp4',
880
+ tipoArchivo: 'video',
881
+ duracion: 1447.26,
882
+ esCC: 'esNOVELA'
883
+ },
884
+ errores: [],
885
+ meta: {
886
+ inicio: 172...,
887
+ fin: 172...,
888
+ duracionMs: 120
889
+ }
890
+ }
891
+ ```
892
+
893
+ > LUNA acepta `string`, `objeto de configuración`, `array de strings` y `array de configuraciones`. Siempre devuelve un objeto con `exito`, `salida` y `errores`.
894
+
731
895
  ---
732
896
 
733
897
  ## 📦 Instalación y dependencias
@@ -821,6 +985,10 @@ Exatlon Mexico [T10] - [TX] [037].mp4 | esPROGRAM| 2530 | tr
821
985
 
822
986
  ---
823
987
 
988
+ <p align="center">
989
+ <img src="./assets/3.png" width="100%">
990
+ </p>
991
+
824
992
  ## ⚠️ Limitaciones conocidas
825
993
 
826
994
  1. **`ProcesosCore.js` tiene un `setTimeout` de prueba** que ejecuta ffmpeg sobre un archivo hardcodeado. **Eliminar en producción.**
@@ -841,7 +1009,7 @@ Exatlon Mexico [T10] - [TX] [037].mp4 | esPROGRAM| 2530 | tr
841
1009
  - **Autor:** Abraham de Jesús Piñirí Megret
842
1010
  - **Agradecimientos:** DeepSeek
843
1011
  - **Licencia:** MIT
844
- - **Versión LUNA:** `1.0.0`
1012
+ - **Versión LUNA:** `1.2.1`
845
1013
  - **Versión DB:** `1.0.0`
846
1014
  - **Fecha DB:** `2026-10-19`
847
1015
 
@@ -868,6 +1036,8 @@ Exatlon Mexico [T10] - [TX] [037].mp4 | esPROGRAM| 2530 | tr
868
1036
 
869
1037
  Esta versión trae mejoras importantes en cómo LUNA limpia y estructura los nombres de los archivos, separando la información de la obra de la información del capítulo.
870
1038
 
1039
+ > Si estás leyendo esto desde el README actual, ten en cuenta que la versión activa del proyecto es 1.2.1. La sección de v1.1.0 queda como referencia histórica del proyecto, no como versión actual.
1040
+
871
1041
  ### 1. Nuevo campo `tituloLimpio`
872
1042
  Ahora LUNA es capaz de separar el nombre base (serie o película) de su título de capítulo/saga, usando el guion espaciado (` - `) como separador universal.
873
1043
 
@@ -0,0 +1,175 @@
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>LUNA · Análisis</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f8fafc;
10
+ --surface: #ffffff;
11
+ --text: #0f172a;
12
+ --muted: #64748b;
13
+ --accent: #3b82f6;
14
+ --border: #e2e8f0;
15
+ --success: #10b981;
16
+ --warning: #f59e0b;
17
+ --danger: #ef4444;
18
+ }
19
+ * { box-sizing: border-box; margin: 0; }
20
+ body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); padding: 40px 20px; }
21
+ .app { max-width: 1200px; margin: 0 auto; }
22
+
23
+ /* Header & Inputs */
24
+ .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
25
+ .brand h1 { font-size: 1.8rem; font-weight: 800; }
26
+ .brand p { color: var(--muted); font-size: 0.9rem; }
27
+
28
+ .controls { background: var(--surface); padding: 24px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 32px; }
29
+ .input-row { display: flex; gap: 12px; margin-bottom: 12px; }
30
+ .input-row input { flex: 1; padding: 12px; border: 1px solid var(--border); border-radius: 8px; }
31
+ .btn { padding: 10px 20px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
32
+ .btn-primary { background: var(--accent); color: white; }
33
+ .btn-secondary { background: #f1f5f9; color: var(--text); }
34
+
35
+ /* Grid & Cards */
36
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
37
+ .card {
38
+ background: var(--surface); border-radius: 16px; border: 1px solid var(--border);
39
+ padding: 20px; display: flex; flex-direction: column; gap: 16px;
40
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05);
41
+ }
42
+ .card-header { display: flex; flex-direction: column; gap: 4px; }
43
+ .card-cat { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); }
44
+ .card-title { font-size: 1.2rem; font-weight: 700; line-height: 1.3; }
45
+ .card-subtitle { font-size: 0.9rem; color: var(--muted); font-weight: 500; }
46
+
47
+ .card-body { display: flex; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border); padding-top: 16px; }
48
+ .data-item { display: flex; flex-direction: column; gap: 2px; }
49
+ .data-label { font-size: 0.7rem; font-weight: 600; color: var(--muted); text-transform: uppercase; }
50
+ .data-value { font-size: 0.9rem; font-weight: 500; }
51
+
52
+ .card-footer { font-size: 0.75rem; color: var(--muted); background: #f8fafc; padding: 8px; border-radius: 6px; word-break: break-all; margin-top: auto; }
53
+
54
+ .badge { padding: 4px 8px; border-radius: 6px; font-size: 0.7rem; font-weight: 700; }
55
+ .badge.ok { background: #d1fae5; color: #065f46; }
56
+ .badge.warn { background: #fef3c7; color: #92400e; }
57
+ .badge.err { background: #fee2e2; color: #991b1b; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div class="app">
62
+ <div class="header">
63
+ <div class="brand">
64
+ <h1>LUNA</h1>
65
+ <p>Clasificador inteligente de archivos</p>
66
+ </div>
67
+ <button class="btn btn-primary" id="runBtn">Ejecutar 20 casos</button>
68
+ </div>
69
+
70
+ <div class="controls">
71
+ <div id="customInputs"></div>
72
+ <div style="display:flex; gap:12px; margin-top:12px;">
73
+ <button class="btn btn-secondary" id="addRowBtn">+ Añadir ruta</button>
74
+ <button class="btn btn-primary" id="runCustomBtn">Analizar rutas</button>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="grid" id="resultsGrid">
79
+ <div style="grid-column: 1/-1; text-align:center; color:var(--muted);">Esperando análisis...</div>
80
+ </div>
81
+ </div>
82
+
83
+ <script>
84
+ const resultsGrid = document.getElementById('resultsGrid');
85
+ const customInputs = document.getElementById('customInputs');
86
+
87
+ // --- Renderizado de Tarjeta ---
88
+ function renderCard(item) {
89
+ const badge = item.estado === 'ok' ? 'ok' : item.estado === 'warning' ? 'warn' : 'err';
90
+ const badgeText = item.estado === 'ok' ? 'OK' : item.estado === 'warning' ? 'Aviso' : 'Error';
91
+
92
+ // Construir HTML de detalles (solo si hay datos)
93
+ let detallesHtml = '';
94
+ if (item.detalles && item.detalles.length > 0) {
95
+ detallesHtml = item.detalles.map(d => `
96
+ <div class="data-item">
97
+ <span class="data-label">${d.etiqueta}</span>
98
+ <span class="data-value">${d.valor}</span>
99
+ </div>
100
+ `).join('');
101
+ }
102
+
103
+ // Subtítulo opcional
104
+ const subtituloHtml = item.subtitulo ? `<div class="card-subtitle">${item.subtitulo}</div>` : '';
105
+
106
+ // Título (si es null, usamos el nombre del archivo)
107
+ const titulo = item.titulo || 'Archivo sin nombre claro';
108
+
109
+ return `
110
+ <div class="card">
111
+ <div class="card-header">
112
+ <div style="display:flex; justify-content:space-between;">
113
+ <span class="card-cat">${item.categoriaHumana || 'Desconocido'}</span>
114
+ <span class="badge ${badge}">${badgeText}</span>
115
+ </div>
116
+ <div class="card-title">${titulo}</div>
117
+ ${subtituloHtml}
118
+ </div>
119
+
120
+ ${detallesHtml ? `<div class="card-body">${detallesHtml}</div>` : ''}
121
+
122
+ <div class="card-footer">${item.ruta}</div>
123
+ ${item.error ? `<div style="color:var(--danger); font-size:0.8rem; margin-top:8px;">${item.error}</div>` : ''}
124
+ </div>
125
+ `;
126
+ }
127
+
128
+ // --- Lógica de la UI ---
129
+ async function runSuite() {
130
+ resultsGrid.innerHTML = '<div style="grid-column:1/-1; text-align:center;">Analizando...</div>';
131
+ try {
132
+ const res = await fetch('/api/run');
133
+ const data = await res.json();
134
+ if (!data.resultados) throw new Error('Sin datos');
135
+ resultsGrid.innerHTML = data.resultados.map(renderCard).join('');
136
+ } catch (e) {
137
+ resultsGrid.innerHTML = `<div style="color:var(--danger);">Error: ${e.message}</div>`;
138
+ }
139
+ }
140
+
141
+ async function runCustom() {
142
+ const inputs = customInputs.querySelectorAll('input');
143
+ const casos = Array.from(inputs).map(i => ({ ruta: i.value.trim() })).filter(c => c.ruta);
144
+ if (casos.length === 0) return alert('Añade alguna ruta');
145
+
146
+ resultsGrid.innerHTML = '<div style="grid-column:1/-1; text-align:center;">Analizando...</div>';
147
+ try {
148
+ const res = await fetch('/api/run-custom', {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({ casos })
152
+ });
153
+ const data = await res.json();
154
+ if (!data.resultados) throw new Error('Sin datos');
155
+ resultsGrid.innerHTML = data.resultados.map(renderCard).join('');
156
+ } catch (e) {
157
+ resultsGrid.innerHTML = `<div style="color:var(--danger);">Error: ${e.message}</div>`;
158
+ }
159
+ }
160
+
161
+ // --- Inputs Dinámicos ---
162
+ function addInput() {
163
+ const div = document.createElement('div');
164
+ div.className = 'input-row';
165
+ div.innerHTML = `<input type="text" placeholder="Ej: D:\\SERIES\\Silo\\Silo - 204.mkv"><button class="btn btn-secondary" onclick="this.parentElement.remove()">×</button>`;
166
+ customInputs.appendChild(div);
167
+ }
168
+
169
+ addInput(); addInput(); addInput();
170
+ document.getElementById('addRowBtn').onclick = addInput;
171
+ document.getElementById('runBtn').onclick = runSuite;
172
+ document.getElementById('runCustomBtn').onclick = runCustom;
173
+ </script>
174
+ </body>
175
+ </html>
@@ -0,0 +1,292 @@
1
+ const http = require('http');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getFFmpegPath } = require('../Servicios/ResolverPathffmpeg.js');
5
+ const LUNA = require('../LUNA_clasificador.js');
6
+
7
+ const PORT = 3466;
8
+ const HTML_PATH = path.join(__dirname, 'luna_dashboard.html');
9
+
10
+ //http://localhost:3466/
11
+
12
+ // Solo id + ruta. El nombre se deriva del nombre de archivo.
13
+ function getRandomizedCases() {
14
+ const rutas = [
15
+ 'D:\\ANIME\\Thunder 3 [Temp 1] [2026] [Caps 12]\\La Novia - 107 [15-10-2026] [Corregido].mp4',
16
+ 'D:\\ANIME\\Thunder 3 [Temp 1] [2026] [Caps 12]\\Dueña de Mi - 139 [16-10-2026].mp4',
17
+ 'D:\\ANIME\\Peliculas\\John Wick 4.mkv',
18
+ 'D:\\ANIME\\Peliculas\\Harry Potter La Piedra Filosofal.mkv',
19
+ 'D:\\ANIME\\Code Geass 2\\[D-SnF] Code Geass - Lelouch of the Rebellion R2 - 11 [BD 1080p].mkv',
20
+ 'D:\\TV\\The Bear\\The Bear - S02E07.mkv',
21
+ 'D:\\SERIES\\Silo\\Season 2\\Silo - 204.mkv',
22
+ 'D:\\PROGRAMAS\\MasterChef\\MasterChef - 008 [HD].mp4',
23
+ 'D:\\PERSONAL\\VID_20240101_214500.mp4',
24
+ 'D:\\VIDEOS\\clip_demo.mp4',
25
+ 'D:\\SERIES\\One Piece\\One Piece - 1034.mp4',
26
+ 'D:\\ANIME\\Shingeki no Kyojin\\Shingeki no Kyojin - 012 [1080p].mkv',
27
+ 'D:\\SHOWS\\La Voz\\La Voz - 010 [TV].mp4',
28
+ 'D:\\PELICULAS\\Dune Part Two (2024).mkv',
29
+ 'D:\\NOVELAS\\La Promesa 18.mp4',
30
+ 'D:\\MISC\\sample_01.mp4',
31
+ 'D:\\SECCION CUBA\\Novelas\\La Vida Que Me Robaste 21.mp4',
32
+ 'D:\\FOTOS\\WhatsApp Video 2024-10-10 at 20.00.00.mp4',
33
+ 'D:\\Anime\\Blue Lock\\Blue Lock - S01E03 [Sub].mkv',
34
+ 'D:\\Cine\\The Batman [4K UHD] [2022].mkv'
35
+ ];
36
+
37
+ return rutas
38
+ .map((ruta, i) => ({ id: `case-${String(i + 1).padStart(2, '0')}`, ruta }))
39
+ .sort(() => Math.random() - 0.5)
40
+ .slice(0, 20);
41
+ }
42
+
43
+ function nombreDesdeRuta(ruta) {
44
+ if (!ruta) return '—';
45
+ const base = ruta.split(/[\\/]/).pop() || ruta;
46
+ return base.replace(/\.[^.]+$/, '');
47
+ }
48
+
49
+ // Normaliza la salida de LUNA a un objeto plano y consistente.
50
+ // No inventa datos: solo propaga lo que LUNA devuelve.
51
+ function serializeResult(item, data) {
52
+ const base = {
53
+ id: item.id,
54
+ ruta: item.ruta,
55
+ estado: 'ok',
56
+ titulo: null,
57
+ subtitulo: null,
58
+ categoriaHumana: null,
59
+ detalles: [], // Solo datos reales
60
+ error: null
61
+ };
62
+
63
+ if (!data || !data.salida) {
64
+ base.estado = 'error';
65
+ base.error = 'No hubo salida de LUNA';
66
+ return base;
67
+ }
68
+
69
+ const s = data.salida || {};
70
+ const info = s.info || {};
71
+
72
+ // Traducir la categoría técnica a lenguaje humano
73
+ const catMap = {
74
+ 'esPELI': 'Película',
75
+ 'esSERIE': 'Serie',
76
+ 'esPROGRAM': 'Programa',
77
+ 'esNOVELA': 'Novela',
78
+ 'esVARIADO': 'Variado'
79
+ };
80
+ base.categoriaHumana = catMap[s.esCC] || null;
81
+
82
+ // El título principal: nombreLimpio (ej: "La Vida Que Me Robaste")
83
+ base.titulo = s.nombreLimpio || info.nombreLimpio || null;
84
+
85
+ // El subtítulo: Puede ser el título del episodio O la temporada/capítulo
86
+ let subParts = [];
87
+ if (info.tituloLimpio) subParts.push(info.tituloLimpio); // Ej: "Lelouch of the Rebellion"
88
+
89
+ if (info.temporada) subParts.push(`Temporada ${info.temporada}`);
90
+ if (info.capituloNumero) subParts.push(`Capítulo ${info.capituloNumero}`);
91
+
92
+ if (subParts.length > 0) {
93
+ base.subtitulo = subParts.join(' · ');
94
+ }
95
+
96
+ // Detalles: Solo lo que existe y es útil
97
+ if (info.serie) base.detalles.push({ etiqueta: 'Serie', valor: info.serie });
98
+ if (info.anio) base.detalles.push({ etiqueta: 'Año', valor: info.anio });
99
+ if (s.fechaExtra) base.detalles.push({ etiqueta: 'Fecha', valor: s.fechaExtra });
100
+
101
+ if (s.duracion && s.duracion > 0) {
102
+ const mins = Math.floor(s.duracion / 60);
103
+ base.detalles.push({ etiqueta: 'Duración', valor: `${mins} min` });
104
+ }
105
+
106
+ if (s.extension) base.detalles.push({ etiqueta: 'Formato', valor: s.extension.toUpperCase() });
107
+ if (s.esCC === 'esSERIE' || s.esCC === 'esNOVELA') {
108
+ if (info.DBmetadata && info.DBmetadata.nombreOficial) {
109
+ base.detalles.push({ etiqueta: 'Nombre Oficial', valor: info.DBmetadata.nombreOficial });
110
+ }
111
+ }
112
+
113
+ if (data.errores && data.errores.length) {
114
+ base.estado = 'warning';
115
+ base.error = data.errores[0].mensaje || 'Hay errores';
116
+ }
117
+
118
+ return base;
119
+ }
120
+
121
+ async function ejecutarCasos(casos) {
122
+ const resultados = [];
123
+
124
+ for (const caso of casos) {
125
+ try {
126
+ const resultado = await LUNA.LUNA(caso.ruta);
127
+ resultados.push(serializeResult(caso, resultado));
128
+ } catch (error) {
129
+ resultados.push({
130
+ id: caso.id,
131
+ ruta: caso.ruta,
132
+ estado: 'error',
133
+ categoria: null,
134
+ esCC: null,
135
+ nombreLimpio: null,
136
+ extension: null,
137
+ tipoArchivo: null,
138
+ duracion: null,
139
+ fechaExtra: null,
140
+ serie: null,
141
+ temporada: null,
142
+ capitulo: null,
143
+ pelicula: null,
144
+ programa: null,
145
+ novela: null,
146
+ error: error.message || 'Error desconocido'
147
+ });
148
+ }
149
+ }
150
+
151
+ return {
152
+ ejecutado: new Date().toISOString(),
153
+ total: resultados.length,
154
+ ok: resultados.filter(r => r.estado === 'ok').length,
155
+ warning: resultados.filter(r => r.estado === 'warning').length,
156
+ error: resultados.filter(r => r.estado === 'error').length,
157
+ resultados
158
+ };
159
+ }
160
+
161
+ async function ejecutarSuite() {
162
+ return ejecutarCasos(getRandomizedCases());
163
+ }
164
+
165
+ // ---------------------------------------------------------------
166
+ // FFmpeg: se intenta cargar, pero si falla LUNA sigue funcionando
167
+ // por nombre (sin duración). No se lanza excepción hacia arriba.
168
+ // ---------------------------------------------------------------
169
+ function configurarFFmpeg() {
170
+ try {
171
+ //const ffmpegExe = getFFmpegPath();
172
+ //const carpeta = path.dirname(ffmpegExe) + path.sep;
173
+ LUNA.setBinariosFromFolder('D:\\NAtifevierddd\\ffmpeg-static');
174
+ console.log('[dashboard] FFmpeg configurado correctamente.');
175
+ return true;
176
+ } catch (e) {
177
+ console.warn('[dashboard] FFmpeg no disponible, LUNA seguirá funcionando por nombre:', e.message);
178
+ return false;
179
+ }
180
+ }
181
+
182
+ configurarFFmpeg();
183
+
184
+ function leerBodyJSON(req) {
185
+ return new Promise((resolve, reject) => {
186
+ let body = '';
187
+ req.on('data', chunk => {
188
+ body += chunk;
189
+ if (body.length > 1e6) {
190
+ reject(new Error('Cuerpo demasiado grande'));
191
+ req.destroy();
192
+ }
193
+ });
194
+ req.on('end', () => {
195
+ if (!body) return resolve({});
196
+ try { resolve(JSON.parse(body)); }
197
+ catch (e) { reject(new Error('JSON inválido')); }
198
+ });
199
+ req.on('error', reject);
200
+ });
201
+ }
202
+
203
+ const server = http.createServer(async (req, res) => {
204
+ const url = new URL(req.url, 'http://localhost');
205
+
206
+ // ---- Suite de 20 casos fijos ----
207
+ if (req.method === 'GET' && url.pathname === '/api/run') {
208
+ try {
209
+ const payload = await ejecutarSuite();
210
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
211
+ res.end(JSON.stringify(payload));
212
+ } catch (error) {
213
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
214
+ res.end(JSON.stringify({ error: error.message }));
215
+ }
216
+ return;
217
+ }
218
+
219
+ // ---- Casos personalizados desde el HTML (POST) ----
220
+ if (req.method === 'POST' && url.pathname === '/api/run-custom') {
221
+ try {
222
+ const body = await leerBodyJSON(req);
223
+ const casos = Array.isArray(body.casos) ? body.casos : [];
224
+
225
+ const limpios = casos
226
+ .map((c, i) => {
227
+ const ruta = typeof c?.ruta === 'string' ? c.ruta.trim() : '';
228
+ if (!ruta) return null;
229
+ return { id: `custom-${i + 1}`, ruta };
230
+ })
231
+ .filter(Boolean);
232
+
233
+ if (limpios.length === 0) {
234
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
235
+ res.end(JSON.stringify({ error: 'No se recibieron rutas válidas.' }));
236
+ return;
237
+ }
238
+
239
+ const payload = await ejecutarCasos(limpios);
240
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
241
+ res.end(JSON.stringify(payload));
242
+ } catch (error) {
243
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
244
+ res.end(JSON.stringify({ error: error.message }));
245
+ }
246
+ return;
247
+ }
248
+
249
+ // ---- Metadata de la DB ----
250
+ if (req.method === 'GET' && url.pathname === '/api/meta') {
251
+ try {
252
+ const meta = typeof LUNA.infoDBMetadata === 'function' ? LUNA.infoDBMetadata() : null;
253
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
254
+ res.end(JSON.stringify({ ok: true, meta }));
255
+ } catch (error) {
256
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
257
+ res.end(JSON.stringify({ ok: false, error: error.message }));
258
+ }
259
+ return;
260
+ }
261
+
262
+ // ---- Health ----
263
+ if (req.method === 'GET' && url.pathname === '/health') {
264
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
265
+ res.end(JSON.stringify({ ok: true, service: 'luna-dashboard', port: PORT }));
266
+ return;
267
+ }
268
+
269
+ // ---- HTML ----
270
+ if (req.method === 'GET') {
271
+ fs.readFile(HTML_PATH, 'utf8', (err, html) => {
272
+ if (err) {
273
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
274
+ res.end('No se pudo cargar la vista HTML.');
275
+ return;
276
+ }
277
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
278
+ res.end(html);
279
+ });
280
+ return;
281
+ }
282
+
283
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
284
+ res.end('404');
285
+ });
286
+
287
+ server.listen(PORT, () => {
288
+ console.log(`\n====================================================`);
289
+ console.log(`LUNA dashboard activo en: http://localhost:${PORT}`);
290
+ console.log(`Prueba visual en: http://localhost:${PORT}`);
291
+ console.log(`====================================================\n`);
292
+ });