Add files via upload

This commit is contained in:
Savsis 2026-07-07 18:19:47 +05:00 • committed by GitHub
parent 07f2ed525b
commit 6e7f0c1505
37 changed files with 4084 additions and 20 deletions

37
LICENSE
View file

@ -1,24 +1,21 @@
This is free and unencumbered software released into the public domain.
MIT License
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
Copyright (c) 2026 savsis
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
For more information, please refer to <https://unlicense.org>
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# nexdex.space — sources
это открытая версия исходников (для гита) — читаемый python/flask код без
скомпилированных бинарей. если нужен готовый инструмент — смотри релиз
`nexdex.space-setup.exe`, который разворачивает всё сам (portable python +
ffmpeg внутри, ничего ставить не надо). лицензия — MIT, см. `LICENSE`.
---
## структура
```
app/ — flask-приложение (см. app/README.md — там всё подробно)
app.py
backend/ — downloader, converter, metadata, scripts_format, search, history
templates/
static/
installer/ — исходники установщика (реальный, рабочий C++)
installer.cpp — консольный лаунчер: при первом запуске достаёт из себя
зашитый ресурс (payload.zip — portable python + весь
app/ + ffmpeg), распаковывает в %LOCALAPPDATA%\nexdex.space,
создаёт ярлык на рабочем столе, дальше просто запускает
installer.rc — иконка + payload как RCDATA-ресурс + версия файла
(CompanyName/LegalCopyright = savsis)
icon.ico
extras/ — декоративное, в билд не входит, просто чтобы на гите было
видно "по чуть-чуть" разных языков
rust/ — тестовый cargo-проект (audio tag, playlist, crc32, progress bar)
cpp/ — отдельный от installer/ хедер-онли C++ (то же самое, что rust/)
go/ — тестовый go-тул (поиск/прогресс, аналогично)
batch/ — фенси .bat-меню (реальный лаунчер — app/start.bat, простой)
formats.ts
fortune.lua
LICENSE — MIT, savsis
```
## как собрать установщик самому
1. поставь python 3.11 embeddable (`python-3.11.9-embed-amd64.zip` с python.org),
включи `import site` в `python311._pth`, добавь строку `Lib\site-packages`,
поставь pip (`get-pip.py`), затем `pip install -r app/requirements.txt`
этим же python'ом.
2. положи скачанный/собранный `ffmpeg.exe`+`ffprobe.exe` в `app/ffmpeg/`.
3. запакуй `python\` и `app\` (портативный питон + приложение) в `payload.zip`
рядом с папкой `installer\`.
4. собери через MSVC:
```
rc.exe /fo installer.res installer.rc
cl.exe /std:c++17 /EHsc /O2 installer.cpp installer.res /Fe:nexdex.space-setup.exe /link shell32.lib ole32.lib
```
`installer.rc` ссылается на `..\payload.zip` и зашивает его прямо в exe
как ресурс (RCDATA) — поэтому результат остаётся нормальным PE-файлом
(в отличие от "приклеивания" zip после exe, что ломает Authenticode-подпись).
5. (опционально) подпиши бесплатным self-signed сертификатом:
```
New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=savsis" -CertStoreLocation Cert:\CurrentUser\My
signtool sign /sha1 <thumbprint> /fd SHA256 nexdex.space-setup.exe
```
самоподписанный сертификат уберёт "неизвестный автор" в свойствах файла
(Windows покажет CN=savsis), но не уберёт предупреждение SmartScreen —
для этого нужен платный сертификат от доверенного CA.
---
made by savsis with ♥

94
app/README.md Normal file
View file

@ -0,0 +1,94 @@
# nexdex.space
локальный веб-инструмент — качает музыку/видео, конвертирует файлы, редактирует
метаданные треков, форматирует py/lua скрипты. открывается сам в браузере на
`localhost`, тёмная тема, гугл-минимализм.
---
## что внутри
**загрузчик** — вставляешь ссылку (youtube / soundcloud / apple music / spotify /
yandex music) или ищешь по названию (видно обложку+автора перед скачиванием),
выбираешь формат — качает и вшивает обложку+теги. для spotify/apple music метаданные
берутся с официальных публичных api, а само аудио ищется и качается с youtube —
без взлома drm. история последних скачиваний сохраняется локально.
**конвертер** — картинки (webp, png, jpg, ico и генерация иконок из любой картинки),
pdf, pptx, аудио (flac/wav/mp3/m4a/ogg/opus/aac), видео (mp4/avi/mov/mkv/webm/gif).
честно: pptx → картинка/pdf не поддерживается (нужен libreoffice/powerpoint для
рендера слайдов — тяжёлая внешняя зависимость, спорит с идеей лёгкого инструмента).
**метаданные** — редактор тегов для mp3/flac/wav/m4a/ogg/opus: название, исполнитель,
альбом, год, номер трека, обложка. есть массовый режим — применить одного исполнителя/
альбом/обложку сразу на несколько файлов.
**скрипты** — форматирование `.py` (black) и `.lua` (stylua, качается сам при первом
использовании), подсветка синтаксических ошибок.
---
## что нужно
- python 3.10+
- ffmpeg — уже лежит в `ffmpeg\`, ничего доп. качать не надо
## установка
```
pip install -r requirements.txt
```
## запуск
```
python app.py
```
или просто дважды кликнуть `start.bat`. браузер откроется сам на
`http://127.0.0.1:5000`. порт меняется через `PORT=8080 python app.py`.
---
## форматы загрузчика
| # | формат | качество |
|---|--------|----------|
| 1 | MP3 | 320 / 256 / 192 / 128 kbps |
| 2 | FLAC | lossless |
| 3 | WAV | lossless |
| 4 | M4A | best |
| 5 | ALAC | lossless |
| 6 | OGG | best |
| 7 | OPUS | best |
| 8 | MP4 | видео + аудио |
---
## структура
```
app.py — flask entrypoint, сам открывает браузер
backend/
downloader.py — detect_platform, download_track (youtube/spotify/apple/yandex/vk)
jobs.py — фоновые задачи скачивания + прогресс
search.py — поиск по youtube/soundcloud/apple music
history.py — история скачанных треков (data/history.json)
converter.py — картинки/pdf/pptx/аудио/видео
metadata.py — чтение/запись тегов+обложки
scripts_format.py — black + stylua
templates/index.html
static/css/style.css
static/js/app.js
ffmpeg/ — ffmpeg.exe, ffprobe.exe
bin/ — сюда качается stylua.exe при первом форматировании lua
data/history.json
downloads/ uploads/ outputs/
```
---
UDP: часть про поиск/скачивание работает через публичные api и youtube — если
ссылка "не режимного" сервиса не отдаёт данные без логина, так и напишет в ошибке.
pptx рендер слайдов — эксперимент на будущее, если приспичит тащить libreoffice.
made by savsis with ♥

284
app/app.py Normal file
View file

@ -0,0 +1,284 @@
import os
import sys
import threading
import time
import uuid
import webbrowser
from pathlib import Path
from flask import Flask, render_template, request, jsonify, send_file
sys.path.insert(0, str(Path(__file__).parent))
from backend import downloader, search as search_mod, history as history_mod
from backend import converter, metadata, scripts_format, jobs
PROJECT_DIR = Path(__file__).parent
DOWNLOADS_DIR = PROJECT_DIR / "downloads"
UPLOAD_DIR = PROJECT_DIR / "uploads"
OUTPUT_DIR = PROJECT_DIR / "outputs"
for d in (DOWNLOADS_DIR, UPLOAD_DIR, OUTPUT_DIR):
d.mkdir(parents=True, exist_ok=True)
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024
@app.route("/")
def index():
return render_template("index.html")
# --- downloader ---
@app.route("/api/download", methods=["POST"])
def api_download():
data = request.json or {}
url = (data.get("url") or "").strip()
fmt = data.get("format", "mp3")
quality = data.get("quality", "320")
if not url:
return jsonify({"error": "нет ссылки"}), 400
job_id = jobs.start_download(url, fmt, quality, str(DOWNLOADS_DIR))
return jsonify({"job_id": job_id})
@app.route("/api/job/<job_id>")
def api_job(job_id):
job = jobs.get_job(job_id)
if not job:
return jsonify({"error": "job not found"}), 404
return jsonify(job)
@app.route("/api/search")
def api_search():
platform = request.args.get("platform", "youtube")
query = request.args.get("q", "").strip()
limit = min(int(request.args.get("limit", 10)), 25)
if not query:
return jsonify({"error": "пустой запрос"}), 400
try:
results = search_mod.search(platform, query, limit)
return jsonify({"results": results})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/history")
def api_history():
return jsonify({"history": history_mod.get_history()})
@app.route("/api/history/clear", methods=["POST"])
def api_history_clear():
history_mod.clear_history()
return jsonify({"ok": True})
# --- converter ---
@app.route("/api/convert/upload", methods=["POST"])
def api_convert_upload():
if "file" not in request.files:
return jsonify({"error": "нет файла"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"error": "имя файла пустое"}), 400
uid = uuid.uuid4().hex[:10]
ext = Path(f.filename).suffix
saved_name = uid + ext
saved_path = UPLOAD_DIR / saved_name
f.save(saved_path)
kind = converter.detect_type(saved_path)
targets = converter.get_targets(kind)
return jsonify({
"id": uid,
"filename": f.filename,
"saved": saved_name,
"type": kind,
"size": saved_path.stat().st_size,
"targets": targets,
})
@app.route("/api/convert/run", methods=["POST"])
def api_convert_run():
data = request.json or {}
uid = data.get("id", "")
saved = data.get("saved", "")
target = data.get("target", "")
original = data.get("filename", "file")
if not all([uid, saved, target]):
return jsonify({"error": "плохие параметры"}), 400
if target not in converter.ALL_TARGETS:
return jsonify({"error": "недопустимый формат"}), 400
src = UPLOAD_DIR / Path(saved).name
if not src.exists():
return jsonify({"error": "файл не найден"}), 404
out_name = f"{uid}_out.{target}"
out_path = OUTPUT_DIR / out_name
ok, err = converter.convert(src, out_path, target)
if not ok:
return jsonify({"error": err}), 500
base = Path(Path(original).stem).stem
result_filename = f"{base}.{target}"
return jsonify({"result": out_name, "filename": result_filename})
@app.route("/api/convert/download/<path:filename>")
def api_convert_download(filename):
safe = Path(filename).name
path = OUTPUT_DIR / safe
if not path.exists():
return "не найдено", 404
return send_file(path, as_attachment=True)
# --- metadata ---
@app.route("/api/metadata/upload", methods=["POST"])
def api_metadata_upload():
if "file" not in request.files:
return jsonify({"error": "нет файла"}), 400
f = request.files["file"]
ext = Path(f.filename).suffix.lower()
if ext not in metadata.SUPPORTED_EXTS:
return jsonify({"error": f"формат {ext} не поддерживается"}), 400
uid = uuid.uuid4().hex[:10]
saved_name = uid + ext
saved_path = UPLOAD_DIR / saved_name
f.save(saved_path)
tags = metadata.read_tags(saved_path)
return jsonify({"id": uid, "saved": saved_name, "filename": f.filename, "tags": tags})
@app.route("/api/metadata/cover/<path:filename>")
def api_metadata_cover(filename):
from flask import Response
path = UPLOAD_DIR / Path(filename).name
if not path.exists():
return "", 404
data = metadata._extract_cover_bytes(path)
if not data:
return "", 404
return Response(data, mimetype="image/jpeg")
@app.route("/api/metadata/save", methods=["POST"])
def api_metadata_save():
saved = request.form.get("saved", "")
if not saved:
return jsonify({"error": "нет файла"}), 400
path = UPLOAD_DIR / Path(saved).name
if not path.exists():
return jsonify({"error": "файл не найден"}), 404
meta = {
"title": request.form.get("title"),
"artist": request.form.get("artist"),
"album": request.form.get("album"),
"year": request.form.get("year"),
"track_number": request.form.get("track_number"),
}
cover_bytes = None
if "cover" in request.files and request.files["cover"].filename:
cover_bytes = metadata._cover_bytes_from_upload(request.files["cover"].read())
try:
metadata.write_tags(path, meta, cover_bytes)
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"ok": True, "download": saved})
@app.route("/api/metadata/batch", methods=["POST"])
def api_metadata_batch():
data = request.json or {}
ids = data.get("saved_files", [])
meta = {
"title": None,
"artist": data.get("artist"),
"album": data.get("album"),
"year": data.get("year"),
"track_number": None,
}
meta = {k: v for k, v in meta.items() if v not in (None, "")}
cover_bytes = None
cover_url = data.get("cover_saved")
if cover_url:
cover_path = UPLOAD_DIR / Path(cover_url).name
if cover_path.exists():
cover_bytes = metadata._cover_bytes_from_upload(cover_path.read_bytes())
updated = []
errors = []
for saved in ids:
path = UPLOAD_DIR / Path(saved).name
if not path.exists():
errors.append(f"{saved}: файл не найден")
continue
try:
metadata.write_tags(path, meta, cover_bytes)
updated.append(saved)
except Exception as e:
errors.append(f"{saved}: {e}")
return jsonify({"updated": updated, "errors": errors})
@app.route("/api/metadata/download/<path:filename>")
def api_metadata_download(filename):
safe = Path(filename).name
path = UPLOAD_DIR / safe
if not path.exists():
return "не найдено", 404
return send_file(path, as_attachment=True)
# --- scripts ---
@app.route("/api/scripts/format", methods=["POST"])
def api_scripts_format():
data = request.json or {}
code = data.get("code", "")
language = data.get("language", "python")
result = scripts_format.format_code(code, language)
return jsonify(result)
def _open_browser(url: str):
time.sleep(1.2)
try:
webbrowser.open(url)
except Exception:
pass
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
url = f"http://127.0.0.1:{port}"
print(f"nexdex.space — {url}")
threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)

0
app/backend/__init__.py Normal file
View file

187
app/backend/converter.py Normal file
View file

