Sebastiankay commited on
Commit
1ba167b
·
verified ·
1 Parent(s): 0c89718

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +323 -252
app.py CHANGED
@@ -1,284 +1,355 @@
1
- # MARK: app.py
 
 
 
 
 
2
 
3
- from fastapi import FastAPI, Request, HTTPException
4
- from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
5
- from fastapi.staticfiles import StaticFiles
6
- from fastapi.templating import Jinja2Templates
7
- from fastapi.middleware.cors import CORSMiddleware
8
- from pathlib import Path
9
- from dominant_color import get_luminance, complementary, get_dominant_color, _is_grayscale, _rgb_to_hsv, hsv_to_rgb, rgb_to_hsv
10
- import subprocess
11
- import urllib.parse
12
- import os
13
- import json
14
- from mutagen import File
15
- from io import BytesIO
16
  import base64
17
- from PIL import Image
18
- from colorsys import rgb_to_hls, hls_to_rgb
19
  import logging
20
- import wave
21
- import struct
22
- import math
23
- import random
24
- import shutil
25
-
26
- # command = "cls"
27
- # if os.name in ("nt", "dos"): # If Machine is running on Win OS, use cls
28
- # subprocess.call(command, shell=True)
29
- # else: # If Machine is running on OSX/Linux
30
- # subprocess.call(["printf", "\033c"])
31
-
32
-
33
- no_style = "\033[0m"
34
- bold = "\033[91m"
35
- black = "\033[30m"
36
- grey = "\033[90m"
37
- white = "\033[97m"
38
- white_bg = "\033[107m"
39
- blue = "\033[94m"
40
- blue_bg = "\033[104m"
41
- green = "\033[92m"
42
- green_bg = "\033[102m"
43
- yellow = "\033[93m"
44
- red = "\033[31m"
45
- red_light = "\033[91m"
46
- red_light_bg = "\033[101m"
47
-
48
- global logger
49
- logger = logging.getLogger()
50
- handler = logging.StreamHandler()
51
- logging.addLevelName(logging.DEBUG, grey + "DEBUG: " + no_style)
52
- logging.addLevelName(logging.INFO, blue + "ℹ️")
53
- logging.addLevelName(logging.WARNING, no_style + yellow + "WARNING:")
54
- logging.addLevelName(logging.ERROR, no_style + red_light_bg + black + "ERROR: " + no_style)
55
- logging.addLevelName(logging.CRITICAL, f"{no_style + red}CRITICAL:{no_style}")
56
- handler.setLevel(logging.INFO) # DEBUG INFO WARNING ERROR CRITICAL
57
- # formatter = logging.Formatter(fmt=" %(levelname)-8s %(message)s", style="%")
58
- # formatter = logging.Formatter(fmt=" %(levelname)-8s %(message)s")
59
- # # formatter = AnsiColorFormatter("{levelname:<8s} {message}", style="{")
60
- formatter = logging.Formatter("{levelname:s} {message}", style="{")
61
- handler.setFormatter(formatter)
62
- logger.addHandler(handler)
63
- logger.setLevel(logging.INFO) # DEBUG INFO WARNING ERROR CRITICAL
64
-
65
-
66
- # MARK: FastAPI
67
- app = FastAPI()
68
-
69
- app.add_middleware(
70
- CORSMiddleware,
71
- allow_origins=["*"],
72
- allow_credentials=True,
73
- allow_methods=["*"],
74
- allow_headers=["*"],
75
- )
76
-
77
- SERVER_HOST = "0.0.0.0"
78
- SERVER_PORT = 7860
79
- BASE_URL = "https://sebastiankay-my-ai-songs.hf.space/"
80
- GET_FILE_URL = BASE_URL + "getfile/"
81
-
82
 
83
- # Serve static files from the "static" directory
84
- app.mount("/static", StaticFiles(directory="static"), name="static")
 
 
 
 
 
 
85
 
86
- # Set up Jinja2 templates
87
- templates = Jinja2Templates(directory="templates")
 
88
 
