diff --git a/LICENSE b/LICENSE index fdddb29..504c799 100644 --- a/LICENSE +++ b/LICENSE @@ -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 +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7be8288 --- /dev/null +++ b/README.md @@ -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 /fd SHA256 nexdex.space-setup.exe + ``` + самоподписанный сертификат уберёт "неизвестный автор" в свойствах файла + (Windows покажет CN=savsis), но не уберёт предупреждение SmartScreen — + для этого нужен платный сертификат от доверенного CA. + +--- + +made by savsis with ♥ diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..141052e --- /dev/null +++ b/app/README.md @@ -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 ♥ diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..d5aef0c --- /dev/null +++ b/app/app.py @@ -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/") +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/") +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/") +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/") +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) diff --git a/app/backend/__init__.py b/app/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/backend/converter.py b/app/backend/converter.py new file mode 100644 index 0000000..f74499f --- /dev/null +++ b/app/backend/converter.py @@ -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) diff --git a/app/backend/downloader.py b/app/backend/downloader.py new file mode 100644 index 0000000..8be23c9 --- /dev/null +++ b/app/backend/downloader.py @@ -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'', + 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 diff --git a/app/backend/history.py b/app/backend/history.py new file mode 100644 index 0000000..02380ed --- /dev/null +++ b/app/backend/history.py @@ -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([]) diff --git a/app/backend/jobs.py b/app/backend/jobs.py new file mode 100644 index 0000000..493d8cb --- /dev/null +++ b/app/backend/jobs.py @@ -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 diff --git a/app/backend/metadata.py b/app/backend/metadata.py new file mode 100644 index 0000000..aaedc45 --- /dev/null +++ b/app/backend/metadata.py @@ -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 diff --git a/app/backend/scripts_format.py b/app/backend/scripts_format.py new file mode 100644 index 0000000..dc0eb69 --- /dev/null +++ b/app/backend/scripts_format.py @@ -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} diff --git a/app/backend/search.py b/app/backend/search.py new file mode 100644 index 0000000..3bc1100 --- /dev/null +++ b/app/backend/search.py @@ -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) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..a63b814 --- /dev/null +++ b/app/requirements.txt @@ -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 diff --git a/app/start.bat b/app/start.bat new file mode 100644 index 0000000..60018da --- /dev/null +++ b/app/start.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python app.py +pause diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..3b96c92 --- /dev/null +++ b/app/static/css/style.css @@ -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; } +} diff --git a/app/static/img/favicon.ico b/app/static/img/favicon.ico new file mode 100644 index 0000000..3ff92e2 Binary files /dev/null and b/app/static/img/favicon.ico differ diff --git a/app/static/img/icon.png b/app/static/img/icon.png new file mode 100644 index 0000000..7ed1ab8 Binary files /dev/null and b/app/static/img/icon.png differ diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..d0350f2 --- /dev/null +++ b/app/static/js/app.js @@ -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]) => ``).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 = ` + +
+
${escapeHtml(item.title)}
+
${escapeHtml(item.artist)} ${item.duration ? "· " + item.duration : ""}
+
+ + `; + 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 = '
ищу...
'; + 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 = '
ничего не найдено
'; + 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 = ` + +
+
${escapeHtml(item.title)}
+
${escapeHtml(item.artist)} · ${item.format.toUpperCase()}
+
+ `; + 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) => ``).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 = `скачать ${data.filename}`; + }); +}); + +// =================== 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 = `сохранено — скачать`; + }); +}); + +// --- 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 = `${escapeHtml(file.name)}загружен`; + 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(); +}); diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..8a7d5cb --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,198 @@ + + + + + +nexdex.space + + + + + +
+
nexdex.space
+ + + + + +
+ + + +
+ + +
+
+
+ + +
+ + + + + + +
+ +
+
+

История

+ +
+
+
+
+ + +
+
+
+

Перетащи файл сюда или

+ +

картинки (webp, png, jpg, ico...) · pdf · pptx · аудио (flac, wav, mp3...) · видео (mp4, mkv...)

+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + +
+ +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ +
made by savsis with ♥
+ + + + diff --git a/extras/batch/menu.bat b/extras/batch/menu.bat new file mode 100644 index 0000000..97b5fce --- /dev/null +++ b/extras/batch/menu.bat @@ -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 diff --git a/extras/cpp/audio_meta.hpp b/extras/cpp/audio_meta.hpp new file mode 100644 index 0000000..fd26c8b --- /dev/null +++ b/extras/cpp/audio_meta.hpp @@ -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 +#include +#include +#include + +namespace nexdex { + +struct AudioTag { + std::string title; + std::string artist; + std::optional album; + std::optional year; + std::optional 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 knownExtensions() { + return {"mp3", "flac", "wav", "m4a", "alac", "ogg", "opus"}; +} + +} // namespace nexdex diff --git a/extras/cpp/demo.cpp b/extras/cpp/demo.cpp new file mode 100644 index 0000000..aa84bf1 --- /dev/null +++ b/extras/cpp/demo.cpp @@ -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 +#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; +} diff --git a/extras/cpp/playlist.hpp b/extras/cpp/playlist.hpp new file mode 100644 index 0000000..5911a38 --- /dev/null +++ b/extras/cpp/playlist.hpp @@ -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 +#include +#include +#include +#include +#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 byArtist(const std::string& artist) const { + std::vector result; + for (const auto& t : tracks_) { + if (equalsIgnoreCase(t.tag.artist, artist)) { + result.push_back(&t); + } + } + return result; + } + + std::optional 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(x)) == + std::tolower(static_cast(y)); + }); + } + + std::vector tracks_; +}; + +} // namespace nexdex diff --git a/extras/cpp/progress_bar.hpp b/extras/cpp/progress_bar.hpp new file mode 100644 index 0000000..ea4f1e6 --- /dev/null +++ b/extras/cpp/progress_bar.hpp @@ -0,0 +1,31 @@ +// extras/cpp — decorative. a tiny console progress bar, echoing the shape +// of the /api/job/ polling response from backend/jobs.py. +#pragma once + +#include +#include + +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 diff --git a/extras/formats.ts b/extras/formats.ts new file mode 100644 index 0000000..99e4b23 --- /dev/null +++ b/extras/formats.ts @@ -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 = { + 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"); diff --git a/extras/fortune.lua b/extras/fortune.lua new file mode 100644 index 0000000..aa6500e --- /dev/null +++ b/extras/fortune.lua @@ -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)]) diff --git a/extras/go/main.go b/extras/go/main.go new file mode 100644 index 0000000..e6b99a8 --- /dev/null +++ b/extras/go/main.go @@ -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") +} diff --git a/extras/rust/Cargo.toml b/extras/rust/Cargo.toml new file mode 100644 index 0000000..a497adc --- /dev/null +++ b/extras/rust/Cargo.toml @@ -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" diff --git a/extras/rust/src/checksum.rs b/extras/rust/src/checksum.rs new file mode 100644 index 0000000..2d35c91 --- /dev/null +++ b/extras/rust/src/checksum.rs @@ -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); + } +} diff --git a/extras/rust/src/formats.rs b/extras/rust/src/formats.rs new file mode 100644 index 0000000..e0d9986 --- /dev/null +++ b/extras/rust/src/formats.rs @@ -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::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()); + } +} diff --git a/extras/rust/src/main.rs b/extras/rust/src/main.rs new file mode 100644 index 0000000..0d22d95 --- /dev/null +++ b/extras/rust/src/main.rs @@ -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"); +} diff --git a/extras/rust/src/playlist.rs b/extras/rust/src/playlist.rs new file mode 100644 index 0000000..9811fa7 --- /dev/null +++ b/extras/rust/src/playlist.rs @@ -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, +} + +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"); + } +} diff --git a/extras/rust/src/progress.rs b/extras/rust/src/progress.rs new file mode 100644 index 0000000..c588bc8 --- /dev/null +++ b/extras/rust/src/progress.rs @@ -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/ polling endpoint. + +pub struct ProgressBar { + width: usize, + label: String, +} + +impl ProgressBar { + pub fn new(label: impl Into, 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)); + } +} diff --git a/extras/rust/src/tags.rs b/extras/rust/src/tags.rs new file mode 100644 index 0000000..3f3c66b --- /dev/null +++ b/extras/rust/src/tags.rs @@ -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, + pub year: Option, + pub track_number: Option, +} + +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, artist: impl Into) -> 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) -> 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)"); + } +} diff --git a/installer/icon.ico b/installer/icon.ico new file mode 100644 index 0000000..3ff92e2 Binary files /dev/null and b/installer/icon.ico differ diff --git a/installer/installer.cpp b/installer/installer.cpp new file mode 100644 index 0000000..e884500 --- /dev/null +++ b/installer/installer.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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(&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(&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 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(ptr), static_cast(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 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; +} diff --git a/installer/installer.rc b/installer/installer.rc new file mode 100644 index 0000000..2f838e2 --- /dev/null +++ b/installer/installer.rc @@ -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