@ -0,0 +1,187 @@
import subprocess
from pathlib import Path
from PIL import Image
from backend.downloader import ffmpeg_path
IMAGE_FORMATS = {"jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff", "ico"}
AUDIO_FORMATS = {"mp3", "flac", "wav", "m4a", "ogg", "opus", "aac"}
VIDEO_FORMATS = {"mp4", "avi", "mov", "mkv", "webm", "gif"}
DOC_FORMATS = {"pdf", "pptx"}
ALL_TARGETS = IMAGE_FORMATS | AUDIO_FORMATS | VIDEO_FORMATS | DOC_FORMATS
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
def detect_type(path: Path) -> str:
ext = path.suffix.lstrip(".").lower()
if ext in IMAGE_FORMATS:
return "image"
if ext in AUDIO_FORMATS:
return "audio"
if ext in VIDEO_FORMATS or ext in {"flv", "wmv", "m4v", "3gp"}:
return "video"
if ext == "pdf":
return "pdf"
if ext == "pptx":
return "pptx"
return "unknown"
def get_targets(kind: str) -> list:
if kind == "image":
return sorted(IMAGE_FORMATS | {"pdf", "pptx"})
if kind == "audio":
return sorted(AUDIO_FORMATS)
if kind == "video":
return sorted(VIDEO_FORMATS | AUDIO_FORMATS)
if kind == "pdf":
return sorted(IMAGE_FORMATS | {"pptx"})
if kind == "pptx":
return ["pdf"] + sorted(IMAGE_FORMATS - {"ico"})
return []
def _convert_image(src: Path, dst: Path, target: str):
img = Image.open(src)
if target == "ico":
rgba = img.convert("RGBA")
sizes = [(s, s) for s in ICO_SIZES if s <= max(rgba.size)] or [(32, 32)]
rgba.save(dst, format="ICO", sizes=sizes)
return
if target in ("jpg", "jpeg"):
img = img.convert("RGB")
fmt = "JPEG" if target in ("jpg", "jpeg") else target.upper()
img.save(dst, format=fmt)
def _convert_image_to_pdf(src: Path, dst: Path):
import img2pdf
with open(dst, "wb") as f:
f.write(img2pdf.convert(str(src)))
EMU_PER_PX = 9525
EMU_MIN = 914400 # 1 inch
EMU_MAX = 51206400 # 56 inches
def _slide_dims(w_px: int, h_px: int) -> tuple[int, int]:
w = w_px * EMU_PER_PX
h = h_px * EMU_PER_PX
smallest = min(w, h)
largest = max(w, h)
if smallest < EMU_MIN:
scale = EMU_MIN / smallest
w, h = int(w * scale), int(h * scale)
elif largest > EMU_MAX:
scale = EMU_MAX / largest
w, h = int(w * scale), int(h * scale)
return w, h
def _convert_image_to_pptx(src: Path, dst: Path):
from pptx import Presentation
from pptx.util import Emu
img = Image.open(src)
w_px, h_px = img.size
prs = Presentation()
prs.slide_width, prs.slide_height = _slide_dims(w_px, h_px)
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.shapes.add_picture(str(src), 0, 0, width=prs.slide_width, height=prs.slide_height)
prs.save(dst)
def _convert_pdf_to_images_or_pptx(src: Path, dst: Path, target: str):
import fitz # pymupdf
doc = fitz.open(src)
if target == "pptx":
from pptx import Presentation
import tempfile
prs = Presentation()
with tempfile.TemporaryDirectory() as tmp:
for i, page in enumerate(doc):
pix = page.get_pixmap(dpi=150)
page_img = Path(tmp) / f"page_{i}.png"
pix.save(page_img)
if i == 0:
prs.slide_width, prs.slide_height = _slide_dims(pix.width, pix.height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.shapes.add_picture(str(page_img), 0, 0, width=prs.slide_width, height=prs.slide_height)
prs.save(dst)
return
# single page -> single image; only first page is used for image targets
page = doc[0]
pix = page.get_pixmap(dpi=200)
pix.save(dst)
def _convert_pptx_to_pdf(src: Path, dst: Path):
raise RuntimeError(
"PPTX -> PDF/картинка требует LibreOffice или PowerPoint (рендер слайдов) — "
"не поддерживается без тяжёлых внешних зависимостей."
)
def _convert_audio_video(src: Path, dst: Path, target: str):
ffmpeg_dir = ffmpeg_path()
exe = str(Path(ffmpeg_dir) / "ffmpeg.exe") if ffmpeg_dir else "ffmpeg"
args = [exe, "-y", "-i", str(src)]
if target == "gif":
args += ["-vf", "fps=12,scale=480:-1:flags=lanczos"]
elif target in AUDIO_FORMATS:
args += ["-vn"]
if target == "mp3":
args += ["-codec:a", "libmp3lame", "-b:a", "320k"]
elif target == "flac":
args += ["-codec:a", "flac"]
elif target == "wav":
args += ["-codec:a", "pcm_s16le"]
elif target == "aac":
args += ["-codec:a", "aac", "-b:a", "256k"]
args.append(str(dst))
result = subprocess.run(args, capture_output=True, text=True, timeout=600)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg error: {result.stderr[-800:]}")
def convert(src: Path, dst: Path, target: str) -> tuple[bool, str | None]:
try:
kind = detect_type(src)
if kind == "image":
if target == "pdf":
_convert_image_to_pdf(src, dst)
elif target == "pptx":
_convert_image_to_pptx(src, dst)
else:
_convert_image(src, dst, target)
elif kind == "pdf":
_convert_pdf_to_images_or_pptx(src, dst, target)
elif kind == "pptx":
_convert_pptx_to_pdf(src, dst)
elif kind in ("audio", "video"):
_convert_audio_video(src, dst, target)
else:
return False, f"неизвестный формат: {src.suffix}"
return True, None
except Exception as e:
return False, str(e)

699
app/backend/downloader.py Normal file
View file

@ -0,0 +1,699 @@
import sys
import re
import json
import time
import requests
import yt_dlp
from pathlib import Path
PROJECT_DIR = Path(__file__).parent.parent
def ffmpeg_path() -> str | None:
exe = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
local = PROJECT_DIR / "ffmpeg" / exe
if local.exists():
return str(PROJECT_DIR / "ffmpeg")
return None
AUDIO_EXTENSIONS = {"mp3", "flac", "wav", "m4a", "ogg", "opus", "mp4", "webm", "aac", "wma"}
THUMB_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
_ANON_TOKEN: str | None = None
_TOKEN_EXPIRY: float = 0.0
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
def _sanitize_filename(name: str) -> str:
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip()
def detect_platform(url: str) -> str:
u = url.lower()
if "spotify.com" in u:
return "spotify"
if "music.apple.com" in u:
return "apple_music"
if "soundcloud.com" in u:
return "soundcloud"
if "music.yandex." in u or "yandex.ru/music" in u or "yandex.com/music" in u:
return "yandex_music"
if "vk.com" in u or "vk.ru" in u or "vkvideo.ru" in u:
return "vk"
return "youtube"
def _extract_spotify_id(url: str) -> tuple[str, str]:
m = re.search(
r"spotify\.com/(?:[a-z]{2}/)?(?:intl-[a-z]+/)?(track|album|playlist)/([A-Za-z0-9]+)",
url,
)
return (m.group(1), m.group(2)) if m else ("", "")
# --- spotify anonymous token path ---
def _get_anon_token() -> str:
global _ANON_TOKEN, _TOKEN_EXPIRY
if _ANON_TOKEN and time.time() < _TOKEN_EXPIRY:
return _ANON_TOKEN
resp = requests.get(
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
headers={"User-Agent": _UA, "app-platform": "WebPlayer"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
_ANON_TOKEN = data["accessToken"]
_TOKEN_EXPIRY = data.get("accessTokenExpirationTimestampMs", 0) / 1000 - 30
return _ANON_TOKEN
def _api(path: str) -> dict:
token = _get_anon_token()
resp = requests.get(
f"https://api.spotify.com/v1/{path}",
headers={"Authorization": f"Bearer {token}", "User-Agent": _UA},
timeout=10,
)
resp.raise_for_status()
return resp.json()
def _parse_api_track(track: dict, album_override: dict | None = None) -> dict:
album = album_override or track.get("album") or {}
images = album.get("images") or []
cover = images[0]["url"] if images else None
artists = track.get("artists") or []
artist = ", ".join(a["name"] for a in artists)
title = track.get("name", "")
return {
"title": title,
"artist": artist,
"album": album.get("name"),
"year": (album.get("release_date") or "")[:4],
"track_number": track.get("track_number"),
"cover_url": cover,
"search_query": f"{artist} - {title} audio",
"duration": (track.get("duration_ms") or 0) // 1000,
}
def _get_via_api(kind: str, sid: str) -> list:
if kind == "track":
return [_parse_api_track(_api(f"tracks/{sid}"))]
if kind == "album":
album = _api(f"albums/{sid}")
results = []
page = album.get("tracks", {})
while True:
for t in page.get("items", []):
results.append(_parse_api_track(t, album))
nxt = page.get("next")
if not nxt:
break
token = _get_anon_token()
page = requests.get(nxt, headers={"Authorization": f"Bearer {token}", "User-Agent": _UA}, timeout=10).json()
return results
if kind == "playlist":
results = []
page = _api(f"playlists/{sid}/tracks?limit=50")
while True:
for item in page.get("items", []):
t = item.get("track")
if t:
results.append(_parse_api_track(t))
nxt = page.get("next")
if not nxt:
break
token = _get_anon_token()
page = requests.get(nxt, headers={"Authorization": f"Bearer {token}", "User-Agent": _UA}, timeout=10).json()
return results
return []
def _embed_url(kind: str, sid: str) -> str:
return f"https://open.spotify.com/embed/{kind}/{sid}"
def _scrape_embed(kind: str, sid: str) -> dict:
resp = requests.get(
_embed_url(kind, sid),
headers={"User-Agent": _UA, "Accept-Language": "en-US,en;q=0.9"},
timeout=12,
)
resp.raise_for_status()
m = re.search(
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
resp.text,
re.DOTALL,
)
if not m:
raise RuntimeError("Could not parse Spotify embed page")
return json.loads(m.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
def _best_cover(images: list) -> str | None:
if not images:
return None
return sorted(images, key=lambda x: x.get("maxWidth", 0), reverse=True)[0]["url"]
def _get_via_embed(kind: str, sid: str) -> list:
entity = _scrape_embed(kind, sid)
if kind == "track":
artists = entity.get("artists") or []
artist = ", ".join(a["name"] for a in artists)
title = entity.get("name") or entity.get("title", "")
year = (entity.get("releaseDate") or {}).get("isoString", "")[:4]
cover = _best_cover(entity.get("visualIdentity", {}).get("image", []))
return [
{
"title": title,
"artist": artist,
"album": None,
"year": year,
"track_number": None,
"cover_url": cover,
"search_query": f"{artist} - {title} audio",
"duration": (entity.get("duration") or 0) // 1000,
}
]
if kind in ("album", "playlist"):
album_name = entity.get("name") or entity.get("title", "")
album_artist = entity.get("subtitle", "")
year = (entity.get("releaseDate") or {}).get("isoString", "")[:4]
cover = _best_cover(entity.get("visualIdentity", {}).get("image", []))
track_list = entity.get("trackList") or []
results = []
for t in track_list:
title = t.get("title") or t.get("name", "")
artist = t.get("subtitle") or album_artist
results.append(
{
"title": title,
"artist": artist,
"album": album_name if kind == "album" else None,
"year": year,
"track_number": None,
"cover_url": cover,
"search_query": f"{artist} - {title} audio",
"duration": (t.get("duration") or 0) // 1000,
}
)
return results
return []
def get_spotify_metadata(url: str) -> list:
kind, sid = _extract_spotify_id(url)
if not sid:
raise RuntimeError(f"Cannot parse Spotify URL: {url}")
try:
return _get_via_api(kind, sid)
except Exception:
pass
return _get_via_embed(kind, sid)
# --- Apple Music (metadata via public itunes API, audio matched on YouTube) ---
def _itunes_track(row: dict, album_name=None, artist_fallback=None, cover_fallback=None, year_fallback=None) -> dict:
cover = (row.get("artworkUrl100") or cover_fallback or "").replace("100x100bb", "600x600bb")
artist = row.get("artistName") or artist_fallback or ""
title = row.get("trackName") or row.get("collectionName") or ""
return {
"title": title,
"artist": artist,
"album": row.get("collectionName") or album_name,
"year": (row.get("releaseDate") or year_fallback or "")[:4],
"track_number": row.get("trackNumber"),
"cover_url": cover,
"search_query": f"{artist} - {title} audio",
"duration": (row.get("trackTimeMillis") or 0) // 1000,
}
def _itunes_lookup(track_id: str) -> list:
try:
resp = requests.get(f"https://itunes.apple.com/lookup?id={track_id}", timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("resultCount"):
return [_itunes_track(data["results"][0])]
except Exception:
pass
return []
def _itunes_album(album_id: str) -> list:
try:
resp = requests.get(f"https://itunes.apple.com/lookup?id={album_id}&entity=song", timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
album_name = artist_name = cover_url = year = None
results = []
for r in data.get("results", []):
if r.get("wrapperType") == "collection":
album_name = r.get("collectionName")
artist_name = r.get("artistName")
cover_url = (r.get("artworkUrl100") or "").replace("100x100bb", "600x600bb")
year = (r.get("releaseDate") or "")[:4]
elif r.get("wrapperType") == "track":
results.append(_itunes_track(r, album_name, artist_name, cover_url, year))
return results
def _itunes_search_from_url(url: str) -> list:
path = re.sub(r"https?://[^/]+", "", url.split("?")[0])
parts = [p for p in path.split("/") if p and not re.fullmatch(r"\d+", p) and p not in ("us","ru","gb","de","fr","jp","kr","cn","au","ca","it","es","pl","tr","ua","kz","by","am","ge","az","uz")]
query = " ".join(parts[-3:]).replace("-", " ")
if not query.strip():
return []
try:
resp = requests.get(
f"https://itunes.apple.com/search?term={requests.utils.quote(query)}&media=music&limit=1",
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if data.get("resultCount"):
return [_itunes_track(data["results"][0])]
except Exception:
pass
return []
def get_apple_music_metadata(url: str) -> list:
m = re.search(r"[?&]i=(\d+)", url)
if m:
r = _itunes_lookup(m.group(1))
if r:
return r
for pattern in (r"/song/[^/?#]+/(\d+)", r"/music-video/[^/?#]+/(\d+)", r"/album/[^/?#]+/(\d+)"):
m = re.search(pattern, url)
if m:
sid = m.group(1)
if "/album/" in pattern:
r = _itunes_album(sid)
else:
r = _itunes_lookup(sid)
if r:
return r
for m in re.finditer(r"/(\d{6,12})(?:[/?#]|$)", url):
r = _itunes_lookup(m.group(1))
if r:
return r
r = _itunes_album(m.group(1))
if r:
return r
return _itunes_search_from_url(url)
# --- yt-dlp options ---
def _build_opts(fmt: str, quality: str, output_dir: str, meta: dict | None = None, embed_thumb: bool = True) -> dict:
if meta and meta.get("title") and meta.get("artist"):
stem = f"{_sanitize_filename(meta['artist'])} - {_sanitize_filename(meta['title'])}"
else:
stem = "%(title)s"
base = {
"outtmpl": str(Path(output_dir) / f"{stem}.%(ext)s"),
"quiet": True,
"no_warnings": True,
"ignoreerrors": False,
}
ffmpeg = ffmpeg_path()
if ffmpeg:
base["ffmpeg_location"] = ffmpeg
if fmt == "mp4":
if quality and quality != "best" and quality.isdigit():
height = quality
video_fmt = (
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/"
f"bestvideo[height<={height}]+bestaudio/best[height<={height}]"
)
else:
video_fmt = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best"
base.update(
{
"format": video_fmt,
"merge_output_format": "mp4",
"writethumbnail": embed_thumb,
"postprocessors": [
{"key": "FFmpegVideoConvertor", "preferedformat": "mp4"},
{"key": "FFmpegMetadata"},
]
+ ([{"key": "EmbedThumbnail"}] if embed_thumb else []),
}
)
else:
codec_map = {"mp3": "mp3", "flac": "flac", "wav": "wav", "m4a": "m4a", "alac": "alac", "ogg": "vorbis", "opus": "opus"}
quality_map = {"320": "320", "256": "256", "192": "192", "128": "128", "best": "0", "lossless": "0"}
codec = codec_map.get(fmt, "mp3")
q = quality_map.get(quality, "0")
base.update(
{
"format": "bestaudio/best",
"writethumbnail": embed_thumb,
"postprocessors": [
{"key": "FFmpegExtractAudio", "preferredcodec": codec, "preferredquality": q},
{"key": "FFmpegMetadata"},
],
}
)
return base
# --- download helpers ---
def _snapshot_dir(directory: str) -> set:
result = set()
p = Path(directory)
if not p.exists():
return result
for f in p.iterdir():
if f.is_file() and f.suffix.lstrip(".").lower() in AUDIO_EXTENSIONS:
result.add(str(f))
return result
def _apply_thumb_file(audio_file: str, thumb_path: str):
import io
from PIL import Image
try:
img = Image.open(thumb_path).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
cover_data = buf.getvalue()
except Exception:
return
ext = Path(audio_file).suffix.lower()
try:
if ext in (".mp3", ".wav"):
from mutagen.id3 import ID3, APIC
from mutagen.id3 import ID3NoHeaderError
try:
tags = ID3(audio_file)
except ID3NoHeaderError:
tags = ID3()
tags.delall("APIC")
tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_data)
tags.save(audio_file)
elif ext == ".flac":
from mutagen.flac import FLAC, Picture
audio = FLAC(audio_file)
audio.clear_pictures()
pic = Picture()
pic.type = 3
pic.mime = "image/jpeg"
pic.data = cover_data
audio.add_picture(pic)
audio.save()
elif ext in (".m4a", ".mp4"):
from mutagen.mp4 import MP4, MP4Cover
audio = MP4(audio_file)
audio["covr"] = [MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)]
audio.save()
elif ext in (".ogg", ".opus"):
import base64
from mutagen.flac import Picture
if ext == ".ogg":
from mutagen.oggvorbis import OggVorbis as _Cls
else:
from mutagen.oggopus import OggOpus as _Cls
audio = _Cls(audio_file)
pic = Picture()
pic.type = 3
pic.mime = "image/jpeg"
pic.data = cover_data
audio["metadata_block_picture"] = [base64.b64encode(pic.write()).decode("ascii")]
audio.save()
except Exception:
pass
def _cleanup_thumbs(output_dir: str):
p = Path(output_dir)
if not p.exists():
return
for f in p.iterdir():
if f.is_file() and f.suffix.lstrip(".").lower() in THUMB_EXTENSIONS:
try:
f.unlink()
except Exception:
pass
def _embed_saved_thumb(audio_file: str, output_dir: str):
stem = Path(audio_file).stem
for ext in THUMB_EXTENSIONS:
thumb = Path(output_dir) / f"{stem}.{ext}"
if thumb.exists():
_apply_thumb_file(audio_file, str(thumb))
try:
thumb.unlink()
except Exception:
pass
return
def _download_url(url: str, opts: dict, output_dir: str, fmt: str = "") -> list:
before = _snapshot_dir(output_dir)
with yt_dlp.YoutubeDL(opts) as ydl:
try:
ydl.download([url])
except yt_dlp.utils.DownloadError as e:
if "Postprocessing" not in str(e):
raise RuntimeError(str(e)) from e
after = _snapshot_dir(output_dir)
new_files = list(after - before)
for f in new_files:
_embed_saved_thumb(f, output_dir)
_cleanup_thumbs(output_dir)
return new_files
# --- yandex / vk / soundcloud fallback (metadata via yt-dlp extract_info) ---
def _meta_from_ytdlp_info(info: dict) -> dict:
title = info.get("track") or info.get("title") or ""
artist = info.get("artist") or info.get("creator") or info.get("uploader") or ""
year = str(info.get("release_year") or (info.get("upload_date") or "")[:4] or "")
query = f"{artist} - {title}".strip(" -") if (artist or title) else title
return {
"title": title,
"artist": artist,
"album": info.get("album"),
"year": year,
"track_number": info.get("track_number"),
"cover_url": info.get("thumbnail"),
"search_query": f"{query} audio",
"duration": info.get("duration"),
}
def _ytdlp_extract_tracks(url: str) -> list:
opts = {"quiet": True, "no_warnings": True, "ignoreerrors": True}
ffmpeg = ffmpeg_path()
if ffmpeg:
opts["ffmpeg_location"] = ffmpeg
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
except Exception:
return []
if not info or not isinstance(info, dict):
return []
entries = info.get("entries")
if entries:
return [_meta_from_ytdlp_info(e) for e in entries if e and isinstance(e, dict)]
return [_meta_from_ytdlp_info(info)]
def _get_yandex_metadata(url: str) -> list:
try:
from yandex_music import Client
client = Client().init()
except Exception:
return []
try:
track = re.search(r"/track/(\d+)", url)
album = re.search(r"/album/(\d+)", url)
playlist_user = re.search(r"/users/([^/]+)/playlists/(\d+)", url)
track_id = track.group(1) if track else ""
album_id = album.group(1) if album else ""
def _to_meta(t):
artists = ", ".join(a.name for a in (t.artists or []))
alb = (t.albums or [None])[0]
cover = None
if alb and alb.cover_uri:
cover = "https://" + alb.cover_uri.replace("%%", "600x600")
return {
"title": t.title or "",
"artist": artists,
"album": getattr(alb, "title", None),
"year": str(getattr(alb, "year", "") or ""),
"track_number": getattr(t, "track_position", None),
"cover_url": cover,
"search_query": f"{artists} - {t.title} audio",
"duration": (t.duration_ms or 0) // 1000,
}
if track_id:
key = f"{track_id}:{album_id}" if album_id else track_id
tracks = client.tracks([key])
return [_to_meta(t) for t in tracks if t]
if album_id and not track_id:
album_obj = client.albums_with_tracks(int(album_id))
results = []
for vol in (album_obj.volumes or []):
for t in vol:
results.append(_to_meta(t))
return results
if playlist_user:
user, kind = playlist_user.group(1), playlist_user.group(2)
playlist = client.users_playlists(int(kind), user)
results = []
for item in (playlist.tracks or []):
t = item.track
if t:
results.append(_to_meta(t))
return results
except Exception:
pass
return []
def _download_with_fallback(url: str, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
from backend.metadata import embed_cover_and_metadata
opts = _build_opts(fmt, quality, output_dir, embed_thumb=True)
if progress_callback:
opts["progress_hooks"] = [progress_callback]
try:
files = _download_url(url, opts, output_dir, fmt)
if files:
return files
except RuntimeError:
pass
platform = detect_platform(url)
tracks = []
if platform == "yandex_music":
tracks = _get_yandex_metadata(url)
if not tracks:
tracks = _ytdlp_extract_tracks(url)
if not tracks:
raise RuntimeError("Could not extract track info. The URL may require login.")
downloaded = []
for meta in tracks:
if not meta.get("search_query"):
continue
sopts = _build_opts(fmt, quality, output_dir, meta=meta, embed_thumb=False)
if progress_callback:
sopts["progress_hooks"] = [progress_callback]
files = _download_url(f"ytsearch1:{meta['search_query']}", sopts, output_dir, fmt)
for f in files:
embed_cover_and_metadata(f, meta)
downloaded.append((f, meta))
return downloaded
def _download_tracks_list(tracks: list, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
from backend.metadata import embed_cover_and_metadata
downloaded = []
for track in tracks:
opts = _build_opts(fmt, quality, output_dir, meta=track, embed_thumb=False)
if progress_callback:
opts["progress_hooks"] = [progress_callback]
new_files = _download_url(f"ytsearch1:{track['search_query']}", opts, output_dir, fmt)
for f in new_files:
embed_cover_and_metadata(f, track)
downloaded.append((f, track))
return downloaded
# --- public API ---
# returns list of (file_path, meta_dict) tuples so callers can log title/artist/cover to history
def download_track(url: str, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
Path(output_dir).mkdir(parents=True, exist_ok=True)
platform = detect_platform(url)
if platform == "spotify":
tracks = get_spotify_metadata(url)
if not tracks:
raise RuntimeError("Could not retrieve Spotify metadata.")
return _download_tracks_list(tracks, fmt, quality, output_dir, progress_callback)
if platform == "apple_music":
tracks = get_apple_music_metadata(url)
if not tracks:
raise RuntimeError("Could not retrieve Apple Music metadata.")
return _download_tracks_list(tracks, fmt, quality, output_dir, progress_callback)
if platform in ("yandex_music", "vk"):
return _download_with_fallback(url, fmt, quality, output_dir, progress_callback)
opts = _build_opts(fmt, quality, output_dir, embed_thumb=True)
if progress_callback:
opts["progress_hooks"] = [progress_callback]
files = _download_url(url, opts, output_dir, fmt)
results = []
for f in files:
try:
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
info = ydl.extract_info(url, download=False)
if info and isinstance(info, dict):
if info.get("entries"):
info = info["entries"][0]
meta = _meta_from_ytdlp_info(info)
else:
meta = {"title": Path(f).stem, "artist": "", "cover_url": None}
except Exception:
meta = {"title": Path(f).stem, "artist": "", "cover_url": None}
results.append((f, meta))
return results

51
app/backend/history.py Normal file
View file

@ -0,0 +1,51 @@
import json
import threading
import time
from pathlib import Path
PROJECT_DIR = Path(__file__).parent.parent
HISTORY_FILE = PROJECT_DIR / "data" / "history.json"
MAX_ENTRIES = 200
_LOCK = threading.Lock()
def _load() -> list:
if not HISTORY_FILE.exists():
return []
try:
return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
except Exception:
return []
def _save(entries: list):
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
def add_history_entry(file_path: str, meta: dict, fmt: str) -> dict:
entry = {
"title": meta.get("title") or Path(file_path).stem,
"artist": meta.get("artist") or "",
"cover_url": meta.get("cover_url"),
"format": fmt,
"path": file_path,
"timestamp": time.time(),
}
with _LOCK:
entries = _load()
entries.insert(0, entry)
entries = entries[:MAX_ENTRIES]
_save(entries)
return entry
def get_history(limit: int = 50) -> list:
with _LOCK:
return _load()[:limit]
def clear_history():
with _LOCK:
_save([])

78
app/backend/jobs.py Normal file
View file

@ -0,0 +1,78 @@
import threading
import time
import uuid
from pathlib import Path
_JOBS: dict[str, dict] = {}
_LOCK = threading.Lock()
def create_job() -> str:
job_id = uuid.uuid4().hex[:12]
with _LOCK:
_JOBS[job_id] = {
"status": "pending",
"progress": 0,
"speed": "",
"filename": "",
"error": None,
"result": None,
"created": time.time(),
}
return job_id
def get_job(job_id: str) -> dict | None:
with _LOCK:
job = _JOBS.get(job_id)
return dict(job) if job else None
def _update(job_id: str, **kwargs):
with _LOCK:
if job_id in _JOBS:
_JOBS[job_id].update(kwargs)
def _progress_hook(job_id: str):
def hook(d):
if d["status"] == "downloading":
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
done = d.get("downloaded_bytes") or 0
pct = int(done * 100 / total) if total else 0
speed = d.get("_speed_str") or ""
fname = Path(d.get("filename", "")).name
_update(job_id, status="downloading", progress=pct, speed=speed, filename=fname)
elif d["status"] == "finished":
_update(job_id, status="processing", progress=100)
return hook
def run_download_job(job_id: str, url: str, fmt: str, quality: str, output_dir: str):
from backend.downloader import download_track
from backend.history import add_history_entry
_update(job_id, status="downloading")
try:
hook = _progress_hook(job_id)
results = download_track(url, fmt, quality, output_dir, progress_callback=hook)
entries = []
for file_path, meta in results:
entry = add_history_entry(file_path, meta, fmt)
entries.append(entry)
_update(job_id, status="done", progress=100, result=entries)
except Exception as e:
_update(job_id, status="error", error=str(e))
def start_download(url: str, fmt: str, quality: str, output_dir: str) -> str:
job_id = create_job()
t = threading.Thread(
target=run_download_job,
args=(job_id, url, fmt, quality, output_dir),
daemon=True,
)
t.start()
return job_id

251
app/backend/metadata.py Normal file
View file

@ -0,0 +1,251 @@
import io
import requests
from pathlib import Path
from PIL import Image
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
from mutagen.id3 import ID3NoHeaderError
from mutagen.flac import FLAC, Picture
from mutagen.mp4 import MP4, MP4Cover
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
SUPPORTED_EXTS = {".mp3", ".flac", ".m4a", ".mp4", ".wav", ".ogg", ".opus"}
def _sanitize(value) -> str:
return str(value) if value is not None else ""
def _fetch_cover(url: str) -> bytes | None:
if not url:
return None
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
img = Image.open(io.BytesIO(r.content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
return buf.getvalue()
except Exception:
return None
def _cover_bytes_from_upload(data: bytes) -> bytes:
img = Image.open(io.BytesIO(data)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
return buf.getvalue()
def _extract_cover_bytes(path: Path) -> bytes | None:
ext = path.suffix.lower()
try:
if ext in (".mp3", ".wav"):
tags = ID3(path)
for tag in tags.values():
if tag.FrameID == "APIC":
return tag.data
elif ext == ".flac":
audio = FLAC(path)
if audio.pictures:
return audio.pictures[0].data
elif ext in (".m4a", ".mp4"):
audio = MP4(path)
covr = audio.get("covr")
if covr:
return bytes(covr[0])
elif ext == ".ogg":
audio = OggVorbis(path)
pics = audio.get("metadata_block_picture")
if pics:
import base64
pic = Picture(base64.b64decode(pics[0]))
return pic.data
elif ext == ".opus":
audio = OggOpus(path)
pics = audio.get("metadata_block_picture")
if pics:
import base64
pic = Picture(base64.b64decode(pics[0]))
return pic.data
except Exception:
pass
return None
def read_tags(path: Path) -> dict:
ext = path.suffix.lower()
tags = {"title": "", "artist": "", "album": "", "year": "", "track_number": "", "has_cover": False}
try:
if ext in (".mp3", ".wav"):
id3 = ID3(path)
tags["title"] = str(id3.get("TIT2", ""))
tags["artist"] = str(id3.get("TPE1", ""))
tags["album"] = str(id3.get("TALB", ""))
tags["year"] = str(id3.get("TDRC", ""))
tags["track_number"] = str(id3.get("TRCK", ""))
elif ext == ".flac":
audio = FLAC(path)
tags["title"] = audio.get("title", [""])[0]
tags["artist"] = audio.get("artist", [""])[0]
tags["album"] = audio.get("album", [""])[0]
tags["year"] = audio.get("date", [""])[0]
tags["track_number"] = audio.get("tracknumber", [""])[0]
elif ext in (".m4a", ".mp4"):
audio = MP4(path)
tags["title"] = (audio.get("\xa9nam") or [""])[0]
tags["artist"] = (audio.get("\xa9ART") or [""])[0]
tags["album"] = (audio.get("\xa9alb") or [""])[0]
tags["year"] = (audio.get("\xa9day") or [""])[0]
elif ext == ".ogg":
audio = OggVorbis(path)
tags["title"] = audio.get("title", [""])[0]
tags["artist"] = audio.get("artist", [""])[0]
tags["album"] = audio.get("album", [""])[0]
tags["year"] = audio.get("date", [""])[0]
elif ext == ".opus":
audio = OggOpus(path)
tags["title"] = audio.get("title", [""])[0]
tags["artist"] = audio.get("artist", [""])[0]
tags["album"] = audio.get("album", [""])[0]
tags["year"] = audio.get("date", [""])[0]
except Exception:
pass
tags["has_cover"] = _extract_cover_bytes(path) is not None
return tags
def _write_mp3_like(path: Path, meta: dict, cover: bytes | None):
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
if meta.get("title") is not None:
tags["TIT2"] = TIT2(encoding=3, text=_sanitize(meta["title"]))
if meta.get("artist") is not None:
tags["TPE1"] = TPE1(encoding=3, text=_sanitize(meta["artist"]))
if meta.get("album") is not None:
tags["TALB"] = TALB(encoding=3, text=_sanitize(meta["album"]))
if meta.get("track_number") is not None:
tags["TRCK"] = TRCK(encoding=3, text=_sanitize(meta["track_number"]))
if meta.get("year") is not None:
tags["TDRC"] = TDRC(encoding=3, text=_sanitize(meta["year"]))
if cover:
tags.delall("APIC")
tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover)
tags.save(path)
def _write_flac(path: Path, meta: dict, cover: bytes | None):
audio = FLAC(path)
if meta.get("title") is not None:
audio["title"] = _sanitize(meta["title"])
if meta.get("artist") is not None:
audio["artist"] = _sanitize(meta["artist"])
if meta.get("album") is not None:
audio["album"] = _sanitize(meta["album"])
if meta.get("track_number") is not None:
audio["tracknumber"] = _sanitize(meta["track_number"])
if meta.get("year") is not None:
audio["date"] = _sanitize(meta["year"])
if cover:
audio.clear_pictures()
pic = Picture()
pic.type = 3
pic.mime = "image/jpeg"
pic.data = cover
audio.add_picture(pic)
audio.save()
def _write_m4a(path: Path, meta: dict, cover: bytes | None):
audio = MP4(path)
if meta.get("title") is not None:
audio["\xa9nam"] = [_sanitize(meta["title"])]
if meta.get("artist") is not None:
audio["\xa9ART"] = [_sanitize(meta["artist"])]
if meta.get("album") is not None:
audio["\xa9alb"] = [_sanitize(meta["album"])]
if meta.get("year") is not None:
audio["\xa9day"] = [_sanitize(meta["year"])]
if meta.get("track_number"):
try:
audio["trkn"] = [(int(meta["track_number"]), 0)]
except (TypeError, ValueError):
pass
if cover:
audio["covr"] = [MP4Cover(cover, imageformat=MP4Cover.FORMAT_JPEG)]
audio.save()
def _write_ogg_like(path: Path, meta: dict, cover: bytes | None, cls):
audio = cls(path)
if meta.get("title") is not None:
audio["title"] = _sanitize(meta["title"])
if meta.get("artist") is not None:
audio["artist"] = _sanitize(meta["artist"])
if meta.get("album") is not None:
audio["album"] = _sanitize(meta["album"])
if meta.get("year") is not None:
audio["date"] = _sanitize(meta["year"])
if cover:
import base64
pic = Picture()
pic.type = 3
pic.mime = "image/jpeg"
pic.data = cover
audio["metadata_block_picture"] = [base64.b64encode(pic.write()).decode("ascii")]
audio.save()
def write_tags(path: Path, meta: dict, cover_bytes: bytes | None = None):
ext = path.suffix.lower()
if ext not in SUPPORTED_EXTS:
raise ValueError(f"Unsupported format: {ext}")
if ext in (".mp3", ".wav"):
_write_mp3_like(path, meta, cover_bytes)
elif ext == ".flac":
_write_flac(path, meta, cover_bytes)
elif ext in (".m4a", ".mp4"):
_write_m4a(path, meta, cover_bytes)
elif ext == ".ogg":
_write_ogg_like(path, meta, cover_bytes, OggVorbis)
elif ext == ".opus":
_write_ogg_like(path, meta, cover_bytes, OggOpus)
def embed_cover_and_metadata(file_path: str, meta: dict):
"""Used by the downloader right after a track finishes downloading."""
path = Path(file_path)
if not path.exists():
return
cover_data = _fetch_cover(meta.get("cover_url"))
try:
write_tags(
path,
{
"title": meta.get("title"),
"artist": meta.get("artist"),
"album": meta.get("album"),
"track_number": meta.get("track_number"),
"year": meta.get("year"),
},
cover_data,
)
except Exception:
pass

View file

@ -0,0 +1,94 @@
import ast
import platform
import subprocess
import urllib.request
import zipfile
import io
from pathlib import Path
PROJECT_DIR = Path(__file__).parent.parent
BIN_DIR = PROJECT_DIR / "bin"
STYLUA_VERSION = "0.20.0"
STYLUA_URL = (
f"https://github.com/JohnnyMorganz/StyLua/releases/download/v{STYLUA_VERSION}/"
f"stylua-windows-x86_64.zip"
)
def format_python(code: str) -> dict:
try:
ast.parse(code)
except SyntaxError as e:
return {
"ok": False,
"error": f"SyntaxError: {e.msg}",
"line": e.lineno,
"formatted": code,
}
try:
import black
formatted = black.format_str(code, mode=black.Mode())
return {"ok": True, "error": None, "line": None, "formatted": formatted}
except Exception as e:
return {"ok": False, "error": str(e), "line": None, "formatted": code}
def _stylua_exe() -> Path:
return BIN_DIR / ("stylua.exe" if platform.system() == "Windows" else "stylua")
def _ensure_stylua() -> Path | None:
exe = _stylua_exe()
if exe.exists():
return exe
if platform.system() != "Windows":
return None
BIN_DIR.mkdir(parents=True, exist_ok=True)
try:
with urllib.request.urlopen(STYLUA_URL, timeout=30) as resp:
data = resp.read()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for name in zf.namelist():
if Path(name).name.lower() == "stylua.exe":
exe.write_bytes(zf.read(name))
return exe
except Exception:
return None
return None
def format_lua(code: str) -> dict:
exe = _ensure_stylua()
if not exe:
return {
"ok": False,
"error": "stylua не удалось скачать автоматически — проверь интернет или положи stylua.exe в bin\\ вручную.",
"line": None,
"formatted": code,
}
try:
result = subprocess.run(
[str(exe), "-"],
input=code,
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
return {"ok": False, "error": result.stderr.strip(), "line": None, "formatted": code}
return {"ok": True, "error": None, "line": None, "formatted": result.stdout}
except Exception as e:
return {"ok": False, "error": str(e), "line": None, "formatted": code}
def format_code(code: str, language: str) -> dict:
if language == "python":
return format_python(code)
if language == "lua":
return format_lua(code)
return {"ok": False, "error": f"unsupported language: {language}", "line": None, "formatted": code}

102
app/backend/search.py Normal file
View file

@ -0,0 +1,102 @@
import requests
import yt_dlp
from backend.downloader import ffmpeg_path
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
def _fmt_duration(seconds) -> str:
try:
seconds = int(seconds)
except (TypeError, ValueError):
return ""
m, s = divmod(seconds, 60)
return f"{m}:{s:02d}"
def _ytdlp_search(prefix: str, query: str, limit: int) -> list:
opts = {"quiet": True, "no_warnings": True, "ignoreerrors": True, "extract_flat": "in_playlist"}
ffmpeg = ffmpeg_path()
if ffmpeg:
opts["ffmpeg_location"] = ffmpeg
results = []
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(f"{prefix}{limit}:{query}", download=False)
except Exception:
return []
if not info:
return []
entries = info.get("entries") or []
for e in entries:
if not e:
continue
results.append(
{
"title": e.get("title") or "",
"artist": e.get("uploader") or e.get("channel") or "",
"cover_url": e.get("thumbnail") or (e.get("thumbnails") or [{}])[-1].get("url"),
"duration": _fmt_duration(e.get("duration")),
"url": e.get("url") or e.get("webpage_url") or e.get("id"),
"source": "youtube" if prefix == "ytsearch" else "soundcloud",
}
)
return results
def search_youtube(query: str, limit: int = 10) -> list:
return _ytdlp_search("ytsearch", query, limit)
def search_soundcloud(query: str, limit: int = 10) -> list:
return _ytdlp_search("scsearch", query, limit)
def search_apple_music(query: str, limit: int = 10) -> list:
try:
resp = requests.get(
"https://itunes.apple.com/search",
params={"term": query, "media": "music", "entity": "song", "limit": limit},
headers={"User-Agent": _UA},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
results = []
for row in data.get("results", []):
cover = (row.get("artworkUrl100") or "").replace("100x100bb", "600x600bb")
results.append(
{
"title": row.get("trackName") or "",
"artist": row.get("artistName") or "",
"cover_url": cover,
"duration": _fmt_duration((row.get("trackTimeMillis") or 0) // 1000),
"url": row.get("trackViewUrl") or "",
"source": "apple_music",
}
)
return results
SEARCH_FUNCS = {
"youtube": search_youtube,
"soundcloud": search_soundcloud,
"apple_music": search_apple_music,
}
def search(platform: str, query: str, limit: int = 10) -> list:
fn = SEARCH_FUNCS.get(platform)
if not fn:
raise ValueError(f"Unknown platform: {platform}")
return fn(query, limit)

9
app/requirements.txt Normal file
View file

@ -0,0 +1,9 @@
flask>=3.0.0
yt-dlp>=2024.3.10
mutagen>=1.47.0
Pillow>=10.3.0
requests>=2.31.0
black>=24.3.0
pymupdf>=1.24.0
python-pptx>=0.6.23
img2pdf>=0.5.1

4
app/start.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python app.py
pause

353
app/static/css/style.css Normal file
View file

@ -0,0 +1,353 @@
:root {
--bg: #0a0a0d;
--grid-line: rgba(255, 255, 255, 0.035);
--surface: #131317;
--surface-2: #1a1a20;
--border: rgba(255, 255, 255, 0.08);
--text: #e8e8ec;
--text-dim: #8b8b96;
--accent: #5d8aef;
--accent-dim: rgba(93, 138, 239, 0.14);
--error: #ff6b6b;
--radius: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background-color: var(--bg);
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 32px 32px;
color: var(--text);
font-family: -apple-system, "Segoe UI", Roboto, Inter, system-ui, sans-serif;
min-height: 100vh;
}
body {
opacity: 0;
transition: opacity .4s ease;
}
body.loaded { opacity: 1; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 36px;
}
.brand {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
}
.brand-accent { color: var(--accent); }
.gh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
text-decoration: none;
transition: color .15s, border-color .15s;
}
.gh-btn:hover { color: var(--text); border-color: var(--accent); }
.tabs {
display: flex;
gap: 22px;
padding: 0 36px;
margin-bottom: 28px;
border-bottom: 1px solid var(--border);
}
.tab-btn {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-size: 14px;
font-weight: 500;
padding: 10px 2px 12px;
margin-bottom: -1px;
cursor: pointer;
transition: color .15s, border-color .15s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--text); border-color: var(--accent); }
main {
max-width: 880px;
margin: 0 auto;
padding: 0 24px 80px;
}
.tab-panel { display: none; flex-direction: column; gap: 20px; }
.tab-panel.active { display: flex; }
.tab-panel.fade-out { opacity: 0; transform: translateY(5px); transition: opacity .18s ease, transform .18s ease; }
.tab-panel.fade-in { animation: fadeInUp .38s cubic-bezier(.16,.8,.24,1); }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
transition: border-color .25s ease, transform .25s ease, box-shadow .25s ease;
}
.card:hover { border-color: rgba(255, 255, 255, 0.14); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text-dim); }
.mode-switch {
display: inline-flex;
background: var(--surface-2);
border-radius: 999px;
padding: 3px;
margin-bottom: 18px;
gap: 2px;
}
.mode-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 13px;
font-weight: 500;
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
transition: background .2s cubic-bezier(.2,.7,.3,1), color .2s ease, transform .1s ease;
}
.mode-btn:active { transform: scale(.94); }
.mode-btn.active { background: var(--accent); color: var(--bg); }
.mode-panel { display: none; }
.mode-panel.active { display: block; }
.big-input, input[type=text], input:not([type]) {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 15px;
padding: 14px 18px;
border-radius: 999px;
outline: none;
margin-bottom: 12px;
transition: border-color .2s ease, box-shadow .2s ease;
}
.big-input:focus, input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row input { margin-bottom: 0; flex: 1; min-width: 120px; }
select {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 14px;
padding: 12px 14px;
border-radius: 10px;
outline: none;
transition: border-color .2s ease, box-shadow .2s ease;
}
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.primary-btn {
background: var(--accent);
color: #05070f;
border: none;
font-size: 14px;
font-weight: 600;
padding: 12px 22px;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: opacity .15s, transform .1s ease;
}
.primary-btn:hover { opacity: .88; }
.primary-btn:active { transform: scale(.96); }
.primary-btn:disabled { opacity: .4; cursor: not-allowed; }
.ghost-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 13px;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
transition: color .15s ease, border-color .15s ease, transform .1s ease;
}
.ghost-btn:hover { color: var(--text); border-color: var(--accent); }
.ghost-btn:active { transform: scale(.96); }
.file-btn { display: inline-flex; cursor: pointer; }
.progress-box { margin-top: 18px; }
.progress-box.hidden { display: none; }
.progress-track {
height: 6px;
background: var(--surface-2);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width .3s cubic-bezier(.2,.7,.3,1);
box-shadow: 0 0 10px 0 var(--accent-dim);
}
.progress-text { font-size: 13px; color: var(--text-dim); margin-top: 8px; transition: opacity .15s ease; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
.result-card {
animation: fadeInUp .3s cubic-bezier(.2,.7,.3,1) backwards;
transition: border-color .15s ease, transform .15s ease;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
display: flex;
gap: 12px;
align-items: center;
}
.result-card img {
width: 52px;
height: 52px;
border-radius: 8px;
object-fit: cover;
background: #222;
flex-shrink: 0;
}
.result-card .meta { flex: 1; min-width: 0; }
.result-card .title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-card .artist { font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-card:hover { border-color: var(--accent); }
.result-card .dl-btn {
background: var(--accent-dim);
color: var(--accent);
border: none;
border-radius: 8px;
padding: 8px 10px;
transition: transform .1s ease, background .15s ease;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.result-card .dl-btn:active { transform: scale(.92); }
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 48px 20px;
text-align: center;
transition: border-color .2s ease, background .2s ease, transform .15s ease;
}
.drop-zone.dragover { border-color: var(--accent); background: var(--accent-dim); transform: scale(1.01); }
.drop-zone p { color: var(--text-dim); margin: 8px 0; }
.drop-zone .hint { font-size: 12px; }
.conv-result { margin-top: 20px; }
.conv-result.hidden { display: none; }
#conv-file-info { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; }
#conv-output { margin-top: 14px; font-size: 14px; }
#conv-output a { color: var(--accent); }
.meta-form { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
.meta-form.hidden { display: none; }
.meta-cover-row { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; }
.cover-preview {
width: 72px; height: 72px;
border-radius: 10px;
object-fit: cover;
background: var(--surface-2);
border: 1px solid var(--border);
}
.batch-list { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
.batch-item { font-size: 13px; color: var(--text-dim); display: flex; justify-content: space-between; }
.batch-item .ok { color: #6ee7a8; }
.batch-item .err { color: var(--error); }
.script-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.script-grid textarea {
width: 100%;
height: 340px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 13px;
padding: 14px;
border-radius: 10px;
resize: vertical;
outline: none;
}
.script-grid textarea:focus { border-color: var(--accent); }
.script-error {
margin-top: 10px;
color: var(--error);
font-size: 13px;
font-family: Consolas, monospace;
}
.script-error.hidden { display: none; }
.hidden { display: none !important; }
.footer {
position: fixed;
bottom: 14px;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.18);
pointer-events: none;
letter-spacing: .02em;
}
@media (max-width: 640px) {
.script-grid { grid-template-columns: 1fr; }
.topbar { padding: 18px 20px; }
.tabs { padding: 0 12px; overflow-x: auto; }
}

BIN
app/static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
app/static/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

467
app/static/js/app.js Normal file
View file

@ -0,0 +1,467 @@
requestAnimationFrame(() => document.body.classList.add("loaded"));
// --- tabs ---
function crossfade(fromPanel, toPanel) {
if (fromPanel === toPanel) return;
if (fromPanel) {
fromPanel.classList.add("fade-out");
setTimeout(() => {
fromPanel.classList.remove("active", "fade-out");
}, 170);
}
setTimeout(() => {
toPanel.classList.add("active", "fade-in");
setTimeout(() => toPanel.classList.remove("fade-in"), 380);
}, fromPanel ? 140 : 0);
}
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.classList.contains("active")) return;
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const current = document.querySelector(".tab-panel.active");
const next = document.getElementById(`tab-${btn.dataset.tab}`);
crossfade(current, next);
});
});
function switchMode(switchEl, panelPrefix, attr) {
switchEl.querySelectorAll(".mode-btn").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.classList.contains("active")) return;
switchEl.querySelectorAll(".mode-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const val = btn.dataset[attr];
const current = switchEl.parentElement.querySelector(".mode-panel.active");
const next = document.getElementById(`${panelPrefix}-${val}`);
crossfade(current, next);
return val;
});
});
}
document.querySelectorAll(".mode-switch").forEach((sw) => {
if (sw.querySelector("[data-mode]")) switchMode(sw, "mode", "mode");
if (sw.querySelector("[data-meta-mode]")) switchMode(sw, "meta", "metaMode");
});
// =================== DOWNLOADER ===================
const dlUrl = document.getElementById("dl-url");
const dlFormat = document.getElementById("dl-format");
const dlQuality = document.getElementById("dl-quality");
const dlGo = document.getElementById("dl-go");
const dlProgress = document.getElementById("dl-progress");
const dlProgressBar = document.getElementById("dl-progress-bar");
const dlProgressText = document.getElementById("dl-progress-text");
const AUDIO_QUALITIES = [
["320", "320 kbps"],
["256", "256 kbps"],
["192", "192 kbps"],
["128", "128 kbps"],
];
const VIDEO_QUALITIES = [
["best", "лучшее"],
["2160", "2160p (4K)"],
["1440", "1440p"],
["1080", "1080p"],
["720", "720p"],
["480", "480p"],
["360", "360p"],
];
function refreshQualityOptions() {
const isVideo = dlFormat.value === "mp4";
const list = isVideo ? VIDEO_QUALITIES : AUDIO_QUALITIES;
dlQuality.innerHTML = list.map(([v, label]) => `<option value="${v}">${label}</option>`).join("");
}
dlFormat.addEventListener("change", refreshQualityOptions);
refreshQualityOptions();
function startDownload(url, fmt, quality) {
dlProgress.classList.remove("hidden");
dlProgressBar.style.width = "0%";
dlProgressText.textContent = "запускаю...";
fetch("/api/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, format: fmt, quality }),
})
.then((r) => r.json())
.then((data) => {
if (data.error) {
dlProgressText.textContent = "ошибка: " + data.error;
return;
}
pollJob(data.job_id);
})
.catch((e) => (dlProgressText.textContent = "ошибка: " + e));
}
function pollJob(jobId) {
const iv = setInterval(() => {
fetch(`/api/job/${jobId}`)
.then((r) => r.json())
.then((job) => {
if (job.error) {
clearInterval(iv);
dlProgressText.textContent = "ошибка: " + job.error;
return;
}
dlProgressBar.style.width = (job.progress || 0) + "%";
if (job.status === "downloading") {
dlProgressText.textContent = `скачиваю... ${job.progress || 0}% ${job.speed || ""}`;
} else if (job.status === "processing") {
dlProgressText.textContent = "обрабатываю...";
} else if (job.status === "done") {
clearInterval(iv);
dlProgressText.textContent = "готово ✓";
loadHistory();
} else if (job.status === "error") {
clearInterval(iv);
dlProgressText.textContent = "ошибка: " + job.error;
}
})
.catch(() => clearInterval(iv));
}, 800);
}
dlGo.addEventListener("click", () => {
const url = dlUrl.value.trim();
if (!url) return;
startDownload(url, dlFormat.value, dlQuality.value);
});
// --- search ---
const searchQ = document.getElementById("search-q");
const searchPlatform = document.getElementById("search-platform");
const searchGo = document.getElementById("search-go");
const searchResults = document.getElementById("search-results");
function renderResultCard(item, delayIndex = 0) {
const div = document.createElement("div");
div.className = "result-card";
div.style.animationDelay = `${Math.min(delayIndex * 35, 300)}ms`;
div.innerHTML = `
<img src="${item.cover_url || ""}" onerror="this.style.opacity=0">
<div class="meta">
<div class="title">${escapeHtml(item.title)}</div>
<div class="artist">${escapeHtml(item.artist)} ${item.duration ? "· " + item.duration : ""}</div>
</div>
<button class="dl-btn">скачать</button>
`;
div.querySelector(".dl-btn").addEventListener("click", () => {
startDownload(item.url, dlFormat.value, dlQuality.value);
});
return div;
}
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
searchGo.addEventListener("click", () => {
const q = searchQ.value.trim();
if (!q) return;
searchResults.innerHTML = '<div class="progress-text">ищу...</div>';
fetch(`/api/search?platform=${searchPlatform.value}&q=${encodeURIComponent(q)}`)
.then((r) => r.json())
.then((data) => {
searchResults.innerHTML = "";
if (data.error || !data.results || !data.results.length) {
searchResults.innerHTML = '<div class="progress-text">ничего не найдено</div>';
return;
}
data.results.forEach((item, i) => searchResults.appendChild(renderResultCard(item, i)));
});
});
// --- history ---
const historyList = document.getElementById("history-list");
function loadHistory() {
fetch("/api/history")
.then((r) => r.json())
.then((data) => {
historyList.innerHTML = "";
(data.history || []).forEach((item, i) => {
const div = document.createElement("div");
div.className = "result-card";
div.style.animationDelay = `${Math.min(i * 35, 300)}ms`;
div.innerHTML = `
<img src="${item.cover_url || ""}" onerror="this.style.opacity=0">
<div class="meta">
<div class="title">${escapeHtml(item.title)}</div>
<div class="artist">${escapeHtml(item.artist)} · ${item.format.toUpperCase()}</div>
</div>
`;
historyList.appendChild(div);
});
});
}
document.getElementById("history-clear").addEventListener("click", () => {
fetch("/api/history/clear", { method: "POST" }).then(loadHistory);
});
loadHistory();
// =================== CONVERTER ===================
const dropZone = document.getElementById("drop-zone");
const convFile = document.getElementById("conv-file");
const convResult = document.getElementById("conv-result");
const convFileInfo = document.getElementById("conv-file-info");
const convTarget = document.getElementById("conv-target");
const convGo = document.getElementById("conv-go");
const convOutput = document.getElementById("conv-output");
let currentUpload = null;
["dragover", "dragenter"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
})
);
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
})
);
dropZone.addEventListener("drop", (e) => {
const file = e.dataTransfer.files[0];
if (file) uploadForConvert(file);
});
convFile.addEventListener("change", () => {
if (convFile.files[0]) uploadForConvert(convFile.files[0]);
});
function uploadForConvert(file) {
const fd = new FormData();
fd.append("file", file);
convOutput.innerHTML = "";
fetch("/api/convert/upload", { method: "POST", body: fd })
.then((r) => r.json())
.then((data) => {
if (data.error) {
convFileInfo.textContent = "ошибка: " + data.error;
convResult.classList.remove("hidden");
return;
}
currentUpload = data;
convFileInfo.textContent = `${data.filename} · ${(data.size / 1024).toFixed(1)} KB · тип: ${data.type}`;
convTarget.innerHTML = data.targets.map((t) => `<option value="${t}">${t.toUpperCase()}</option>`).join("");
convResult.classList.remove("hidden");
});
}
convGo.addEventListener("click", () => {
if (!currentUpload) return;
convOutput.textContent = "конвертирую...";
fetch("/api/convert/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: currentUpload.id,
saved: currentUpload.saved,
filename: currentUpload.filename,
target: convTarget.value,
}),
})
.then((r) => r.json())
.then((data) => {
if (data.error) {
convOutput.textContent = "ошибка: " + data.error;
return;
}
convOutput.innerHTML = `<a href="/api/convert/download/${data.result}" download="${data.filename}">скачать ${data.filename}</a>`;
});
});
// =================== METADATA ===================
const metaFile = document.getElementById("meta-file");
const metaForm = document.getElementById("meta-form");
const metaCoverPreview = document.getElementById("meta-cover-preview");
const metaCoverInput = document.getElementById("meta-cover-input");
const metaTitle = document.getElementById("meta-title");
const metaArtist = document.getElementById("meta-artist");
const metaAlbum = document.getElementById("meta-album");
const metaYear = document.getElementById("meta-year");
const metaTrack = document.getElementById("meta-track");
const metaSave = document.getElementById("meta-save");
const metaSaveResult = document.getElementById("meta-save-result");
let currentMetaFile = null;
let newCoverFile = null;
metaFile.addEventListener("change", () => {
const file = metaFile.files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
fetch("/api/metadata/upload", { method: "POST", body: fd })
.then((r) => r.json())
.then((data) => {
if (data.error) {
alert(data.error);
return;
}
currentMetaFile = data;
newCoverFile = null;
metaTitle.value = data.tags.title || "";
metaArtist.value = data.tags.artist || "";
metaAlbum.value = data.tags.album || "";
metaYear.value = data.tags.year || "";
metaTrack.value = data.tags.track_number || "";
metaCoverPreview.src = data.tags.has_cover ? `/api/metadata/cover/${data.saved}?t=${Date.now()}` : "";
metaForm.classList.remove("hidden");
metaSaveResult.textContent = "";
});
});
metaCoverInput.addEventListener("change", () => {
const file = metaCoverInput.files[0];
if (!file) return;
newCoverFile = file;
metaCoverPreview.src = URL.createObjectURL(file);
});
metaSave.addEventListener("click", () => {
if (!currentMetaFile) return;
const fd = new FormData();
fd.append("saved", currentMetaFile.saved);
fd.append("title", metaTitle.value);
fd.append("artist", metaArtist.value);
fd.append("album", metaAlbum.value);
fd.append("year", metaYear.value);
fd.append("track_number", metaTrack.value);
if (newCoverFile) fd.append("cover", newCoverFile);
fetch("/api/metadata/save", { method: "POST", body: fd })
.then((r) => r.json())
.then((data) => {
if (data.error) {
metaSaveResult.textContent = "ошибка: " + data.error;
return;
}
metaSaveResult.innerHTML = `сохранено — <a href="/api/metadata/download/${data.download}" download>скачать</a>`;
});
});
// --- batch ---
const metaBatchFiles = document.getElementById("meta-batch-files");
const metaBatchList = document.getElementById("meta-batch-list");
const metaBatchForm = document.getElementById("meta-batch-form");
const metaBatchCoverPreview = document.getElementById("meta-batch-cover-preview");
const metaBatchCoverInput = document.getElementById("meta-batch-cover-input");
const metaBatchArtist = document.getElementById("meta-batch-artist");
const metaBatchAlbum = document.getElementById("meta-batch-album");
const metaBatchYear = document.getElementById("meta-batch-year");
const metaBatchSave = document.getElementById("meta-batch-save");
const metaBatchResult = document.getElementById("meta-batch-result");
let batchUploads = [];
let batchCoverSaved = null;
metaBatchFiles.addEventListener("change", async () => {
batchUploads = [];
metaBatchList.innerHTML = "";
for (const file of metaBatchFiles.files) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/metadata/upload", { method: "POST", body: fd }).then((r) => r.json());
if (!res.error) {
batchUploads.push(res);
const div = document.createElement("div");
div.className = "batch-item";
div.innerHTML = `<span>${escapeHtml(file.name)}</span><span class="ok">загружен</span>`;
metaBatchList.appendChild(div);
}
}
if (batchUploads.length) metaBatchForm.classList.remove("hidden");
});
metaBatchCoverInput.addEventListener("change", async () => {
const file = metaBatchCoverInput.files[0];
if (!file) return;
metaBatchCoverPreview.src = URL.createObjectURL(file);
const fd = new FormData();
fd.append("file", file, "cover.jpg");
// reuse the audio upload-like storage isn't right for images; store cover directly via convert upload endpoint instead
const res = await fetch("/api/convert/upload", { method: "POST", body: fd }).then((r) => r.json());
if (!res.error) batchCoverSaved = res.saved;
});
metaBatchSave.addEventListener("click", () => {
fetch("/api/metadata/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
saved_files: batchUploads.map((u) => u.saved),
artist: metaBatchArtist.value,
album: metaBatchAlbum.value,
year: metaBatchYear.value,
cover_saved: batchCoverSaved,
}),
})
.then((r) => r.json())
.then((data) => {
metaBatchResult.innerHTML = `обновлено: ${data.updated.length}` + (data.errors.length ? `, ошибок: ${data.errors.length}` : "");
});
});
// =================== SCRIPTS ===================
const scriptInput = document.getElementById("script-input");
const scriptOutput = document.getElementById("script-output");
const scriptFormat = document.getElementById("script-format");
const scriptDownload = document.getElementById("script-download");
const scriptError = document.getElementById("script-error");
let currentLang = "python";
document.querySelectorAll("[data-lang]").forEach((btn) => {
btn.addEventListener("click", () => {
currentLang = btn.dataset.lang;
});
});
scriptFormat.addEventListener("click", () => {
scriptError.classList.add("hidden");
fetch("/api/scripts/format", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: scriptInput.value, language: currentLang }),
})
.then((r) => r.json())
.then((data) => {
scriptOutput.value = data.formatted || "";
if (!data.ok) {
scriptError.textContent = (data.line ? `строка ${data.line}: ` : "") + data.error;
scriptError.classList.remove("hidden");
scriptDownload.disabled = true;
} else {
scriptDownload.disabled = false;
}
});
});
scriptDownload.addEventListener("click", () => {
const ext = currentLang === "python" ? "py" : "lua";
const blob = new Blob([scriptOutput.value], { type: "text/plain" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `formatted.${ext}`;
a.click();
});

198
app/templates/index.html Normal file
View file

@ -0,0 +1,198 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nexdex.space</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="topbar">
<div class="brand">nex<span class="brand-accent">dex</span>.space</div>
<a class="gh-btn" href="https://github.com/imsavsis" target="_blank" rel="noopener" title="GitHub">
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13
-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66
.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15
-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27
.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12
.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48
0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
</header>
<nav class="tabs">
<button class="tab-btn active" data-tab="downloader">Загрузчик</button>
<button class="tab-btn" data-tab="converter">Конвертер</button>
<button class="tab-btn" data-tab="metadata">Метаданные</button>
<button class="tab-btn" data-tab="scripts">Скрипты</button>
</nav>
<main>
<!-- ================= DOWNLOADER ================= -->
<section id="tab-downloader" class="tab-panel active">
<div class="card">
<div class="mode-switch">
<button class="mode-btn active" data-mode="link">Ссылка</button>
<button class="mode-btn" data-mode="search">Поиск</button>
</div>
<div id="mode-link" class="mode-panel active">
<input id="dl-url" class="big-input" type="text" placeholder="Вставь ссылку — YouTube / SoundCloud / Apple Music / Spotify / Yandex Music">
<div class="row">
<select id="dl-format">
<option value="mp3">MP3</option>
<option value="flac">FLAC (lossless)</option>
<option value="wav">WAV (lossless)</option>
<option value="m4a">M4A</option>
<option value="alac">ALAC (lossless)</option>
<option value="ogg">OGG</option>
<option value="opus">OPUS</option>
<option value="mp4">MP4 (видео)</option>
</select>
<select id="dl-quality"></select>
<button id="dl-go" class="primary-btn">Скачать</button>
</div>
</div>
<div id="mode-search" class="mode-panel">
<div class="row">
<input id="search-q" class="big-input" type="text" placeholder="Название трека или исполнитель...">
<select id="search-platform">
<option value="youtube">YouTube</option>
<option value="soundcloud">SoundCloud</option>
<option value="apple_music">Apple Music</option>
</select>
<button id="search-go" class="primary-btn">Искать</button>
</div>
<div id="search-results" class="card-grid"></div>
</div>
<div id="dl-progress" class="progress-box hidden">
<div class="progress-track"><div id="dl-progress-bar" class="progress-fill"></div></div>
<div id="dl-progress-text" class="progress-text"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>История</h3>
<button id="history-clear" class="ghost-btn">очистить</button>
</div>
<div id="history-list" class="card-grid"></div>
</div>
</section>
<!-- ================= CONVERTER ================= -->
<section id="tab-converter" class="tab-panel">
<div class="card">
<div id="drop-zone" class="drop-zone">
<p>Перетащи файл сюда или</p>
<label class="primary-btn file-btn">
выбрать файл
<input id="conv-file" type="file" hidden>
</label>
<p class="hint">картинки (webp, png, jpg, ico...) · pdf · pptx · аудио (flac, wav, mp3...) · видео (mp4, mkv...)</p>
</div>
<div id="conv-result" class="conv-result hidden">
<div id="conv-file-info"></div>
<div class="row">
<select id="conv-target"></select>
<button id="conv-go" class="primary-btn">Конвертировать</button>
</div>
<div id="conv-output"></div>
</div>
</div>
</section>
<!-- ================= METADATA ================= -->
<section id="tab-metadata" class="tab-panel">
<div class="card">
<div class="mode-switch">
<button class="mode-btn active" data-meta-mode="single">Один файл</button>
<button class="mode-btn" data-meta-mode="batch">Массово</button>
</div>
<div id="meta-single" class="mode-panel active">
<label class="primary-btn file-btn">
выбрать аудиофайл
<input id="meta-file" type="file" accept=".mp3,.flac,.wav,.m4a,.mp4,.ogg,.opus" hidden>
</label>
<div id="meta-form" class="meta-form hidden">
<div class="meta-cover-row">
<img id="meta-cover-preview" class="cover-preview" alt="обложка">
<label class="ghost-btn file-btn">
сменить обложку
<input id="meta-cover-input" type="file" accept="image/*" hidden>
</label>
</div>
<input id="meta-title" placeholder="название">
<input id="meta-artist" placeholder="исполнитель">
<input id="meta-album" placeholder="альбом">
<div class="row">
<input id="meta-year" placeholder="год" style="max-width:120px">
<input id="meta-track" placeholder="№ трека" style="max-width:120px">
</div>
<button id="meta-save" class="primary-btn">Сохранить</button>
<div id="meta-save-result"></div>
</div>
</div>
<div id="meta-batch" class="mode-panel">
<label class="primary-btn file-btn">
выбрать несколько файлов
<input id="meta-batch-files" type="file" accept=".mp3,.flac,.wav,.m4a,.mp4,.ogg,.opus" multiple hidden>
</label>
<div id="meta-batch-list" class="batch-list"></div>
<div id="meta-batch-form" class="meta-form hidden">
<div class="meta-cover-row">
<img id="meta-batch-cover-preview" class="cover-preview" alt="обложка">
<label class="ghost-btn file-btn">
обложка для всех
<input id="meta-batch-cover-input" type="file" accept="image/*" hidden>
</label>
</div>
<input id="meta-batch-artist" placeholder="исполнитель (для всех)">
<input id="meta-batch-album" placeholder="альбом (для всех)">
<input id="meta-batch-year" placeholder="год (для всех)" style="max-width:160px">
<button id="meta-batch-save" class="primary-btn">Применить ко всем</button>
<div id="meta-batch-result"></div>
</div>
</div>
</div>
</section>
<!-- ================= SCRIPTS ================= -->
<section id="tab-scripts" class="tab-panel">
<div class="card">
<div class="mode-switch">
<button class="mode-btn active" data-lang="python">Python</button>
<button class="mode-btn" data-lang="lua">Lua</button>
</div>
<div class="script-grid">
<textarea id="script-input" placeholder="вставь код сюда..." spellcheck="false"></textarea>
<textarea id="script-output" placeholder="результат появится здесь" spellcheck="false" readonly></textarea>
</div>
<div class="row">
<button id="script-format" class="primary-btn">Форматировать</button>
<button id="script-download" class="ghost-btn" disabled>Скачать</button>
</div>
<div id="script-error" class="script-error hidden"></div>
</div>
</section>
</main>
<footer class="footer">made by savsis with ♥</footer>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>

55
extras/batch/menu.bat Normal file
View file

@ -0,0 +1,55 @@
@echo off
REM extras/batch/menu.bat -- decorative, not part of the real installer.
REM the real launcher is app/start.bat (just "python app.py"). this one is
REM just a slightly fancier menu wrapper, kept here for show.
setlocal enabledelayedexpansion
title nexdex.space -- dev menu
:menu
cls
echo ============================================
echo nexdex.space -- dev menu
echo made by savsis with ^<3
echo ============================================
echo.
echo 1. run the app (python app.py)
echo 2. install dependencies (pip install -r requirements.txt)
echo 3. format a python file with black
echo 4. open downloads folder
echo 5. exit
echo.
set /p choice="choose 1-5: "
if "%choice%"=="1" goto run
if "%choice%"=="2" goto deps
if "%choice%"=="3" goto format
if "%choice%"=="4" goto downloads
if "%choice%"=="5" goto end
goto menu
:run
cd /d "%~dp0..\..\app"
python app.py
pause
goto menu
:deps
cd /d "%~dp0..\..\app"
pip install -r requirements.txt
pause
goto menu
:format
set /p pyfile="path to .py file: "
python -m black "%pyfile%"
pause
goto menu
:downloads
start "" "%~dp0..\..\app\downloads"
goto menu
:end
endlocal
exit /b 0

68
extras/cpp/audio_meta.hpp Normal file
View file

@ -0,0 +1,68 @@
// extras/cpp — decorative, separate from installer/installer.cpp (the real,
// functional C++ source). a tiny header-only audio-tag toy, mirroring
// app/backend/metadata.py in spirit.
#pragma once
#include <string>
#include <optional>
#include <sstream>
#include <vector>
namespace nexdex {
struct AudioTag {
std::string title;
std::string artist;
std::optional<std::string> album;
std::optional<int> year;
std::optional<int> trackNumber;
bool isComplete() const {
return !title.empty() && !artist.empty();
}
std::string describe() const {
std::ostringstream out;
out << artist << " -- " << title;
if (album) out << " [" << *album << "]";
if (year) out << " (" << *year << ")";
if (trackNumber) out << " track #" << *trackNumber;
return out.str();
}
};
class AudioTagBuilder {
public:
AudioTagBuilder(std::string title, std::string artist) {
tag_.title = std::move(title);
tag_.artist = std::move(artist);
}
AudioTagBuilder& album(std::string album) {
tag_.album = std::move(album);
return *this;
}
AudioTagBuilder& year(int year) {
tag_.year = year;
return *this;
}
AudioTagBuilder& trackNumber(int n) {
tag_.trackNumber = n;
return *this;
}
AudioTag build() const {
return tag_;
}
private:
AudioTag tag_;
};
inline std::vector<std::string> knownExtensions() {
return {"mp3", "flac", "wav", "m4a", "alac", "ogg", "opus"};
}
} // namespace nexdex

34
extras/cpp/demo.cpp Normal file
View file

@ -0,0 +1,34 @@
// extras/cpp/demo.cpp — decorative, not part of the build. compile with:
// cl.exe /std:c++17 /EHsc demo.cpp /Fe:demo.exe
// or: g++ -std=c++17 demo.cpp -o demo
#include <iostream>
#include "audio_meta.hpp"
#include "progress_bar.hpp"
#include "playlist.hpp"
int main() {
using nexdex::AudioTagBuilder;
using nexdex::ProgressBar;
using nexdex::Playlist;
using nexdex::Track;
auto tag = AudioTagBuilder("MATADORA (Extended)", "SUKA.")
.year(2026)
.build();
std::cout << tag.describe() << "\n\n";
ProgressBar bar("MATADORA (Extended)", 30);
for (int pct = 0; pct <= 100; pct += 20) {
std::cout << bar.render(pct) << "\n";
}
Playlist playlist;
playlist.add(Track{tag, "downloads/MATADORA (Extended).flac", 222});
std::cout << "\nplaylist: " << playlist.size() << " track(s), total "
<< Playlist::formatDuration(playlist.totalDurationSecs()) << "\n";
std::cout << "\nmade by savsis with <3\n";
return 0;
}

79
extras/cpp/playlist.hpp Normal file
View file

@ -0,0 +1,79 @@
// extras/cpp — decorative. mirrors extras/rust/src/playlist.rs, just in
// C++: a tiny in-memory playlist model with no real I/O.
#pragma once
#include <vector>
#include <string>
#include <algorithm>
#include <optional>
#include <numeric>
#include "audio_meta.hpp"
namespace nexdex {
struct Track {
AudioTag tag;
std::string path;
int durationSecs = 0;
};
class Playlist {
public:
void add(Track track) {
tracks_.push_back(std::move(track));
}
size_t size() const { return tracks_.size(); }
bool empty() const { return tracks_.empty(); }
int totalDurationSecs() const {
return std::accumulate(
tracks_.begin(), tracks_.end(), 0,
[](int sum, const Track& t) { return sum + t.durationSecs; });
}
std::vector<const Track*> byArtist(const std::string& artist) const {
std::vector<const Track*> result;
for (const auto& t : tracks_) {
if (equalsIgnoreCase(t.tag.artist, artist)) {
result.push_back(&t);
}
}
return result;
}
std::optional<const Track*> longest() const {
if (tracks_.empty()) return std::nullopt;
auto it = std::max_element(
tracks_.begin(), tracks_.end(),
[](const Track& a, const Track& b) { return a.durationSecs < b.durationSecs; });
return &(*it);
}
static std::string formatDuration(int totalSecs) {
int hours = totalSecs / 3600;
int minutes = (totalSecs % 3600) / 60;
int seconds = totalSecs % 60;
char buf[32];
if (hours > 0) {
snprintf(buf, sizeof(buf), "%d:%02d:%02d", hours, minutes, seconds);
} else {
snprintf(buf, sizeof(buf), "%d:%02d", minutes, seconds);
}
return std::string(buf);
}
private:
static bool equalsIgnoreCase(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
return std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) ==
std::tolower(static_cast<unsigned char>(y));
});
}
std::vector<Track> tracks_;
};
} // namespace nexdex