89
- # Configure the base directory for your music files
90
- app.mount("/files/music", StaticFiles(directory="files/music"), name="music")
91
- MUSIC_DIR = Path("files/music")
92
- MUSIC_DIR_2 = Path("files/music_2")
93
-
94
-
95
- def create_peaks_json(file_name):
96
- input_file = f"{MUSIC_DIR / file_name}.mp3"
97
- output_file = f"{MUSIC_DIR / file_name}.peaks.json"
98
- command = f'audiowaveform -i "{input_file}" -o "{output_file}" -b 8'
99
- # print("Executing command:", command)
100
- subprocess.call(command, shell=True)
101
-
102
- # with open(output_file, "r") as f:
103
- # peaks = json.load(f)
104
-
105
- # # return peaks["data"]
106
-
107
-
108
- # Function to get the list of music files with metadata
109
- global get_music_files_with_metadata
110
- def get_music_files_with_metadata():
111
- if Path("music_files.json").is_file():
112
- print("Loading music_files.json")
113
- with open("music_files.json", "r") as f:
114
- return json.load(f)
115
-
116
- music_files = []
117
- for file in MUSIC_DIR.iterdir():
118
- if file.is_file():
119
-
120
- if "_HIDETHIS" in file.name or file.name.endswith(".json") or file.name.endswith(".png"):
121
- print(f"{yellow}Ignoring File: {no_style} {file.name}")
122
- continue
123
-
124
- filename_without_mp3 = file.name.rsplit(".", 1)[0]
125
- filename_coverart = filename_without_mp3 + ".coverart.png"
126
- filename_coverart_encoded = urllib.parse.quote(filename_coverart)
127
- filename_peaks = filename_without_mp3 + ".peaks.json"
128
- filename_peaks_encoded = urllib.parse.quote(filename_without_mp3)
129
- if not Path(MUSIC_DIR / filename_peaks).is_file():
130
- print(f"{blue}No peaks file found for: {no_style} {file.name}")
131
- create_peaks_json(filename_without_mp3)
132
-
133
- try: # Wrap metadata extraction in try-except
134
- audio = File(file)
135
- if audio is None: # Handle cases where mutagen can't read the file
136
- print(f"Warning: Could not read metadata for {file.name}")
137
- continue
138
-
139
- track_number_str = audio.get("TRCK", ["0"])[0].split("/")[0]
140
- track_number = int(track_number_str) if track_number_str.isdigit() else 0
141
-
142
- metadata = {
143
- "encoded_title": base64.b64encode(file.name.encode("utf-8")).decode("utf-8"),
144
- "title": str(audio.get("TIT2", ["Unknown"])[0]),
145
- "artist": str(audio.get("TPE1", ["Unknown"])[0]),
146
- "genre": str(audio.get("TCON", ["Unknown"])[0]),
147
- "duration": int(audio.info.length) if hasattr(audio, "info") and hasattr(audio.info, "length") else 0,
148
- "track_number": track_number,
149
- "audiofile": GET_FILE_URL + file.name,
150
- "coverart": GET_FILE_URL + filename_coverart_encoded,
151
- "peaksfile": str(BASE_URL + "api/peaks/" + filename_peaks_encoded) if Path(MUSIC_DIR / filename_peaks).is_file() else "",
152
- "dominant_color_1": "128, 128, 128",
153
- "complementary_color_1": "128, 128, 128",
154
- "dominant_color_2": "128, 128, 128",
155
- "complementary_color_2": "128, 128, 128",
156
- # "peaks": peaks,
157
- }
158
 
159
- music_files.append(metadata)
 
 
 
 
 
 
 
160
 
161
- # --- Cover Art Processing ---
162
- cover_art_path = MUSIC_DIR / filename_coverart
163
- if cover_art_path.is_file():
164
- try:
165
- colors = get_dominant_color(cover_art_path, min_brightness=0.4, max_brightness=0.8, n_clusters=4)
166
 
167
- metadata["dominant_color_1"] = ", ".join(map(str, colors[0]))
168
- metadata["complementary_color_1"] = ", ".join(map(str, colors[1]))
 
 
 
