Spaces:
Runtime error
Runtime error
Delete app.py
Browse files
app.py
DELETED
|
@@ -1,346 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import io
|
| 3 |
-
import json
|
| 4 |
-
import sqlite3
|
| 5 |
-
import asyncio
|
| 6 |
-
from typing import Optional
|
| 7 |
-
os.environ["GRADIO_SSR_MODE"] = "false"
|
| 8 |
-
import gradio as gr
|
| 9 |
-
import uvicorn
|
| 10 |
-
from fastapi import FastAPI, Request, HTTPException
|
| 11 |
-
from fastapi.responses import StreamingResponse, Response, FileResponse, HTMLResponse, JSONResponse
|
| 12 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
-
from pydantic import BaseModel
|
| 14 |
-
from telethon import TelegramClient
|
| 15 |
-
from telethon.tl.types import DocumentAttributeAudio, DocumentAttributeFilename
|
| 16 |
-
from mutagen.flac import FLAC
|
| 17 |
-
|
| 18 |
-
# Use /tmp/aether_data because Hugging Face Spaces (/app) is read-only for UID 1000
|
| 19 |
-
DATA_DIR = "/tmp/aether_data"
|
| 20 |
-
COVERS_DIR = os.path.join(DATA_DIR, "covers")
|
| 21 |
-
CONFIG_PATH = os.path.join(DATA_DIR, "config.json")
|
| 22 |
-
DB_PATH = os.path.join(DATA_DIR, "catalogue.db")
|
| 23 |
-
SESSION_PATH = os.path.join(DATA_DIR, "aether_lossless_session")
|
| 24 |
-
|
| 25 |
-
os.makedirs(COVERS_DIR, exist_ok=True)
|
| 26 |
-
|
| 27 |
-
app = FastAPI(title="Aether Lossless Telegram Server")
|
| 28 |
-
app.add_middleware(
|
| 29 |
-
CORSMiddleware,
|
| 30 |
-
allow_origins=["*"],
|
| 31 |
-
allow_methods=["*"],
|
| 32 |
-
allow_headers=["*"],
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
client: Optional[TelegramClient] = None
|
| 36 |
-
|
| 37 |
-
def init_db():
|
| 38 |
-
conn = sqlite3.connect(DB_PATH)
|
| 39 |
-
c = conn.cursor()
|
| 40 |
-
c.execute("""
|
| 41 |
-
CREATE TABLE IF NOT EXISTS tracks (
|
| 42 |
-
msg_id INTEGER PRIMARY KEY,
|
| 43 |
-
title TEXT,
|
| 44 |
-
artist TEXT,
|
| 45 |
-
album TEXT,
|
| 46 |
-
genre TEXT,
|
| 47 |
-
year TEXT,
|
| 48 |
-
track_num INTEGER,
|
| 49 |
-
duration REAL,
|
| 50 |
-
bit_depth INTEGER,
|
| 51 |
-
sample_rate INTEGER,
|
| 52 |
-
channels INTEGER,
|
| 53 |
-
file_size INTEGER,
|
| 54 |
-
mime_type TEXT,
|
| 55 |
-
has_cover INTEGER DEFAULT 0
|
| 56 |
-
)
|
| 57 |
-
""")
|
| 58 |
-
conn.commit()
|
| 59 |
-
conn.close()
|
| 60 |
-
|
| 61 |
-
init_db()
|
| 62 |
-
|
| 63 |
-
def load_config():
|
| 64 |
-
if os.path.exists(CONFIG_PATH):
|
| 65 |
-
with open(CONFIG_PATH, "r") as f:
|
| 66 |
-
return json.load(f)
|
| 67 |
-
return {
|
| 68 |
-
"api_id": int(os.getenv("TG_API_ID", "0")),
|
| 69 |
-
"api_hash": os.getenv("TG_API_HASH", ""),
|
| 70 |
-
"channel_id": int(os.getenv("TG_CHANNEL_ID", "0"))
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
def save_config(cfg):
|
| 74 |
-
with open(CONFIG_PATH, "w") as f:
|
| 75 |
-
json.dump(cfg, f, indent=2)
|
| 76 |
-
|
| 77 |
-
async def ensure_client():
|
| 78 |
-
global client
|
| 79 |
-
cfg = load_config()
|
| 80 |
-
if not cfg["api_id"] or not cfg["api_hash"]:
|
| 81 |
-
return None
|
| 82 |
-
if client is None:
|
| 83 |
-
client = TelegramClient(SESSION_PATH, int(cfg["api_id"]), cfg["api_hash"])
|
| 84 |
-
await client.connect()
|
| 85 |
-
return client
|
| 86 |
-
|
| 87 |
-
class SetupRequest(BaseModel):
|
| 88 |
-
api_id: int
|
| 89 |
-
api_hash: str
|
| 90 |
-
channel_id: int
|
| 91 |
-
phone: str
|
| 92 |
-
|
| 93 |
-
class VerifyRequest(BaseModel):
|
| 94 |
-
phone: str
|
| 95 |
-
code: str
|
| 96 |
-
password: Optional[str] = None
|
| 97 |
-
|
| 98 |
-
@app.get("/api/status")
|
| 99 |
-
async def get_status():
|
| 100 |
-
cfg = load_config()
|
| 101 |
-
if not cfg["api_id"] or not cfg["api_hash"] or not cfg["channel_id"]:
|
| 102 |
-
return {"configured": False, "authorized": False}
|
| 103 |
-
cl = await ensure_client()
|
| 104 |
-
if not cl:
|
| 105 |
-
return {"configured": False, "authorized": False}
|
| 106 |
-
authorized = await cl.is_user_authorized()
|
| 107 |
-
return {"configured": True, "authorized": authorized, "channel_id": cfg["channel_id"]}
|
| 108 |
-
|
| 109 |
-
@app.post("/api/auth/send_code")
|
| 110 |
-
async def send_auth_code(req: SetupRequest):
|
| 111 |
-
global client
|
| 112 |
-
save_config({"api_id": req.api_id, "api_hash": req.api_hash, "channel_id": req.channel_id})
|
| 113 |
-
if client:
|
| 114 |
-
await client.disconnect()
|
| 115 |
-
client = TelegramClient(SESSION_PATH, req.api_id, req.api_hash)
|
| 116 |
-
await client.connect()
|
| 117 |
-
if not await client.is_user_authorized():
|
| 118 |
-
await client.send_code_request(req.phone)
|
| 119 |
-
return {"status": "code_sent"}
|
| 120 |
-
return {"status": "already_authorized"}
|
| 121 |
-
|
| 122 |
-
@app.post("/api/auth/verify")
|
| 123 |
-
async def verify_auth_code(req: VerifyRequest):
|
| 124 |
-
cl = await ensure_client()
|
| 125 |
-
if not cl:
|
| 126 |
-
raise HTTPException(status_code=400, detail="Client not initialized")
|
| 127 |
-
try:
|
| 128 |
-
await cl.sign_in(req.phone, req.code)
|
| 129 |
-
except Exception as e:
|
| 130 |
-
if "SessionPasswordNeededError" in str(type(e)) and req.password:
|
| 131 |
-
await cl.sign_in(password=req.password)
|
| 132 |
-
else:
|
| 133 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 134 |
-
return {"status": "authorized"}
|
| 135 |
-
|
| 136 |
-
async def extract_flac_header_metadata(cl: TelegramClient, msg):
|
| 137 |
-
header_bytes = bytearray()
|
| 138 |
-
async for chunk in cl.iter_download(msg.document, offset=0, request_size=65536):
|
| 139 |
-
header_bytes.extend(chunk)
|
| 140 |
-
if len(header_bytes) >= 524288:
|
| 141 |
-
break
|
| 142 |
-
|
| 143 |
-
meta = {
|
| 144 |
-
"title": None,
|
| 145 |
-
"artist": None,
|
| 146 |
-
"album": "Single / Unknown Album",
|
| 147 |
-
"genre": "Lossless",
|
| 148 |
-
"year": "",
|
| 149 |
-
"track_num": 0,
|
| 150 |
-
"duration": 0.0,
|
| 151 |
-
"bit_depth": 16,
|
| 152 |
-
"sample_rate": 44100,
|
| 153 |
-
"channels": 2,
|
| 154 |
-
"has_cover": 0
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
for attr in msg.document.attributes:
|
| 158 |
-
if isinstance(attr, DocumentAttributeAudio):
|
| 159 |
-
meta["title"] = attr.title
|
| 160 |
-
meta["artist"] = attr.performer
|
| 161 |
-
meta["duration"] = float(attr.duration or 0)
|
| 162 |
-
elif isinstance(attr, DocumentAttributeFilename):
|
| 163 |
-
if not meta["title"]:
|
| 164 |
-
meta["title"] = os.path.splitext(attr.file_name)[0]
|
| 165 |
-
|
| 166 |
-
cover_file = os.path.join(COVERS_DIR, f"{msg.id}.jpg")
|
| 167 |
-
try:
|
| 168 |
-
bio = io.BytesIO(bytes(header_bytes))
|
| 169 |
-
flac = FLAC(bio)
|
| 170 |
-
if flac.info:
|
| 171 |
-
meta["bit_depth"] = getattr(flac.info, "bits_per_sample", 16)
|
| 172 |
-
meta["sample_rate"] = getattr(flac.info, "sample_rate", 44100)
|
| 173 |
-
meta["channels"] = getattr(flac.info, "channels", 2)
|
| 174 |
-
meta["duration"] = float(getattr(flac.info, "length", meta["duration"]))
|
| 175 |
-
if flac.tags:
|
| 176 |
-
meta["title"] = flac.tags.get("title", [meta["title"]])[0]
|
| 177 |
-
meta["artist"] = flac.tags.get("artist", [meta["artist"] or "Unknown Artist"])[0]
|
| 178 |
-
meta["album"] = flac.tags.get("album", [meta["album"]])[0]
|
| 179 |
-
meta["genre"] = flac.tags.get("genre", [meta["genre"]])[0]
|
| 180 |
-
meta["year"] = flac.tags.get("date", flac.tags.get("year", [""]))[0]
|
| 181 |
-
tr = flac.tags.get("tracknumber", ["0"])[0]
|
| 182 |
-
meta["track_num"] = int(str(tr).split("/")[0]) if str(tr).split("/")[0].isdigit() else 0
|
| 183 |
-
if flac.pictures:
|
| 184 |
-
pic = flac.pictures[0]
|
| 185 |
-
with open(cover_file, "wb") as cf:
|
| 186 |
-
cf.write(pic.data)
|
| 187 |
-
meta["has_cover"] = 1
|
| 188 |
-
except Exception:
|
| 189 |
-
try:
|
| 190 |
-
thumb = await cl.download_media(msg, thumb=-1, file=bytes)
|
| 191 |
-
if thumb:
|
| 192 |
-
with open(cover_file, "wb") as cf:
|
| 193 |
-
cf.write(thumb)
|
| 194 |
-
meta["has_cover"] = 1
|
| 195 |
-
except Exception:
|
| 196 |
-
pass
|
| 197 |
-
|
| 198 |
-
if not meta["title"]:
|
| 199 |
-
meta["title"] = f"Track {msg.id}"
|
| 200 |
-
if not meta["artist"]:
|
| 201 |
-
meta["artist"] = "Unknown Artist"
|
| 202 |
-
return meta
|
| 203 |
-
|
| 204 |
-
@app.post("/api/sync")
|
| 205 |
-
async def sync_catalogue():
|
| 206 |
-
cl = await ensure_client()
|
| 207 |
-
cfg = load_config()
|
| 208 |
-
if not cl or not await cl.is_user_authorized():
|
| 209 |
-
raise HTTPException(status_code=401, detail="Telegram not authorized")
|
| 210 |
-
|
| 211 |
-
channel_id = int(cfg["channel_id"])
|
| 212 |
-
conn = sqlite3.connect(DB_PATH)
|
| 213 |
-
c = conn.cursor()
|
| 214 |
-
c.execute("SELECT msg_id FROM tracks")
|
| 215 |
-
existing_ids = {row[0] for row in c.fetchall()}
|
| 216 |
-
|
| 217 |
-
added = 0
|
| 218 |
-
async for msg in cl.iter_messages(channel_id, limit=500):
|
| 219 |
-
if msg.id in existing_ids:
|
| 220 |
-
continue
|
| 221 |
-
if msg.document and msg.file:
|
| 222 |
-
mime = (msg.file.mime_type or "").lower()
|
| 223 |
-
fname = (msg.file.name or "").lower()
|
| 224 |
-
if "audio" in mime or fname.endswith((".flac", ".m4a", ".alac", ".wav")):
|
| 225 |
-
meta = await extract_flac_header_metadata(cl, msg)
|
| 226 |
-
c.execute("""
|
| 227 |
-
INSERT OR REPLACE INTO tracks
|
| 228 |
-
(msg_id, title, artist, album, genre, year, track_num, duration, bit_depth, sample_rate, channels, file_size, mime_type, has_cover)
|
| 229 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 230 |
-
""", (
|
| 231 |
-
msg.id, meta["title"], meta["artist"], meta["album"], meta["genre"],
|
| 232 |
-
meta["year"], meta["track_num"], meta["duration"], meta["bit_depth"],
|
| 233 |
-
meta["sample_rate"], meta["channels"], msg.file.size,
|
| 234 |
-
msg.file.mime_type or "audio/flac", meta["has_cover"]
|
| 235 |
-
))
|
| 236 |
-
conn.commit()
|
| 237 |
-
added += 1
|
| 238 |
-
|
| 239 |
-
conn.close()
|
| 240 |
-
return {"status": "synced", "new_tracks_indexed": added}
|
| 241 |
-
|
| 242 |
-
@app.get("/api/catalogue")
|
| 243 |
-
async def get_catalogue():
|
| 244 |
-
conn = sqlite3.connect(DB_PATH)
|
| 245 |
-
conn.row_factory = sqlite3.Row
|
| 246 |
-
c = conn.cursor()
|
| 247 |
-
c.execute("SELECT * FROM tracks ORDER BY album ASC, track_num ASC, msg_id DESC")
|
| 248 |
-
rows = [dict(r) for r in c.fetchall()]
|
| 249 |
-
conn.close()
|
| 250 |
-
return rows
|
| 251 |
-
|
| 252 |
-
@app.get("/api/cover/{msg_id}")
|
| 253 |
-
async def get_cover(msg_id: int):
|
| 254 |
-
cover_path = os.path.join(COVERS_DIR, f"{msg_id}.jpg")
|
| 255 |
-
if os.path.exists(cover_path):
|
| 256 |
-
return FileResponse(cover_path, media_type="image/jpeg")
|
| 257 |
-
cl = await ensure_client()
|
| 258 |
-
cfg = load_config()
|
| 259 |
-
if cl and await cl.is_user_authorized():
|
| 260 |
-
msg = await cl.get_messages(int(cfg["channel_id"]), ids=msg_id)
|
| 261 |
-
if msg:
|
| 262 |
-
thumb = await cl.download_media(msg, thumb=-1, file=bytes)
|
| 263 |
-
if thumb:
|
| 264 |
-
with open(cover_path, "wb") as f:
|
| 265 |
-
f.write(thumb)
|
| 266 |
-
return Response(content=thumb, media_type="image/jpeg")
|
| 267 |
-
raise HTTPException(status_code=404, detail="Cover not found")
|
| 268 |
-
|
| 269 |
-
@app.get("/api/stream/{msg_id}")
|
| 270 |
-
async def stream_lossless(msg_id: int, request: Request):
|
| 271 |
-
cl = await ensure_client()
|
| 272 |
-
cfg = load_config()
|
| 273 |
-
if not cl or not await cl.is_user_authorized():
|
| 274 |
-
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 275 |
-
|
| 276 |
-
msg = await cl.get_messages(int(cfg["channel_id"]), ids=msg_id)
|
| 277 |
-
if not msg or not msg.document:
|
| 278 |
-
raise HTTPException(status_code=404, detail="Track not found in Telegram channel")
|
| 279 |
-
|
| 280 |
-
file_size = msg.file.size
|
| 281 |
-
range_header = request.headers.get("Range")
|
| 282 |
-
offset = 0
|
| 283 |
-
end = file_size - 1
|
| 284 |
-
|
| 285 |
-
if range_header:
|
| 286 |
-
parts = range_header.replace("bytes=", "").split("-")
|
| 287 |
-
offset = int(parts[0]) if parts[0] else 0
|
| 288 |
-
if len(parts) > 1 and parts[1]:
|
| 289 |
-
end = int(parts[1])
|
| 290 |
-
|
| 291 |
-
aligned_offset = offset - (offset % 4096)
|
| 292 |
-
skip_bytes = offset - aligned_offset
|
| 293 |
-
content_length = end - offset + 1
|
| 294 |
-
|
| 295 |
-
async def chunk_generator():
|
| 296 |
-
first = True
|
| 297 |
-
remaining = content_length
|
| 298 |
-
async for chunk in cl.iter_download(msg.document, offset=aligned_offset, request_size=65536):
|
| 299 |
-
if first and skip_bytes:
|
| 300 |
-
chunk = chunk[skip_bytes:]
|
| 301 |
-
first = False
|
| 302 |
-
if len(chunk) >= remaining:
|
| 303 |
-
yield chunk[:remaining]
|
| 304 |
-
break
|
| 305 |
-
remaining -= len(chunk)
|
| 306 |
-
yield chunk
|
| 307 |
-
|
| 308 |
-
headers = {
|
| 309 |
-
"Content-Range": f"bytes {offset}-{end}/{file_size}",
|
| 310 |
-
"Accept-Ranges": "bytes",
|
| 311 |
-
"Content-Length": str(content_length),
|
| 312 |
-
"Cache-Control": "public, max-age=86400"
|
| 313 |
-
}
|
| 314 |
-
return StreamingResponse(
|
| 315 |
-
chunk_generator(),
|
| 316 |
-
status_code=206 if range_header else 200,
|
| 317 |
-
media_type=msg.file.mime_type or "audio/flac",
|
| 318 |
-
headers=headers
|
| 319 |
-
)
|
| 320 |
-
|
| 321 |
-
INDEX_HTML = '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />\n <title>Aether • Lossless Music Streamer</title>\n <link rel="manifest" href="/manifest.json" />\n <style>\n :root {\n --glass-bg: rgba(255, 255, 255, 0.07);\n --glass-border: rgba(255, 255, 255, 0.16);\n --accent: #ff2d55;\n }\n * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif; }\n body {\n background: #07080c; color: #fff; height: 100vh; width: 100vw;\n overflow: hidden; display: flex; flex-direction: column; position: relative;\n }\n .ambient-canvas {\n position: fixed; inset: -60px; background-size: cover; background-position: center;\n filter: blur(85px) brightness(0.42) saturate(2.1); transform: scale(1.25);\n transition: background-image 0.9s cubic-bezier(0.22, 1, 0.36, 1); z-index: 0;\n }\n .app-layout {\n position: relative; z-index: 1; display: grid;\n grid-template-columns: 240px 1fr 340px; gap: 16px;\n height: calc(100vh - 104px); padding: 16px 16px 8px 16px;\n }\n .glass-card {\n background: var(--glass-bg);\n backdrop-filter: blur(40px) saturate(190%);\n -webkit-backdrop-filter: blur(40px) saturate(190%);\n border: 1px solid var(--glass-border);\n border-radius: 24px; padding: 18px; overflow-y: auto;\n box-shadow: 0 20px 50px rgba(0,0,0,0.45);\n }\n /* Sidebar Navigation */\n .brand { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 22px; display: flex; align-items: center; gap: 10px; }\n .brand span { background: linear-gradient(135deg, #fff, #a5b4fc); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }\n .nav-btn {\n width: 100%; text-align: left; background: transparent; border: none; color: rgba(255,255,255,0.75);\n padding: 11px 14px; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer;\n transition: 0.2s; margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between;\n }\n .nav-btn:hover, .nav-btn.active { background: rgba(255,255,255,0.14); color: #fff; }\n .sync-btn {\n margin-top: auto; width: 100%; padding: 12px; border-radius: 14px; border: 1px solid rgba(255,255,255,0.25);\n background: rgba(255,255,255,0.1); color: #fff; font-weight: 700; cursor: pointer; transition: 0.2s;\n }\n .sync-btn:hover { background: rgba(255,255,255,0.22); }\n /* Center Catalogue */\n .top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }\n .search-input {\n flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);\n padding: 10px 16px; border-radius: 99px; color: #fff; font-size: 14px; outline: none;\n }\n .grid-albums { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 16px; }\n .album-card {\n background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);\n padding: 10px; border-radius: 18px; cursor: pointer; transition: transform 0.2s, background 0.2s;\n }\n .album-card:hover { transform: translateY(-4px); background: rgba(255,255,255,0.12); }\n .album-card img { width: 100%; aspect-ratio: 1; border-radius: 12px; object-fit: cover; margin-bottom: 8px; background: #1a1c23; }\n .song-row {\n display: grid; grid-template-columns: 46px 1fr 180px 110px 60px; align-items: center;\n gap: 12px; padding: 8px 12px; border-radius: 14px; cursor: pointer; transition: 0.15s;\n border-bottom: 1px solid rgba(255,255,255,0.04);\n }\n .song-row:hover, .song-row.active { background: rgba(255,255,255,0.13); }\n .song-row img { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: #1a1c23; }\n .hi-res-pill {\n font-size: 10px; font-weight: 700; padding: 3px 7px; border-radius: 6px;\n background: rgba(255, 214, 10, 0.18); color: #ffd60a; border: 1px solid rgba(255, 214, 10, 0.35);\n text-align: center; width: fit-content;\n }\n /* Right Panel: Lyrics & EQ */\n .tab-header { display: flex; gap: 8px; margin-bottom: 14px; }\n .tab-pill {\n flex: 1; padding: 8px; border-radius: 10px; border: none; background: rgba(255,255,255,0.07);\n color: #fff; font-size: 12px; font-weight: 600; cursor: pointer;\n }\n .tab-pill.active { background: rgba(255,255,255,0.22); }\n .lyric-line {\n font-size: 16px; font-weight: 600; opacity: 0.32; margin: 14px 0;\n transition: all 0.35s ease; cursor: pointer; line-height: 1.4;\n }\n .lyric-line.active {\n opacity: 1; font-size: 20px; transform: scale(1.02); transform-origin: left;\n text-shadow: 0 0 24px rgba(255,255,255,0.65);\n }\n /* Bottom Player Bar */\n .player-dock {\n position: relative; z-index: 2; height: 88px; margin: 0 16px 12px 16px;\n display: grid; grid-template-columns: 300px 1fr 300px; align-items: center; padding: 0 20px;\n }\n .now-info { display: flex; align-items: center; gap: 14px; }\n .now-info img { width: 56px; height: 56px; border-radius: 12px; object-fit: cover; box-shadow: 0 8px 20px rgba(0,0,0,0.4); }\n .transport { display: flex; flex-direction: column; align-items: center; gap: 6px; }\n .ctrl-btns { display: flex; align-items: center; gap: 18px; }\n .btn-circle {\n width: 40px; height: 40px; border-radius: 50%; border: none; background: #fff; color: #000;\n font-size: 16px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center;\n }\n .btn-icon { background: transparent; border: none; color: #fff; font-size: 15px; cursor: pointer; opacity: 0.8; }\n .btn-icon:hover { opacity: 1; }\n .seek-bar { width: 100%; max-width: 480px; display: flex; align-items: center; gap: 10px; font-size: 11px; opacity: 0.8; }\n .seek-bar input { flex: 1; accent-color: #fff; cursor: pointer; }\n /* Modal for Telegram Setup */\n .modal-backdrop {\n position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(20px);\n z-index: 50; display: none; align-items: center; justify-content: center;\n }\n .modal-box { width: 420px; max-width: 92vw; }\n .field { margin-bottom: 12px; }\n .field label { display: block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }\n .field input {\n width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.2);\n background: rgba(255,255,255,0.08); color: #fff;\n }\n @media (max-width: 900px) {\n .app-layout { grid-template-columns: 1fr; height: calc(100vh - 150px); }\n .sidebar-card { display: none; }\n .player-dock { grid-template-columns: 1fr; height: auto; padding: 12px; gap: 10px; }\n }\n </style>\n</head>\n<body>\n <div class="ambient-canvas" id="ambientBg"></div>\n\n <div class="app-layout">\n <!-- Left Sidebar -->\n <div class="glass-card sidebar-card" style="display:flex; flex-direction:column;">\n <div class="brand">◈ <span>Aether Lossless</span></div>\n <button class="nav-btn active" onclick="setView(\'songs\', this)"><span>All Songs</span><small id="countSongs">0</small></button>\n <button class="nav-btn" onclick="setView(\'albums\', this)"><span>Albums</span><small id="countAlbums">0</small></button>\n <button class="nav-btn" onclick="setView(\'artists\', this)"><span>Artists</span><small id="countArtists">0</small></button>\n <button class="nav-btn" onclick="setView(\'hires\', this)"><span>24-Bit Hi-Res</span><small id="countHiRes">0</small></button>\n\n <div style="margin-top:auto; display:flex; flex-direction:column; gap:8px;">\n <button class="sync-btn" onclick="syncTelegram()" id="syncBtn">↻ Sync Telegram Vault</button>\n <button class="nav-btn" onclick="openSetupModal()" style="font-size:12px; justify-content:center;">⚙ Telegram Settings</button>\n </div>\n </div>\n\n <!-- Center Library Catalogue -->\n <div class="glass-card" id="mainView">\n <div class="top-bar">\n <h2 id="viewTitle" style="font-size: 22px;">Lossless Catalogue</h2>\n <input type="text" class="search-input" id="searchInput" placeholder="Search songs, albums, artists, or 24-bit..." oninput="renderCatalogue()" />\n </div>\n <div id="catalogueContent"></div>\n </div>\n\n <!-- Right Panel: Synced Lyrics + Audiophile DSP -->\n <div class="glass-card" style="display:flex; flex-direction:column;">\n <div class="tab-header">\n <button class="tab-pill active" id="tabLyricsBtn" onclick="switchRightTab(\'lyrics\')">Synced Lyrics</button>\n <button class="tab-pill" id="tabEqBtn" onclick="switchRightTab(\'eq\')">Audio Engine & EQ</button>\n </div>\n\n <!-- Lyrics View -->\n <div id="lyricsPane" style="flex:1; overflow-y:auto;">\n <p style="opacity:0.5; font-size:14px;">Select any lossless track from your catalogue to stream and load time-synced lyrics.</p>\n </div>\n\n <!-- Audiophile DSP & Bit-Perfect Toggle View -->\n <div id="eqPane" style="display:none; flex:1; overflow-y:auto;">\n <div style="background:rgba(255,255,255,0.06); padding:12px; border-radius:14px; margin-bottom:14px;">\n <label style="display:flex; justify-content:space-between; align-items:center; font-size:13px; font-weight:700; cursor:pointer;">\n <span>Bit-Perfect Direct Stream (Bypass DSP)</span>\n <input type="checkbox" id="bitPerfectToggle" checked onchange="toggleBitPerfect()" />\n </label>\n <p style="font-size:11px; opacity:0.65; margin-top:6px;">\n When enabled, audio bypasses browser DSP filters for pure untouched 16-bit/24-bit FLAC output. Turn off to engage the 5-band Equalizer & Spatial Crossfeed.\n </p>\n </div>\n\n <div id="dspControls" style="opacity:0.4; pointer-events:none;">\n <div class="field"><label>Sub-Bass (60 Hz)</label><input type="range" id="eq60" min="-12" max="12" value="0" /></div>\n <div class="field"><label>Low-Mid (250 Hz)</label><input type="range" id="eq250" min="-12" max="12" value="0" /></div>\n <div class="field"><label>Vocal Presence (1 kHz)</label><input type="range" id="eq1k" min="-12" max="12" value="0" /></div>\n <div class="field"><label>Clarity (4 kHz)</label><input type="range" id="eq4k" min="-12" max="12" value="0" /></div>\n <div class="field"><label>Air / Brilliance (12 kHz)</label><input type="range" id="eq12k" min="-12" max="12" value="0" /></div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Bottom Glass Player Dock -->\n <div class="glass-card player-dock">\n <div class="now-info">\n <img id="nowCover" src="https://via.placeholder.com/120/15171e/ffffff?text=FLAC" />\n <div style="overflow:hidden;">\n <div id="nowTitle" style="font-weight:700; font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">Ready to Stream</div>\n <div id="nowArtist" style="font-size:12px; opacity:0.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">Telegram Lossless Server</div>\n <div id="nowBadge" class="hi-res-pill" style="margin-top:4px;">LOSSLESS FLAC</div>\n </div>\n </div>\n\n <div class="transport">\n <div class="ctrl-btns">\n <button class="btn-icon" onclick="prevTrack()" title="Previous">⏮</button>\n <button class="btn-circle" id="playPauseBtn" onclick="togglePlay()">▶</button>\n <button class="btn-icon" onclick="nextTrack()" title="Next">⏭</button>\n <button class="btn-icon" onclick="startSmartRadio()" title="Start Smart Radio Queue">📻 Radio</button>\n </div>\n <div class="seek-bar">\n <span id="curTime">0:00</span>\n <input type="range" id="seekSlider" min="0" max="100" value="0" oninput="seekTo(this.value)" />\n <span id="durTime">0:00</span>\n </div>\n </div>\n\n <div style="display:flex; justify-content:flex-end; align-items:center; gap:10px;">\n <span style="font-size:12px; opacity:0.7;">🔊</span>\n <input type="range" min="0" max="1" step="0.01" value="1" style="width:110px; accent-color:#fff;" oninput="audio.volume = this.value" />\n </div>\n </div>\n\n <audio id="audioElement" crossorigin="anonymous" preload="auto"></audio>\n\n <!-- Setup Modal -->\n <div class="modal-backdrop" id="setupModal">\n <div class="glass-card modal-box">\n <h3 style="margin-bottom:10px;">Connect Telegram Lossless Vault</h3>\n <p style="font-size:12px; opacity:0.75; margin-bottom:14px;">\n Enter your credentials from <b>my.telegram.org</b> and your private channel ID (starting with <code>-100</code>).\n </p>\n <div class="field"><label>API ID</label><input type="number" id="inpApiId" placeholder="12345678" /></div>\n <div class="field"><label>API Hash</label><input type="text" id="inpApiHash" placeholder="0123456789abcdef..." /></div>\n <div class="field"><label>Private Channel ID</label><input type="number" id="inpChannelId" placeholder="-1001928374650" /></div>\n <div class="field"><label>Phone Number (with country code)</label><input type="text" id="inpPhone" placeholder="+919876543210" /></div>\n <div class="field" id="otpBox" style="display:none;"><label>Telegram Login Code (OTP)</label><input type="text" id="inpOtp" placeholder="12345" /></div>\n\n <div style="display:flex; gap:10px; margin-top:16px;">\n <button class="sync-btn" onclick="submitSetup()" id="setupSubmitBtn">Send Login Code</button>\n <button class="nav-btn" onclick="closeSetupModal()" style="width:auto;">Close</button>\n </div>\n </div>\n </div>\n\n <script>\n const audio = document.getElementById("audioElement");\n let catalogue = [];\n let filteredQueue = [];\n let currentIndex = -1;\n let currentView = "songs";\n let syncedLyrics = [];\n let audioCtx, sourceNode, filters = [];\n\n async function initApp() {\n const st = await fetch("/api/status").then(r => r.json()).catch(() => ({configured: false}));\n if (!st.configured || !st.authorized) {\n openSetupModal();\n } else {\n await loadCatalogue();\n }\n }\n\n async function loadCatalogue() {\n catalogue = await fetch("/api/catalogue").then(r => r.json());\n document.getElementById("countSongs").innerText = catalogue.length;\n document.getElementById("countAlbums").innerText = new Set(catalogue.map(t => t.album)).size;\n document.getElementById("countArtists").innerText = new Set(catalogue.map(t => t.artist)).size;\n document.getElementById("countHiRes").innerText = catalogue.filter(t => t.bit_depth >= 24).length;\n renderCatalogue();\n }\n\n function setView(view, btn) {\n currentView = view;\n document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));\n if (btn) btn.classList.add("active");\n renderCatalogue();\n }\n\n function formatTime(sec) {\n if (!sec || isNaN(sec)) return "0:00";\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60).toString().padStart(2, "0");\n return `${m}:${s}`;\n }\n\n function renderCatalogue() {\n const q = document.getElementById("searchInput").value.toLowerCase();\n const box = document.getElementById("catalogueContent");\n let list = catalogue.filter(t =>\n t.title.toLowerCase().includes(q) ||\n t.artist.toLowerCase().includes(q) ||\n t.album.toLowerCase().includes(q) ||\n `${t.bit_depth}-bit`.includes(q)\n );\n\n if (currentView === "hires") {\n list = list.filter(t => t.bit_depth >= 24);\n }\n\n filteredQueue = list;\n\n if (currentView === "albums") {\n const albums = {};\n list.forEach(t => {\n if (!albums[t.album]) albums[t.album] = t;\n });\n box.innerHTML = `<div class="grid-albums">` + Object.values(albums).map(a => `\n <div class="album-card" onclick="filterByText(\'${a.album.replace(/\'/g, "\\\'")}\')">\n <img src="/api/cover/${a.msg_id}" onerror="this.src=\'https://via.placeholder.com/200/15171e/ffffff?text=FLAC\'" />\n <div style="font-weight:700; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${a.album}</div>\n <div style="font-size:11px; opacity:0.65;">${a.artist}</div>\n </div>\n `).join("") + `</div>`;\n return;\n }\n\n box.innerHTML = list.map((t, idx) => {\n const khz = (t.sample_rate / 1000).toFixed(1).replace(".0", "");\n return `\n <div class="song-row ${filteredQueue[currentIndex]?.msg_id === t.msg_id ? \'active\' : \'\'}" onclick="playIndex(${idx})">\n <img src="/api/cover/${t.msg_id}" onerror="this.src=\'https://via.placeholder.com/80/15171e/ffffff?text=♪\'" />\n <div style="overflow:hidden;">\n <div style="font-weight:600; font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${t.title}</div>\n <div style="font-size:12px; opacity:0.65;">${t.artist}</div>\n </div>\n <div style="font-size:12px; opacity:0.65; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${t.album}</div>\n <div class="hi-res-pill">${t.bit_depth}-BIT • ${khz}kHz</div>\n <div style="font-size:12px; opacity:0.6; text-align:right;">${formatTime(t.duration)}</div>\n </div>\n `;\n }).join("");\n }\n\n function filterByText(txt) {\n document.getElementById("searchInput").value = txt;\n setView("songs");\n }\n\n function playIndex(idx) {\n if (idx < 0 || idx >= filteredQueue.length) return;\n currentIndex = idx;\n const t = filteredQueue[idx];\n const khz = (t.sample_rate / 1000).toFixed(1).replace(".0", "");\n\n document.getElementById("nowTitle").innerText = t.title;\n document.getElementById("nowArtist").innerText = `${t.artist} — ${t.album}`;\n document.getElementById("nowBadge").innerText = `${t.bit_depth}-BIT / ${khz}kHz LOSSLESS`;\n const coverUrl = `/api/cover/${t.msg_id}`;\n document.getElementById("nowCover").src = coverUrl;\n document.getElementById("ambientBg").style.backgroundImage = `url(${coverUrl})`;\n\n audio.src = `/api/stream/${t.msg_id}`;\n audio.play();\n document.getElementById("playPauseBtn").innerText = "⏸";\n renderCatalogue();\n loadLyrics(t.title, t.artist);\n\n if (\'mediaSession\' in navigator) {\n navigator.mediaSession.metadata = new MediaMetadata({\n title: t.title, artist: t.artist, album: t.album,\n artwork: [{ src: coverUrl, sizes: \'512x512\', type: \'image/jpeg\' }]\n });\n }\n }\n\n function togglePlay() {\n if (!audio.src && filteredQueue.length) return playIndex(0);\n if (audio.paused) { audio.play(); document.getElementById("playPauseBtn").innerText = "⏸"; }\n else { audio.pause(); document.getElementById("playPauseBtn").innerText = "▶"; }\n }\n\n function nextTrack() { if (currentIndex + 1 < filteredQueue.length) playIndex(currentIndex + 1); }\n function prevTrack() { if (currentIndex - 1 >= 0) playIndex(currentIndex - 1); }\n audio.onended = () => nextTrack();\n\n audio.ontimeupdate = () => {\n document.getElementById("curTime").innerText = formatTime(audio.currentTime);\n document.getElementById("durTime").innerText = formatTime(audio.duration);\n if (audio.duration) {\n document.getElementById("seekSlider").value = (audio.currentTime / audio.duration) * 100;\n }\n let activeIdx = -1;\n for (let i = 0; i < syncedLyrics.length; i++) {\n if (audio.currentTime >= syncedLyrics[i].time) activeIdx = i;\n }\n syncedLyrics.forEach((item, i) => {\n if (i === activeIdx) {\n item.el.classList.add("active");\n item.el.scrollIntoView({ behavior: "smooth", block: "center" });\n } else {\n item.el.classList.remove("active");\n }\n });\n };\n\n function seekTo(pct) {\n if (audio.duration) audio.currentTime = (pct / 100) * audio.duration;\n }\n\n async function loadLyrics(title, artist) {\n const pane = document.getElementById("lyricsPane");\n pane.innerHTML = `<p style="opacity:0.5;">Fetching time-synced lyrics...</p>`;\n syncedLyrics = [];\n try {\n const res = await fetch(`https://lrclib.net/api/search?q=${encodeURIComponent(title + " " + artist)}`);\n const data = await res.json();\n const found = data.find(d => d.syncedLyrics);\n if (!found) { pane.innerHTML = `<p style="opacity:0.5;">No synced lyrics found for this track.</p>`; return; }\n pane.innerHTML = "";\n found.syncedLyrics.split("\\n").forEach(line => {\n const m = line.match(/\\[(\\d+):(\\d+\\.\\d+)\\](.*)/);\n if (m) {\n const time = parseInt(m[1]) * 60 + parseFloat(m[2]);\n const text = m[3].trim();\n if (text) {\n const div = document.createElement("div");\n div.className = "lyric-line";\n div.innerText = text;\n div.onclick = () => { audio.currentTime = time; };\n pane.appendChild(div);\n syncedLyrics.push({ time, el: div });\n }\n }\n });\n } catch (e) {\n pane.innerHTML = `<p style="opacity:0.5;">Lyrics service unreachable.</p>`;\n }\n }\n\n function toggleBitPerfect() {\n const bp = document.getElementById("bitPerfectToggle").checked;\n const ctrl = document.getElementById("dspControls");\n if (bp) {\n ctrl.style.opacity = "0.4";\n ctrl.style.pointerEvents = "none";\n filters.forEach(f => f.gain.value = 0);\n } else {\n ctrl.style.opacity = "1";\n ctrl.style.pointerEvents = "auto";\n if (!audioCtx) {\n audioCtx = new (window.AudioContext || window.webkitAudioContext)();\n sourceNode = audioCtx.createMediaElementSource(audio);\n const freqs = [60, 250, 1000, 4000, 12000];\n const ids = ["eq60", "eq250", "eq1k", "eq4k", "eq12k"];\n let prev = sourceNode;\n freqs.forEach((f, idx) => {\n const node = audioCtx.createBiquadFilter();\n node.type = idx === 0 ? "lowshelf" : idx === 4 ? "highshelf" : "peaking";\n node.frequency.value = f;\n prev.connect(node);\n prev = node;\n filters.push(node);\n document.getElementById(ids[idx]).oninput = e => node.gain.value = parseFloat(e.target.value);\n });\n prev.connect(audioCtx.destination);\n }\n }\n }\n\n function startSmartRadio() {\n if (currentIndex < 0) return;\n const seed = filteredQueue[currentIndex];\n // Smart Radio: prioritizes same genre/artist or similar bit-depth lossless tracks\n const radioQueue = [seed, ...catalogue.filter(t => t.msg_id !== seed.msg_id).sort((a, b) => {\n const scoreA = (a.artist === seed.artist ? 3 : 0) + (a.genre === seed.genre ? 2 : 0) + Math.random();\n const scoreB = (b.artist === seed.artist ? 3 : 0) + (b.genre === seed.genre ? 2 : 0) + Math.random();\n return scoreB - scoreA;\n })];\n filteredQueue = radioQueue;\n currentIndex = 0;\n renderCatalogue();\n }\n\n function switchRightTab(tab) {\n document.getElementById("lyricsPane").style.display = tab === "lyrics" ? "block" : "none";\n document.getElementById("eqPane").style.display = tab === "eq" ? "block" : "none";\n document.getElementById("tabLyricsBtn").classList.toggle("active", tab === "lyrics");\n document.getElementById("tabEqBtn").classList.toggle("active", tab === "eq");\n }\n\n async function syncTelegram() {\n const btn = document.getElementById("syncBtn");\n btn.innerText = "⏳ Indexing FLAC Headers...";\n const res = await fetch("/api/sync", { method: "POST" });\n if (res.ok) {\n await loadCatalogue();\n btn.innerText = "✓ Vault Synced";\n setTimeout(() => btn.innerText = "↻ Sync Telegram Vault", 2500);\n } else {\n btn.innerText = "⚠ Auth Required";\n openSetupModal();\n }\n }\n\n function openSetupModal() { document.getElementById("setupModal").style.display = "flex"; }\n function closeSetupModal() { document.getElementById("setupModal").style.display = "none"; }\n\n let awaitingOtp = false;\n async function submitSetup() {\n const api_id = parseInt(document.getElementById("inpApiId").value);\n const api_hash = document.getElementById("inpApiHash").value.trim();\n const channel_id = parseInt(document.getElementById("inpChannelId").value);\n const phone = document.getElementById("inpPhone").value.trim();\n\n if (!awaitingOtp) {\n const res = await fetch("/api/auth/send_code", {\n method: "POST", headers: { "Content-Type": "application/json" },\n body: JSON.stringify({ api_id, api_hash, channel_id, phone })\n }).then(r => r.json());\n if (res.status === "code_sent") {\n awaitingOtp = true;\n document.getElementById("otpBox").style.display = "block";\n document.getElementById("setupSubmitBtn").innerText = "Verify OTP & Connect";\n } else if (res.status === "already_authorized") {\n closeSetupModal();\n syncTelegram();\n }\n } else {\n const code = document.getElementById("inpOtp").value.trim();\n const res = await fetch("/api/auth/verify", {\n method: "POST", headers: { "Content-Type": "application/json" },\n body: JSON.stringify({ phone, code })\n });\n if (res.ok) {\n closeSetupModal();\n syncTelegram();\n }\n }\n }\n\n initApp();\n </script>\n</body>\n</html>\n'
|
| 322 |
-
MANIFEST_JSON = '{\n "name": "Aether Lossless Music",\n "short_name": "Aether",\n "description": "Bit-Perfect 24-Bit Lossless Music Player powered by Telegram Cloud",\n "start_url": "/",\n "display": "standalone",\n "background_color": "#07080c",\n "theme_color": "#07080c",\n "icons": [\n {\n "src": "https://cdn-icons-png.flaticon.com/512/3844/3844724.png",\n "sizes": "512x512",\n "type": "image/png"\n }\n ]\n}\n'
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
@app.get("/manifest.json")
|
| 326 |
-
async def serve_manifest():
|
| 327 |
-
return Response(content=MANIFEST_JSON, media_type="application/json")
|
| 328 |
-
|
| 329 |
-
@app.get("/player")
|
| 330 |
-
async def serve_player():
|
| 331 |
-
return HTMLResponse(content=INDEX_HTML)
|
| 332 |
-
|
| 333 |
-
# Real Gradio Blocks App so Hugging Face Gradio 6.30 & ZeroGPU runtime checks pass 100% natively!
|
| 334 |
-
with gr.Blocks(title="Aether Lossless") as demo:
|
| 335 |
-
gr.HTML("""
|
| 336 |
-
<div style="position:fixed; inset:0; z-index:999999; background:#07080c;">
|
| 337 |
-
<iframe src="/player" style="width:100%; height:100%; border:none;" allow="autoplay; encrypted-media"></iframe>
|
| 338 |
-
</div>
|
| 339 |
-
""")
|
| 340 |
-
|
| 341 |
-
# Mount the real Gradio app onto our FastAPI server
|
| 342 |
-
app = gr.mount_gradio_app(app, demo, path="/", ssr_mode=False)
|
| 343 |
-
|
| 344 |
-
if __name__ == "__main__":
|
| 345 |
-
port = int(os.environ.get("GRADIO_SERVER_PORT", 7860))
|
| 346 |
-
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|