View file

@ -0,0 +1,31 @@
// extras/cpp — decorative. a tiny console progress bar, echoing the shape
// of the /api/job/<id> polling response from backend/jobs.py.
#pragma once
#include <string>
#include <algorithm>
namespace nexdex {
class ProgressBar {
public:
ProgressBar(std::string label, int width) : label_(std::move(label)), width_(width) {}
std::string render(int percent) const {
percent = std::clamp(percent, 0, 100);
int filled = width_ * percent / 100;
int empty = width_ - filled;
std::string bar;
bar.append(filled, '#');
bar.append(empty, '-');
return label_ + " [" + bar + "] " + std::to_string(percent) + "%";
}
private:
std::string label_;
int width_;
};
} // namespace nexdex

98
extras/formats.ts Normal file
View file

@ -0,0 +1,98 @@
// extras/formats.ts — decorative, not wired into the Flask/JS frontend
// (the real frontend logic lives in app/static/js/app.js, plain JS).
// just a typed reference of the formats the downloader/converter understand.
type AudioFormat = "mp3" | "flac" | "wav" | "m4a" | "alac" | "ogg" | "opus";
type VideoFormat = "mp4" | "avi" | "mov" | "mkv" | "webm" | "gif";
type ImageFormat = "jpg" | "png" | "webp" | "bmp" | "gif" | "tiff" | "ico";
type DocFormat = "pdf" | "pptx";
interface FormatInfo {
ext: string;
lossless: boolean;
}
const AUDIO_FORMATS: Record<AudioFormat, FormatInfo> = {
mp3: { ext: "mp3", lossless: false },
flac: { ext: "flac", lossless: true },
wav: { ext: "wav", lossless: true },
m4a: { ext: "m4a", lossless: false },
alac: { ext: "alac", lossless: true },
ogg: { ext: "ogg", lossless: false },
opus: { ext: "opus", lossless: false },
};
const VIDEO_FORMATS: VideoFormat[] = ["mp4", "avi", "mov", "mkv", "webm", "gif"];
const IMAGE_FORMATS: ImageFormat[] = ["jpg", "png", "webp", "bmp", "gif", "tiff", "ico"];
const DOC_FORMATS: DocFormat[] = ["pdf", "pptx"];
function isLossless(fmt: AudioFormat): boolean {
return AUDIO_FORMATS[fmt].lossless;
}
interface SearchResult {
title: string;
artist: string;
coverUrl: string | null;
duration: string;
source: "youtube" | "soundcloud" | "apple_music";
}
function describeResult(r: SearchResult): string {
const cover = r.coverUrl ? "with cover" : "no cover";
return `[${r.source}] ${r.artist} -- ${r.title} (${r.duration}, ${cover})`;
}
interface DownloadJob {
jobId: string;
status: "pending" | "downloading" | "processing" | "done" | "error";
progress: number;
speed: string;
}
class ProgressBar {
constructor(private label: string, private width: number) {}
render(percent: number): string {
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.round((this.width * clamped) / 100);
const empty = this.width - filled;
return `${this.label.padEnd(20)} [${"#".repeat(filled)}${"-".repeat(empty)}] ${clamped}%`;
}
}
function simulateJob(jobId: string): DownloadJob[] {
const steps: DownloadJob[] = [];
for (let progress = 0; progress <= 100; progress += 25) {
steps.push({
jobId,
status: progress < 100 ? "downloading" : "done",
progress,
speed: progress < 100 ? "1.2MiB/s" : "",
});
}
return steps;
}
console.log("nexdex.space formats reference");
console.log("audio formats:", Object.keys(AUDIO_FORMATS));
console.log("lossless formats:", (Object.keys(AUDIO_FORMATS) as AudioFormat[]).filter(isLossless));
console.log("video formats:", VIDEO_FORMATS);
console.log("image formats:", IMAGE_FORMATS);
console.log("doc formats:", DOC_FORMATS);
const sample: SearchResult = {
title: "MATADORA (Extended)",
artist: "SUKA.",
coverUrl: null,
duration: "3:42",
source: "youtube",
};
console.log(describeResult(sample));
const bar = new ProgressBar("MATADORA (Extended)", 30);
for (const job of simulateJob("demo-job")) {
console.log(bar.render(job.progress), job.status);
}
console.log("made by savsis with <3");