169
 
170
- metadata["dominant_color_2"] = ", ".join(map(str, colors[2]))
171
- metadata["complementary_color_2"] = ", ".join(map(str, colors[3]))
 
 
 
172
 
173
- except Exception as e:
174
- print(f"{red_light}Error{no_style} processing cover art {metadata['coverart']}: {e}")
175
- else:
176
- print(f"Cover art file not found: {cover_art_path}")
177
- # --- End Cover Art Processing ---
178
 
179
- except Exception as e:
180
- print(f"Error processing file {file.name}: {e}")
 
181
 
182
- # Sort music files by track number in descending order
183
- music_files.sort(key=lambda x: x["track_number"], reverse=True)
184
 
185
- with open("music_files.json", "w") as f:
186
- json.dump(music_files, f, indent=4)
187
 
188
- return music_files
 
 
 
 
189
 
 
 
 
190
 
191
- @app.get("/", response_class=FileResponse)
192
- async def read_root():
193
- """Serves the main index.html file from the static directory."""
194
- static_file_path = Path("static/index.html")
195
- if not static_file_path.is_file():
196
- raise HTTPException(status_code=404, detail="index.html not found in static directory")
197
- return FileResponse(static_file_path)
198
 
 
 
 
 
 
 
 
 
199
 
200
- @app.get("/{trackname}", response_class=FileResponse)
201
- async def read_root():
202
- """Serves the main index.html file from the static directory."""
203
- static_file_path = Path("static/index.html")
204
- if not static_file_path.is_file():
205
- raise HTTPException(status_code=404, detail="index.html not found in static directory")
206
- return FileResponse(static_file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
 
209
  @app.get("/getfile/{filename}")
210
- async def get_file(filename: str):
211
- """Endpoint to download files from the music directory."""
212
- # Security check: prevent path traversal
 
 
213
  if ".." in filename or filename.startswith("/"):
214
- raise HTTPException(status_code=400, detail="Invalid filename")
215
 
216
- # Create the full file path
217
  file_path = MUSIC_DIR / filename
218
-
219
- # Check if file exists and is a file
220
  if not file_path.is_file():
221
- raise HTTPException(status_code=404, detail="File not found")
222
-
223
- # get_peaks(file_path)
224
-
225
- # Determine media type based on file extension
226
- media_type = "application/octet-stream" # default binary type
227
- if filename.lower().endswith(".wav"):
228
- media_type = "audio/wav"
229
- elif filename.lower().endswith(".mp3"):
230
- media_type = "audio/mpeg"
231
- elif filename.lower().endswith(".ogg"):
232
- media_type = "audio/ogg"
233
- elif filename.lower().endswith(".flac"):
234
- media_type = "audio/flac"
235
- elif filename.lower().endswith(".png"):
236
- media_type = "image/png"
237
- elif filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"):
238
- media_type = "image/jpeg"
239
- elif filename.lower().endswith(".gif"):
240
- media_type = "image/gif"
241
-
242
- # def iterfile(): # (1)
243
- # with open(file_path, mode="rb") as file_like: # (2)
244
- # yield from file_like # (3)
245
-
246
- # return StreamingResponse(iterfile(), media_type=media_type)
247
- return FileResponse(path=file_path, media_type=media_type, filename=filename)
248
-
249
-
250
- @app.get("/api/peaks/{filename}")
251
- async def get_music_data_peaks(filename: str):
252
- with open(f"{MUSIC_DIR / filename}.peaks.json", "r") as f:
253
- peaks = json.load(f)
254
- peaks_data = peaks["data"]
255
- return peaks_data
256
 
257
 
258
  @app.get("/api/music")
259
- async def get_music_data():
260
- music_files = get_music_files_with_metadata()
261
- return music_files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
 
 
 
263
 
264
  if __name__ == "__main__":
265
- import uvicorn
266
-
267
- if Path("music_files.json").is_file():
268
- print("Removing old music_files.json")
269
- os.remove("music_files.json")
270
-
271
- get_music_files_with_metadat()
272
-
273
- # print(f"\n\nStarting server on: \nhttp://{SERVER_HOST}:{SERVER_PORT}")
274
- print()
275
- logger.info("=============================================================")
276
- logger.info(" ")
277
- logger.info(f"API DOCS: {BASE_URL}docs")
278
- logger.info(f"ENDPOINT: {BASE_URL}api/music")
279
- logger.info(f"ENDPOINT PEAKS: {BASE_URL}api/peaks")
280
- logger.info("")
281
- logger.info("=============================================================")
282
- print()
283
- print()
284
- uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)
 
