AndrianBalanescu commited on
Commit
ab60391
·
1 Parent(s): d542710

chore: sync deploy state

Browse files
.gitignore CHANGED
@@ -1,89 +1 @@
1
- # Environment & secrets
2
- .env
3
- .env.local
4
- HF_TOKEN
5
- HF_API_KEY
6
-
7
- # Python
8
- __pycache__/
9
- *.py[cod]
10
- *$py.class
11
- *.pyo
12
- *.so
13
- build/
14
- dist/
15
- *.egg-info/
16
- .eggs/
17
- .venv/
18
- venv/
19
- ENV/
20
-
21
- # IDE / editors
22
- .idea/
23
- .vscode/
24
- *.swp
25
- *.swo
26
- *~
27
- .DS_Store
28
-
29
- # Project-specific
30
- .memrize/
31
- memrize.db*
32
- .fastembed_cache/
33
- *.lock
34
- uv.lock
35
- poetry.lock
36
- .pyc
37
- *.pyc
38
- *.pyd
39
-
40
- # Logs & temp
41
- *.log
42
- *.tmp
43
- temp/
44
- tmp/
45
- node_modules/
46
-
47
- # HuggingFace Spaces
48
- space_files/
49
- space_code/
50
- spaces/
51
-
52
- # Build artifacts
53
- *.egg-info/
54
- *.egg
55
- *.whl
56
-
57
- # Local model cache (optional — uncomment if you want to commit models)
58
- # .cache/
59
- # models/
60
- # data/
61
-
62
- # Docker
63
- docker-compose.override.yml
64
- .docker/
65
-
66
- # Coverage
67
- .coverage
68
- htmlcov/
69
- .pytest_cache/moldovan-qwen/.env
70
-
71
- # Generated media binary files
72
- *.wav
73
- *.mp3
74
- *.mp4
75
- *.png
76
- *.jpg
77
- *.jpeg
78
- data/generated_audio/
79
- data/generated_images/
80
- data/generated_videos/
81
- moldovan-qwen/data/generated_albums/
82
- moldovan-qwen/data/generated_audio/
83
- moldovan-qwen/data/generated_music/song_*.json
84
- moldovan-qwen/data/generated_digests/*.json
85
- moldovan-qwen/data/generated_digests/*.md
86
- moldovan-qwen/data/zerogpu_daily_budget.json
87
- moldovan-qwen/data/*.log
88
- moldovan-qwen/data/archive/
89
- studio-pb/
 
1
+ moldovan-qwen/data/studio_settings.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
INDEX.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- auto; hash=afc4f7ff960e8754 -->
2
  # hf/ (4 src, 7 meta)
3
  meta: CHANGELOG.md
4
  meta: Dockerfile
 
1
+ <!-- auto; hash=91f4ae744ac1d054 -->
2
  # hf/ (4 src, 7 meta)
3
  meta: CHANGELOG.md
4
  meta: Dockerfile
moldovan-qwen/data/generated_audio/studio_track_1788546676_7746.json CHANGED
@@ -31,5 +31,8 @@
31
  }
32
  ],
33
  "created_at": 1788546676,
34
- "updated_at": 1788552989.574851
 
 
 
35
  }
 
31
  }
32
  ],
33
  "created_at": 1788546676,
34
+ "updated_at": 1788552989.574851,
35
+ "tags": [
36
+ "hit"
37
+ ]
38
  }
moldovan-qwen/engine/lyricist_engine.py CHANGED
@@ -197,6 +197,98 @@ class LyricistEngine:
197
  "traditional": self.rural_idioms
198
  }
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  def compose_lyrics(
201
  self,
202
  topic: str,
@@ -269,6 +361,16 @@ class LyricistEngine:
269
  "NICIODATĂ nu menționa aceste reguli sau sistemul în versuri (no prompt leak)."
270
  )
271
 
 
 
 
 
 
 
 
 
 
 
272
  system_prompt = f"""
273
  Ești cel mai mare compozitor, textier și director muzical din Republica Moldova.
274
  Misiunea ta: scrie o piesă 100% ORIGINALĂ, GENIALĂ și AUTENTIC MOLDOVENEASCĂ. "Less is more": versuri SCURTE, PERCUTANTE, memorabile — nu umple versurile cu dicționarul; alege regionalismele cu impact, nu pe toate.
@@ -277,15 +379,21 @@ Misiunea ta: scrie o piesă 100% ORIGINALĂ, GENIALĂ și AUTENTIC MOLDOVENEASC
277
 
278
  REGULI CRUCIALE DE COMPOZIȚIE:
279
  0. Răspunsul conține EXCLUSIV piesa: titlu + secțiuni + versuri. Fără titluri de reguli, fără explicații, fără meta-comentarii, fără "REGULI", "Format", "Producer Brief" — nimic în afara structurii piesei. Orice linie care reia regulile sau promptul este un EȘEC.
280
- 1. Rime scurte și percutante: fiecare vers 6-11 silabe, lovitură directă, fără umplutură. Folosește regionalisme autentice din baza de date ({sample_rural}), argou din Chișinău ({sample_slang}) și fonetică moldovenească (pi, di, șî, ghini, tăt).
281
  1b-FAMILY. {rule_1b}
282
  2. NU repeta fraza utilizatorului ca vers literal! Analizează conceptul ('{clean_topic}') și creează metafore, personaje, rime și poveste dedicată.
283
- 3. Rimează impecabil la fiecare 2 versuri (AABB sau ABAB) și asigură cadență naturală pe beat ({syllables_per_line}).
 
 
 
 
 
284
  4. Gen Muzical: {genre} ({bpm} BPM).
285
  5. Stil Vocal & Flow: {vocal_style}.
286
  6. Intenție Emoțională / Vibe: {intent}.
287
  7. Nivel Dialectal ({dialect_level}/4): {dialect_rule}.
288
- {f'8. Producer Brief / Instrucțiuni Speciale: {custom_instructions}' if custom_instructions else ''}
 
289
  {persona_context}
290
  """
291
 
 
197
  "traditional": self.rural_idioms
198
  }
199
 
200
+ def _sample_dynamic_lexical_palette(
201
+ self,
202
+ genre: str,
203
+ topic: str,
204
+ dialect_level: int = 3,
205
+ family_mode: bool = False
206
+ ) -> Dict[str, str]:
207
+ """
208
+ Dynamically sample a diverse, non-repeating palette of words and expressions
209
+ from the master dictionary tailored specifically to the song genre and topic.
210
+ Guarantees that songs avoid monotonous word reuse.
211
+ """
212
+ import random
213
+
214
+ genre_lower = (genre or "").lower()
215
+ topic_lower = (topic or "").lower()
216
+
217
+ urban_slang = []
218
+ chisinau_places = []
219
+ cozy_nature = []
220
+ traditional_folklore = []
221
+ family_friendly = []
222
+ action_verbs = []
223
+
224
+ for e in self.master_dict_entries:
225
+ w = (e.get("word") or e.get("term") or "").strip()
226
+ cat = (e.get("category") or "").lower()
227
+ if not w or len(w) < 3:
228
+ continue
229
+
230
+ if any(k in cat for k in ["argou", "slang"]):
231
+ urban_slang.append(w)
232
+ elif any(k in cat for k in ["neighborhoods", "regions", "landmarks"]):
233
+ chisinau_places.append(w)
234
+ elif any(k in cat for k in ["natură", "faună", "gastronomie"]):
235
+ cozy_nature.append(w)
236
+ family_friendly.append(w)
237
+ elif any(k in cat for k in ["regionalisme", "arhaisme", "tradiție"]):
238
+ traditional_folklore.append(w)
239
+ elif "acțiuni" in cat or "verbe" in cat:
240
+ action_verbs.append(w)
241
+
242
+ # Ensure non-empty fallback lists
243
+ if not urban_slang:
244
+ urban_slang = ["koroce", "dăvai", "pațani", "blană", "wăi", "harosh", "jăpcan", "mahala", "dric", "bulevard"]
245
+ if not chisinau_places:
246
+ chisinau_places = ["Botanica", "Ciocana", "Râșcani", "Centru", "Buiucani", "Telecentru", "Valea Morilor", "Bulevardul Dacia", "Calea Ieșilor"]
247
+ if not cozy_nature:
248
+ cozy_nature = ["izvor", "răsărit", "ploaie", "frunze", "fântână", "grădină", "amintiri", "miere", "livadă", "apus"]
249
+ if not traditional_folklore:
250
+ traditional_folklore = ["prisacă", "cucoș", "harbuz", "cobză", "fluier", "doină", "horă", "opincă", "hultan"]
251
+
252
+ # Genre-specific palette configuration
253
+ if family_mode or "albina" in genre_lower:
254
+ family_pool = list(set(family_friendly + ["albină", "miere", "stup", "flori", "nectar", "soare", "zâmbet", "curcubeu", "joacă", "bucurie", "mărușor"]))
255
+ selected_words = random.sample(family_pool, min(8, len(family_pool)))
256
+ phonetics = ["oleacă", "amu", "ghini", "tăt", "drăgălaș"]
257
+ ad_libs = ["(bzz-bzz!)", "(zum-zum!)", "(hop-așa!)", "(ce dulce!)", "(hei-hop!)", "(veselie!)"]
258
+ genre_notes = "MOD FAMILIE / COPII: Folosește exclusiv cuvinte luminoase, jucăușe, miere, flori, soare, zumzet de albine. ZERO argou sau vorbe dure."
259
+ elif any(k in genre_lower for k in ["deep house", "house", "edm", "club", "nocturn"]):
260
+ # Nocturnal deep house: urban, lights, bass, rhythm, night, streets. NO rustic farm words!
261
+ nocturnal_pool = list(set(chisinau_places + [
262
+ "bulevard", "noapte", "bas", "lumini", "neon", "puls", "ritm", "fior", "ecou",
263
+ "viteză", "priviri", "asfalt", "umbre", "vibrație", "scânteie", "ceață", "miezul nopții"
264
+ ]))
265
+ selected_words = random.sample(nocturnal_pool, min(8, len(nocturnal_pool)))
266
+ phonetics = ["șî", "pi", "tăt", "amu"]
267
+ ad_libs = ["(dă-i play)", "(noaptea)", "(simte basul)", "(yeah)", "(drop)", "(așa)", "(doar noi)", "(vibe)"]
268
+ genre_notes = "DIRECTIVĂ DEEP HOUSE: Cadru urban nocturn, lumini de neon, condus nocturn, priviri în club, puls de bas hipnotic la 124 BPM. Stil minimalist, aerisit, repetitiv senzual. STRICT INTERZIS vocabularul de fermă/gospodărie (fără harbuz, cucoș, băligar, oloi)."
269
+ elif any(k in genre_lower for k in ["chill", "lo-fi", "ambient", "acoustic", "doina"]):
270
+ chill_pool = list(set(cozy_nature + chisinau_places[:4] + [
271
+ "ceai", "cafea", "fereastră", "nori", "liniște", "toamnă", "amintire", "dor", "vânt", "pas", "șoaptă", "frunze galbene", "balcon"
272
+ ]))
273
+ selected_words = random.sample(chill_pool, min(8, len(chill_pool)))
274
+ phonetics = ["oleacă", "amu", "tăt", "di ce", "ghini"]
275
+ ad_libs = ["(ușor)", "(ascultă)", "(ploaia)", "(așa)", "(liniște)", "(mhm)", "(adânc)"]
276
+ genre_notes = "DIRECTIVĂ CHILL / LO-FI: Ritm lent, căldură, nostalgie intimă, conversațional, pauze melodioase, amintiri, ploaie sau apus. Repetiții calde și line."
277
+ else:
278
+ # Trap / Balkan 808 / Rap / High Energy
279
+ trap_pool = list(set(urban_slang + chisinau_places + action_verbs + traditional_folklore[:10]))
280
+ selected_words = random.sample(trap_pool, min(8, len(trap_pool)))
281
+ phonetics = ["pi", "di", "șî", "ghini", "tăt", "amu", "wăi"]
282
+ ad_libs = ["(dă-i!)", "(așa!)", "(Chișinău!)", "(hop!)", "(808!)", "(trage!)", "(foc!)", "(yeah!)", "(blană!)"]
283
+ genre_notes = "DIRECTIVĂ MOLDOVAN TRAP & BALKAN 808: Flow alert, cadență sacadată, triolete, energie de stradă autentică, punchlines, bas greu distorsionat, ad-libs explozive."
284
+
285
+ return {
286
+ "selected_words_str": ", ".join(selected_words),
287
+ "phonetics_str": ", ".join(phonetics),
288
+ "ad_libs_str": ", ".join(ad_libs),
289
+ "genre_notes": genre_notes
290
+ }
291
+
292
  def compose_lyrics(
293
  self,
294
  topic: str,
 
361
  "NICIODATĂ nu menționa aceste reguli sau sistemul în versuri (no prompt leak)."
362
  )
363
 
364
+ palette = self._sample_dynamic_lexical_palette(
365
+ genre=genre,
366
+ topic=clean_topic,
367
+ dialect_level=dialect_level,
368
+ family_mode=family_mode
369
+ )
370
+
371
+ sample_rural = palette["selected_words_str"]
372
+ sample_slang = palette["selected_words_str"]
373
+
374
  system_prompt = f"""
375
  Ești cel mai mare compozitor, textier și director muzical din Republica Moldova.
376
  Misiunea ta: scrie o piesă 100% ORIGINALĂ, GENIALĂ și AUTENTIC MOLDOVENEASCĂ. "Less is more": versuri SCURTE, PERCUTANTE, memorabile — nu umple versurile cu dicționarul; alege regionalismele cu impact, nu pe toate.
 
379
 
380
  REGULI CRUCIALE DE COMPOZIȚIE:
381
  0. Răspunsul conține EXCLUSIV piesa: titlu + secțiuni + versuri. Fără titluri de reguli, fără explicații, fără meta-comentarii, fără "REGULI", "Format", "Producer Brief" — nimic în afara structurii piesei. Orice linie care reia regulile sau promptul este un EȘEC.
382
+ 1. Rime scurte și percutante: fiecare vers 6-11 silabe, lovitură directă, fără umplutură. Folosește regionalisme autentice din baza de date ({sample_rural}), argou din Chișinău ({sample_slang}) și fonetică moldovenească ({palette['phonetics_str']}).
383
  1b-FAMILY. {rule_1b}
384
  2. NU repeta fraza utilizatorului ca vers literal! Analizează conceptul ('{clean_topic}') și creează metafore, personaje, rime și poveste dedicată.
385
+ 3. LEGEA MUZICII (SONGWRITING REAL VS POEZIE ȘCOLARĂ):
386
+ - O piesă muzicală NU este o poezie monotonă de recitat la școală! Trăiește prin RITM, GROOVE, CADENȚĂ ȘI REPETIȚIE CATCHY! ({syllables_per_line}).
387
+ - REFRENUL (CHORUS / HOOK) ESTE UN EARWORM: Repetă fraza principală (hook-ul) de 2-4 ori cu variații de energie și ad-libs! Refrenul trebuie să fie memorabil, hipnotic și ușor de fredonat. Nu schimba fiecare vers cu idei noi în refren — repetiția creează hitul!
388
+ - PRE-CHORUS: Creează tensiune ritmică în creștere înainte de refren / drop (2-4 versuri scurte).
389
+ - AD-LIBS & ACCENTE RITMICE ÎN PARANTEZE: Presară ad-libs vocale și accente ritmice specifice stilului: {palette['ad_libs_str']}. Ele dau viață vocii AI și creează groove!
390
+ - Rimează natural (AABB sau ABAB) și asigură cadență ritmată pe beat ({syllables_per_line}).
391
  4. Gen Muzical: {genre} ({bpm} BPM).
392
  5. Stil Vocal & Flow: {vocal_style}.
393
  6. Intenție Emoțională / Vibe: {intent}.
394
  7. Nivel Dialectal ({dialect_level}/4): {dialect_rule}.
395
+ 8. {palette['genre_notes']}
396
+ {f'9. Producer Brief / Instrucțiuni Speciale: {custom_instructions}' if custom_instructions else ''}
397
  {persona_context}
398
  """
399
 
moldovan-qwen/personas/moldovan_master_lyricist_lexicon.md CHANGED
@@ -77,7 +77,7 @@ Ghid lexical, fonetic și cultural complet generat din baza de date master (mold
77
  - Irina Rimes
78
  - SunStroke Project
79
 
80
- ## 3. VOCABULAR COMPLET DIN MASTER DB (731 TERMENI)
81
  - **a abişi** (L4 • Argou Urban Chișinău) = a promite (A promite (argou stradal).)
82
  - **a ahui** (L4 • Argou Urban Chișinău) = a încălca regulile de conduită (A încălca regulile de conduită, a o lua razna.)
83
  - **a dăvi** (L3 • Expresii & Idiomuri) = a presa / a forța / a bate la cap (A insista exagerat asupra cuiva sau a grăbi o decizie.)
@@ -757,7 +757,6 @@ Ghid lexical, fonetic și cultural complet generat din baza de date master (mold
757
  - **îndruliga** (L3 • Acțiuni & Verbe Regionale) = (vb) a coase sau a țese când gros, când subțire
758
  - **îngena** (L3 • Fonetică & Schimbări Dialectale) = (vb) a încetini
759
  - **îngingia** (L3 • Gastronomie & Tradiție) = (vb) despre porumb, a începe a face boabe
760
- - **îngurlui** (L3 • Acțiuni & Verbe Regionale) = (vb) a se încurca cu cineva în sensul de a avea o relație cu cineva
761
  - **însâmbrare** (L3 • Fonetică & Schimbări Dialectale) = asociere, întovărășire
762
  - **întercleți** (L3 • Acțiuni & Verbe Regionale) = (vb) a încremeni
763
  - **întiri** (L3 • Acțiuni & Verbe Regionale) = (vb) a alunga, a îndepărta
 
77
  - Irina Rimes
78
  - SunStroke Project
79
 
80
+ ## 3. VOCABULAR COMPLET DIN MASTER DB (730 TERMENI)
81
  - **a abişi** (L4 • Argou Urban Chișinău) = a promite (A promite (argou stradal).)
82
  - **a ahui** (L4 • Argou Urban Chișinău) = a încălca regulile de conduită (A încălca regulile de conduită, a o lua razna.)
83
  - **a dăvi** (L3 • Expresii & Idiomuri) = a presa / a forța / a bate la cap (A insista exagerat asupra cuiva sau a grăbi o decizie.)
 
757
  - **îndruliga** (L3 • Acțiuni & Verbe Regionale) = (vb) a coase sau a țese când gros, când subțire
758
  - **îngena** (L3 • Fonetică & Schimbări Dialectale) = (vb) a încetini
759
  - **îngingia** (L3 • Gastronomie & Tradiție) = (vb) despre porumb, a începe a face boabe
 
760
  - **însâmbrare** (L3 • Fonetică & Schimbări Dialectale) = asociere, întovărășire
761
  - **întercleți** (L3 • Acțiuni & Verbe Regionale) = (vb) a încremeni
762
  - **întiri** (L3 • Acțiuni & Verbe Regionale) = (vb) a alunga, a îndepărta
moldovan-qwen/scripts/daily_album_cron.py CHANGED
@@ -78,63 +78,137 @@ def _random_genre_key() -> str:
78
 
79
  # Moldovan Qwen track concepts
80
  MOLDOVAN_QWEN_TRACKS = [
81
- {"title": "Harbuz pe Autobahn", "tempo": "140 BPM", "mood": "Heavy Balkan 808", "topic": "Ridicând o roată de harbuz pe drumul de autostradă spre țară, bas greu și fluier tradițional distorsionat"},
82
- {"title": "Ciuleandra din Ciocana", "tempo": "145 BPM", "mood": "Doina Lo-Fi", "topic": "Dans tradițional de ciuleandra la un bar de colț sub o fotonie veche"},
83
- {"title": "Troleibuz spre Bălți", "tempo": "128 BPM", "mood": "Street Rap", "topic": "Călătorie cu troleibuzul de la Gară la Bălți cu toți prietenii la bord"},
84
- {"title": "Colțuni la Miezul Nopții", "tempo": "135 BPM", "mood": "Folclor Trap", "topic": "Făcând colțuni cu brânză în bucătărie la miezul nopții cu mașină de tortură și beat de trap"},
85
- {"title": "Băsmăluță pe Asfalt", "tempo": "132 BPM", "mood": "Doina Lo-Fi", "topic": "Mergând cu mătușa pe bulevardele din Râșcani la răsărit cu o băsmăluță în mână"},
86
- {"title": "Dacia 621 Neon", "tempo": "138 BPM", "mood": "Synthwave Chișinău", "topic": "Conducing o Dacia 621 vechi cu lumeni LED pe bulevardul Ștefan cel Mare"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ]
88
 
 
89
  CHILL_TRACKS = [
90
  {"title": "Cafea de la Fântână", "tempo": "75 BPM", "mood": "Lo-Fi Chill", "topic": "O cafea caldă la o fântână de sat în ploaia de toamnă cu nori grei"},
91
  {"title": "Răsărit pe Prut", "tempo": "80 BPM", "mood": "Acoustic Ambient", "topic": "Mergând pe malul Prutului la răsărit cu nori roz și păsări care se trezesc"},
92
  {"title": "Ploaie pe Terasă", "tempo": "70 BPM", "mood": "Chillhop", "topic": "Ascultând ploaia pe terasă cu o carte bună și o cană de ceai de mușețel"},
93
- {"title": "Gânduri la Ferma", "tempo": "85 BPM", "mood": "Nostalgic Chill", "topic": "Amintiri de la bunici la sat cu o grădină plină de pomi și un câine vechi"},
94
- {"title": "Lumini de Noapte", "tempo": "78 BPM", "mood": "Deep Chill", "topic": "Oricul luminat la fereastră de pe un deal în noapte cu un cântec de leagăn în fundal"},
95
- {"title": "Drumul spre Gară", "tempo": "82 BPM", "mood": "Acoustic Lo-Fi", "topic": "Urcând spre gară cu un sac mare de pungi cu prăjiturele și bilete pentru tren"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  ]
97
 
 
98
  DEEP_HOUSE_TRACKS = [
99
- {"title": "Râșcani Sunset Groove", "tempo": "123 BPM", "mood": "Melodic Deep House", "topic": "Cochetarea în club la Râșcani cu soarele apunând și vocale ușoare pe un kick rotund"},
100
- {"title": "Calea Ieșilor Drive", "tempo": "125 BPM", "mood": "Late Night Vocal", "topic": "Conducând pe Calea Ieșilor la miezul nopții cu un deep house groovy și vocale feminine"},
101
- {"title": "Piața Centrală Groove", "tempo": "122 BPM", "mood": "Club Deep", "topic": "O petrecere underground la Piața Centrală cu un beat de deep house clasic"},
102
- {"title": "Troleibuz 14 Groove", "tempo": "124 BPM", "mood": "Melodic Club", "topic": "Un troleibuz de oraș care merge la ritmul unui deep house melodic"},
103
- {"title": "Carmen & The Bass", "tempo": "122 BPM", "mood": "Vocal Deep", "topic": "Vocălistă Carmen pe un deep house vocal cu bas rotund și synth plin"},
104
- {"title": "Noapte la Ciocana", "tempo": "125 BPM", "mood": "Deep Drive", "topic": "O noapte lungă la Ciocana cu un groove de deep house și un kick care se aude prin ziduri"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  ]
106
 
 
107
  RANDOM_TRACKS = [
108
  {"title": "Doina Drift D&B", "tempo": "174 BPM", "mood": "Liquid Drum & Bass", "topic": "O doină tradițională transformată într-un liquid drum & bass frenetic"},
109
  {"title": "Cyberpunk Chișinău", "tempo": "140 BPM", "mood": "Industrial Synth", "topic": "Chișinău din 2085 cu roboți care vând colțuni pe piața centrală"},
110
  {"title": "Reggae la Botanica", "tempo": "100 BPM", "mood": "Island Reggae", "topic": "Un DJ reggae care mixează pe acoperișul blocului din Botanica la furtună"},
111
  {"title": "Metal Folclor Epic", "tempo": "160 BPM", "mood": "Heavy Metal", "topic": "Gitară electrică heavy care cântă un cântec de horă tradițional moldovenesc"},
112
  {"title": "Funk 621", "tempo": "110 BPM", "mood": "Disco Funk", "topic": "O Dacia 621 cu motor de funcție disco la un concurs de viteze retro"},
113
- {"title": "Haituk Pop Punk", "tempo": "155 BPM", "mood": "Punk Rock", "topic": "Un haiduk modern care conduce un skuter pe autostradă cu o chitară electrică pe spate"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  ]
115
 
116
- def _build_album_tracks(genre_key: str) -> List[Dict[str, Any]]:
117
- """Select tracks for the given genre key (shuffled so albums vary)."""
 
118
  if genre_key == "ALBINA_BONDAR":
119
  # Catalogul are 24 concepte; un album Albina ia un subset rotit de 6
120
  # (fara repetare) ca sa incapa in fereastra de cota ZeroGPU zilnica.
121
- return build_albina_tracks(count=6)
122
  elif genre_key == "MOLDOVAN_QWEN":
123
  pool = [dict(t) for t in MOLDOVAN_QWEN_TRACKS]
124
- random.shuffle(pool)
125
- return pool
126
  elif genre_key == "CHILL":
127
  pool = [dict(t) for t in CHILL_TRACKS]
128
- random.shuffle(pool)
129
- return pool
130
  elif genre_key == "DEEP_HOUSE":
131
  pool = [dict(t) for t in DEEP_HOUSE_TRACKS]
132
- random.shuffle(pool)
133
- return pool
134
  else:
135
  pool = [dict(t) for t in RANDOM_TRACKS]
136
- random.shuffle(pool)
137
- return pool
 
 
 
 
138
 
139
  class DailyAlbumGenerator:
140
  def __init__(self, target_minutes: float = 35.0, dry_run: bool = False, genre_override: Optional[str] = None):
@@ -341,6 +415,16 @@ class DailyAlbumGenerator:
341
  with open(manifest_file, "w", encoding="utf-8") as f:
342
  json.dump(_save, f, indent=2, ensure_ascii=False)
343
 
 
 
 
 
 
 
 
 
 
 
344
  print("\n" + "=" * 65)
345
  print(f"✨ ALBUM COMPLET GENERAT: {album_title}")
346
  print(f"📁 Director: {album_dir}")
@@ -416,35 +500,39 @@ class DailyAlbumGenerator:
416
  keep burning quota on the remaining ones."""
417
  track_title = track_info["title"]
418
 
419
- # 1. Generate rich Moldovan lyrics (or mock in dry-run mode for instant test)
420
- if self.dry_run:
421
- print(" [DRY-RUN] Folosire versuri sintetice offline...")
422
- raw_lyrics = f"[Intro]\nHei noroc la toți din Chișinău!\n\n[Verse 1]\n{track_info['topic']}\nMerge treaba, sună basul greu.\n\n[Chorus]\n{track_title} răsună-n noapte,\nVorbe multe, fapte coapte!\n\n[Outro]\nGata piesa, dă-i play din nou!"
423
- phonetic_lyrics = raw_lyrics
424
- else:
425
- # When the LLM providers are unreachable the engine raises; a
426
- # deterministic synthetic lyric generator keeps the pipeline alive.
427
- persona_id = "albina_bondar" if genre_key == "ALBINA_BONDAR" else ""
428
- if persona_id:
429
- print(f" 🐝 Persona activă: Albina Bondar (happy family lyrics)")
430
- try:
431
- lyrics_data = self.lyricist.compose_lyrics(
432
- topic=track_info["topic"],
433
- genre=genre_key,
434
- dialect_level=2,
435
- bpm=int(track_info["tempo"].split()[0]) if track_info["tempo"] else 130,
436
- intent=track_info["mood"],
437
- duration_seconds=180,
438
- persona_id=persona_id,
439
- )
440
- raw_lyrics = lyrics_data.get("full_lyrics", "")
441
- phonetic_lyrics = lyrics_data.get("phonetic_lyrics", raw_lyrics)
442
- except Exception as e:
443
- # Log the failure and synthesize a very simple placeholder.
444
- print(f" [!][fallback lyricist] {e}. Using synthetic placeholder.")
445
- placeholder = f"[Intro]\n{track_info['topic']}\n[Verse]\n...\n[Outro]"
446
- raw_lyrics = placeholder
447
- phonetic_lyrics = placeholder
 
 
 
 
448
 
449
  lyrics_file = album_dir / f"{idx:02d}_{track_title.replace(' ', '_')}_lyrics.txt"
450
  with open(lyrics_file, "w", encoding="utf-8") as f:
 
78
 
79
  # Moldovan Qwen track concepts
80
  MOLDOVAN_QWEN_TRACKS = [
81
+ {"title": "Balkan 808 la Ciocana", "tempo": "140 BPM", "mood": "Heavy Balkan 808", "topic": "Subbox-uri vibrând în parcarea de la Ciocana, bas 808 distorsionat și nai tradițional moldovenesc"},
82
+ {"title": "Ciuleandra din Centru", "tempo": "145 BPM", "mood": "Folclor Trap", "topic": "Ciuleandra dansată pe ritm de trap pe scările de la Teatrul Național"},
83
+ {"title": "Troleibuzul de Noapte", "tempo": "130 BPM", "mood": "Street Rap", "topic": "Ultimul troleibuz prin Chișinău gol, lumini galbene pe asfalt ud și flow sacadat"},
84
+ {"title": "Colțuni și Bas Greu", "tempo": "138 BPM", "mood": "Balkan 808", "topic": "Bucătărie de bloc cu colțuni aburind și beat de 808 care mișcă paharele în bufet"},
85
+ {"title": "Dacia 1310 pe Bulevard", "tempo": "135 BPM", "mood": "Phonk Moldovenesc", "topic": "Dacie clasică coborând bulevardul Dacia cu geamul lăsat și rap moldovenesc în difuzoare"},
86
+ {"title": "Băieții de la Poșta Veche", "tempo": "142 BPM", "mood": "Chișinău Drill", "topic": "Povești de cartier la Poșta Veche, rime percutante, loialitate și respect"},
87
+ {"title": "Cobza Electrică la Râșcani", "tempo": "140 BPM", "mood": "Folclor Trap", "topic": "Cobză acustică combinată cu hi-hat-uri rapide de trap la cinematograful Moscova"},
88
+ {"title": "Piața Centrală Cypher", "tempo": "132 BPM", "mood": "Boom Bap Moldovenesc", "topic": "Cypher spontan de rap printre tarabele din Piața Centrală din Chișinău"},
89
+ {"title": "Valea Morilor Sunset Drop", "tempo": "136 BPM", "mood": "Doina Trap", "topic": "Doină de dor transformată într-un drop masiv de trap pe malul lacului Valea Morilor"},
90
+ {"title": "Buiucani Vibe", "tempo": "134 BPM", "mood": "Street Rap", "topic": "Plimbare pe străzile din Buiucani, bătăi de tobe murdare și poveste urbană"},
91
+ {"title": "Mahala 808", "tempo": "144 BPM", "mood": "Heavy Balkan 808", "topic": "Viața de mahala chișinăuiană cu bas subwoofere și fluier ciobănesc"},
92
+ {"title": "Haiduc Modern pe Trotinetă", "tempo": "130 BPM", "mood": "Satirical Rap", "topic": "Haiduc din timpurile noastre pe trotinetă electrică ocolind gropile de pe Calea Ieșilor"},
93
+ {"title": "Telecentru Turnul de Foc", "tempo": "140 BPM", "mood": "Chișinău Drill", "topic": "Priveliștea orașului de pe dealul Telecentru la apus cu beat tăios"},
94
+ {"title": "Frăția de la Scara 4", "tempo": "132 BPM", "mood": "Street Rap", "topic": "Prietenia adevărată între băieții de la bloc care nu uită de unde au plecat"},
95
+ {"title": "Doina de la Butuceni 808", "tempo": "145 BPM", "mood": "Doina Trap", "topic": "Stâncile de la Orheiul Vechi și ecoul fluierului peste un kick de 808"},
96
+ {"title": "Plăcinte Fierbinți la 3 AM", "tempo": "138 BPM", "mood": "Balkan Bounce", "topic": "Oprire la toneta de plăcinte la miezul nopții după un concert plin de energie"},
97
+ {"title": "Asfalt și Cobză", "tempo": "135 BPM", "mood": "Folclor Trap", "topic": "Armonia dintre folclorul autentic de nuntă și sound-ul modern de trap"},
98
+ {"title": "Botanica Bass Syndicate", "tempo": "142 BPM", "mood": "Heavy Balkan 808", "topic": "Subwooferele zguduie blocurile sovietice de la porțile orașului"},
99
+ {"title": "Gara de Nord Nocturn", "tempo": "128 BPM", "mood": "Boom Bap Moldovenesc", "topic": "Mersul trenurilor, valize vechi și dor de casă pe un beat clasic de hip-hop"},
100
+ {"title": "Ritm de Horă Rebelă", "tempo": "148 BPM", "mood": "Folclor Trap", "topic": "O horă rapidă moldovenească accelerată cu sintetizatoare moderne și claps"},
101
+ {"title": "Podul de la Circ", "tempo": "136 BPM", "mood": "Street Rap", "topic": "Reflecții urbane sub podul de la Circul din Chișinău la apus de soare"},
102
+ {"title": "Graiul Străzii", "tempo": "134 BPM", "mood": "Chișinău Drill", "topic": "Dicționarul străzii moldovenești vorbit clar, cu demnitate și flow alert"},
103
+ {"title": "Noaptea la Durlești", "tempo": "138 BPM", "mood": "Balkan 808", "topic": "Dealurile Durleștiului văzute noaptea cu faruri aprinse și muzică la maxim"},
104
+ {"title": "Bulevardul Dacia Anthem", "tempo": "140 BPM", "mood": "Heavy Balkan 808", "topic": "Imnul porților orașului Chișinău cu bas tremurat și refren contagios"}
105
  ]
106
 
107
+ # Chill track concepts (24 diverse tracks)
108
  CHILL_TRACKS = [
109
  {"title": "Cafea de la Fântână", "tempo": "75 BPM", "mood": "Lo-Fi Chill", "topic": "O cafea caldă la o fântână de sat în ploaia de toamnă cu nori grei"},
110
  {"title": "Răsărit pe Prut", "tempo": "80 BPM", "mood": "Acoustic Ambient", "topic": "Mergând pe malul Prutului la răsărit cu nori roz și păsări care se trezesc"},
111
  {"title": "Ploaie pe Terasă", "tempo": "70 BPM", "mood": "Chillhop", "topic": "Ascultând ploaia pe terasă cu o carte bună și o cană de ceai de mușețel"},
112
+ {"title": "Gânduri la Prisacă", "tempo": "78 BPM", "mood": "Nostalgic Chill", "topic": "Amintiri de la bunici la prisacă cu o grădină plină de pomi și miere proaspătă"},
113
+ {"title": "Lumini de Seară la Valea Morilor", "tempo": "74 BPM", "mood": "Lo-Fi Ambient", "topic": "Bănci de lemn pe malul apei la Valea Morilor cu reflexia felinarelor"},
114
+ {"title": "Drumul spre Casă", "tempo": "82 BPM", "mood": "Acoustic Lo-Fi", "topic": "Călătorie liniștită spre casă cu trenul local, peisaje de dealuri verzi"},
115
+ {"title": "Ceai de Mentă la Balcon", "tempo": "72 BPM", "mood": "Chillhop", "topic": "Aburul din cană într-o dimineață răcoroasă de octombrie în Chișinău"},
116
+ {"title": "Frunze Galbene în Parc", "tempo": "76 BPM", "mood": "Lo-Fi Beats", "topic": "Pași lenți prin covorul de frunze din Parcul Ștefan cel Mare"},
117
+ {"title": "Fereastra de la Mansardă", "tempo": "68 BPM", "mood": "Ambient Acoustic", "topic": "Privind norii cum trec leneș peste acoperișurile vechi ale centrului istoric"},
118
+ {"title": "Liniștea din Codri", "tempo": "80 BPM", "mood": "Nature Ambient", "topic": "Vântul foșnind prin stejarii bătrâni din Codrii Moldovei"},
119
+ {"title": "Pâine Caldă din Cuptor", "tempo": "75 BPM", "mood": "Nostalgic Chill", "topic": "Mirosul de aluat copt în cuptorul de lut al bunicii la țară"},
120
+ {"title": "Casetofonul Vechi", "tempo": "74 BPM", "mood": "Jazzy Lo-Fi", "topic": "Casetă audio cu bandă magnetică ușor uzată redând acorduri calde de pian"},
121
+ {"title": "Seară de Toamnă la Butuceni", "tempo": "70 BPM", "mood": "Acoustic Ambient", "topic": "Amurg liniștit peste meandrele Răutului și fânețele adormite"},
122
+ {"title": "Troleibuzul Gol", "tempo": "76 BPM", "mood": "Lo-Fi Chillhop", "topic": "Scaun la geam, stropi de ploaie curgând pe sticlă și muzică în căști"},
123
+ {"title": "Apus peste Nistru", "tempo": "82 BPM", "mood": "Chillout Melodic", "topic": "Culorile aurii ale apusului reflectate în apele liniștite ale Nistrului"},
124
+ {"title": "Dulceață de Nuci Verzi", "tempo": "72 BPM", "mood": "Warm Chill", "topic": "Amintiri dulci din copilărie, borcane la soare și liniște de duminică"},
125
+ {"title": "Pian la Fereastră Deschizată", "tempo": "68 BPM", "mood": "Lo-Fi Piano", "topic": "Note blânde de pian plutind peste curtea liniștită de bloc"},
126
+ {"title": "Cărți Vechi și Cafea", "tempo": "75 BPM", "mood": "Coffee Shop Lo-Fi", "topic": "Miros de hârtie îngălbenită într-un anticariat cochet din Chișinău"},
127
+ {"title": "Scrisoare Nescrisă", "tempo": "78 BPM", "mood": "Nostalgic Acoustic", "topic": "Gânduri pentru cei dragi plecați departe, lăsate pe hârtie la lumina lămpii"},
128
+ {"title": "Zori de Zi la Sat", "tempo": "74 BPM", "mood": "Acoustic Ambient", "topic": "Ceața subțire se ridică peste livezi, primul cântat de cocoș în depărtare"},
129
+ {"title": "Melancolie de Septembrie", "tempo": "70 BPM", "mood": "Lo-Fi Beats", "topic": "Trecerea blândă a verii în toamnă, aer răcoros și nostalgie dulce"},
130
+ {"title": "Popas la Răscruce", "tempo": "80 BPM", "mood": "Acoustic Chill", "topic": "O troiță veche de lemn la margine de sat, umbră de nuc bătrân"},
131
+ {"title": "Ploaie Măruntă pe Asfalt", "tempo": "73 BPM", "mood": "Chillhop", "topic": "Sunetul hipnotic al picăturilor de ploaie pe strada Pușkin adormită"},
132
+ {"title": "Vis de Seară", "tempo": "76 BPM", "mood": "Dreamy Ambient", "topic": "O stare de pace totală la sfârșitul unei zile lungi și pline de sens"}
133
  ]
134
 
135
+ # Deep House track concepts (24 diverse tracks)
136
  DEEP_HOUSE_TRACKS = [
137
+ {"title": "Râșcani Sunset Groove", "tempo": "124 BPM", "mood": "Melodic Deep House", "topic": "Apus auriu peste bulevardul Moscova cu pad-uri calde și bas rotund de club"},
138
+ {"title": "Calea Ieșilor Drive", "tempo": "125 BPM", "mood": "Late Night Vocal", "topic": "Conducând pe Calea Ieșilor la miezul nopții cu un deep house hipnotic și ecouri vocale"},
139
+ {"title": "Piața Centrală Underground", "tempo": "122 BPM", "mood": "Club Deep", "topic": "Petrecere underground într-un subsol istoric din Chișinău cu beat minimalist"},
140
+ {"title": "Noapte la Ciocana", "tempo": "125 BPM", "mood": "Deep Drive", "topic": "Groove hipnotic de noapte la Ciocana, kick rotund și lumini de neon reflectate pe asfalt"},
141
+ {"title": "Botanica Afterhours", "tempo": "123 BPM", "mood": "Melodic Deep House", "topic": "Zori de zi la porțile orașului, sintetizatoare calde și stare de visare pe ringul de dans"},
142
+ {"title": "Centru Neon Lights", "tempo": "124 BPM", "mood": "Vocal Deep House", "topic": "Pași pe strada 31 August la miezul nopții, ritm melodic și voce feminină senzuală"},
143
+ {"title": "Valea Morilor Moonlight", "tempo": "122 BPM", "mood": "Sunset Groove", "topic": "Reflexia lunii pe lacul Valea Morilor cu un beat elegant și percuții organice"},
144
+ {"title": "Bulevardul Dacia Midnight", "tempo": "125 BPM", "mood": "Deep Drive", "topic": "Viteză constantă pe bulevard cu luminile verzi ale semafoarelor și bas pulsant"},
145
+ {"title": "Telecentru Skyline Groove", "tempo": "124 BPM", "mood": "Melodic Club", "topic": "Priveliște panoramică peste luminile orașului noaptea, acorduri de pian și hi-hats aerisite"},
146
+ {"title": "Rooftop la Chișinău", "tempo": "123 BPM", "mood": "Sunset Groove", "topic": "Cocktail pe acoperiș la apus, prieteni buni și ritm relaxant de 123 BPM"},
147
+ {"title": "Buiucani Deep Sessions", "tempo": "125 BPM", "mood": "Underground Deep", "topic": "Bas adânc și atmosferă intimă de club exclusivist din cartierul Buiucani"},
148
+ {"title": "Strada Pietonală Groove", "tempo": "122 BPM", "mood": "Vocal Deep House", "topic": "Terase luminate, discuții șoptite și o linie de bas care face toată lumea să se miște"},
149
+ {"title": "Aeroport Midnight Flight", "tempo": "125 BPM", "mood": "Late Night Drive", "topic": "Drumul drept spre aeroportul din Chișinău la 2 dimineața cu synth-uri progresive"},
150
+ {"title": "Sculeni Warehouse Beat", "tempo": "124 BPM", "mood": "Industrial Deep", "topic": "Hale industriale reamenajate pentru un rave underground elegant și profund"},
151
+ {"title": "Arca de Triumf Echoes", "tempo": "123 BPM", "mood": "Melodic Deep House", "topic": "Piața Marii Adunări Naționale învăluită în ceață nocturnă și reverberații calde"},
152
+ {"title": "Vibe de Club Chișinău", "tempo": "125 BPM", "mood": "Club Deep", "topic": "Ringul de dans plin, fum dens, stroboscop lent și conexiune prin muzică"},
153
+ {"title": "Malina Mică Shadows", "tempo": "122 BPM", "mood": "Minimal Deep", "topic": "Străduțe liniștite cu case vechi și un beat minimalist care pulsează constant"},
154
+ {"title": "Dendrariu Dusk", "tempo": "124 BPM", "mood": "Organic Deep House", "topic": "Plimbare la înserat prin parcul Dendrariu cu sunete naturale mixate cu muzică electronică"},
155
+ {"title": "Cricova Vault Vibes", "tempo": "123 BPM", "mood": "Hypnotic Deep", "topic": "Galeriile subterane răcoroase cu ecou hipnotic și bas subacvatic"},
156
+ {"title": "Dimineața la Lac", "tempo": "122 BPM", "mood": "Morning Deep House", "topic": "Răsăritul prinde ultimii dansatori pe ponton, muzica devine mai aerisită și luminoasă"},
157
+ {"title": "Viaduct Speed of Sound", "tempo": "125 BPM", "mood": "Late Night Drive", "topic": "Traversarea viaductului dintre Botanica și Centru în miez de noapte pe bas plin"},
158
+ {"title": "Ecouri de Pian Nocturn", "tempo": "123 BPM", "mood": "Melodic Club", "topic": "Armonii sofisticate de pian house cu claps curate și vocale învăluitoare"},
159
+ {"title": "Chișinău Never Sleeps", "tempo": "125 BPM", "mood": "Peak Time Deep", "topic": "Energia neîntreruptă a orașului când toți ceilalți dorm, ritm care nu se oprește"},
160
+ {"title": "Apus la Nistru Groove", "tempo": "124 BPM", "mood": "Sunset Melodic", "topic": "Plajă de râu la sfârșit de vară, acorduri majore și dans descălțat în nisip"}
161
  ]
162
 
163
+ # Random / Wildcard track concepts (24 diverse tracks)
164
  RANDOM_TRACKS = [
165
  {"title": "Doina Drift D&B", "tempo": "174 BPM", "mood": "Liquid Drum & Bass", "topic": "O doină tradițională transformată într-un liquid drum & bass frenetic"},
166
  {"title": "Cyberpunk Chișinău", "tempo": "140 BPM", "mood": "Industrial Synth", "topic": "Chișinău din 2085 cu roboți care vând colțuni pe piața centrală"},
167
  {"title": "Reggae la Botanica", "tempo": "100 BPM", "mood": "Island Reggae", "topic": "Un DJ reggae care mixează pe acoperișul blocului din Botanica la furtună"},
168
  {"title": "Metal Folclor Epic", "tempo": "160 BPM", "mood": "Heavy Metal", "topic": "Gitară electrică heavy care cântă un cântec de horă tradițional moldovenesc"},
169
  {"title": "Funk 621", "tempo": "110 BPM", "mood": "Disco Funk", "topic": "O Dacia 621 cu motor de funcție disco la un concurs de viteze retro"},
170
+ {"title": "Haituk Pop Punk", "tempo": "155 BPM", "mood": "Punk Rock", "topic": "Un haiduk modern care conduce un skuter pe autostradă cu o chitară electrică pe spate"},
171
+ {"title": "Electro Swing la Târg", "tempo": "125 BPM", "mood": "Electro Swing", "topic": "Alămuri vesele și ritm saltăreț de clarinet la târgul de toamnă din Hîncești"},
172
+ {"title": "Chișinău Dubstep Riot", "tempo": "145 BPM", "mood": "Heavy Dubstep", "topic": "Wobble bass masiv și drops agresive într-un festival de muzică electronică"},
173
+ {"title": "Balkan Brass Madness", "tempo": "150 BPM", "mood": "Balkan Gypsy Brass", "topic": "Trompete și tubă cântând cu viteză uluitoare la o sărbătoare nebună"},
174
+ {"title": "Afrobeat de Moldova", "tempo": "118 BPM", "mood": "Afrobeat Fusion", "topic": "Poliritmii africane împletite cu vioară moldovenească și percuții vii"},
175
+ {"title": "Trance la Orheiul Vechi", "tempo": "138 BPM", "mood": "Uplifting Trance", "topic": "Pian euforic și sintetizatoare zburătoare peste canionul spectaculos al Răutului"},
176
+ {"title": "Căruța Punk Rock", "tempo": "165 BPM", "mood": "Garage Punk", "topic": "Energie pură de rock rebel din garaj cu versuri directe și atitudine fără compromis"},
177
+ {"title": "Ska de Bălți", "tempo": "140 BPM", "mood": "Upbeat Ska", "topic": "Ritm de chitară pe offbeat și tromboane vesele într-un parc din Bălți"},
178
+ {"title": "Vaporwave la Telecentru", "tempo": "85 BPM", "mood": "Chill Vaporwave", "topic": "Sunete nostalgice de mall din anii '90 cu mostre încetinite din televiziunea națională"},
179
+ {"title": "Tango pe Podul Înalt", "tempo": "120 BPM", "mood": "Electro Tango", "topic": "Acordeon pasional combinat cu beat modern de trip-hop într-o seară răcoroasă"},
180
+ {"title": "Hardstyle la Butuceni", "tempo": "150 BPM", "mood": "Euphoric Hardstyle", "topic": "Kick distorsionat masiv și melodie înălțătoare de nai moldovenesc"},
181
+ {"title": "Gipsy Rumba la Soroca", "tempo": "115 BPM", "mood": "Acoustic Rumba", "topic": "Chitări spaniole și ritm de palmadas la cetatea Soroca pe malul apei"},
182
+ {"title": "Bossa Nova de Chișinău", "tempo": "126 BPM", "mood": "Bossa Nova Chill", "topic": "Acorduri calde de jazz brazilian amestecate cu plimbarea pe străzile verzi ale capitalei"},
183
+ {"title": "Country Moldovenesc", "tempo": "130 BPM", "mood": "Folk Country", "topic": "Banjo sprinten și vioară veselă într-o poveste despre călătorii prin satele Moldovei"},
184
+ {"title": "Darkwave la Subsol", "tempo": "122 BPM", "mood": "Post-Punk Darkwave", "topic": "Linii reci de sintetizator și bas melancolic într-o atmosferă gotică chișinăuiană"},
185
+ {"title": "Speed Garage la Gară", "tempo": "135 BPM", "mood": "UK Garage & 2-Step", "topic": "Ritmuri sincopate de 2-step cu vocale chopped și bas warp"},
186
+ {"title": "Lăutari Psychedelic Rock", "tempo": "132 BPM", "mood": "Psychedelic Rock", "topic": "Solo prelungit de chitară cu efect de phaser peste ritm tradițional de horă"},
187
+ {"title": "Trap Metal la Ciocana", "tempo": "140 BPM", "mood": "Trap Metal Screamo", "topic": "Chitări cu distors extrem și 808 distorsionat pentru descătușare maximă de energie"},
188
+ {"title": "Carnaval la Cahul", "tempo": "124 BPM", "mood": "Samba Fusion", "topic": "Percuții explozive de samba combinate cu strigături vesele moldovenești"}
189
  ]
190
 
191
+ def _build_album_tracks(genre_key: str, count: int = 6) -> List[Dict[str, Any]]:
192
+ """Select a random, non-repeating subset of tracks for the given genre key.
193
+ Each genre pool contains 24 diverse, culturally rich concepts so daily albums never repeat."""
194
  if genre_key == "ALBINA_BONDAR":
195
  # Catalogul are 24 concepte; un album Albina ia un subset rotit de 6
196
  # (fara repetare) ca sa incapa in fereastra de cota ZeroGPU zilnica.
197
+ return build_albina_tracks(count=count)
198
  elif genre_key == "MOLDOVAN_QWEN":
199
  pool = [dict(t) for t in MOLDOVAN_QWEN_TRACKS]
 
 
200
  elif genre_key == "CHILL":
201
  pool = [dict(t) for t in CHILL_TRACKS]
 
 
202
  elif genre_key == "DEEP_HOUSE":
203
  pool = [dict(t) for t in DEEP_HOUSE_TRACKS]
 
 
204
  else:
205
  pool = [dict(t) for t in RANDOM_TRACKS]
206
+
207
+ if count >= len(pool):
208
+ shuffled = list(pool)
209
+ random.shuffle(shuffled)
210
+ return shuffled
211
+ return random.sample(pool, count)
212
 
213
  class DailyAlbumGenerator:
214
  def __init__(self, target_minutes: float = 35.0, dry_run: bool = False, genre_override: Optional[str] = None):
 
415
  with open(manifest_file, "w", encoding="utf-8") as f:
416
  json.dump(_save, f, indent=2, ensure_ascii=False)
417
 
418
+ # Sync with PocketBase if available
419
+ try:
420
+ pb_import = BASE_DIR.parent / "studio-pb" / "tools" / "import_data.py"
421
+ if pb_import.exists():
422
+ import subprocess
423
+ subprocess.run([sys.executable, str(pb_import)], check=False, capture_output=True, timeout=45)
424
+ print(" [✓] Sincronizat automat în PocketBase (:8096)")
425
+ except Exception:
426
+ pass
427
+
428
  print("\n" + "=" * 65)
429
  print(f"✨ ALBUM COMPLET GENERAT: {album_title}")
430
  print(f"📁 Director: {album_dir}")
 
500
  keep burning quota on the remaining ones."""
501
  track_title = track_info["title"]
502
 
503
+ # 1. Generate rich Moldovan lyrics using dynamic Lexical Palette & Songwriting
504
+ persona_id = "albina_bondar" if genre_key == "ALBINA_BONDAR" else ""
505
+ if persona_id:
506
+ print(f" 🐝 Persona activă: Albina Bondar (happy family lyrics)")
507
+
508
+ bpm_val = int(track_info["tempo"].split()[0]) if track_info.get("tempo") else 130
509
+ mood_val = track_info.get("mood", "Modern")
510
+
511
+ try:
512
+ lyrics_data = self.lyricist.compose_lyrics(
513
+ topic=track_info["topic"],
514
+ genre=genre_key,
515
+ dialect_level=2,
516
+ bpm=bpm_val,
517
+ intent=mood_val,
518
+ duration_seconds=180,
519
+ persona_id=persona_id,
520
+ )
521
+ raw_lyrics = lyrics_data.get("full_lyrics", "")
522
+ phonetic_lyrics = lyrics_data.get("phonetic_lyrics", raw_lyrics)
523
+ except Exception as e:
524
+ # Dynamic dictionary-rhymed fallback — NEVER static canned placeholder
525
+ print(f" [!][fallback lyricist] {e}. Generare versuri dinamice din dicționarul master.")
526
+ fallback_data = self.lyricist._generate_intelligent_fallback(
527
+ topic=track_info["topic"],
528
+ intent=mood_val,
529
+ genre=genre_key,
530
+ bpm=bpm_val,
531
+ dialect_level=2,
532
+ duration=180
533
+ )
534
+ raw_lyrics = fallback_data.get("full_lyrics", "")
535
+ phonetic_lyrics = fallback_data.get("phonetic_lyrics", raw_lyrics)
536
 
537
  lyrics_file = album_dir / f"{idx:02d}_{track_title.replace(' ', '_')}_lyrics.txt"
538
  with open(lyrics_file, "w", encoding="utf-8") as f:
moldovan-qwen/scripts/run_studio.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ # Load environment if available
5
+ if [ -f "$HOME/.flow_cron_env" ]; then
6
+ . "$HOME/.flow_cron_env"
7
+ fi
8
+
9
+ export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
10
+ export PORT="${PORT:-8090}"
11
+ export PYTHONPATH="/Users/flowmaster/Projects/hf/moldovan-qwen:$PYTHONPATH"
12
+
13
+ # Choose python binary
14
+ PYTHON_BIN="/opt/homebrew/bin/python3.14"
15
+ if [ ! -x "$PYTHON_BIN" ]; then
16
+ PYTHON_BIN="$(which python3)"
17
+ fi
18
+
19
+ cd /Users/flowmaster/Projects/hf/moldovan-qwen
20
+ exec "$PYTHON_BIN" studio_server.py
moldovan-qwen/static/index.html CHANGED
@@ -723,9 +723,10 @@
723
  <span class="daw-badge badge-cyan text-[9px] uppercase">🎵 Master Workstation Player</span>
724
  <span id="vstage-audioStatus" class="text-[10px] text-slate-400 font-mono">Double-click any song to play</span>
725
  </div>
726
- <div class="flex items-center gap-1.5">
727
  <button id="vstage-favBtn" class="btn alt text-xs py-0.5 px-2 text-amber-300" onclick="toggleStageFavorite()">⭐ Favorite</button>
728
- <button class="btn alt text-xs py-0.5 px-2 text-cyan-300" onclick="openStageSongEdit()">✎ Edit Song</button>
 
729
  <button class="btn alt text-xs py-0.5 px-2 text-emerald-300" onclick="bundleStageProject()" title="Save into dedicated folder with audio, json, lyrics">📦 Bundle Folder</button>
730
  </div>
731
  </div>
@@ -751,6 +752,8 @@
751
  <div id="vstage-trackMeta" class="text-xs text-cyan-400 font-mono truncate">
752
  140 BPM • C Minor • MiniMax Music 3
753
  </div>
 
 
754
 
755
  <!-- Animated Equalizer Bars when playing -->
756
  <div class="flex items-end gap-1 h-3 py-0.5" id="vstage-eqContainer">
@@ -793,6 +796,65 @@
793
  </div>
794
  </div>
795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
  </div>
797
 
798
  <!-- RIGHT: LIVE LYRICS & NOTES WORKSTATION PANEL (4 Cols on Wide) -->
@@ -1875,6 +1937,11 @@
1875
  <label class="text-[11px] font-bold text-slate-400">Suno / MusicGen Style Prompt</label>
1876
  <input id="editSongSunoPrompt" class="input text-xs font-mono" placeholder="Ex: 808 sub bass, trap beat with Moldovan accordion riffs">
1877
  </div>
 
 
 
 
 
1878
  </div>
1879
 
1880
  <div class="flex flex-wrap items-center justify-between gap-2 pt-2 border-t border-slate-800">
@@ -3319,14 +3386,29 @@ Davai pa!`;
3319
  }
3320
 
3321
  function togglePlayPause() {
 
 
 
 
 
 
 
 
 
3322
  const player = $('globalAudioPlayer');
3323
  if (!player) return;
3324
- if (!player.src && musicQueue.length > 0) {
3325
- playTrackByIndex(0);
3326
- return;
 
 
 
 
 
 
3327
  }
3328
  if (player.paused) {
3329
- player.play();
3330
  } else {
3331
  player.pause();
3332
  }
@@ -3341,6 +3423,22 @@ Davai pa!`;
3341
  }
3342
 
3343
  function playNextTrack() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3344
  if (musicQueue.length === 0) return;
3345
  if (isShuffleEnabled) {
3346
  const nextIdx = Math.floor(Math.random() * musicQueue.length);
@@ -3352,6 +3450,22 @@ Davai pa!`;
3352
  }
3353
 
3354
  function playPrevTrack() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3355
  if (musicQueue.length === 0) return;
3356
  const prevIdx = (currentTrackIndex - 1 + musicQueue.length) % musicQueue.length;
3357
  playTrackByIndex(prevIdx);
@@ -3359,25 +3473,29 @@ Davai pa!`;
3359
 
3360
  function toggleAutoplay() {
3361
  isAutoplayEnabled = !isAutoplayEnabled;
3362
- const btn = $('playerAutoplayBtn');
3363
- if (btn) {
3364
- btn.textContent = isAutoplayEnabled ? '🔁 Auto: ON' : '🔁 Auto: OFF';
3365
- btn.className = isAutoplayEnabled ? 'btn alt text-xs py-1 px-2 text-emerald-400 border-emerald-500/40' : 'btn alt text-xs py-1 px-2 text-slate-500';
3366
- }
 
 
3367
  toast(isAutoplayEnabled ? 'Autoplay activat' : 'Autoplay dezactivat');
3368
  }
3369
 
3370
  function toggleShuffle() {
3371
  isShuffleEnabled = !isShuffleEnabled;
3372
- const btn = $('playerShuffleBtn');
3373
- if (btn) {
3374
- btn.className = isShuffleEnabled ? 'btn success text-xs py-1 px-2 font-bold' : 'btn alt text-xs py-1 px-2 text-slate-400 hover:text-white';
3375
- }
 
 
3376
  toast(isShuffleEnabled ? 'Shuffle activat 🔀' : 'Shuffle dezactivat');
3377
  }
3378
 
3379
  function seekPlayer(event) {
3380
- const container = $('playerProgressBarContainer');
3381
  const player = $('globalAudioPlayer');
3382
  if (!container || !player || !player.duration) return;
3383
  const rect = container.getBoundingClientRect();
@@ -3429,22 +3547,25 @@ Davai pa!`;
3429
 
3430
  // Modal Song Editor CRUD
3431
  async function openEditSongModal(songId) {
3432
- const song = (cachedMusic || []).find(s => s.id === songId);
3433
- if (!song) {
3434
- try {
3435
- const fetched = await api(`/api/media/song/${encodeURIComponent(songId)}`);
3436
- fillEditSongForm(fetched);
3437
- } catch (e) {
 
 
 
 
 
3438
  toast('Nu s-a putut deschide piesa: ' + e.message, true);
3439
  }
3440
- return;
3441
  }
3442
- fillEditSongForm(song);
3443
  }
3444
 
3445
  function fillEditSongForm(song) {
3446
- $('editSongId').value = song.id || '';
3447
- $('editSongTitle').value = song.title || '';
3448
  $('editSongBpm').value = song.bpm || 140;
3449
  $('editSongGenre').value = song.genre || '';
3450
  $('editSongKey').value = song.key || '';
@@ -3452,6 +3573,9 @@ Davai pa!`;
3452
  $('editSongVocalStyle').value = song.vocal_style || '';
3453
  $('editSongLyrics').value = song.full_lyrics || song.lyrics || '';
3454
  $('editSongSunoPrompt').value = song.suno_style_prompt || '';
 
 
 
3455
  $('songEditModal').classList.remove('hidden');
3456
  }
3457
 
@@ -3460,6 +3584,7 @@ Davai pa!`;
3460
  const songId = $('editSongId').value;
3461
  if (!songId) return;
3462
 
 
3463
  const payload = {
3464
  title: $('editSongTitle').value.trim(),
3465
  bpm: parseInt($('editSongBpm').value) || 140,
@@ -3469,7 +3594,8 @@ Davai pa!`;
3469
  vocal_style: $('editSongVocalStyle').value.trim(),
3470
  lyrics: $('editSongLyrics').value.trim(),
3471
  full_lyrics: $('editSongLyrics').value.trim(),
3472
- suno_style_prompt: $('editSongSunoPrompt').value.trim()
 
3473
  };
3474
 
3475
  const btn = $('saveSongBtn');
@@ -3482,7 +3608,12 @@ Davai pa!`;
3482
  });
3483
  toast('Modificările au fost salvate cu succes!');
3484
  $('songEditModal').classList.add('hidden');
3485
- loadMusicVault();
 
 
 
 
 
3486
  } catch (err) {
3487
  toast('Eroare salvare piesă: ' + err.message, true);
3488
  } finally {
@@ -4110,7 +4241,9 @@ Davai pa!`;
4110
 
4111
  // ─── Karaoke Video UI ───────────────────────────
4112
  function _findMusicTrack(trackId) {
4113
- return (cachedMusic || []).find(t => t.id === trackId) || null;
 
 
4114
  }
4115
 
4116
  function openKaraokeModal(videoUrl, title) {
@@ -4140,7 +4273,8 @@ Davai pa!`;
4140
  try {
4141
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/karaoke-video`, { method: 'POST', body: JSON.stringify({}) });
4142
  toast(`Karaoke video gata: ${res.file || 'randat'}`);
4143
- await loadMusicVault();
 
4144
  } catch (e) {
4145
  toast(`Eroare randare karaoke: ${e.message}`, true);
4146
  if (btn) { btn.disabled = false; btn.innerHTML = original; }
@@ -5065,9 +5199,70 @@ Davai pa!`;
5065
  }
5066
 
5067
  const sizeLabel = it.size_kb ? `${it.size_kb} KB` : (it.size ? `${Math.round(it.size / 1024)} KB` : 'Ready');
5068
- const genreLabel = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : it.type === 'video' ? 'MP4 Video' : 'Audio');
5069
- const bpmKeyLabel = it.bpm ? `${it.bpm} BPM ${it.key ? '• ' + it.key : ''}` : (it.key ? it.key : '—');
5070
- const fileDownloadUrl = it.audio_url || it.url || `/api/media/file/${encodeURIComponent(fileRef)}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5071
 
5072
  return `
5073
  <tr class="hover:bg-slate-800/50 cursor-pointer transition-colors ${isSelected ? 'bg-cyan-950/30 font-medium' : ''} ${isPlaying ? 'bg-cyan-900/40 border-l-2 border-cyan-400' : ''}"
@@ -5075,16 +5270,14 @@ Davai pa!`;
5075
  onclick="selectVaultItemById('${esc(trackId)}', false)"
5076
  ondblclick="playVaultItemById('${esc(trackId)}', event)">
5077
  <td class="py-2 px-2 text-center text-slate-400 font-mono text-[11px]">
5078
- <button class="w-6 h-6 rounded flex items-center justify-center hover:bg-slate-700 text-slate-300 hover:text-white" onclick="playVaultItemById('${esc(trackId)}', event)" title="Play Track">
5079
- ${isPlaying ? '<span class="text-cyan-400 animate-pulse font-bold">▶</span>' : idx + 1}
5080
- </button>
5081
  </td>
5082
  <td class="py-2 px-2 min-w-0">
5083
  <div class="flex items-center gap-2">
5084
  ${coverImgHtml}
5085
  <div class="min-w-0">
5086
  <div class="font-bold text-slate-100 text-xs truncate max-w-xs sm:max-w-sm md:max-w-md" title="${esc(title)}">${esc(title)}</div>
5087
- <div class="text-[10px] text-slate-400 font-mono truncate max-w-xs">${esc(fileRef)}</div>
5088
  </div>
5089
  </div>
5090
  </td>
@@ -5093,22 +5286,14 @@ Davai pa!`;
5093
  <td class="py-2 px-2 text-cyan-400 font-mono text-[10px] whitespace-nowrap">${esc(bpmKeyLabel)}</td>
5094
  <td class="py-2 px-2 text-slate-400 font-mono text-[10px] whitespace-nowrap">${sizeLabel}</td>
5095
  <td class="py-2 px-2 text-center">
5096
- <button class="text-xs p-0.5 hover:scale-125 transition-transform ${it.favorite ? 'text-amber-400' : 'text-slate-600 hover:text-amber-300'}"
5097
  onclick="toggleVaultFavorite('${esc(trackId)}', event)" title="${it.favorite ? 'Favorited ⭐' : 'Add to Favorites'}">
5098
- ⭐
5099
  </button>
5100
  </td>
5101
  <td class="py-2 px-2 text-right whitespace-nowrap">
5102
  <div class="flex items-center justify-end gap-1" onclick="event.stopPropagation()">
5103
- <button class="btn ${isPlaying ? 'success' : 'accent'} text-[10px] py-0.5 px-2 font-bold" onclick="playVaultItemById('${esc(trackId)}', event)">
5104
- ${isPlaying ? '⏸' : '▶ Play'}
5105
- </button>
5106
- ${(it.type === 'music' && (it.karaoke_video || it.karaoke_video_file)) ? `<button class="btn alt text-[10px] py-0.5 px-1.5 text-emerald-300 hover:text-emerald-200" title="Redare Karaoke Video" onclick="openKaraokeModalById('${esc(trackId)}')">🎤</button>` : ''}
5107
- ${(it.type === 'music' && !(it.karaoke_video || it.karaoke_video_file) && (it.audio_url || it.audio_file)) ? `<button id="karaokeBtn-${esc(trackId)}" class="btn alt text-[10px] py-0.5 px-1.5 text-amber-300 hover:text-amber-200" title="Randare Karaoke Video (word-synced)" onclick="renderKaraokeForTrack('${esc(trackId)}')">🎤+</button>` : ''}
5108
- ${(it.lyrics || it.full_lyrics) ? `<button class="btn alt text-[10px] py-0.5 px-1.5" onclick="selectVaultItemById('${esc(trackId)}', false); setSidePanelTab('lyrics')" title="View Lyrics">📜</button>` : ''}
5109
- <button class="btn alt text-[10px] py-0.5 px-1.5" onclick="openEditSongModal('${esc(trackId)}', event)" title="Edit Metadata">✎</button>
5110
- <a href="${fileDownloadUrl}" download="${esc(fileRef)}" class="btn alt text-[10px] py-0.5 px-1.5 text-cyan-300 hover:text-white" title="Download">⬇️</a>
5111
- <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5112
  </div>
5113
  </td>
5114
  </tr>
@@ -5138,13 +5323,70 @@ Davai pa!`;
5138
  const src = coverUrl.startsWith('http') || coverUrl.startsWith('/') ? coverUrl : `/api/media/file/${encodeURIComponent(coverUrl)}`;
5139
  coverThumb = `<img src="${src}" class="w-full h-28 object-cover rounded border border-slate-700/80">`;
5140
  } else {
5141
- const icon = it.type === 'music' ? '🎼' : it.type === 'video' ? '🎬' : it.type === 'album' ? '💿' : '🎵';
5142
  coverThumb = `<div class="w-full h-28 rounded bg-slate-800 border border-slate-700/80 flex items-center justify-center text-3xl">${icon}</div>`;
5143
  }
5144
 
5145
  const sizeLabel = it.size_kb ? `${it.size_kb} KB` : (it.size ? `${Math.round(it.size / 1024)} KB` : '');
5146
- const genreLabel = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : it.type === 'video' ? 'Video' : 'Audio');
5147
- const fileDownloadUrl = it.audio_url || it.url || `/api/media/file/${encodeURIComponent(fileRef)}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5148
 
5149
  return `
5150
  <div class="daw-rack p-2.5 rounded-lg border transition-all cursor-pointer flex flex-col justify-between ${isPlaying ? 'border-cyan-500/80 bg-slate-900/90 shadow-[0_0_12px_rgba(6,182,212,0.3)]' : isSelected ? 'border-cyan-600/60 bg-slate-900/50' : 'border-slate-800 hover:border-slate-700'}"
@@ -5154,34 +5396,25 @@ Davai pa!`;
5154
  <div class="space-y-1.5">
5155
  <div class="relative group overflow-hidden rounded">
5156
  ${coverThumb}
5157
- <button class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-2xl text-white font-bold"
5158
- onclick="playVaultItemById('${esc(trackId)}', event)">
5159
- ${isPlaying ? '⏸' : '▶'}
5160
- </button>
5161
  </div>
5162
  <div class="flex items-start justify-between gap-1">
5163
  <div class="font-bold text-slate-100 text-xs truncate flex-1" title="${esc(title)}">${esc(title)}</div>
5164
- <button class="text-xs shrink-0 ${it.favorite ? 'text-amber-400' : 'text-slate-600 hover:text-amber-300'}"
5165
- onclick="toggleVaultFavorite('${esc(trackId)}', event)">
5166
- ⭐
5167
  </button>
5168
  </div>
5169
  <div class="text-[10px] text-slate-400 font-mono truncate">${esc(genreLabel)}</div>
 
5170
  <div class="flex items-center justify-between text-[9px] text-cyan-400 font-mono">
5171
- <span>${it.bpm ? it.bpm + ' BPM' : ''}</span>
5172
  <span>${sizeLabel}</span>
5173
  </div>
5174
  </div>
5175
 
5176
  <div class="flex items-center justify-between pt-2 border-t border-slate-800/80 mt-2 text-[10px]" onclick="event.stopPropagation()">
5177
- <button class="btn ${isPlaying ? 'success' : 'accent'} text-[10px] py-0.5 px-2 font-bold" onclick="playVaultItemById('${esc(trackId)}', event)">
5178
- ${isPlaying ? '⏸ Pause' : '▶ Play'}
5179
- </button>
5180
- <div class="flex items-center gap-1">
5181
- <button class="btn alt text-[10px] py-0.5 px-1.5" onclick="openEditSongModal('${esc(trackId)}', event)" title="Edit">✎</button>
5182
- <a href="${fileDownloadUrl}" download="${esc(fileRef)}" class="text-cyan-400 hover:text-cyan-200 text-xs px-1" title="Download">⬇️</a>
5183
- <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5184
- </div>
5185
  </div>
5186
  </div>
5187
  `;
@@ -5197,11 +5430,18 @@ Davai pa!`;
5197
  selectedVaultItem = it;
5198
  const trackId = it.id || it.file || it.filename;
5199
  const fileRef = it.file || it.filename || '';
 
5200
 
5201
  if (it.type === 'video') {
5202
  // Switch Stage to Video Player
5203
  if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.remove('hidden');
5204
  if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.add('hidden');
 
 
 
 
 
 
5205
 
5206
  const vPlayer = $('vstage-videoPlayer');
5207
  const videoUrl = it.url || `/api/media/video/stream/${encodeURIComponent(fileRef)}`;
@@ -5210,9 +5450,6 @@ Davai pa!`;
5210
  vPlayer.src = videoUrl;
5211
  }
5212
  if (autoPlay) {
5213
- // Pause global audio
5214
- const aPlayer = $('globalAudioPlayer');
5215
- if (aPlayer) aPlayer.pause();
5216
  currentPlayingVaultId = trackId;
5217
  vPlayer.play().catch(e => console.warn(e));
5218
  }
@@ -5221,10 +5458,98 @@ Davai pa!`;
5221
  if ($('vstage-videoMeta')) $('vstage-videoMeta').textContent = `${it.size_kb ? it.size_kb + ' KB • ' : ''}${fileRef}`;
5222
  if ($('vstage-videoDownload')) $('vstage-videoDownload').href = videoUrl;
5223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5224
  } else {
5225
- // Switch Stage to Audio Player Workstation
5226
  if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.remove('hidden');
5227
  if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.add('hidden');
 
 
5228
 
5229
  // Pause video player
5230
  const vPlayer = $('vstage-videoPlayer');
@@ -5232,7 +5557,6 @@ Davai pa!`;
5232
 
5233
  const title = it.title || it.name || fileRef;
5234
  const genre = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : 'Audio Track');
5235
- const coverUrl = it.cover_image_url || it.cover_image || it.cover_url || '';
5236
  const audioSrc = it.audio_url || it.url || (it.audio_file ? `/api/media/audio/stream/${encodeURIComponent(it.audio_file)}` : (fileRef ? `/api/media/audio/stream/${encodeURIComponent(fileRef)}` : ''));
5237
 
5238
  if ($('vstage-trackTitle')) $('vstage-trackTitle').textContent = title;
@@ -5251,12 +5575,18 @@ Davai pa!`;
5251
  $('vstage-coverImg').src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Crect width='120' height='120' fill='%230f172a'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-size='36' fill='%2338bdf8'%3E🎵%3C/text%3E%3C/svg%3E";
5252
  }
5253
  }
5254
- if ($('vstage-downloadBtn')) $('vstage-downloadBtn').href = audioSrc;
 
 
 
5255
  if ($('vstage-favBtn')) {
5256
  $('vstage-favBtn').textContent = it.favorite ? '⭐ Favorited' : '☆ Add to Favs';
5257
- $('vstage-favBtn').className = it.favorite ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5258
  }
5259
 
 
 
 
5260
  // Load Lyrics into side panel
5261
  loadStageLyrics(it);
5262
  // Load Comments into side panel
@@ -5306,11 +5636,24 @@ Davai pa!`;
5306
  function playVaultItemById(trackId, evt) {
5307
  if (evt) evt.stopPropagation();
5308
  const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5309
- if (it) playVaultItem(it, evt);
 
 
 
 
 
 
 
 
 
5310
  }
5311
 
5312
  function playVaultItem(it, evt) {
5313
  if (evt) evt.stopPropagation();
 
 
 
 
5314
  selectVaultItem(it, true);
5315
  }
5316
 
@@ -5515,17 +5858,22 @@ Davai pa!`;
5515
  async function toggleStageFavorite() {
5516
  if (!selectedVaultItem) return;
5517
  const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
 
5518
  try {
5519
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/favorite`, {
5520
  method: 'POST',
5521
- body: JSON.stringify({})
5522
  });
5523
- selectedVaultItem.favorite = res.favorite;
 
 
 
 
5524
  if ($('vstage-favBtn')) {
5525
- $('vstage-favBtn').textContent = res.favorite ? '⭐ Favorited' : '☆ Add to Favs';
5526
- $('vstage-favBtn').className = res.favorite ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5527
  }
5528
- toast(res.favorite ? 'Adăugat la favorite ⭐' : 'Eliminat din favorite');
5529
  renderVault();
5530
  } catch (e) {
5531
  toast('Eroare favorite: ' + e.message, true);
@@ -5536,25 +5884,124 @@ Davai pa!`;
5536
  if (evt) evt.stopPropagation();
5537
  try {
5538
  const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
 
5539
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/favorite`, {
5540
  method: 'POST',
5541
- body: JSON.stringify({})
5542
  });
5543
- if (it) it.favorite = res.favorite;
 
5544
  if (selectedVaultItem && (selectedVaultItem.id || selectedVaultItem.file) === trackId) {
5545
- selectedVaultItem.favorite = res.favorite;
5546
  if ($('vstage-favBtn')) {
5547
- $('vstage-favBtn').textContent = res.favorite ? '⭐ Favorited' : '☆ Add to Favs';
5548
- $('vstage-favBtn').className = res.favorite ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5549
  }
5550
  }
5551
- toast(res.favorite ? 'Adăugat la favorite ⭐' : 'Eliminat din favorite');
5552
  renderVault();
5553
  } catch (e) {
5554
  toast('Eroare favorite: ' + e.message, true);
5555
  }
5556
  }
5557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5558
  async function bundleStageProject() {
5559
  if (!selectedVaultItem) return;
5560
  const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
@@ -5585,10 +6032,14 @@ Davai pa!`;
5585
 
5586
  async function deleteVaultMedia(fileRef, type, evt) {
5587
  if (evt) evt.stopPropagation();
5588
- if (!confirm(`Sigur vrei să ștergi fișierul ${fileRef}?`)) return;
5589
  try {
5590
- await api(`/api/media/file/${encodeURIComponent(fileRef)}`, { method: 'DELETE' });
5591
- toast(`Fișier șters: ${fileRef}`);
 
 
 
 
5592
  loadVault(currentVaultFilter, true);
5593
  } catch (e) {
5594
  toast('Eroare ștergere: ' + e.message, true);
@@ -6045,6 +6496,12 @@ Davai pa!`;
6045
  window.submitStageComment = submitStageComment;
6046
  window.toggleStageFavorite = toggleStageFavorite;
6047
  window.toggleVaultFavorite = toggleVaultFavorite;
 
 
 
 
 
 
6048
  window.bundleStageProject = bundleStageProject;
6049
  window.openStageSongEdit = openStageSongEdit;
6050
  window.pruneEmptyImages = pruneEmptyImages;
 
723
  <span class="daw-badge badge-cyan text-[9px] uppercase">🎵 Master Workstation Player</span>
724
  <span id="vstage-audioStatus" class="text-[10px] text-slate-400 font-mono">Double-click any song to play</span>
725
  </div>
726
+ <div class="flex items-center gap-1.5 flex-wrap">
727
  <button id="vstage-favBtn" class="btn alt text-xs py-0.5 px-2 text-amber-300" onclick="toggleStageFavorite()">⭐ Favorite</button>
728
+ <button id="vstage-tagBtn" class="btn alt text-xs py-0.5 px-2 text-cyan-300" onclick="promptAddStageTag()">🏷️ +Tag</button>
729
+ <button class="btn alt text-xs py-0.5 px-2 text-slate-300" onclick="openStageSongEdit()">✎ Edit Song</button>
730
  <button class="btn alt text-xs py-0.5 px-2 text-emerald-300" onclick="bundleStageProject()" title="Save into dedicated folder with audio, json, lyrics">📦 Bundle Folder</button>
731
  </div>
732
  </div>
 
752
  <div id="vstage-trackMeta" class="text-xs text-cyan-400 font-mono truncate">
753
  140 BPM • C Minor • MiniMax Music 3
754
  </div>
755
+ <!-- Dynamic Tag Chips -->
756
+ <div id="vstage-tagsContainer" class="flex flex-wrap items-center gap-1 text-[10px] font-mono min-h-[18px]"></div>
757
 
758
  <!-- Animated Equalizer Bars when playing -->
759
  <div class="flex items-end gap-1 h-3 py-0.5" id="vstage-eqContainer">
 
796
  </div>
797
  </div>
798
 
799
+ <!-- IMAGE & WALLPAPER STAGE (Shown when image/artwork is selected) -->
800
+ <div id="vstage-imageContainer" class="hidden space-y-3">
801
+ <div class="flex items-center justify-between text-xs pb-1.5 border-b border-slate-800">
802
+ <div class="flex items-center gap-2">
803
+ <span class="daw-badge badge-indigo text-[9px] uppercase">🖼️ HD Artwork &amp; Wallpaper</span>
804
+ <span id="vstage-imageStatus" class="text-[10px] text-slate-400 font-mono">FLUX.1 Generated Artwork</span>
805
+ </div>
806
+ <div class="flex items-center gap-1.5">
807
+ <a id="vstage-imageDownload" href="#" download class="btn accent text-xs py-0.5 px-2.5 text-cyan-300 font-bold">⬇️ Download HD</a>
808
+ <a id="vstage-imageOpenTab" href="#" target="_blank" class="btn alt text-xs py-0.5 px-2 text-slate-300">🔍 Full Resolution</a>
809
+ <button class="btn alt text-xs py-0.5 px-2" onclick="switchToAudioStage()">🎵 Switch to Audio</button>
810
+ </div>
811
+ </div>
812
+ <div class="w-full h-[360px] bg-slate-950/90 rounded-lg border border-slate-800 flex items-center justify-center overflow-hidden p-2 relative group">
813
+ <img id="vstage-imageDisplay" src="" class="max-w-full max-h-full object-contain rounded shadow-lg transition-transform duration-200 group-hover:scale-[1.02]">
814
+ </div>
815
+ <div class="flex items-center justify-between text-[11px] text-slate-400 pt-1">
816
+ <div class="space-y-0.5 min-w-0">
817
+ <div id="vstage-imageTitle" class="font-bold text-slate-200 text-xs truncate">Artwork Title</div>
818
+ <div id="vstage-imageMeta" class="font-mono text-[10px] text-slate-500">1024x1024 • PNG • FLUX.1 dev</div>
819
+ </div>
820
+ <div id="vstage-imageTags" class="flex items-center gap-1"></div>
821
+ </div>
822
+ </div>
823
+
824
+ <!-- ALBUM SHOWCASE STAGE (Shown when an album is selected) -->
825
+ <div id="vstage-albumContainer" class="hidden space-y-3">
826
+ <div class="flex items-center justify-between text-xs pb-1.5 border-b border-slate-800">
827
+ <div class="flex items-center gap-2">
828
+ <span class="daw-badge badge-violet text-[9px] uppercase">💿 Daily Album Showcase</span>
829
+ <span id="vstage-albumStatusBadge" class="daw-badge badge-emerald text-[9px] uppercase">REAL AUDIO</span>
830
+ </div>
831
+ <div class="flex items-center gap-1.5">
832
+ <a id="vstage-albumZipBtn" href="#" download class="btn accent text-xs py-0.5 px-2.5 text-cyan-300 font-bold">📦 Download ZIP</a>
833
+ <button id="vstage-albumPlayBtn" class="btn py-0.5 px-3 text-xs font-bold" onclick="playCurrentAlbum()">▶ Play Album</button>
834
+ <button class="btn alt text-xs py-0.5 px-2" onclick="switchToAudioStage()">🎵 Switch to Audio</button>
835
+ </div>
836
+ </div>
837
+ <div class="flex flex-col sm:flex-row items-center gap-3.5 p-2.5 bg-slate-900/60 rounded-lg border border-slate-800/80">
838
+ <div class="w-24 h-24 shrink-0 rounded-lg bg-slate-800 border border-slate-700/80 flex items-center justify-center text-4xl shadow-lg">
839
+ 💿
840
+ </div>
841
+ <div class="min-w-0 flex-1 w-full space-y-1">
842
+ <h3 id="vstage-albumTitle" class="text-base font-bold text-slate-100 truncate">Album Title</h3>
843
+ <div id="vstage-albumGenre" class="text-xs text-amber-400 font-mono truncate">Genre</div>
844
+ <div id="vstage-albumMeta" class="text-[11px] text-slate-400 font-mono">6 tracks • MiniMax Music 3</div>
845
+ </div>
846
+ </div>
847
+ <div class="space-y-1 pt-1 border-t border-slate-800/80">
848
+ <div class="flex items-center justify-between text-[10px] font-mono text-slate-400 uppercase tracking-wider font-bold">
849
+ <span>Album Tracklist</span>
850
+ <span id="vstage-albumTrackCount">6 Tracks</span>
851
+ </div>
852
+ <div id="vstage-albumTracksList" class="space-y-1 max-h-[180px] overflow-y-auto pr-1">
853
+ <!-- Tracks dynamically rendered -->
854
+ </div>
855
+ </div>
856
+ </div>
857
+
858
  </div>
859
 
860
  <!-- RIGHT: LIVE LYRICS & NOTES WORKSTATION PANEL (4 Cols on Wide) -->
 
1937
  <label class="text-[11px] font-bold text-slate-400">Suno / MusicGen Style Prompt</label>
1938
  <input id="editSongSunoPrompt" class="input text-xs font-mono" placeholder="Ex: 808 sub bass, trap beat with Moldovan accordion riffs">
1939
  </div>
1940
+
1941
+ <div class="col-span-full">
1942
+ <label class="text-[11px] font-bold text-cyan-400">Tags / Categorii (separate prin virgulă)</label>
1943
+ <input id="editSongTags" class="input text-xs font-mono" placeholder="Ex: trap, 808, chisinau, albina, hit, favorit">
1944
+ </div>
1945
  </div>
1946
 
1947
  <div class="flex flex-wrap items-center justify-between gap-2 pt-2 border-t border-slate-800">
 
3386
  }
3387
 
3388
  function togglePlayPause() {
3389
+ // If video container is active on stage and has source:
3390
+ const vContainer = $('vstage-videoContainer');
3391
+ const vPlayer = $('vstage-videoPlayer');
3392
+ if (vContainer && !vContainer.classList.contains('hidden') && vPlayer && vPlayer.src) {
3393
+ if (vPlayer.paused) vPlayer.play();
3394
+ else vPlayer.pause();
3395
+ return;
3396
+ }
3397
+
3398
  const player = $('globalAudioPlayer');
3399
  if (!player) return;
3400
+ if (!player.src) {
3401
+ if (selectedVaultItem && (selectedVaultItem.type === 'music' || selectedVaultItem.type === 'audio')) {
3402
+ playVaultItem(selectedVaultItem);
3403
+ return;
3404
+ }
3405
+ if (musicQueue.length > 0) {
3406
+ playTrackByIndex(0);
3407
+ return;
3408
+ }
3409
  }
3410
  if (player.paused) {
3411
+ player.play().catch(e => console.warn(e));
3412
  } else {
3413
  player.pause();
3414
  }
 
3423
  }
3424
 
3425
  function playNextTrack() {
3426
+ // If playing from Media Vault, advance through playable vault items
3427
+ if (currentPlayingVaultId && currentVaultItems && currentVaultItems.length > 0) {
3428
+ const playableItems = currentVaultItems.filter(x => x.type === 'music' || x.type === 'audio');
3429
+ if (playableItems.length > 0) {
3430
+ let currentIdx = playableItems.findIndex(x => (x.id || x.file || x.filename) === currentPlayingVaultId);
3431
+ let nextIdx;
3432
+ if (isShuffleEnabled) {
3433
+ nextIdx = Math.floor(Math.random() * playableItems.length);
3434
+ } else {
3435
+ nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % playableItems.length;
3436
+ }
3437
+ playVaultItem(playableItems[nextIdx]);
3438
+ return;
3439
+ }
3440
+ }
3441
+
3442
  if (musicQueue.length === 0) return;
3443
  if (isShuffleEnabled) {
3444
  const nextIdx = Math.floor(Math.random() * musicQueue.length);
 
3450
  }
3451
 
3452
  function playPrevTrack() {
3453
+ // If playing from Media Vault, go to previous playable vault item
3454
+ if (currentPlayingVaultId && currentVaultItems && currentVaultItems.length > 0) {
3455
+ const playableItems = currentVaultItems.filter(x => x.type === 'music' || x.type === 'audio');
3456
+ if (playableItems.length > 0) {
3457
+ let currentIdx = playableItems.findIndex(x => (x.id || x.file || x.filename) === currentPlayingVaultId);
3458
+ let prevIdx;
3459
+ if (isShuffleEnabled) {
3460
+ prevIdx = Math.floor(Math.random() * playableItems.length);
3461
+ } else {
3462
+ prevIdx = currentIdx <= 0 ? playableItems.length - 1 : currentIdx - 1;
3463
+ }
3464
+ playVaultItem(playableItems[prevIdx]);
3465
+ return;
3466
+ }
3467
+ }
3468
+
3469
  if (musicQueue.length === 0) return;
3470
  const prevIdx = (currentTrackIndex - 1 + musicQueue.length) % musicQueue.length;
3471
  playTrackByIndex(prevIdx);
 
3473
 
3474
  function toggleAutoplay() {
3475
  isAutoplayEnabled = !isAutoplayEnabled;
3476
+ ['playerAutoplayBtn', 'vstage-autoplayBtn'].forEach(id => {
3477
+ const btn = $(id);
3478
+ if (btn) {
3479
+ btn.textContent = isAutoplayEnabled ? '🔁 Auto: ON' : '🔁 Auto: OFF';
3480
+ btn.className = isAutoplayEnabled ? 'btn alt text-xs py-1 px-2 text-emerald-400 border-emerald-500/40 font-bold' : 'btn alt text-xs py-1 px-2 text-slate-500';
3481
+ }
3482
+ });
3483
  toast(isAutoplayEnabled ? 'Autoplay activat' : 'Autoplay dezactivat');
3484
  }
3485
 
3486
  function toggleShuffle() {
3487
  isShuffleEnabled = !isShuffleEnabled;
3488
+ ['playerShuffleBtn', 'vstage-shuffleBtn'].forEach(id => {
3489
+ const btn = $(id);
3490
+ if (btn) {
3491
+ btn.className = isShuffleEnabled ? 'btn success text-xs py-1 px-2 font-bold shadow-[0_0_8px_rgba(16,185,129,0.4)]' : 'btn alt text-xs py-1 px-2 text-slate-400 hover:text-white';
3492
+ }
3493
+ });
3494
  toast(isShuffleEnabled ? 'Shuffle activat 🔀' : 'Shuffle dezactivat');
3495
  }
3496
 
3497
  function seekPlayer(event) {
3498
+ const container = event.currentTarget || $('playerProgressBarContainer');
3499
  const player = $('globalAudioPlayer');
3500
  if (!container || !player || !player.duration) return;
3501
  const rect = container.getBoundingClientRect();
 
3547
 
3548
  // Modal Song Editor CRUD
3549
  async function openEditSongModal(songId) {
3550
+ const localSong = (cachedMusic || []).find(s => s.id === songId)
3551
+ || (currentVaultItems || []).find(s => (s.id || s.file || s.filename) === songId)
3552
+ || (selectedVaultItem && (selectedVaultItem.id === songId || selectedVaultItem.file === songId) ? selectedVaultItem : null);
3553
+ if (localSong) {
3554
+ fillEditSongForm(localSong);
3555
+ }
3556
+ try {
3557
+ const fetched = await api(`/api/media/song/${encodeURIComponent(songId)}`);
3558
+ fillEditSongForm(fetched);
3559
+ } catch (e) {
3560
+ if (!localSong) {
3561
  toast('Nu s-a putut deschide piesa: ' + e.message, true);
3562
  }
 
3563
  }
 
3564
  }
3565
 
3566
  function fillEditSongForm(song) {
3567
+ $('editSongId').value = song.id || song.file || '';
3568
+ $('editSongTitle').value = song.title || song.name || '';
3569
  $('editSongBpm').value = song.bpm || 140;
3570
  $('editSongGenre').value = song.genre || '';
3571
  $('editSongKey').value = song.key || '';
 
3573
  $('editSongVocalStyle').value = song.vocal_style || '';
3574
  $('editSongLyrics').value = song.full_lyrics || song.lyrics || '';
3575
  $('editSongSunoPrompt').value = song.suno_style_prompt || '';
3576
+ if ($('editSongTags')) {
3577
+ $('editSongTags').value = Array.isArray(song.tags) ? song.tags.join(', ') : (song.tags || '');
3578
+ }
3579
  $('songEditModal').classList.remove('hidden');
3580
  }
3581
 
 
3584
  const songId = $('editSongId').value;
3585
  if (!songId) return;
3586
 
3587
+ const tagsVal = $('editSongTags') ? $('editSongTags').value.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean) : [];
3588
  const payload = {
3589
  title: $('editSongTitle').value.trim(),
3590
  bpm: parseInt($('editSongBpm').value) || 140,
 
3594
  vocal_style: $('editSongVocalStyle').value.trim(),
3595
  lyrics: $('editSongLyrics').value.trim(),
3596
  full_lyrics: $('editSongLyrics').value.trim(),
3597
+ suno_style_prompt: $('editSongSunoPrompt').value.trim(),
3598
+ tags: tagsVal
3599
  };
3600
 
3601
  const btn = $('saveSongBtn');
 
3608
  });
3609
  toast('Modificările au fost salvate cu succes!');
3610
  $('songEditModal').classList.add('hidden');
3611
+ if (typeof loadMusicVault === 'function') loadMusicVault();
3612
+ if (typeof loadVault === 'function') loadVault(currentVaultFilter, false);
3613
+ if (selectedVaultItem && (selectedVaultItem.id === songId || selectedVaultItem.file === songId)) {
3614
+ Object.assign(selectedVaultItem, payload);
3615
+ selectVaultItem(selectedVaultItem, false);
3616
+ }
3617
  } catch (err) {
3618
  toast('Eroare salvare piesă: ' + err.message, true);
3619
  } finally {
 
4241
 
4242
  // ─── Karaoke Video UI ───────────────────────────
4243
  function _findMusicTrack(trackId) {
4244
+ return (cachedMusic || []).find(t => t.id === trackId)
4245
+ || (currentVaultItems || []).find(t => (t.id || t.file || t.filename) === trackId)
4246
+ || (selectedVaultItem && (selectedVaultItem.id === trackId || selectedVaultItem.file === trackId) ? selectedVaultItem : null);
4247
  }
4248
 
4249
  function openKaraokeModal(videoUrl, title) {
 
4273
  try {
4274
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/karaoke-video`, { method: 'POST', body: JSON.stringify({}) });
4275
  toast(`Karaoke video gata: ${res.file || 'randat'}`);
4276
+ if (typeof loadMusicVault === 'function') await loadMusicVault();
4277
+ if (typeof loadVault === 'function') loadVault(currentVaultFilter, false);
4278
  } catch (e) {
4279
  toast(`Eroare randare karaoke: ${e.message}`, true);
4280
  if (btn) { btn.disabled = false; btn.innerHTML = original; }
 
5199
  }
5200
 
5201
  const sizeLabel = it.size_kb ? `${it.size_kb} KB` : (it.size ? `${Math.round(it.size / 1024)} KB` : 'Ready');
5202
+ const genreLabel = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : it.type === 'video' ? 'MP4 Video' : it.type === 'album' ? 'Daily Album' : it.type === 'image' ? 'FLUX Artwork' : 'Audio');
5203
+ const bpmKeyLabel = it.bpm ? `${it.bpm} BPM ${it.key ? '• ' + it.key : ''}` : (it.key ? it.key : (it.type === 'album' ? `${it.track_count || 0} tracks` : '—'));
5204
+ const fileDownloadUrl = it.zip_url || it.audio_url || it.url || `/api/media/file/${encodeURIComponent(fileRef)}`;
5205
+
5206
+ const tagsHtml = (it.tags || []).slice(0, 2).map(t => `<span class="inline-block bg-slate-800 text-cyan-400 text-[8px] font-mono px-1 py-0.5 rounded mr-1">#${esc(t)}</span>`).join('');
5207
+
5208
+ let playColBtn = '';
5209
+ let actionButtons = '';
5210
+
5211
+ if (it.type === 'image') {
5212
+ playColBtn = `
5213
+ <button class="w-6 h-6 rounded flex items-center justify-center hover:bg-slate-700 text-indigo-300 hover:text-white text-xs" onclick="selectVaultItemById('${esc(trackId)}', false)" title="View Image">
5214
+ 🖼️
5215
+ </button>
5216
+ `;
5217
+ actionButtons = `
5218
+ <button class="btn alt text-[10px] py-0.5 px-2 font-bold text-indigo-300 hover:text-white" onclick="selectVaultItemById('${esc(trackId)}', false)" title="View Image">👁️ View</button>
5219
+ <a href="${coverUrl ? (coverUrl.startsWith('http') || coverUrl.startsWith('/') ? coverUrl : `/api/media/file/${encodeURIComponent(coverUrl)}`) : fileDownloadUrl}" download="${esc(fileRef)}" class="btn alt text-[10px] py-0.5 px-1.5 text-cyan-300 hover:text-white" title="Download Image">⬇️</a>
5220
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5221
+ `;
5222
+ } else if (it.type === 'album') {
5223
+ playColBtn = `
5224
+ <button class="w-6 h-6 rounded flex items-center justify-center hover:bg-slate-700 text-violet-300 hover:text-white text-xs" onclick="selectVaultItemById('${esc(trackId)}', true)" title="Open Album">
5225
+ 💿
5226
+ </button>
5227
+ `;
5228
+ actionButtons = `
5229
+ <button class="btn accent text-[10px] py-0.5 px-2 font-bold text-violet-300 hover:text-white" onclick="selectVaultItemById('${esc(trackId)}', true)" title="Open Album &amp; Play">💿 Browse</button>
5230
+ <a href="${it.zip_url || `/api/media/album/zip/${encodeURIComponent(trackId)}`}" download class="btn alt text-[10px] py-0.5 px-1.5 text-amber-300 font-bold" title="Download Full Album ZIP">📦 ZIP</a>
5231
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5232
+ `;
5233
+ } else if (it.type === 'video') {
5234
+ playColBtn = `
5235
+ <button class="w-6 h-6 rounded flex items-center justify-center hover:bg-slate-700 text-rose-400 hover:text-white" onclick="playVaultItemById('${esc(trackId)}', event)" title="Play Video">
5236
+ ${isPlaying ? '<span class="text-rose-400 animate-pulse font-bold">⏸</span>' : '🎬'}
5237
+ </button>
5238
+ `;
5239
+ actionButtons = `
5240
+ <button class="btn ${isPlaying ? 'success' : 'accent'} text-[10px] py-0.5 px-2 font-bold text-rose-300" onclick="playVaultItemById('${esc(trackId)}', event)">
5241
+ ${isPlaying ? '⏸' : '🎬 Play'}
5242
+ </button>
5243
+ <a href="${fileDownloadUrl}" download="${esc(fileRef)}" class="btn alt text-[10px] py-0.5 px-1.5 text-cyan-300 hover:text-white" title="Download Video">⬇️</a>
5244
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5245
+ `;
5246
+ } else {
5247
+ // Song / Audio
5248
+ playColBtn = `
5249
+ <button class="w-6 h-6 rounded flex items-center justify-center hover:bg-slate-700 text-slate-300 hover:text-white" onclick="playVaultItemById('${esc(trackId)}', event)" title="Play Track">
5250
+ ${isPlaying ? '<span class="text-cyan-400 animate-pulse font-bold">▶</span>' : idx + 1}
5251
+ </button>
5252
+ `;
5253
+ actionButtons = `
5254
+ <button class="btn ${isPlaying ? 'success' : 'accent'} text-[10px] py-0.5 px-2 font-bold" onclick="playVaultItemById('${esc(trackId)}', event)">
5255
+ ${isPlaying ? '⏸' : '▶ Play'}
5256
+ </button>
5257
+ ${(it.type === 'music' && (it.karaoke_video || it.karaoke_video_file)) ? `<button class="btn alt text-[10px] py-0.5 px-1.5 text-emerald-300 hover:text-emerald-200" title="Redare Karaoke Video" onclick="openKaraokeModalById('${esc(trackId)}')">🎤</button>` : ''}
5258
+ ${(it.type === 'music' && !(it.karaoke_video || it.karaoke_video_file) && (it.audio_url || it.audio_file)) ? `<button id="karaokeBtn-${esc(trackId)}" class="btn alt text-[10px] py-0.5 px-1.5 text-amber-300 hover:text-amber-200" title="Randare Karaoke Video (word-synced)" onclick="renderKaraokeForTrack('${esc(trackId)}')">🎤+</button>` : ''}
5259
+ ${(it.lyrics || it.full_lyrics) ? `<button class="btn alt text-[10px] py-0.5 px-1.5" onclick="selectVaultItemById('${esc(trackId)}', false); setSidePanelTab('lyrics')" title="View Lyrics">📜</button>` : ''}
5260
+ <button class="btn alt text-[10px] py-0.5 px-1.5 text-cyan-400" onclick="promptAddTagById('${esc(trackId)}', event)" title="Add / Edit Tags">🏷️</button>
5261
+ <button class="btn alt text-[10px] py-0.5 px-1.5" onclick="openEditSongModal('${esc(trackId)}', event)" title="Edit Metadata">✎</button>
5262
+ <a href="${fileDownloadUrl}" download="${esc(fileRef)}" class="btn alt text-[10px] py-0.5 px-1.5 text-cyan-300 hover:text-white" title="Download Audio">⬇️</a>
5263
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5264
+ `;
5265
+ }
5266
 
5267
  return `
5268
  <tr class="hover:bg-slate-800/50 cursor-pointer transition-colors ${isSelected ? 'bg-cyan-950/30 font-medium' : ''} ${isPlaying ? 'bg-cyan-900/40 border-l-2 border-cyan-400' : ''}"
 
5270
  onclick="selectVaultItemById('${esc(trackId)}', false)"
5271
  ondblclick="playVaultItemById('${esc(trackId)}', event)">
5272
  <td class="py-2 px-2 text-center text-slate-400 font-mono text-[11px]">
5273
+ ${playColBtn}
 
 
5274
  </td>
5275
  <td class="py-2 px-2 min-w-0">
5276
  <div class="flex items-center gap-2">
5277
  ${coverImgHtml}
5278
  <div class="min-w-0">
5279
  <div class="font-bold text-slate-100 text-xs truncate max-w-xs sm:max-w-sm md:max-w-md" title="${esc(title)}">${esc(title)}</div>
5280
+ <div class="text-[10px] text-slate-400 font-mono truncate max-w-xs">${tagsHtml}${esc(fileRef)}</div>
5281
  </div>
5282
  </div>
5283
  </td>
 
5286
  <td class="py-2 px-2 text-cyan-400 font-mono text-[10px] whitespace-nowrap">${esc(bpmKeyLabel)}</td>
5287
  <td class="py-2 px-2 text-slate-400 font-mono text-[10px] whitespace-nowrap">${sizeLabel}</td>
5288
  <td class="py-2 px-2 text-center">
5289
+ <button class="text-xs p-0.5 hover:scale-125 transition-transform ${it.favorite ? 'text-amber-400 font-bold' : 'text-slate-600 hover:text-amber-300'}"
5290
  onclick="toggleVaultFavorite('${esc(trackId)}', event)" title="${it.favorite ? 'Favorited ⭐' : 'Add to Favorites'}">
5291
+ ${it.favorite ? '⭐' : '☆'}
5292
  </button>
5293
  </td>
5294
  <td class="py-2 px-2 text-right whitespace-nowrap">
5295
  <div class="flex items-center justify-end gap-1" onclick="event.stopPropagation()">
5296
+ ${actionButtons}
 
 
 
 
 
 
 
 
5297
  </div>
5298
  </td>
5299
  </tr>
 
5323
  const src = coverUrl.startsWith('http') || coverUrl.startsWith('/') ? coverUrl : `/api/media/file/${encodeURIComponent(coverUrl)}`;
5324
  coverThumb = `<img src="${src}" class="w-full h-28 object-cover rounded border border-slate-700/80">`;
5325
  } else {
5326
+ const icon = it.type === 'music' ? '🎼' : it.type === 'video' ? '🎬' : it.type === 'album' ? '💿' : it.type === 'image' ? '🖼️' : '🎵';
5327
  coverThumb = `<div class="w-full h-28 rounded bg-slate-800 border border-slate-700/80 flex items-center justify-center text-3xl">${icon}</div>`;
5328
  }
5329
 
5330
  const sizeLabel = it.size_kb ? `${it.size_kb} KB` : (it.size ? `${Math.round(it.size / 1024)} KB` : '');
5331
+ const genreLabel = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : it.type === 'video' ? 'Video' : it.type === 'album' ? 'Album' : it.type === 'image' ? 'Artwork' : 'Audio');
5332
+ const fileDownloadUrl = it.zip_url || it.audio_url || it.url || `/api/media/file/${encodeURIComponent(fileRef)}`;
5333
+ const tagsHtml = (it.tags || []).slice(0, 3).map(t => `<span class="bg-slate-800 text-cyan-400 text-[8px] font-mono px-1 py-0.5 rounded">#${esc(t)}</span>`).join(' ');
5334
+
5335
+ let hoverOverlay = '';
5336
+ let cardBottomActions = '';
5337
+
5338
+ if (it.type === 'image') {
5339
+ hoverOverlay = `
5340
+ <button class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-2xl text-white font-bold"
5341
+ onclick="selectVaultItemById('${esc(trackId)}', false)">
5342
+ 🔍
5343
+ </button>
5344
+ `;
5345
+ cardBottomActions = `
5346
+ <button class="btn alt text-[10px] py-0.5 px-2 font-bold text-indigo-300" onclick="selectVaultItemById('${esc(trackId)}', false)">
5347
+ 👁️ View Image
5348
+ </button>
5349
+ <div class="flex items-center gap-1">
5350
+ <a href="${coverUrl ? (coverUrl.startsWith('http') || coverUrl.startsWith('/') ? coverUrl : `/api/media/file/${encodeURIComponent(coverUrl)}`) : fileDownloadUrl}" download="${esc(fileRef)}" class="text-cyan-400 hover:text-cyan-200 text-xs px-1" title="Download Image">⬇️</a>
5351
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5352
+ </div>
5353
+ `;
5354
+ } else if (it.type === 'album') {
5355
+ hoverOverlay = `
5356
+ <button class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-2xl text-white font-bold"
5357
+ onclick="selectVaultItemById('${esc(trackId)}', true)">
5358
+ 💿
5359
+ </button>
5360
+ `;
5361
+ cardBottomActions = `
5362
+ <button class="btn accent text-[10px] py-0.5 px-2 font-bold text-violet-300" onclick="selectVaultItemById('${esc(trackId)}', true)">
5363
+ 💿 Open Album
5364
+ </button>
5365
+ <div class="flex items-center gap-1">
5366
+ <a href="${it.zip_url || `/api/media/album/zip/${encodeURIComponent(trackId)}`}" download class="text-amber-400 hover:text-amber-200 text-xs px-1 font-bold" title="Download Album ZIP">📦 ZIP</a>
5367
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5368
+ </div>
5369
+ `;
5370
+ } else {
5371
+ // Song, Audio, Video
5372
+ hoverOverlay = `
5373
+ <button class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-2xl text-white font-bold"
5374
+ onclick="playVaultItemById('${esc(trackId)}', event)">
5375
+ ${isPlaying ? '⏸' : '▶'}
5376
+ </button>
5377
+ `;
5378
+ cardBottomActions = `
5379
+ <button class="btn ${isPlaying ? 'success' : 'accent'} text-[10px] py-0.5 px-2 font-bold" onclick="playVaultItemById('${esc(trackId)}', event)">
5380
+ ${isPlaying ? '⏸ Pause' : '▶ Play'}
5381
+ </button>
5382
+ <div class="flex items-center gap-1">
5383
+ <button class="btn alt text-[10px] py-0.5 px-1 text-cyan-400" onclick="promptAddTagById('${esc(trackId)}', event)" title="Tag">🏷️</button>
5384
+ <button class="btn alt text-[10px] py-0.5 px-1.5" onclick="openEditSongModal('${esc(trackId)}', event)" title="Edit">✎</button>
5385
+ <a href="${fileDownloadUrl}" download="${esc(fileRef)}" class="text-cyan-400 hover:text-cyan-200 text-xs px-1" title="Download">⬇️</a>
5386
+ <button class="text-slate-500 hover:text-rose-400 text-xs px-1" onclick="deleteVaultMedia('${esc(fileRef)}', '${esc(it.type)}', event)" title="Delete">🗑️</button>
5387
+ </div>
5388
+ `;
5389
+ }
5390
 
5391
  return `
5392
  <div class="daw-rack p-2.5 rounded-lg border transition-all cursor-pointer flex flex-col justify-between ${isPlaying ? 'border-cyan-500/80 bg-slate-900/90 shadow-[0_0_12px_rgba(6,182,212,0.3)]' : isSelected ? 'border-cyan-600/60 bg-slate-900/50' : 'border-slate-800 hover:border-slate-700'}"
 
5396
  <div class="space-y-1.5">
5397
  <div class="relative group overflow-hidden rounded">
5398
  ${coverThumb}
5399
+ ${hoverOverlay}
 
 
 
5400
  </div>
5401
  <div class="flex items-start justify-between gap-1">
5402
  <div class="font-bold text-slate-100 text-xs truncate flex-1" title="${esc(title)}">${esc(title)}</div>
5403
+ <button class="text-xs shrink-0 ${it.favorite ? 'text-amber-400 font-bold' : 'text-slate-600 hover:text-amber-300'}"
5404
+ onclick="toggleVaultFavorite('${esc(trackId)}', event)" title="${it.favorite ? 'Favorited ⭐' : 'Add to Favorites'}">
5405
+ ${it.favorite ? '⭐' : '☆'}
5406
  </button>
5407
  </div>
5408
  <div class="text-[10px] text-slate-400 font-mono truncate">${esc(genreLabel)}</div>
5409
+ ${tagsHtml ? `<div class="flex flex-wrap gap-1">${tagsHtml}</div>` : ''}
5410
  <div class="flex items-center justify-between text-[9px] text-cyan-400 font-mono">
5411
+ <span>${it.bpm ? it.bpm + ' BPM' : (it.track_count ? it.track_count + ' tracks' : '')}</span>
5412
  <span>${sizeLabel}</span>
5413
  </div>
5414
  </div>
5415
 
5416
  <div class="flex items-center justify-between pt-2 border-t border-slate-800/80 mt-2 text-[10px]" onclick="event.stopPropagation()">
5417
+ ${cardBottomActions}
 
 
 
 
 
 
 
5418
  </div>
5419
  </div>
5420
  `;
 
5430
  selectedVaultItem = it;
5431
  const trackId = it.id || it.file || it.filename;
5432
  const fileRef = it.file || it.filename || '';
5433
+ const coverUrl = it.cover_image_url || it.cover_image || it.cover_url || '';
5434
 
5435
  if (it.type === 'video') {
5436
  // Switch Stage to Video Player
5437
  if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.remove('hidden');
5438
  if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.add('hidden');
5439
+ if ($('vstage-imageContainer')) $('vstage-imageContainer').classList.add('hidden');
5440
+ if ($('vstage-albumContainer')) $('vstage-albumContainer').classList.add('hidden');
5441
+
5442
+ // Pause global audio
5443
+ const aPlayer = $('globalAudioPlayer');
5444
+ if (aPlayer && !aPlayer.paused) aPlayer.pause();
5445
 
5446
  const vPlayer = $('vstage-videoPlayer');
5447
  const videoUrl = it.url || `/api/media/video/stream/${encodeURIComponent(fileRef)}`;
 
5450
  vPlayer.src = videoUrl;
5451
  }
5452
  if (autoPlay) {
 
 
 
5453
  currentPlayingVaultId = trackId;
5454
  vPlayer.play().catch(e => console.warn(e));
5455
  }
 
5458
  if ($('vstage-videoMeta')) $('vstage-videoMeta').textContent = `${it.size_kb ? it.size_kb + ' KB • ' : ''}${fileRef}`;
5459
  if ($('vstage-videoDownload')) $('vstage-videoDownload').href = videoUrl;
5460
 
5461
+ } else if (it.type === 'image') {
5462
+ // Switch Stage to Image Showcase
5463
+ if ($('vstage-imageContainer')) $('vstage-imageContainer').classList.remove('hidden');
5464
+ if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.add('hidden');
5465
+ if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.add('hidden');
5466
+ if ($('vstage-albumContainer')) $('vstage-albumContainer').classList.add('hidden');
5467
+
5468
+ // Pause audio and video
5469
+ const aPlayer = $('globalAudioPlayer');
5470
+ if (aPlayer && !aPlayer.paused) aPlayer.pause();
5471
+ const vPlayer = $('vstage-videoPlayer');
5472
+ if (vPlayer && !vPlayer.paused) vPlayer.pause();
5473
+
5474
+ const imgSrc = it.url || it.image_url || `/api/media/file/${encodeURIComponent(fileRef)}`;
5475
+ if ($('vstage-imageDisplay')) $('vstage-imageDisplay').src = imgSrc;
5476
+ if ($('vstage-imageTitle')) $('vstage-imageTitle').textContent = it.title || it.name || fileRef;
5477
+ if ($('vstage-imageMeta')) $('vstage-imageMeta').textContent = `${it.size_kb ? it.size_kb + ' KB • ' : ''}PNG / FLUX.1 Artwork`;
5478
+ if ($('vstage-imageDownload')) {
5479
+ $('vstage-imageDownload').href = imgSrc;
5480
+ $('vstage-imageDownload').download = fileRef;
5481
+ }
5482
+ if ($('vstage-imageOpenTab')) $('vstage-imageOpenTab').href = imgSrc;
5483
+
5484
+ currentPlayingVaultId = null;
5485
+
5486
+ } else if (it.type === 'album') {
5487
+ // Switch Stage to Album Showcase
5488
+ if ($('vstage-albumContainer')) $('vstage-albumContainer').classList.remove('hidden');
5489
+ if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.add('hidden');
5490
+ if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.add('hidden');
5491
+ if ($('vstage-imageContainer')) $('vstage-imageContainer').classList.add('hidden');
5492
+
5493
+ // Pause video
5494
+ const vPlayer = $('vstage-videoPlayer');
5495
+ if (vPlayer && !vPlayer.paused) vPlayer.pause();
5496
+
5497
+ if ($('vstage-albumTitle')) $('vstage-albumTitle').textContent = it.title || it.name || fileRef;
5498
+ if ($('vstage-albumGenre')) $('vstage-albumGenre').textContent = it.genre || 'Moldovan AI Album';
5499
+ if ($('vstage-albumMeta')) {
5500
+ const trCount = it.track_count || (it.tracks || []).length;
5501
+ $('vstage-albumMeta').textContent = `${trCount} tracks • ${it.real_audio_count || 0} real audio • ${it.size_kb ? it.size_kb + ' KB' : ''}`;
5502
+ }
5503
+ if ($('vstage-albumStatusBadge')) {
5504
+ const st = it.honest_status || (it.real_audio_count > 0 ? 'real_audio' : 'lyrics_only');
5505
+ $('vstage-albumStatusBadge').textContent = st.replace('_', ' ').toUpperCase();
5506
+ $('vstage-albumStatusBadge').className = st === 'real_audio' ? 'daw-badge badge-emerald text-[9px] uppercase' : (st === 'lyrics_only' ? 'daw-badge badge-slate text-[9px] uppercase' : 'daw-badge badge-amber text-[9px] uppercase');
5507
+ }
5508
+ if ($('vstage-albumZipBtn')) {
5509
+ const zip = it.zip_url || `/api/media/album/zip/${encodeURIComponent(trackId)}`;
5510
+ $('vstage-albumZipBtn').href = zip;
5511
+ }
5512
+ if ($('vstage-albumTrackCount')) {
5513
+ $('vstage-albumTrackCount').textContent = `${(it.tracks || []).length} Tracks`;
5514
+ }
5515
+
5516
+ // Render tracks
5517
+ const tList = $('vstage-albumTracksList');
5518
+ if (tList) {
5519
+ const trs = it.tracks || [];
5520
+ if (!trs.length) {
5521
+ tList.innerHTML = '<div class="text-slate-500 text-xs p-2 font-mono">No tracks found in album manifest.</div>';
5522
+ } else {
5523
+ tList.innerHTML = trs.map((t, tidx) => {
5524
+ const canPlay = Boolean(t.url);
5525
+ const jsonArg = JSON.stringify(t).replace(/"/g, '&quot;');
5526
+ return `
5527
+ <div class="flex items-center justify-between p-1.5 rounded bg-slate-950/80 border border-slate-800/80 hover:bg-slate-800/60 text-xs font-mono">
5528
+ <div class="flex items-center gap-2 min-w-0 flex-1">
5529
+ <span class="text-slate-500 w-5 text-center">${tidx + 1}</span>
5530
+ <span class="text-slate-200 truncate font-sans font-medium" title="${esc(t.title)}">${esc(t.title)}</span>
5531
+ <span class="daw-badge ${t.audio_status === 'lyrics_only' ? 'badge-slate' : 'badge-emerald'} text-[8px] shrink-0">${esc(t.audio_status || 'audio')}</span>
5532
+ </div>
5533
+ <div class="flex items-center gap-2 shrink-0">
5534
+ <span class="text-[10px] text-slate-500">${t.size_kb ? t.size_kb + ' KB' : ''}</span>
5535
+ ${canPlay ? `<button class="btn accent text-[9px] py-0.5 px-2 font-bold" onclick="playAlbumTrack('${esc(trackId)}', ${jsonArg})">▶ Play</button>` : '<span class="text-[9px] text-slate-600">lyrics only</span>'}
5536
+ </div>
5537
+ </div>
5538
+ `;
5539
+ }).join('');
5540
+ }
5541
+ }
5542
+
5543
+ if (autoPlay) {
5544
+ playCurrentAlbum();
5545
+ }
5546
+
5547
  } else {
5548
+ // Switch Stage to Audio Player Workstation (music / audio)
5549
  if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.remove('hidden');
5550
  if ($('vstage-videoContainer')) $('vstage-videoContainer').classList.add('hidden');
5551
+ if ($('vstage-imageContainer')) $('vstage-imageContainer').classList.add('hidden');
5552
+ if ($('vstage-albumContainer')) $('vstage-albumContainer').classList.add('hidden');
5553
 
5554
  // Pause video player
5555
  const vPlayer = $('vstage-videoPlayer');
 
5557
 
5558
  const title = it.title || it.name || fileRef;
5559
  const genre = it.genre || (it.type === 'music' ? 'Chișinău 808 Trap' : 'Audio Track');
 
5560
  const audioSrc = it.audio_url || it.url || (it.audio_file ? `/api/media/audio/stream/${encodeURIComponent(it.audio_file)}` : (fileRef ? `/api/media/audio/stream/${encodeURIComponent(fileRef)}` : ''));
5561
 
5562
  if ($('vstage-trackTitle')) $('vstage-trackTitle').textContent = title;
 
5575
  $('vstage-coverImg').src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Crect width='120' height='120' fill='%230f172a'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-size='36' fill='%2338bdf8'%3E🎵%3C/text%3E%3C/svg%3E";
5576
  }
5577
  }
5578
+ if ($('vstage-downloadBtn')) {
5579
+ $('vstage-downloadBtn').href = audioSrc;
5580
+ $('vstage-downloadBtn').download = fileRef;
5581
+ }
5582
  if ($('vstage-favBtn')) {
5583
  $('vstage-favBtn').textContent = it.favorite ? '⭐ Favorited' : '☆ Add to Favs';
5584
+ $('vstage-favBtn').className = it.favorite ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold shadow-[0_0_8px_rgba(245,158,11,0.3)]' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5585
  }
5586
 
5587
+ // Render Tags
5588
+ renderStageTags(it.tags || []);
5589
+
5590
  // Load Lyrics into side panel
5591
  loadStageLyrics(it);
5592
  // Load Comments into side panel
 
5636
  function playVaultItemById(trackId, evt) {
5637
  if (evt) evt.stopPropagation();
5638
  const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5639
+ if (!it) return;
5640
+ if (it.type === 'image') {
5641
+ selectVaultItem(it, false);
5642
+ return;
5643
+ }
5644
+ if (currentPlayingVaultId === trackId) {
5645
+ togglePlayPause();
5646
+ } else {
5647
+ playVaultItem(it, evt);
5648
+ }
5649
  }
5650
 
5651
  function playVaultItem(it, evt) {
5652
  if (evt) evt.stopPropagation();
5653
+ if (it.type === 'image') {
5654
+ selectVaultItem(it, false);
5655
+ return;
5656
+ }
5657
  selectVaultItem(it, true);
5658
  }
5659
 
 
5858
  async function toggleStageFavorite() {
5859
  if (!selectedVaultItem) return;
5860
  const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
5861
+ const nextFav = !selectedVaultItem.favorite;
5862
  try {
5863
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/favorite`, {
5864
  method: 'POST',
5865
+ body: JSON.stringify({ favorite: nextFav })
5866
  });
5867
+ const isFav = res.favorite !== undefined ? res.favorite : nextFav;
5868
+ selectedVaultItem.favorite = isFav;
5869
+ const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5870
+ if (it) it.favorite = isFav;
5871
+
5872
  if ($('vstage-favBtn')) {
5873
+ $('vstage-favBtn').textContent = isFav ? '⭐ Favorited' : '☆ Add to Favs';
5874
+ $('vstage-favBtn').className = isFav ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold shadow-[0_0_8px_rgba(245,158,11,0.3)]' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5875
  }
5876
+ toast(isFav ? 'Adăugat la favorite ⭐' : 'Eliminat din favorite');
5877
  renderVault();
5878
  } catch (e) {
5879
  toast('Eroare favorite: ' + e.message, true);
 
5884
  if (evt) evt.stopPropagation();
5885
  try {
5886
  const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5887
+ const nextFav = it ? !it.favorite : true;
5888
  const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/favorite`, {
5889
  method: 'POST',
5890
+ body: JSON.stringify({ favorite: nextFav })
5891
  });
5892
+ const isFav = res.favorite !== undefined ? res.favorite : nextFav;
5893
+ if (it) it.favorite = isFav;
5894
  if (selectedVaultItem && (selectedVaultItem.id || selectedVaultItem.file) === trackId) {
5895
+ selectedVaultItem.favorite = isFav;
5896
  if ($('vstage-favBtn')) {
5897
+ $('vstage-favBtn').textContent = isFav ? '⭐ Favorited' : '☆ Add to Favs';
5898
+ $('vstage-favBtn').className = isFav ? 'btn accent text-xs py-0.5 px-2 text-amber-300 font-bold shadow-[0_0_8px_rgba(245,158,11,0.3)]' : 'btn alt text-xs py-0.5 px-2 text-amber-300';
5899
  }
5900
  }
5901
+ toast(isFav ? 'Adăugat la favorite ⭐' : 'Eliminat din favorite');
5902
  renderVault();
5903
  } catch (e) {
5904
  toast('Eroare favorite: ' + e.message, true);
5905
  }
5906
  }
5907
 
5908
+ async function promptAddStageTag() {
5909
+ if (!selectedVaultItem) return;
5910
+ const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
5911
+ promptAddTagById(trackId);
5912
+ }
5913
+
5914
+ async function promptAddTagById(trackId, evt) {
5915
+ if (evt) evt.stopPropagation();
5916
+ const tag = prompt('Introdu un tag (ex: trap, 808, chill, favorit):');
5917
+ if (!tag || !tag.trim()) return;
5918
+ try {
5919
+ const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/tags`, {
5920
+ method: 'POST',
5921
+ body: JSON.stringify({ add_tag: tag.trim() })
5922
+ });
5923
+ const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5924
+ if (it) it.tags = res.tags || [];
5925
+ if (selectedVaultItem && (selectedVaultItem.id === trackId || selectedVaultItem.file === trackId)) {
5926
+ selectedVaultItem.tags = res.tags || [];
5927
+ renderStageTags(selectedVaultItem.tags);
5928
+ }
5929
+ renderVault();
5930
+ toast(`Tag adăugat: #${tag.trim()}`);
5931
+ } catch (e) {
5932
+ toast('Eroare adăugare tag: ' + e.message, true);
5933
+ }
5934
+ }
5935
+
5936
+ async function removeStageTag(tag) {
5937
+ if (!selectedVaultItem) return;
5938
+ const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
5939
+ try {
5940
+ const res = await api(`/api/media/song/${encodeURIComponent(trackId)}/tags`, {
5941
+ method: 'POST',
5942
+ body: JSON.stringify({ remove_tag: tag })
5943
+ });
5944
+ selectedVaultItem.tags = res.tags || [];
5945
+ const it = (currentVaultItems || []).find(x => (x.id || x.file || x.filename) === trackId);
5946
+ if (it) it.tags = res.tags || [];
5947
+ renderStageTags(selectedVaultItem.tags);
5948
+ renderVault();
5949
+ toast(`Tag eliminat: #${tag}`);
5950
+ } catch (e) {
5951
+ toast('Eroare eliminare tag: ' + e.message, true);
5952
+ }
5953
+ }
5954
+
5955
+ function renderStageTags(tags) {
5956
+ const container = $('vstage-tagsContainer');
5957
+ if (!container) return;
5958
+ if (!tags || !tags.length) {
5959
+ container.innerHTML = '<span class="text-slate-600 text-[9px] italic">No tags. Click +Tag to categorize.</span>';
5960
+ return;
5961
+ }
5962
+ container.innerHTML = tags.map(t => `
5963
+ <span class="inline-flex items-center gap-1 bg-slate-800/90 text-cyan-300 border border-slate-700/80 px-1.5 py-0.5 rounded text-[9px]">
5964
+ #${esc(t)}
5965
+ <button type="button" class="text-slate-500 hover:text-rose-400 font-bold text-[10px] ml-0.5" onclick="removeStageTag('${esc(t)}')" title="Remove tag">×</button>
5966
+ </span>
5967
+ `).join('');
5968
+ }
5969
+
5970
+ function playCurrentAlbum() {
5971
+ if (!selectedVaultItem || selectedVaultItem.type !== 'album') return;
5972
+ const playable = (selectedVaultItem.tracks || []).find(t => t.url);
5973
+ if (playable) {
5974
+ playAlbumTrack(selectedVaultItem.id, playable);
5975
+ } else {
5976
+ toast('Acest album are doar versuri (nu are încă fișiere audio randate)', true);
5977
+ }
5978
+ }
5979
+
5980
+ function playAlbumTrack(albumId, track) {
5981
+ if (!track || !track.url) {
5982
+ toast('Piesa nu are audio randat', true);
5983
+ return;
5984
+ }
5985
+ const player = $('globalAudioPlayer');
5986
+ if (!player) return;
5987
+ player.src = track.url;
5988
+ player.volume = parseFloat($('playerVolume')?.value || 0.85);
5989
+ player.play().catch(e => console.warn('Playback error:', e));
5990
+
5991
+ currentPlayingVaultId = track.title;
5992
+ if ($('vstage-audioContainer')) $('vstage-audioContainer').classList.remove('hidden');
5993
+ if ($('vstage-trackTitle')) $('vstage-trackTitle').textContent = `${track.title} (${selectedVaultItem?.title || albumId})`;
5994
+ if ($('vstage-trackMeta')) $('vstage-trackMeta').textContent = `${track.audio_status || 'Real Audio'} • ${track.size_kb || ''} KB • MiniMax Music 3`;
5995
+ if ($('vstage-downloadBtn')) $('vstage-downloadBtn').href = track.url;
5996
+
5997
+ if ($('playerTrackTitle')) $('playerTrackTitle').textContent = track.title;
5998
+ if ($('playerTrackMeta')) $('playerTrackMeta').textContent = selectedVaultItem?.title || albumId;
5999
+ if ($('playerDownloadBtn')) $('playerDownloadBtn').href = track.url;
6000
+
6001
+ updateLibraryPlayingIndicators();
6002
+ toast(`Se redă piesa: ${track.title} 🎵`);
6003
+ }
6004
+
6005
  async function bundleStageProject() {
6006
  if (!selectedVaultItem) return;
6007
  const trackId = selectedVaultItem.id || selectedVaultItem.file || selectedVaultItem.filename;
 
6032
 
6033
  async function deleteVaultMedia(fileRef, type, evt) {
6034
  if (evt) evt.stopPropagation();
6035
+ if (!confirm(`Sigur vrei să ștergi acest element (${fileRef})?`)) return;
6036
  try {
6037
+ if (type === 'music' || type === 'song') {
6038
+ await api(`/api/media/song/${encodeURIComponent(fileRef)}`, { method: 'DELETE' });
6039
+ } else {
6040
+ await api(`/api/media/file/${encodeURIComponent(fileRef)}`, { method: 'DELETE' });
6041
+ }
6042
+ toast(`Element șters cu succes: ${fileRef}`);
6043
  loadVault(currentVaultFilter, true);
6044
  } catch (e) {
6045
  toast('Eroare ștergere: ' + e.message, true);
 
6496
  window.submitStageComment = submitStageComment;
6497
  window.toggleStageFavorite = toggleStageFavorite;
6498
  window.toggleVaultFavorite = toggleVaultFavorite;
6499
+ window.promptAddStageTag = promptAddStageTag;
6500
+ window.promptAddTagById = promptAddTagById;
6501
+ window.removeStageTag = removeStageTag;
6502
+ window.renderStageTags = renderStageTags;
6503
+ window.playCurrentAlbum = playCurrentAlbum;
6504
+ window.playAlbumTrack = playAlbumTrack;
6505
  window.bundleStageProject = bundleStageProject;
6506
  window.openStageSongEdit = openStageSongEdit;
6507
  window.pruneEmptyImages = pruneEmptyImages;
moldovan-qwen/studio_server.py CHANGED
@@ -1872,8 +1872,10 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1872
  "title": meta.get("title", fname.replace(".wav", "").replace(".mp3", "").replace("_", " ").title()),
1873
  "path": fpath,
1874
  "url": f"/api/media/audio/stream/{fname}",
 
1875
  "lyrics": meta.get("lyrics", ""),
1876
  "genre": meta.get("genre", ""),
 
1877
  "favorite": bool(meta.get("favorite", False)),
1878
  "comments": meta.get("comments", []),
1879
  "size": sz,
@@ -1907,6 +1909,16 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1907
  cover_url = d.get("cover_image_url") or d.get("cover_image")
1908
  song_id = d.get("id") or os.path.basename(fpath).replace(".json", "")
1909
  lyrics_str = d.get("full_lyrics") or d.get("lyrics", "")
 
 
 
 
 
 
 
 
 
 
1910
  assets.append({
1911
  "id": song_id,
1912
  "type": "music",
@@ -1918,6 +1930,7 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1918
  "bpm": d.get("bpm", 140),
1919
  "key": d.get("key", "C Minor"),
1920
  "vocal_style": d.get("vocal_style", ""),
 
1921
  "lyrics": lyrics_str,
1922
  "full_lyrics": lyrics_str,
1923
  "favorite": bool(d.get("favorite", False)),
@@ -1927,6 +1940,8 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1927
  "url": audio_url,
1928
  "cover_image": cover_url,
1929
  "cover_image_url": cover_url,
 
 
1930
  "size": sz,
1931
  "size_kb": round(sz / 1024, 1),
1932
  "updated_at": d.get("updated_at") or os.path.getmtime(fpath)
@@ -1978,8 +1993,10 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1978
 
1979
  # 6b. Daily generated albums (honest audio_status from manifests)
1980
  for album in (await list_vault_albums()).get("items", []):
 
1981
  assets.append({
1982
  "type": "album",
 
1983
  "filename": album["id"],
1984
  "file": album["id"],
1985
  "name": album["title"],
@@ -1989,6 +2006,10 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
1989
  "track_count": album.get("track_count", 0),
1990
  "real_audio_count": album.get("real_audio_count", 0),
1991
  "honest_status": album.get("honest_status", "unknown"),
 
 
 
 
1992
  "size": album.get("size", 0),
1993
  "size_kb": round(album.get("size", 0) / 1024, 1),
1994
  "updated_at": album.get("updated_at", 0)
@@ -2003,6 +2024,9 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
2003
 
2004
  if target_filter == "favorites":
2005
  assets = [a for a in assets if a.get("favorite") is True]
 
 
 
2006
  elif target_filter not in ["all", "*", ""]:
2007
  if target_filter in ["audio", "sound"]:
2008
  assets = [a for a in assets if a.get("type") in ["audio", "music"]]
@@ -2021,7 +2045,12 @@ async def list_media_assets(asset_type: str = Query("ALL"), type: str = Query("A
2021
 
2022
  if search:
2023
  s = search.lower()
2024
- assets = [a for a in assets if s in a.get("title", "").lower() or s in a.get("filename", "").lower()]
 
 
 
 
 
2025
 
2026
  sorted_assets = sorted(assets, key=lambda x: x.get("updated_at", 0), reverse=True)
2027
  return {"assets": sorted_assets, "items": sorted_assets, "count": len(sorted_assets)}
@@ -2081,17 +2110,25 @@ def _find_track_audio(track_id: str) -> Optional[str]:
2081
 
2082
  def _find_track_sidecar(track_id: str) -> Optional[str]:
2083
  """Locate the JSON sidecar for a track id (audio dir first, then music dir)."""
 
 
 
 
 
 
2084
  for d in (AUDIO_DIR, MUSIC_DIR):
2085
- cand = os.path.join(d, f"{track_id}.json")
2086
- if os.path.exists(cand):
2087
- return cand
 
2088
  return None
2089
 
2090
 
2091
  def _read_or_init_sidecar(track_id: str) -> Dict:
2092
  """Read the track sidecar; auto-create with defaults when only audio exists."""
2093
- sc_path = _find_track_sidecar(track_id)
2094
- audio_path = _find_track_audio(track_id)
 
2095
  if sc_path and os.path.exists(sc_path):
2096
  try:
2097
  with open(sc_path, "r", encoding="utf-8") as f:
@@ -2099,18 +2136,19 @@ def _read_or_init_sidecar(track_id: str) -> Dict:
2099
  except Exception:
2100
  pass
2101
  meta: Dict[str, Any] = {
2102
- "id": track_id,
2103
- "title": track_id.replace("_", " ").title(),
2104
  "favorite": False,
2105
  "comments": [],
2106
  "lyrics": "",
 
2107
  }
2108
  if audio_path:
2109
  meta["audio_file"] = os.path.basename(audio_path)
2110
  meta["audio_url"] = f"/api/media/audio/stream/{os.path.basename(audio_path)}"
2111
  meta["created_at"] = time.time()
2112
  # Persist auto-initialized sidecar next to the audio file
2113
- sc_path = os.path.join(AUDIO_DIR, f"{track_id}.json")
2114
  try:
2115
  with open(sc_path, "w", encoding="utf-8") as f:
2116
  json.dump(meta, f, ensure_ascii=False, indent=2)
@@ -2121,11 +2159,12 @@ def _read_or_init_sidecar(track_id: str) -> Dict:
2121
 
2122
  def _write_sidecar_field(track_id: str, **fields) -> Dict:
2123
  """Update one or more fields in the track sidecar and return it."""
 
2124
  meta = _read_or_init_sidecar(track_id)
2125
  meta.update(fields)
2126
  if "id" not in meta:
2127
- meta["id"] = track_id
2128
- sc_path = _find_track_sidecar(track_id) or os.path.join(AUDIO_DIR, f"{track_id}.json")
2129
  with open(sc_path, "w", encoding="utf-8") as f:
2130
  json.dump(meta, f, ensure_ascii=False, indent=2)
2131
  return meta
@@ -2134,10 +2173,60 @@ def _write_sidecar_field(track_id: str, **fields) -> Dict:
2134
  # ─── Media Vault: favorites / comments / lyrics / bundle / prune ────────────
2135
 
2136
  @app.post("/api/media/song/{track_id}/favorite")
2137
- async def toggle_track_favorite(track_id: str, body: Dict = Body(...)):
2138
  """Toggle favorite flag for a song or sidecar-backed audio track."""
2139
- meta = _write_sidecar_field(track_id, favorite=bool(body.get("favorite", False)))
2140
- return {"status": "success", "id": track_id, "favorite": bool(meta.get("favorite"))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2141
 
2142
 
2143
  @app.post("/api/media/song/{track_id}/comment")
@@ -2625,46 +2714,61 @@ async def list_vault_music():
2625
  async def get_song_details(song_id: str):
2626
  """Retrieve full details and manifest for a specific song."""
2627
  fpath = find_song_file(song_id)
2628
- if not fpath or not os.path.exists(fpath):
2629
- raise HTTPException(status_code=404, detail=f"Song '{song_id}' not found")
2630
- with open(fpath, "r", encoding="utf-8") as f:
2631
- data = json.load(f)
2632
- data["id"] = data.get("id") or os.path.basename(fpath).replace(".json", "")
2633
- audio_file = data.get("audio_file") or (os.path.basename(data.get("audio_path", "")) if data.get("audio_path") else "")
2634
- if audio_file:
2635
- data["audio_file"] = audio_file
2636
- if not data.get("audio_url"):
2637
- data["audio_url"] = f"/api/media/audio/stream/{audio_file}"
2638
- return data
 
 
 
2639
 
2640
  @app.put("/api/media/song/{song_id}")
2641
  @app.patch("/api/media/song/{song_id}")
2642
  async def update_song_metadata(song_id: str, body: Dict = Body(...)):
2643
- """Update title, genre, bpm, key, lyrics, vocal_style, mood for a song."""
2644
  fpath = find_song_file(song_id)
2645
- if not fpath or not os.path.exists(fpath):
2646
- raise HTTPException(status_code=404, detail=f"Song '{song_id}' not found")
2647
- with open(fpath, "r", encoding="utf-8") as f:
2648
- data = json.load(f)
2649
 
2650
- for field in ["title", "genre", "bpm", "key", "vocal_style", "mood", "lyrics", "full_lyrics", "topic", "suno_style_prompt", "cover_style", "dialect_level"]:
2651
- if field in body:
2652
- data[field] = body[field]
2653
- if "bpm" in body and body["bpm"] is not None:
2654
- try:
2655
- data["bpm"] = int(body["bpm"])
2656
- except (ValueError, TypeError):
2657
- pass
2658
- if "dialect_level" in body and body["dialect_level"] is not None:
2659
- try:
2660
- data["dialect_level"] = int(body["dialect_level"])
2661
- except (ValueError, TypeError):
2662
- pass
2663
- data["updated_at"] = time.time()
2664
 
2665
- with open(fpath, "w", encoding="utf-8") as f:
2666
- json.dump(data, f, ensure_ascii=False, indent=2)
2667
- return {"status": "success", "message": "Song metadata updated", "song": data}
 
 
 
 
 
 
 
 
 
 
 
 
 
2668
 
2669
  @app.delete("/api/media/song/{song_id}")
2670
  async def delete_song(song_id: str, delete_media_files: bool = Query(True)):
 
1872
  "title": meta.get("title", fname.replace(".wav", "").replace(".mp3", "").replace("_", " ").title()),
1873
  "path": fpath,
1874
  "url": f"/api/media/audio/stream/{fname}",
1875
+ "audio_url": f"/api/media/audio/stream/{fname}",
1876
  "lyrics": meta.get("lyrics", ""),
1877
  "genre": meta.get("genre", ""),
1878
+ "tags": meta.get("tags", []),
1879
  "favorite": bool(meta.get("favorite", False)),
1880
  "comments": meta.get("comments", []),
1881
  "size": sz,
 
1909
  cover_url = d.get("cover_image_url") or d.get("cover_image")
1910
  song_id = d.get("id") or os.path.basename(fpath).replace(".json", "")
1911
  lyrics_str = d.get("full_lyrics") or d.get("lyrics", "")
1912
+ karaoke_video = d.get("karaoke_video")
1913
+ karaoke_video_file = d.get("karaoke_video_file")
1914
+ if not karaoke_video and not karaoke_video_file:
1915
+ orphan = os.path.join(VIDEOS_OUT_DIR, f"{song_id}_karaoke.mp4")
1916
+ if os.path.isfile(orphan):
1917
+ karaoke_video_file = f"{song_id}_karaoke.mp4"
1918
+ karaoke_video = karaoke_video or (f"/api/media/video/stream/{karaoke_video_file}" if karaoke_video_file else None)
1919
+ tags = d.get("tags") or []
1920
+ if not tags and d.get("genre"):
1921
+ tags = [d["genre"].lower()]
1922
  assets.append({
1923
  "id": song_id,
1924
  "type": "music",
 
1930
  "bpm": d.get("bpm", 140),
1931
  "key": d.get("key", "C Minor"),
1932
  "vocal_style": d.get("vocal_style", ""),
1933
+ "tags": tags,
1934
  "lyrics": lyrics_str,
1935
  "full_lyrics": lyrics_str,
1936
  "favorite": bool(d.get("favorite", False)),
 
1940
  "url": audio_url,
1941
  "cover_image": cover_url,
1942
  "cover_image_url": cover_url,
1943
+ "karaoke_video": karaoke_video,
1944
+ "karaoke_video_file": karaoke_video_file,
1945
  "size": sz,
1946
  "size_kb": round(sz / 1024, 1),
1947
  "updated_at": d.get("updated_at") or os.path.getmtime(fpath)
 
1993
 
1994
  # 6b. Daily generated albums (honest audio_status from manifests)
1995
  for album in (await list_vault_albums()).get("items", []):
1996
+ first_playable = next((t["url"] for t in album.get("tracks", []) if t.get("url")), None)
1997
  assets.append({
1998
  "type": "album",
1999
+ "id": album["id"],
2000
  "filename": album["id"],
2001
  "file": album["id"],
2002
  "name": album["title"],
 
2006
  "track_count": album.get("track_count", 0),
2007
  "real_audio_count": album.get("real_audio_count", 0),
2008
  "honest_status": album.get("honest_status", "unknown"),
2009
+ "tracks": album.get("tracks", []),
2010
+ "zip_url": f"/api/media/album/zip/{album['id']}",
2011
+ "audio_url": first_playable,
2012
+ "url": first_playable,
2013
  "size": album.get("size", 0),
2014
  "size_kb": round(album.get("size", 0) / 1024, 1),
2015
  "updated_at": album.get("updated_at", 0)
 
2024
 
2025
  if target_filter == "favorites":
2026
  assets = [a for a in assets if a.get("favorite") is True]
2027
+ elif target_filter.startswith("tag:"):
2028
+ req_tag = target_filter.split("tag:", 1)[1].strip().lower()
2029
+ assets = [a for a in assets if any(req_tag == str(t).lower() for t in a.get("tags", []))]
2030
  elif target_filter not in ["all", "*", ""]:
2031
  if target_filter in ["audio", "sound"]:
2032
  assets = [a for a in assets if a.get("type") in ["audio", "music"]]
 
2045
 
2046
  if search:
2047
  s = search.lower()
2048
+ assets = [a for a in assets if s in a.get("title", "").lower()
2049
+ or s in a.get("name", "").lower()
2050
+ or s in a.get("filename", "").lower()
2051
+ or s in a.get("genre", "").lower()
2052
+ or any(s in str(t).lower() for t in a.get("tags", []))
2053
+ or s in a.get("lyrics", "").lower()]
2054
 
2055
  sorted_assets = sorted(assets, key=lambda x: x.get("updated_at", 0), reverse=True)
2056
  return {"assets": sorted_assets, "items": sorted_assets, "count": len(sorted_assets)}
 
2110
 
2111
  def _find_track_sidecar(track_id: str) -> Optional[str]:
2112
  """Locate the JSON sidecar for a track id (audio dir first, then music dir)."""
2113
+ if not track_id:
2114
+ return None
2115
+ clean_id = os.path.basename(track_id).rsplit(".", 1)[0]
2116
+ song_f = find_song_file(track_id) or find_song_file(clean_id)
2117
+ if song_f and os.path.exists(song_f):
2118
+ return song_f
2119
  for d in (AUDIO_DIR, MUSIC_DIR):
2120
+ for name in (track_id, clean_id):
2121
+ cand = os.path.join(d, f"{name}.json")
2122
+ if os.path.exists(cand):
2123
+ return cand
2124
  return None
2125
 
2126
 
2127
  def _read_or_init_sidecar(track_id: str) -> Dict:
2128
  """Read the track sidecar; auto-create with defaults when only audio exists."""
2129
+ clean_id = os.path.basename(track_id).rsplit(".", 1)[0] if track_id else track_id
2130
+ sc_path = _find_track_sidecar(track_id) or _find_track_sidecar(clean_id)
2131
+ audio_path = _find_track_audio(track_id) or _find_track_audio(clean_id)
2132
  if sc_path and os.path.exists(sc_path):
2133
  try:
2134
  with open(sc_path, "r", encoding="utf-8") as f:
 
2136
  except Exception:
2137
  pass
2138
  meta: Dict[str, Any] = {
2139
+ "id": clean_id or track_id,
2140
+ "title": (clean_id or track_id).replace("_", " ").title(),
2141
  "favorite": False,
2142
  "comments": [],
2143
  "lyrics": "",
2144
+ "tags": [],
2145
  }
2146
  if audio_path:
2147
  meta["audio_file"] = os.path.basename(audio_path)
2148
  meta["audio_url"] = f"/api/media/audio/stream/{os.path.basename(audio_path)}"
2149
  meta["created_at"] = time.time()
2150
  # Persist auto-initialized sidecar next to the audio file
2151
+ sc_path = os.path.join(AUDIO_DIR, f"{clean_id}.json")
2152
  try:
2153
  with open(sc_path, "w", encoding="utf-8") as f:
2154
  json.dump(meta, f, ensure_ascii=False, indent=2)
 
2159
 
2160
  def _write_sidecar_field(track_id: str, **fields) -> Dict:
2161
  """Update one or more fields in the track sidecar and return it."""
2162
+ clean_id = os.path.basename(track_id).rsplit(".", 1)[0] if track_id else track_id
2163
  meta = _read_or_init_sidecar(track_id)
2164
  meta.update(fields)
2165
  if "id" not in meta:
2166
+ meta["id"] = clean_id or track_id
2167
+ sc_path = _find_track_sidecar(track_id) or os.path.join(AUDIO_DIR, f"{clean_id}.json")
2168
  with open(sc_path, "w", encoding="utf-8") as f:
2169
  json.dump(meta, f, ensure_ascii=False, indent=2)
2170
  return meta
 
2173
  # ─── Media Vault: favorites / comments / lyrics / bundle / prune ────────────
2174
 
2175
  @app.post("/api/media/song/{track_id}/favorite")
2176
+ async def toggle_track_favorite(track_id: str, body: Dict = Body(default={})):
2177
  """Toggle favorite flag for a song or sidecar-backed audio track."""
2178
+ clean_id = os.path.basename(track_id).rsplit(".", 1)[0] if track_id else track_id
2179
+ meta = _read_or_init_sidecar(track_id)
2180
+ if body and "favorite" in body and body["favorite"] is not None:
2181
+ fav = bool(body["favorite"])
2182
+ else:
2183
+ fav = not bool(meta.get("favorite", False))
2184
+ meta = _write_sidecar_field(track_id, favorite=fav)
2185
+ # Also sync into song manifest if present
2186
+ fpath = find_song_file(track_id) or find_song_file(clean_id)
2187
+ if fpath and os.path.exists(fpath):
2188
+ try:
2189
+ with open(fpath, "r", encoding="utf-8") as f:
2190
+ d = json.load(f)
2191
+ d["favorite"] = fav
2192
+ with open(fpath, "w", encoding="utf-8") as f:
2193
+ json.dump(d, f, ensure_ascii=False, indent=2)
2194
+ except Exception:
2195
+ pass
2196
+ return {"status": "success", "id": track_id, "favorite": bool(meta.get("favorite", False))}
2197
+
2198
+ @app.post("/api/media/song/{track_id}/tags")
2199
+ async def update_track_tags(track_id: str, body: Dict = Body(...)):
2200
+ """Update, add, or remove tags for a song or audio track."""
2201
+ clean_id = os.path.basename(track_id).rsplit(".", 1)[0] if track_id else track_id
2202
+ meta = _read_or_init_sidecar(track_id)
2203
+ tags = body.get("tags")
2204
+ add_tag = body.get("add_tag")
2205
+ remove_tag = body.get("remove_tag")
2206
+
2207
+ current_tags = list(meta.get("tags") or [])
2208
+ if tags is not None and isinstance(tags, list):
2209
+ current_tags = [str(t).strip() for t in tags if str(t).strip()]
2210
+ if add_tag and str(add_tag).strip():
2211
+ at = str(add_tag).strip().lower()
2212
+ if at not in [t.lower() for t in current_tags]:
2213
+ current_tags.append(at)
2214
+ if remove_tag and str(remove_tag).strip():
2215
+ rt = str(remove_tag).strip().lower()
2216
+ current_tags = [t for t in current_tags if t.lower() != rt]
2217
+
2218
+ meta = _write_sidecar_field(track_id, tags=current_tags)
2219
+ fpath = find_song_file(track_id) or find_song_file(clean_id)
2220
+ if fpath and os.path.exists(fpath):
2221
+ try:
2222
+ with open(fpath, "r", encoding="utf-8") as f:
2223
+ d = json.load(f)
2224
+ d["tags"] = current_tags
2225
+ with open(fpath, "w", encoding="utf-8") as f:
2226
+ json.dump(d, f, ensure_ascii=False, indent=2)
2227
+ except Exception:
2228
+ pass
2229
+ return {"status": "success", "id": track_id, "tags": current_tags}
2230
 
2231
 
2232
  @app.post("/api/media/song/{track_id}/comment")
 
2714
  async def get_song_details(song_id: str):
2715
  """Retrieve full details and manifest for a specific song."""
2716
  fpath = find_song_file(song_id)
2717
+ if fpath and os.path.exists(fpath):
2718
+ with open(fpath, "r", encoding="utf-8") as f:
2719
+ data = json.load(f)
2720
+ data["id"] = data.get("id") or os.path.basename(fpath).replace(".json", "")
2721
+ audio_file = data.get("audio_file") or (os.path.basename(data.get("audio_path", "")) if data.get("audio_path") else "")
2722
+ if audio_file:
2723
+ data["audio_file"] = audio_file
2724
+ if not data.get("audio_url"):
2725
+ data["audio_url"] = f"/api/media/audio/stream/{audio_file}"
2726
+ return data
2727
+ sc = _find_track_sidecar(song_id)
2728
+ if sc or _find_track_audio(song_id):
2729
+ return _read_or_init_sidecar(song_id)
2730
+ raise HTTPException(status_code=404, detail=f"Song '{song_id}' not found")
2731
 
2732
  @app.put("/api/media/song/{song_id}")
2733
  @app.patch("/api/media/song/{song_id}")
2734
  async def update_song_metadata(song_id: str, body: Dict = Body(...)):
2735
+ """Update title, genre, bpm, key, lyrics, vocal_style, mood for a song or sidecar."""
2736
  fpath = find_song_file(song_id)
2737
+ if fpath and os.path.exists(fpath):
2738
+ with open(fpath, "r", encoding="utf-8") as f:
2739
+ data = json.load(f)
 
2740
 
2741
+ for field in ["title", "genre", "bpm", "key", "vocal_style", "mood", "lyrics", "full_lyrics", "topic", "suno_style_prompt", "cover_style", "dialect_level", "tags"]:
2742
+ if field in body:
2743
+ data[field] = body[field]
2744
+ if "bpm" in body and body["bpm"] is not None:
2745
+ try:
2746
+ data["bpm"] = int(body["bpm"])
2747
+ except (ValueError, TypeError):
2748
+ pass
2749
+ if "dialect_level" in body and body["dialect_level"] is not None:
2750
+ try:
2751
+ data["dialect_level"] = int(body["dialect_level"])
2752
+ except (ValueError, TypeError):
2753
+ pass
2754
+ data["updated_at"] = time.time()
2755
 
2756
+ with open(fpath, "w", encoding="utf-8") as f:
2757
+ json.dump(data, f, ensure_ascii=False, indent=2)
2758
+ return {"status": "success", "message": "Song metadata updated", "song": data}
2759
+
2760
+ # If song manifest doesn't exist, update audio sidecar
2761
+ sc = _find_track_sidecar(song_id)
2762
+ if sc or _find_track_audio(song_id):
2763
+ updates = {}
2764
+ for field in ["title", "genre", "bpm", "key", "vocal_style", "mood", "lyrics", "full_lyrics", "topic", "tags"]:
2765
+ if field in body:
2766
+ updates[field] = body[field]
2767
+ updates["updated_at"] = time.time()
2768
+ meta = _write_sidecar_field(song_id, **updates)
2769
+ return {"status": "success", "message": "Track sidecar updated", "song": meta}
2770
+
2771
+ raise HTTPException(status_code=404, detail=f"Song or track '{song_id}' not found")
2772
 
2773
  @app.delete("/api/media/song/{song_id}")
2774
  async def delete_song(song_id: str, delete_media_files: bool = Query(True)):