14
extras/fortune.lua Normal file
View file

@ -0,0 +1,14 @@
-- extras/fortune.lua — decorative, not wired into the app.
-- a tiny fortune-cookie script, sitting here mostly so the Scripts/Lua tab
-- has something native to point at. run with: lua fortune.lua
local fortunes = {
"lossless или ничего.",
"320kbps -- это не lossless, не обманывай себя.",
"обложка важнее битрейта.",
"stylua лучше форматирует lua, чем ты руками.",
"made by savsis with <3",
}
math.randomseed(os.time())
print(fortunes[math.random(#fortunes)])

82
extras/go/main.go Normal file
View file

@ -0,0 +1,82 @@
// extras/go — decorative, not wired into the build. a toy CLI that mirrors
// the shape of backend/search.py's result cards (title/artist/cover/duration)
// without actually calling anything. run with: go run main.go
package main
import (
"fmt"
"strings"
)
// SearchResult mirrors the dicts returned by backend/search.py's
// search_youtube / search_soundcloud / search_apple_music.
type SearchResult struct {
Title string
Artist string
CoverURL string
Duration string
Source string
}
func (r SearchResult) String() string {
return fmt.Sprintf("[%s] %s -- %s (%s)", strings.ToUpper(r.Source), r.Artist, r.Title, r.Duration)
}
type AudioFormat struct {
Ext string
Lossless bool
}
var formats = []AudioFormat{
{"mp3", false},
{"flac", true},
{"wav", true},
{"m4a", false},
{"alac", true},
{"ogg", false},
{"opus", false},
}
func losslessFormats() []string {
var out []string
for _, f := range formats {
if f.Lossless {
out = append(out, f.Ext)
}
}
return out
}
func renderProgress(label string, percent int, width int) string {
if percent > 100 {
percent = 100
}
if percent < 0 {
percent = 0
}
filled := width * percent / 100
bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
return fmt.Sprintf("%-20s [%s] %3d%%", label, bar, percent)
}
func main() {
results := []SearchResult{
{"MATADORA (Extended)", "SUKA.", "", "3:42", "youtube"},
{"Aria Math (Epic Version)", "Logan Feece", "", "4:10", "soundcloud"},
}
fmt.Println("nexdex.space -- go toy search demo")
for _, r := range results {
fmt.Println(r)
}
fmt.Println("\nlossless formats:", strings.Join(losslessFormats(), ", "))
fmt.Println()
for pct := 0; pct <= 100; pct += 20 {
fmt.Println(renderProgress("MATADORA (Extended)", pct, 30))
}
fmt.Println("\nmade by savsis with <3")
}

9
extras/rust/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "nexdex-tools"
version = "0.1.0"
edition = "2021"
description = "decorative rust toys, not part of the nexdex.space build"
[[bin]]
name = "hello"
path = "src/main.rs"

View file

@ -0,0 +1,46 @@
// a small dependency-free crc32 (used nowhere, just a rust toy sitting
// next to the real python download/convert pipeline).
const POLY: u32 = 0xEDB88320;
fn table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0u32;
while i < 256 {
let mut c = i;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 { POLY ^ (c >> 1) } else { c >> 1 };
k += 1;
}
table[i as usize] = c;
i += 1;
}
table
}
pub fn crc32(data: &[u8]) -> u32 {
let table = table();
let mut crc: u32 = 0xFFFFFFFF;
for &byte in data {
let idx = ((crc ^ byte as u32) & 0xFF) as usize;
crc = table[idx] ^ (crc >> 8);
}
crc ^ 0xFFFFFFFF
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_vector() {
// crc32("123456789") == 0xCBF43926, the standard check value.
assert_eq!(crc32(b"123456789"), 0xCBF43926);
}
#[test]
fn empty_input() {
assert_eq!(crc32(b""), 0);
}
}

View file

@ -0,0 +1,91 @@
// mirrors the format list from app/backend/converter.py, just as a rust toy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioFormat {
Mp3,
Flac,
Wav,
M4a,
Alac,
Ogg,
Opus,
}
impl AudioFormat {
pub fn all() -> &'static [AudioFormat] {
use AudioFormat::*;
&[Mp3, Flac, Wav, M4a, Alac, Ogg, Opus]
}
pub fn extension(&self) -> &'static str {
match self {
AudioFormat::Mp3 => "mp3",
AudioFormat::Flac => "flac",
AudioFormat::Wav => "wav",
AudioFormat::M4a => "m4a",
AudioFormat::Alac => "alac",
AudioFormat::Ogg => "ogg",
AudioFormat::Opus => "opus",
}
}
pub fn is_lossless(&self) -> bool {
matches!(self, AudioFormat::Flac | AudioFormat::Wav | AudioFormat::Alac)
}
pub fn from_extension(ext: &str) -> Option<AudioFormat> {
AudioFormat::all()
.iter()
.copied()
.find(|f| f.extension().eq_ignore_ascii_case(ext))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Jpg,
Png,
Webp,
Bmp,
Gif,
Tiff,
Ico,
}
impl ImageFormat {
pub fn all() -> &'static [ImageFormat] {
use ImageFormat::*;
&[Jpg, Png, Webp, Bmp, Gif, Tiff, Ico]
}
pub fn extension(&self) -> &'static str {
match self {
ImageFormat::Jpg => "jpg",
ImageFormat::Png => "png",
ImageFormat::Webp => "webp",
ImageFormat::Bmp => "bmp",
ImageFormat::Gif => "gif",
ImageFormat::Tiff => "tiff",
ImageFormat::Ico => "ico",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_extension() {
for fmt in AudioFormat::all() {
let ext = fmt.extension();
assert_eq!(AudioFormat::from_extension(ext), Some(*fmt));
}
}
#[test]
fn flac_is_lossless() {
assert!(AudioFormat::Flac.is_lossless());
assert!(!AudioFormat::Mp3.is_lossless());
}
}