1
+ """
2
+ app.py
3
+ FastAPI‑Server für das Ausliefern von Musik‑ und Bilddateien inkl. Metadaten.
4
+ """
5
+
6
+ from __future__ import annotations
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import base64
9
+ import json
 
10
  import logging
11
+ import os
12
+ import subprocess
13
+ import urllib.parse
14
+ from pathlib import Path
15
+ from typing import List
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
+ import uvicorn
18
+ from fastapi import FastAPI, HTTPException, Request
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import FileResponse, JSONResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+ from fastapi.templating import Jinja2Templates
23
+ from mutagen import File as MutagenFile
24
+ from dominant_color import get_dominant_color
25
 
26
+ # --------------------------------------------------------------------------- #
27
+ # --------------------------- Logging-Konfiguration ------------------------ #
28
+ # --------------------------------------------------------------------------- #
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ # Farben für die Konsole (nur für Development, bei Produktion meist deaktivieren)
32
+ class AnsiColor:
33
+ GREY = "\033[90m"
34
+ BLUE = "\033[94m"
35
+ YELLOW = "\033[93m"
36
+ RED_BG = "\033[101m"
37
+ RED = "\033[31m"
38
+ RESET = "\033[0m"
39
 
 
 
 
 
 
40
 
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(levelname)s %(message)s",
44
+ handlers=[logging.StreamHandler()],
45
+ )
46
 
47
+ # Schönere Level‑Bezeichnungen (optional)
48
+ logging.addLevelName(logging.DEBUG, f"{AnsiColor.GREY}DEBUG{AnsiColor.RESET}")
49
+ logging.addLevelName(logging.INFO, f"{AnsiColor.BLUE}INFO{AnsiColor.RESET}")
50
+ logging.addLevelName(logging.WARNING, f"{AnsiColor.YELLOW}WARNING{AnsiColor.RESET}")
51
+ logging.addLevelName(logging.ERROR, f"{AnsiColor.RED_BG}{AnsiColor.RED}ERROR{AnsiColor.RESET}")
52
 
53
+ log = logging.getLogger(__name__)
 
 
 
 
54
 
55
+ # --------------------------------------------------------------------------- #
56
+ # ---------------------------- Konfiguration -------------------------------- #
57
+ # --------------------------------------------------------------------------- #
58
 
59
+ SERVER_HOST = "0.0.0.0"
60
+ SERVER_PORT = 7860
61
 
62
+ BASE_URL = os.getenv("BASE_URL", "https://sebastiankay-my-ai-songs.hf.space/")
63
+ GET_FILE_URL = f"{BASE_URL}getfile/"
64
 
65
+ # Pfade
66
+ STATIC_DIR = Path("static")
67
+ TEMPLATE_DIR = Path("templates")
68
+ MUSIC_DIR = Path("files/music")
69
+ CACHE_FILE = Path("music_files.json")
70
 
71
+ # --------------------------------------------------------------------------- #
72
+ # ---------------------------- FastAPI‑App ---------------------------------- #
73
+ # --------------------------------------------------------------------------- #
74
 
75
+ app = FastAPI(
76
+ title="Sebastians Music API",
77
+ description="Ausliefern von Musikdateien, Cover‑Art und Metadaten.",
78
+ version="1.0.0",
79
+ )
 
 
80
 
81
+ # CORS
82
+ app.add_middleware(
83
+ CORSMiddleware,
84
+ allow_origins=["*"],
85
+ allow_credentials=True,
86
+ allow_methods=["*"],
87
+ allow_headers=["*"],
88
+ )
89
 
90
+ # Statisches Verzeichnis (HTML/JS/CSS)
91
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
92
+ # Für direkte Downloads aus dem music‑Ordner
93
+ app.mount("/files/music", StaticFiles(directory=MUSIC_DIR), name="music")
94
+
95
+ templates = Jinja2Templates(directory=TEMPLATE_DIR)
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # --------------------------- Hilfs‑Funktionen ------------------------------- #
99
+ # --------------------------------------------------------------------------- #
100
+
101
+
102
+ def _create_peaks_file(stem: str) -> None:
103
+ """
104
+ Erstellt die *.peaks.json‑Datei mit dem externen Tool `audiowaveform`.
105
+ Wir nutzen `subprocess.run` – das gibt uns besseres Fehler‑Handling
106
+ als `subprocess.call`.
107
+ """
108
+ input_path = MUSIC_DIR / f"{stem}.mp3"
109
+ output_path = MUSIC_DIR / f"{stem}.peaks.json"
110
+
111
+ if not input_path.is_file():
112
+ log.warning(f"Audio‑Datei nicht gefunden: {input_path}")
113
+ return
114
+
115
+ cmd = [
116
+ "audiowaveform",
117
+ "-i",
118
+ str(input_path),
119
+ "-o",
120
+ str(output_path),
121
+ "-b",
122
+ "8",
123
+ ]
124
+
125
+ try:
126
+ subprocess.run(cmd, check=True, capture_output=True)
127
+ log.info(f"Peaks‑File erzeugt: {output_path.name}")
128
+ except subprocess.CalledProcessError as exc:
129
+ log.error(f"Fehler beim Erzeugen von {output_path.name}: {exc.stderr.decode()}")
130
+
131
+
132
+ def _extract_metadata(file_path: Path) -> dict | None:
133
+ """
134
+ Liest Metadaten mit Mutagen und gibt ein Dictionary zurück.
135
+ Rückgabe `None` bei komplettem Fehlschlag (z. B. kein unterstütztes Format).
136
+ """
137
+ audio = MutagenFile(file_path)
138
+ if audio is None:
139
+ log.warning(f"Mutagen kann {file_path.name} nicht lesen.")
140
+ return None
141
+
142
+ # Track‑Nummer (z. B. "5/12") → int, fallback 0
143
+ track_raw = audio.get("TRCK", ["0"])[0].split("/")[0]
144
+ track_number = int(track_raw) if track_raw.isdigit() else 0
145
+
146
+ # Grund‑Metadaten (mit sinnvollen Fallbacks)
147
+ meta = {
148
+ "encoded_title": base64.b64encode(file_path.name.encode()).decode(),
149
+ "title": str(audio.get("TIT2", ["Unknown"])[0]),
150
+ "artist": str(audio.get("TPE1", ["Unknown"])[0]),
151
+ "genre": str(audio.get("TCON", ["Unknown"])[0]),
152
+ "duration": int(audio.info.length) if getattr(audio, "info", None) else 0,
153
+ "track_number": track_number,
154
+ "audiofile": f"{GET_FILE_URL}{file_path.name}",
155
+ }
156
+
157
+ # Cover‑Art und Peaks‑File ergänzen (falls vorhanden)
158
+ stem = file_path.stem
159
+ cover_name = f"{stem}.coverart.png"
160
+ peaks_name = f"{stem}.peaks.json"
161
+
162
+ meta["coverart"] = f"{GET_FILE_URL}{urllib.parse.quote(cover_name)}"
163
+ if (MUSIC_DIR / peaks_name).is_file():
164
+ meta["peaksfile"] = f"{BASE_URL}api/peaks/{urllib.parse.quote(stem)}"
165
+ else:
166
+ # Wenn das Peaks‑File fehlt, on‑the‑fly erzeugen
167
+ _create_peaks_file(stem)
168
+ if (MUSIC_DIR / peaks_name).is_file():
169
+ meta["peaksfile"] = f"{BASE_URL}api/peaks/{urllib.parse.quote(stem)}"
170
+ else:
171
+ meta["peaksfile"] = ""
172
+
173
+ # Farben aus dem Cover‑Art bestimmen (falls vorhanden)
174
+ cover_path = MUSIC_DIR / cover_name
175
+ if cover_path.is_file():
176
+ try:
177
+ # Wir holen vier Farben: 2 dominante + 2 komplementäre
178
+ colors = get_dominant_color(
179
+ cover_path,
180
+ min_brightness=0.4,
181
+ max_brightness=0.8,
182
+ n_clusters=4,
183
+ )
184
+ meta.update(
185
+ {
186
+ "dominant_color_1": ", ".join(map(str, colors[0])),
187
+ "complementary_color_1": ", ".join(map(str, colors[1])),
188
+ "dominant_color_2": ", ".join(map(str, colors[2])),
189
+ "complementary_color_2": ", ".join(map(str, colors[3])),
190
+ }
191
+ )
192
+ except Exception as exc:
193
+ log.error(f"Fehler beim Auswerten von {cover_name}: {exc}")
194
+ else:
195
+ log.debug(f"Kein Cover‑Art gefunden: {cover_name}")
196
+
197
+ return meta
198
+
199
+
200
+ def _load_or_build_cache() -> List[dict]:
201
+ """
202
+ Lädt die JSON‑Cache‑Datei, wenn sie existiert, ansonsten wird sie neu gebaut.
203
+ Der Cache reduziert das Laden von Metadaten bei jedem Request dramatisch.
204
+ """
205
+ if CACHE_FILE.is_file():
206
+ log.info("Cache‑Datei gefunden → lade Metadaten")
207
+ try:
208
+ return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
209
+ except json.JSONDecodeError as exc:
210
+ log.warning(f"Cache ist korrupt: {exc} → neu bauen")
211
+ CACHE_FILE.unlink(missing_ok=True)
212
+
213
+ # ---- Neu bauen ---------------------------------------------------------
214
+ log.info("Cache wird neu erzeugt …")
215
+ music_data: List[dict] = []
216
+
217
+ for entry in MUSIC_DIR.iterdir():
218
+ if not entry.is_file():
219
+ continue
220
+ # Ignorier‑Regeln (wie du sie hattest)
221
+ if any(
222
+ (
223
+ "_HIDETHIS" in entry.name,
224
+ entry.suffix.lower() in {".json", ".png"},
225
+ )
226
+ ):
227
+ log.debug(f"Ignoriere Datei: {entry.name}")
228
+ continue
229
+
230
+ meta = _extract_metadata(entry)
231
+ if meta:
232
+ music_data.append(meta)
233
+
234
+ # Sortieren nach Track‑Nummer (absteigend)
235
+ music_data.sort(key=lambda x: x["track_number"], reverse=True)
236
+
237
+ # Cache schreiben
238
+ CACHE_FILE.write_text(json.dumps(music_data, indent=4, ensure_ascii=False), encoding="utf-8")
239
+ log.info("Cache geschrieben → %s Einträge", len(music_data))
240
+ return music_data
241
+
242
+
243
+ # --------------------------------------------------------------------------- #
244
+ # ----------------------------- API‑Endpoints -------------------------------- #
245
+ # --------------------------------------------------------------------------- #
246
+
247
+
248
+ @app.get("/", response_class=FileResponse, include_in_schema=False)
249
+ async def serve_index():
250
+ """
251
+ Liefert das `index.html` aus dem static‑Verzeichnis.
252
+ """
253
+ index_path = STATIC_DIR / "index.html"
254
+ if not index_path.is_file():
255
+ raise HTTPException(status_code=404, detail="index.html fehlt")
256
+ return FileResponse(index_path)
257
+
258
+
259
+ @app.get("/{track_name}", response_class=FileResponse, include_in_schema=False)
260
+ async def serve_spa(track_name: str):
261
+ """
262
+ Für Single‑Page‑Applications: jede unbekannte Route liefert wieder das `index.html`.
263
+ """
264
+ # Wir ignorieren den Parameter – das Frontend übernimmt das Routing.
265
+ return await serve_index()
266
 