59
extras/rust/src/main.rs Normal file
View file

@ -0,0 +1,59 @@
// extras/rust — decorative toy crate, not wired into the actual nexdex.space
// build (that's the C++ installer + python app). kept here as a small rust
// playground alongside the app. build with: cargo run --manifest-path Cargo.toml
mod formats;
mod tags;
mod checksum;
mod progress;
mod playlist;
fn banner() -> &'static str {
r#"
_ __ _____ ____| | _____ __
| '_ \ / _ \ \/ / _` |/ _ \ \/ /
| | | | __/> < (_| | __/> <
|_| |_|\___/_/\_\__,_|\___/_/\_\
.space
"#
}
fn main() {
println!("{}", banner());
let track = tags::Tag {
title: "MATADORA (Extended)".to_string(),
artist: "SUKA.".to_string(),
album: None,
year: Some(2026),
track_number: None,
};
println!("{}", track.describe());
for fmt in formats::AudioFormat::all() {
println!(
"{:<6} lossless={}",
fmt.extension(),
fmt.is_lossless()
);
}
let data = b"nexdex.space made by savsis";
println!("crc32({:?}) = {:08x}", String::from_utf8_lossy(data), checksum::crc32(data));
progress::simulate_download("MATADORA (Extended)");
let mut pl = playlist::Playlist::new();
pl.add(playlist::Track {
tag: track.clone(),
path: "downloads/MATADORA (Extended).flac".to_string(),
duration_secs: 222,
});
println!(
"playlist: {} track(s), total {}",
pl.len(),
playlist::Playlist::format_duration(pl.total_duration_secs())
);
println!("made by savsis with <3");
}