267
 
268
  @app.get("/getfile/{filename}")
269
+ async def download_file(filename: str):
270
+ """
271
+ Stellt jede Datei aus dem Musik‑Ordner zum Download bereit.
272
+ Sicherheits‑Check verhindert Pfad‑Traversal.
273
+ """
274
  if ".." in filename or filename.startswith("/"):
275
+ raise HTTPException(status_code=400, detail="Ungültiger Dateiname")
276
 
 
277
  file_path = MUSIC_DIR / filename
 
 
278
  if not file_path.is_file():
279
+ raise HTTPException(status_code=404, detail="Datei nicht gefunden")
280
+
281
+ # Media‑Type ermitteln – kann später zu einem Mapping ausgelagert werden.
282
+ mime = {
283
+ ".wav": "audio/wav",
284
+ ".mp3": "audio/mpeg",
285
+ ".ogg": "audio/ogg",
286
+ ".flac": "audio/flac",
287
+ ".png": "image/png",
288
+ ".jpg": "image/jpeg",
289
+ ".jpeg": "image/jpeg",
290
+ ".gif": "image/gif",
291
+ }.get(file_path.suffix.lower(), "application/octet-stream")
292
+
293
+ return FileResponse(path=file_path, media_type=mime, filename=filename)
294
+
295
+
296
+ @app.get("/api/peaks/{stem}")
297
+ async def get_peaks(stem: str):
298
+ """
299
+ Liefert das bereits generierte Peaks‑JSON.
300
+ """
301
+ peaks_path = MUSIC_DIR / f"{stem}.peaks.json"
302
+ if not peaks_path.is_file():
303
+ # Noch nicht vorhanden → versuchen wir, es on‑the‑fly zu erzeugen
304
+ _create_peaks_file(stem)
305
+ if not peaks_path.is_file():
306
+ raise HTTPException(status_code=404, detail="Peaks‑File nicht gefunden")
307
+
308
+ try:
309
+ data = json.loads(peaks_path.read_text(encoding="utf-8"))
310
+ return JSONResponse(content=data.get("data", []))
311
+ except json.JSONDecodeError:
312
+ raise HTTPException(status_code=500, detail="Ungültiges Peaks‑JSON")
 
313
 
314
 
315
  @app.get("/api/music")
316
+ async def list_music():
317
+ """
318
+ Gibt die komplette Metadaten‑Liste zurück.
319
+ """
320
+ return _load_or_build_cache()
321
+
322
+
323
+ # --------------------------------------------------------------------------- #
324
+ # ------------------------------- Startup ------------------------------------ #
325
+ # --------------------------------------------------------------------------- #
326
+
327
+
328
+ @app.on_event("startup")
329
+ async def startup_event():
330
+ """
331
+ Beim Server‑Start den Cache (wenn nötig) bauen.
332
+ """
333
+ # Nur einmal beim Start – danach bleibt das im Speicher.
334
+ _load_or_build_cache()
335
+ log.info("FastAPI‑Server bereit 🎉")
336
+
337
 
338
+ # --------------------------------------------------------------------------- #
339
+ # ------------------------------ Main‑Block --------------------------------- #
340
+ # --------------------------------------------------------------------------- #
341
 
342
  if __name__ == "__main__":
343
+ # Im Entwicklungs‑Modus alte Cache‑Datei entfernen (optional)
344
+ if CACHE_FILE.is_file():
345
+ log.debug("Alte Cache‑Datei wird gelöscht")
346
+ CACHE_FILE.unlink()
347
+
348
+ # Server starten
349
+ uvicorn.run(
350
+ app,
351
+ host=SERVER_HOST,
352
+ port=SERVER_PORT,
353
+ log_level="info",
354
+ # reload=True, # <-- für lokales Entwickeln aktivieren
355
+ )