111
extras/rust/src/playlist.rs Normal file
View file

@ -0,0 +1,111 @@
// extras/rust — decorative toy: a tiny in-memory playlist model, echoing
// the shape of data/history.json (backend/history.py) but with no I/O.
use crate::tags::Tag;
#[derive(Debug, Clone)]
pub struct Track {
pub tag: Tag,
pub path: String,
pub duration_secs: u32,
}
#[derive(Debug, Default)]
pub struct Playlist {
tracks: Vec<Track>,
}
impl Playlist {
pub fn new() -> Self {
Playlist { tracks: Vec::new() }
}
pub fn add(&mut self, track: Track) {
self.tracks.push(track);
}
pub fn len(&self) -> usize {
self.tracks.len()
}
pub fn is_empty(&self) -> bool {
self.tracks.is_empty()
}
pub fn total_duration_secs(&self) -> u32 {
self.tracks.iter().map(|t| t.duration_secs).sum()
}
pub fn by_artist(&self, artist: &str) -> Vec<&Track> {
self.tracks
.iter()
.filter(|t| t.tag.artist.eq_ignore_ascii_case(artist))
.collect()
}
pub fn sorted_by_title(&self) -> Vec<&Track> {
let mut sorted: Vec<&Track> = self.tracks.iter().collect();
sorted.sort_by(|a, b| a.tag.title.cmp(&b.tag.title));
sorted
}
pub fn longest(&self) -> Option<&Track> {
self.tracks.iter().max_by_key(|t| t.duration_secs)
}
pub fn format_duration(total_secs: u32) -> String {
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{}:{:02}", minutes, seconds)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tags::TagBuilder;
fn sample_track(title: &str, artist: &str, secs: u32) -> Track {
Track {
tag: TagBuilder::new(title, artist).build(),
path: format!("downloads/{}.flac", title),
duration_secs: secs,
}
}
#[test]
fn total_duration_sums_all_tracks() {
let mut pl = Playlist::new();
pl.add(sample_track("A", "X", 100));
pl.add(sample_track("B", "Y", 200));
assert_eq!(pl.total_duration_secs(), 300);
}
#[test]
fn filters_by_artist_case_insensitively() {
let mut pl = Playlist::new();
pl.add(sample_track("A", "SUKA.", 100));
pl.add(sample_track("B", "suka.", 50));
pl.add(sample_track("C", "Someone Else", 10));
assert_eq!(pl.by_artist("suka.").len(), 2);
}
#[test]
fn finds_longest_track() {
let mut pl = Playlist::new();
pl.add(sample_track("Short", "X", 60));
pl.add(sample_track("Long", "X", 600));
assert_eq!(pl.longest().unwrap().tag.title, "Long");
}
#[test]
fn formats_duration_with_and_without_hours() {
assert_eq!(Playlist::format_duration(65), "1:05");
assert_eq!(Playlist::format_duration(3665), "1:01:05");
}
}

View file

@ -0,0 +1,61 @@
// a toy terminal progress bar, echoing the shape of the progress_hook
// callbacks used by backend/jobs.py — just rendered with print! here
// instead of pushed over the /api/job/<id> polling endpoint.
pub struct ProgressBar {
width: usize,
label: String,
}
impl ProgressBar {
pub fn new(label: impl Into<String>, width: usize) -> Self {
ProgressBar {
width,
label: label.into(),
}
}
pub fn render(&self, percent: u8) -> String {
let percent = percent.min(100) as usize;
let filled = (self.width * percent) / 100;
let empty = self.width - filled;
format!(
"{:<20} [{}{}] {:>3}%",
self.label,
"#".repeat(filled),
"-".repeat(empty),
percent
)
}
}
pub fn simulate_download(label: &str) {
let bar = ProgressBar::new(label, 30);
for pct in (0..=100).step_by(20) {
println!("{}", bar.render(pct));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_percent_is_all_empty() {
let bar = ProgressBar::new("test", 10);
assert_eq!(bar.render(0), format!("{:<20} [{}] {:>3}%", "test", "-".repeat(10), 0));
}
#[test]
fn hundred_percent_is_all_filled() {
let bar = ProgressBar::new("test", 10);
assert_eq!(bar.render(100), format!("{:<20} [{}] {:>3}%", "test", "#".repeat(10), 100));
}
#[test]
fn clamps_above_100() {
let bar = ProgressBar::new("test", 10);
assert_eq!(bar.render(255), bar.render(100));
}
}

86
extras/rust/src/tags.rs Normal file
View file

@ -0,0 +1,86 @@
// a tiny stand-in for the tag struct app/backend/metadata.py works with —
// decorative, the real read/write logic lives in python (mutagen).
#[derive(Debug, Clone)]
pub struct Tag {
pub title: String,
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub track_number: Option<u32>,
}
impl Tag {
pub fn describe(&self) -> String {
let mut parts = vec![format!("{} — {}", self.artist, self.title)];
if let Some(album) = &self.album {
parts.push(format!("[{}]", album));
}
if let Some(year) = self.year {
parts.push(format!("({})", year));
}
if let Some(track) = self.track_number {
parts.push(format!("track #{}", track));
}
parts.join(" ")
}
pub fn is_complete(&self) -> bool {
!self.title.is_empty() && !self.artist.is_empty()
}
}
pub struct TagBuilder {
tag: Tag,
}
impl TagBuilder {
pub fn new(title: impl Into<String>, artist: impl Into<String>) -> Self {
TagBuilder {
tag: Tag {
title: title.into(),
artist: artist.into(),
album: None,
year: None,
track_number: None,
},
}
}
pub fn album(mut self, album: impl Into<String>) -> Self {
self.tag.album = Some(album.into());
self
}
pub fn year(mut self, year: u32) -> Self {
self.tag.year = Some(year);
self
}
pub fn track_number(mut self, n: u32) -> Self {
self.tag.track_number = Some(n);
self
}
pub fn build(self) -> Tag {
self.tag
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_produces_expected_tag() {
let tag = TagBuilder::new("Aria Math", "Logan Feece")
.album("C418 covers")
.year(2024)
.build();
assert!(tag.is_complete());
assert_eq!(tag.describe(), "Logan Feece — Aria Math [C418 covers] (2024)");
}
}

BIN
installer/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

176
installer/installer.cpp Normal file
View file

@ -0,0 +1,176 @@
// nexdex.space installer/launcher
//
// the app payload (portable python runtime + full app source, including
// ffmpeg) is embedded inside this exe as a plain RCDATA resource (a zip).
// on first run it dumps that resource to a temp zip file and extracts it
// next to itself via the OS-native tar.exe, then launches the app with the
// bundled python — no system python required at all. embedding as a real
// PE resource (rather than appending the zip after the exe) keeps this a
// normal, well-formed PE, so it can still be Authenticode-signed as usual.
#include <windows.h>
#include <shlobj.h>
#include <shobjidl.h>
#include <objbase.h>
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <filesystem>
namespace fs = std::filesystem;
static void createDesktopShortcut(const fs::path& targetExe) {
wchar_t* desktopPath = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_Desktop, 0, nullptr, &desktopPath))) return;
fs::path shortcutPath = fs::path(desktopPath) / L"nexdex.space.lnk";
CoTaskMemFree(desktopPath);
if (fs::exists(shortcutPath)) return;
IShellLinkW* shellLink = nullptr;
if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
IID_IShellLinkW, reinterpret_cast<void**>(&shellLink))))
return;
shellLink->SetPath(targetExe.c_str());
shellLink->SetWorkingDirectory(targetExe.parent_path().c_str());
shellLink->SetIconLocation(targetExe.c_str(), 0);
shellLink->SetDescription(L"nexdex.space -- made by savsis");
IPersistFile* persistFile = nullptr;
if (SUCCEEDED(shellLink->QueryInterface(IID_IPersistFile, reinterpret_cast<void**>(&persistFile)))) {
persistFile->Save(shortcutPath.c_str(), TRUE);
persistFile->Release();
}
shellLink->Release();
}
static std::wstring quote(const std::wstring& s) {
return L"\"" + s + L"\"";
}
static bool runAndWait(const std::wstring& cmdLine) {
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::vector<wchar_t> buf(cmdLine.begin(), cmdLine.end());
buf.push_back(0);
if (!CreateProcessW(nullptr, buf.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) {
return false;
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return code == 0;
}
static bool extractPayloadResource(const fs::path& outZipPath) {
HMODULE self = GetModuleHandleW(nullptr);
HRSRC res = FindResourceW(self, MAKEINTRESOURCEW(102), MAKEINTRESOURCEW(10) /* RT_RCDATA */);
if (!res) return false;
HGLOBAL data = LoadResource(self, res);
if (!data) return false;
DWORD size = SizeofResource(self, res);
void* ptr = LockResource(data);
if (!ptr || size == 0) return false;
std::ofstream out(outZipPath, std::ios::binary);
if (!out) return false;
out.write(static_cast<const char*>(ptr), static_cast<std::streamsize>(size));
return out.good();
}
static bool runDetached(const std::wstring& cmdLine, const std::wstring& cwd) {
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::vector<wchar_t> buf(cmdLine.begin(), cmdLine.end());
buf.push_back(0);
BOOL ok = CreateProcessW(nullptr, buf.data(), nullptr, nullptr, FALSE,
DETACHED_PROCESS, nullptr, cwd.c_str(), &si, &pi);
if (!ok) return false;
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return true;
}
int wmain() {
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
wchar_t exePathBuf[MAX_PATH];
GetModuleFileNameW(nullptr, exePathBuf, MAX_PATH);
fs::path exePath(exePathBuf);
wchar_t* localAppData = nullptr;
SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &localAppData);
fs::path installDir = fs::path(localAppData) / L"nexdex.space";
CoTaskMemFree(localAppData);
fs::path appDir = installDir / L"app";
fs::path pythonExe = installDir / L"python" / L"pythonw.exe";
fs::path appPy = appDir / L"app.py";
fs::path marker = installDir / L".installed";
std::wcout << L"nexdex.space\n";
if (!fs::exists(marker)) {
std::wcout << L"первый запуск -- распаковываю (это займёт немного времени)...\n";
std::error_code ec;
fs::create_directories(installDir, ec);
fs::path tempZip = installDir / L"_payload.zip";
if (!extractPayloadResource(tempZip)) {
std::wcerr << L"не удалось прочитать встроенные данные приложения.\n";
Sleep(4000);
return 1;
}
wchar_t sysDir[MAX_PATH];
GetSystemDirectoryW(sysDir, MAX_PATH);
std::wstring tarExe = std::wstring(sysDir) + L"\\tar.exe";
std::wstring cmd = quote(tarExe) + L" -xf " + quote(tempZip.wstring()) +
L" -C " + quote(installDir.wstring());
bool ok = runAndWait(cmd);
fs::remove(tempZip, ec);
if (!ok) {
std::wcerr << L"ошибка распаковки. попробуй запустить ещё раз или проверь права доступа.\n";
Sleep(4000);
return 1;
}
std::ofstream(marker.string()).put('1');
createDesktopShortcut(exePath);
std::wcout << L"готово -- ярлык добавлен на рабочий стол.\n";
}
if (!fs::exists(appPy) || !fs::exists(pythonExe)) {
std::wcerr << L"файлы приложения повреждены -- удали папку " << installDir.wstring()
<< L" и запусти установщик заново.\n";
Sleep(5000);
return 1;
}
std::wcout << L"запускаю...\n";
std::wstring runCmd = quote(pythonExe.wstring()) + L" " + quote(appPy.wstring());
if (!runDetached(runCmd, appDir.wstring())) {
std::wcerr << L"не удалось запустить приложение.\n";
Sleep(4000);
return 1;
}
Sleep(1200);
return 0;
}

31
installer/installer.rc Normal file
View file

@ -0,0 +1,31 @@
101 ICON "icon.ico"
102 RCDATA "..\payload.zip"
1 VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
FILETYPE 0x1L
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "savsis"
VALUE "FileDescription", "nexdex.space launcher"
VALUE "FileVersion", "1.0.0.0"
VALUE "InternalName", "nexdex.space"
VALUE "LegalCopyright", "Copyright (C) 2026 savsis"
VALUE "OriginalFilename","nexdex.space-setup.exe"
VALUE "ProductName", "nexdex.space"
VALUE "ProductVersion", "1.0.0.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END