Add files via upload
This commit is contained in:
parent
07f2ed525b
commit
6e7f0c1505
37 changed files with 4084 additions and 20 deletions
37
LICENSE
37
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 <https://unlicense.org>
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
|
|||
65
README.md
Normal file
65
README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# nexdex.space — sources
|
||||
|
||||
это открытая версия исходников (для гита) — читаемый python/flask код без
|
||||
скомпилированных бинарей. если нужен готовый инструмент — смотри релиз
|
||||
`nexdex.space-setup.exe`, который разворачивает всё сам (portable python +
|
||||
ffmpeg внутри, ничего ставить не надо). лицензия — MIT, см. `LICENSE`.
|
||||
|
||||
---
|
||||
|
||||
## структура
|
||||
|
||||
```
|
||||
app/ — flask-приложение (см. app/README.md — там всё подробно)
|
||||
app.py
|
||||
backend/ — downloader, converter, metadata, scripts_format, search, history
|
||||
templates/
|
||||
static/
|
||||
installer/ — исходники установщика (реальный, рабочий C++)
|
||||
installer.cpp — консольный лаунчер: при первом запуске достаёт из себя
|
||||
зашитый ресурс (payload.zip — portable python + весь
|
||||
app/ + ffmpeg), распаковывает в %LOCALAPPDATA%\nexdex.space,
|
||||
создаёт ярлык на рабочем столе, дальше просто запускает
|
||||
installer.rc — иконка + payload как RCDATA-ресурс + версия файла
|
||||
(CompanyName/LegalCopyright = savsis)
|
||||
icon.ico
|
||||
extras/ — декоративное, в билд не входит, просто чтобы на гите было
|
||||
видно "по чуть-чуть" разных языков
|
||||
rust/ — тестовый cargo-проект (audio tag, playlist, crc32, progress bar)
|
||||
cpp/ — отдельный от installer/ хедер-онли C++ (то же самое, что rust/)
|
||||
go/ — тестовый go-тул (поиск/прогресс, аналогично)
|
||||
batch/ — фенси .bat-меню (реальный лаунчер — app/start.bat, простой)
|
||||
formats.ts
|
||||
fortune.lua
|
||||
LICENSE — MIT, savsis
|
||||
```
|
||||
|
||||
## как собрать установщик самому
|
||||
|
||||
1. поставь python 3.11 embeddable (`python-3.11.9-embed-amd64.zip` с python.org),
|
||||
включи `import site` в `python311._pth`, добавь строку `Lib\site-packages`,
|
||||
поставь pip (`get-pip.py`), затем `pip install -r app/requirements.txt`
|
||||
этим же python'ом.
|
||||
2. положи скачанный/собранный `ffmpeg.exe`+`ffprobe.exe` в `app/ffmpeg/`.
|
||||
3. запакуй `python\` и `app\` (портативный питон + приложение) в `payload.zip`
|
||||
рядом с папкой `installer\`.
|
||||
4. собери через MSVC:
|
||||
```
|
||||
rc.exe /fo installer.res installer.rc
|
||||
cl.exe /std:c++17 /EHsc /O2 installer.cpp installer.res /Fe:nexdex.space-setup.exe /link shell32.lib ole32.lib
|
||||
```
|
||||
`installer.rc` ссылается на `..\payload.zip` и зашивает его прямо в exe
|
||||
как ресурс (RCDATA) — поэтому результат остаётся нормальным PE-файлом
|
||||
(в отличие от "приклеивания" zip после exe, что ломает Authenticode-подпись).
|
||||
5. (опционально) подпиши бесплатным self-signed сертификатом:
|
||||
```
|
||||
New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=savsis" -CertStoreLocation Cert:\CurrentUser\My
|
||||
signtool sign /sha1 <thumbprint> /fd SHA256 nexdex.space-setup.exe
|
||||
```
|
||||
самоподписанный сертификат уберёт "неизвестный автор" в свойствах файла
|
||||
(Windows покажет CN=savsis), но не уберёт предупреждение SmartScreen —
|
||||
для этого нужен платный сертификат от доверенного CA.
|
||||
|
||||
---
|
||||
|
||||
made by savsis with ♥
|
||||
94
app/README.md
Normal file
94
app/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# nexdex.space
|
||||
|
||||
локальный веб-инструмент — качает музыку/видео, конвертирует файлы, редактирует
|
||||
метаданные треков, форматирует py/lua скрипты. открывается сам в браузере на
|
||||
`localhost`, тёмная тема, гугл-минимализм.
|
||||
|
||||
---
|
||||
|
||||
## что внутри
|
||||
|
||||
**загрузчик** — вставляешь ссылку (youtube / soundcloud / apple music / spotify /
|
||||
yandex music) или ищешь по названию (видно обложку+автора перед скачиванием),
|
||||
выбираешь формат — качает и вшивает обложку+теги. для spotify/apple music метаданные
|
||||
берутся с официальных публичных api, а само аудио ищется и качается с youtube —
|
||||
без взлома drm. история последних скачиваний сохраняется локально.
|
||||
|
||||
**конвертер** — картинки (webp, png, jpg, ico и генерация иконок из любой картинки),
|
||||
pdf, pptx, аудио (flac/wav/mp3/m4a/ogg/opus/aac), видео (mp4/avi/mov/mkv/webm/gif).
|
||||
честно: pptx → картинка/pdf не поддерживается (нужен libreoffice/powerpoint для
|
||||
рендера слайдов — тяжёлая внешняя зависимость, спорит с идеей лёгкого инструмента).
|
||||
|
||||
**метаданные** — редактор тегов для mp3/flac/wav/m4a/ogg/opus: название, исполнитель,
|
||||
альбом, год, номер трека, обложка. есть массовый режим — применить одного исполнителя/
|
||||
альбом/обложку сразу на несколько файлов.
|
||||
|
||||
**скрипты** — форматирование `.py` (black) и `.lua` (stylua, качается сам при первом
|
||||
использовании), подсветка синтаксических ошибок.
|
||||
|
||||
---
|
||||
|
||||
## что нужно
|
||||
|
||||
- python 3.10+
|
||||
- ffmpeg — уже лежит в `ffmpeg\`, ничего доп. качать не надо
|
||||
|
||||
## установка
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## запуск
|
||||
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
|
||||
или просто дважды кликнуть `start.bat`. браузер откроется сам на
|
||||
`http://127.0.0.1:5000`. порт меняется через `PORT=8080 python app.py`.
|
||||
|
||||
---
|
||||
|
||||
## форматы загрузчика
|
||||
|
||||
| # | формат | качество |
|
||||
|---|--------|----------|
|
||||
| 1 | MP3 | 320 / 256 / 192 / 128 kbps |
|
||||
| 2 | FLAC | lossless |
|
||||
| 3 | WAV | lossless |
|
||||
| 4 | M4A | best |
|
||||
| 5 | ALAC | lossless |
|
||||
| 6 | OGG | best |
|
||||
| 7 | OPUS | best |
|
||||
| 8 | MP4 | видео + аудио |
|
||||
|
||||
---
|
||||
|
||||
## структура
|
||||
|
||||
```
|
||||
app.py — flask entrypoint, сам открывает браузер
|
||||
backend/
|
||||
downloader.py — detect_platform, download_track (youtube/spotify/apple/yandex/vk)
|
||||
jobs.py — фоновые задачи скачивания + прогресс
|
||||
search.py — поиск по youtube/soundcloud/apple music
|
||||
history.py — история скачанных треков (data/history.json)
|
||||
converter.py — картинки/pdf/pptx/аудио/видео
|
||||
metadata.py — чтение/запись тегов+обложки
|
||||
scripts_format.py — black + stylua
|
||||
templates/index.html
|
||||
static/css/style.css
|
||||
static/js/app.js
|
||||
ffmpeg/ — ffmpeg.exe, ffprobe.exe
|
||||
bin/ — сюда качается stylua.exe при первом форматировании lua
|
||||
data/history.json
|
||||
downloads/ uploads/ outputs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
UDP: часть про поиск/скачивание работает через публичные api и youtube — если
|
||||
ссылка "не режимного" сервиса не отдаёт данные без логина, так и напишет в ошибке.
|
||||
pptx рендер слайдов — эксперимент на будущее, если приспичит тащить libreoffice.
|
||||
made by savsis with ♥
|
||||
284
app/app.py
Normal file
284
app/app.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, send_file
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from backend import downloader, search as search_mod, history as history_mod
|
||||
from backend import converter, metadata, scripts_format, jobs
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
DOWNLOADS_DIR = PROJECT_DIR / "downloads"
|
||||
UPLOAD_DIR = PROJECT_DIR / "uploads"
|
||||
OUTPUT_DIR = PROJECT_DIR / "outputs"
|
||||
|
||||
for d in (DOWNLOADS_DIR, UPLOAD_DIR, OUTPUT_DIR):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
# --- downloader ---
|
||||
|
||||
@app.route("/api/download", methods=["POST"])
|
||||
def api_download():
|
||||
data = request.json or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
fmt = data.get("format", "mp3")
|
||||
quality = data.get("quality", "320")
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "нет ссылки"}), 400
|
||||
|
||||
job_id = jobs.start_download(url, fmt, quality, str(DOWNLOADS_DIR))
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
|
||||
@app.route("/api/job/<job_id>")
|
||||
def api_job(job_id):
|
||||
job = jobs.get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "job not found"}), 404
|
||||
return jsonify(job)
|
||||
|
||||
|
||||
@app.route("/api/search")
|
||||
def api_search():
|
||||
platform = request.args.get("platform", "youtube")
|
||||
query = request.args.get("q", "").strip()
|
||||
limit = min(int(request.args.get("limit", 10)), 25)
|
||||
|
||||
if not query:
|
||||
return jsonify({"error": "пустой запрос"}), 400
|
||||
|
||||
try:
|
||||
results = search_mod.search(platform, query, limit)
|
||||
return jsonify({"results": results})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
return jsonify({"history": history_mod.get_history()})
|
||||
|
||||
|
||||
@app.route("/api/history/clear", methods=["POST"])
|
||||
def api_history_clear():
|
||||
history_mod.clear_history()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# --- converter ---
|
||||
|
||||
@app.route("/api/convert/upload", methods=["POST"])
|
||||
def api_convert_upload():
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "нет файла"}), 400
|
||||
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return jsonify({"error": "имя файла пустое"}), 400
|
||||
|
||||
uid = uuid.uuid4().hex[:10]
|
||||
ext = Path(f.filename).suffix
|
||||
saved_name = uid + ext
|
||||
saved_path = UPLOAD_DIR / saved_name
|
||||
f.save(saved_path)
|
||||
|
||||
kind = converter.detect_type(saved_path)
|
||||
targets = converter.get_targets(kind)
|
||||
|
||||
return jsonify({
|
||||
"id": uid,
|
||||
"filename": f.filename,
|
||||
"saved": saved_name,
|
||||
"type": kind,
|
||||
"size": saved_path.stat().st_size,
|
||||
"targets": targets,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/convert/run", methods=["POST"])
|
||||
def api_convert_run():
|
||||
data = request.json or {}
|
||||
uid = data.get("id", "")
|
||||
saved = data.get("saved", "")
|
||||
target = data.get("target", "")
|
||||
original = data.get("filename", "file")
|
||||
|
||||
if not all([uid, saved, target]):
|
||||
return jsonify({"error": "плохие параметры"}), 400
|
||||
if target not in converter.ALL_TARGETS:
|
||||
return jsonify({"error": "недопустимый формат"}), 400
|
||||
|
||||
src = UPLOAD_DIR / Path(saved).name
|
||||
if not src.exists():
|
||||
return jsonify({"error": "файл не найден"}), 404
|
||||
|
||||
out_name = f"{uid}_out.{target}"
|
||||
out_path = OUTPUT_DIR / out_name
|
||||
|
||||
ok, err = converter.convert(src, out_path, target)
|
||||
if not ok:
|
||||
return jsonify({"error": err}), 500
|
||||
|
||||
base = Path(Path(original).stem).stem
|
||||
result_filename = f"{base}.{target}"
|
||||
return jsonify({"result": out_name, "filename": result_filename})
|
||||
|
||||
|
||||
@app.route("/api/convert/download/<path:filename>")
|
||||
def api_convert_download(filename):
|
||||
safe = Path(filename).name
|
||||
path = OUTPUT_DIR / safe
|
||||
if not path.exists():
|
||||
return "не найдено", 404
|
||||
return send_file(path, as_attachment=True)
|
||||
|
||||
|
||||
# --- metadata ---
|
||||
|
||||
@app.route("/api/metadata/upload", methods=["POST"])
|
||||
def api_metadata_upload():
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "нет файла"}), 400
|
||||
|
||||
f = request.files["file"]
|
||||
ext = Path(f.filename).suffix.lower()
|
||||
if ext not in metadata.SUPPORTED_EXTS:
|
||||
return jsonify({"error": f"формат {ext} не поддерживается"}), 400
|
||||
|
||||
uid = uuid.uuid4().hex[:10]
|
||||
saved_name = uid + ext
|
||||
saved_path = UPLOAD_DIR / saved_name
|
||||
f.save(saved_path)
|
||||
|
||||
tags = metadata.read_tags(saved_path)
|
||||
return jsonify({"id": uid, "saved": saved_name, "filename": f.filename, "tags": tags})
|
||||
|
||||
|
||||
@app.route("/api/metadata/cover/<path:filename>")
|
||||
def api_metadata_cover(filename):
|
||||
from flask import Response
|
||||
path = UPLOAD_DIR / Path(filename).name
|
||||
if not path.exists():
|
||||
return "", 404
|
||||
data = metadata._extract_cover_bytes(path)
|
||||
if not data:
|
||||
return "", 404
|
||||
return Response(data, mimetype="image/jpeg")
|
||||
|
||||
|
||||
@app.route("/api/metadata/save", methods=["POST"])
|
||||
def api_metadata_save():
|
||||
saved = request.form.get("saved", "")
|
||||
if not saved:
|
||||
return jsonify({"error": "нет файла"}), 400
|
||||
|
||||
path = UPLOAD_DIR / Path(saved).name
|
||||
if not path.exists():
|
||||
return jsonify({"error": "файл не найден"}), 404
|
||||
|
||||
meta = {
|
||||
"title": request.form.get("title"),
|
||||
"artist": request.form.get("artist"),
|
||||
"album": request.form.get("album"),
|
||||
"year": request.form.get("year"),
|
||||
"track_number": request.form.get("track_number"),
|
||||
}
|
||||
|
||||
cover_bytes = None
|
||||
if "cover" in request.files and request.files["cover"].filename:
|
||||
cover_bytes = metadata._cover_bytes_from_upload(request.files["cover"].read())
|
||||
|
||||
try:
|
||||
metadata.write_tags(path, meta, cover_bytes)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return jsonify({"ok": True, "download": saved})
|
||||
|
||||
|
||||
@app.route("/api/metadata/batch", methods=["POST"])
|
||||
def api_metadata_batch():
|
||||
data = request.json or {}
|
||||
ids = data.get("saved_files", [])
|
||||
meta = {
|
||||
"title": None,
|
||||
"artist": data.get("artist"),
|
||||
"album": data.get("album"),
|
||||
"year": data.get("year"),
|
||||
"track_number": None,
|
||||
}
|
||||
meta = {k: v for k, v in meta.items() if v not in (None, "")}
|
||||
|
||||
cover_bytes = None
|
||||
cover_url = data.get("cover_saved")
|
||||
if cover_url:
|
||||
cover_path = UPLOAD_DIR / Path(cover_url).name
|
||||
if cover_path.exists():
|
||||
cover_bytes = metadata._cover_bytes_from_upload(cover_path.read_bytes())
|
||||
|
||||
updated = []
|
||||
errors = []
|
||||
for saved in ids:
|
||||
path = UPLOAD_DIR / Path(saved).name
|
||||
if not path.exists():
|
||||
errors.append(f"{saved}: файл не найден")
|
||||
continue
|
||||
try:
|
||||
metadata.write_tags(path, meta, cover_bytes)
|
||||
updated.append(saved)
|
||||
except Exception as e:
|
||||
errors.append(f"{saved}: {e}")
|
||||
|
||||
return jsonify({"updated": updated, "errors": errors})
|
||||
|
||||
|
||||
@app.route("/api/metadata/download/<path:filename>")
|
||||
def api_metadata_download(filename):
|
||||
safe = Path(filename).name
|
||||
path = UPLOAD_DIR / safe
|
||||
if not path.exists():
|
||||
return "не найдено", 404
|
||||
return send_file(path, as_attachment=True)
|
||||
|
||||
|
||||
# --- scripts ---
|
||||
|
||||
@app.route("/api/scripts/format", methods=["POST"])
|
||||
def api_scripts_format():
|
||||
data = request.json or {}
|
||||
code = data.get("code", "")
|
||||
language = data.get("language", "python")
|
||||
result = scripts_format.format_code(code, language)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _open_browser(url: str):
|
||||
time.sleep(1.2)
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
print(f"nexdex.space — {url}")
|
||||
threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
|
||||
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
|
||||
0
app/backend/__init__.py
Normal file
0
app/backend/__init__.py
Normal file
187
app/backend/converter.py
Normal file
187
app/backend/converter.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from backend.downloader import ffmpeg_path
|
||||
|
||||
IMAGE_FORMATS = {"jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff", "ico"}
|
||||
AUDIO_FORMATS = {"mp3", "flac", "wav", "m4a", "ogg", "opus", "aac"}
|
||||
VIDEO_FORMATS = {"mp4", "avi", "mov", "mkv", "webm", "gif"}
|
||||
DOC_FORMATS = {"pdf", "pptx"}
|
||||
|
||||
ALL_TARGETS = IMAGE_FORMATS | AUDIO_FORMATS | VIDEO_FORMATS | DOC_FORMATS
|
||||
|
||||
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
|
||||
def detect_type(path: Path) -> str:
|
||||
ext = path.suffix.lstrip(".").lower()
|
||||
if ext in IMAGE_FORMATS:
|
||||
return "image"
|
||||
if ext in AUDIO_FORMATS:
|
||||
return "audio"
|
||||
if ext in VIDEO_FORMATS or ext in {"flv", "wmv", "m4v", "3gp"}:
|
||||
return "video"
|
||||
if ext == "pdf":
|
||||
return "pdf"
|
||||
if ext == "pptx":
|
||||
return "pptx"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_targets(kind: str) -> list:
|
||||
if kind == "image":
|
||||
return sorted(IMAGE_FORMATS | {"pdf", "pptx"})
|
||||
if kind == "audio":
|
||||
return sorted(AUDIO_FORMATS)
|
||||
if kind == "video":
|
||||
return sorted(VIDEO_FORMATS | AUDIO_FORMATS)
|
||||
if kind == "pdf":
|
||||
return sorted(IMAGE_FORMATS | {"pptx"})
|
||||
if kind == "pptx":
|
||||
return ["pdf"] + sorted(IMAGE_FORMATS - {"ico"})
|
||||
return []
|
||||
|
||||
|
||||
def _convert_image(src: Path, dst: Path, target: str):
|
||||
img = Image.open(src)
|
||||
|
||||
if target == "ico":
|
||||
rgba = img.convert("RGBA")
|
||||
sizes = [(s, s) for s in ICO_SIZES if s <= max(rgba.size)] or [(32, 32)]
|
||||
rgba.save(dst, format="ICO", sizes=sizes)
|
||||
return
|
||||
|
||||
if target in ("jpg", "jpeg"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
fmt = "JPEG" if target in ("jpg", "jpeg") else target.upper()
|
||||
img.save(dst, format=fmt)
|
||||
|
||||
|
||||
def _convert_image_to_pdf(src: Path, dst: Path):
|
||||
import img2pdf
|
||||
with open(dst, "wb") as f:
|
||||
f.write(img2pdf.convert(str(src)))
|
||||
|
||||
|
||||
EMU_PER_PX = 9525
|
||||
EMU_MIN = 914400 # 1 inch
|
||||
EMU_MAX = 51206400 # 56 inches
|
||||
|
||||
|
||||
def _slide_dims(w_px: int, h_px: int) -> tuple[int, int]:
|
||||
w = w_px * EMU_PER_PX
|
||||
h = h_px * EMU_PER_PX
|
||||
smallest = min(w, h)
|
||||
largest = max(w, h)
|
||||
if smallest < EMU_MIN:
|
||||
scale = EMU_MIN / smallest
|
||||
w, h = int(w * scale), int(h * scale)
|
||||
elif largest > EMU_MAX:
|
||||
scale = EMU_MAX / largest
|
||||
w, h = int(w * scale), int(h * scale)
|
||||
return w, h
|
||||
|
||||
|
||||
def _convert_image_to_pptx(src: Path, dst: Path):
|
||||
from pptx import Presentation
|
||||
from pptx.util import Emu
|
||||
|
||||
img = Image.open(src)
|
||||
w_px, h_px = img.size
|
||||
prs = Presentation()
|
||||
prs.slide_width, prs.slide_height = _slide_dims(w_px, h_px)
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
slide.shapes.add_picture(str(src), 0, 0, width=prs.slide_width, height=prs.slide_height)
|
||||
prs.save(dst)
|
||||
|
||||
|
||||
def _convert_pdf_to_images_or_pptx(src: Path, dst: Path, target: str):
|
||||
import fitz # pymupdf
|
||||
|
||||
doc = fitz.open(src)
|
||||
|
||||
if target == "pptx":
|
||||
from pptx import Presentation
|
||||
import tempfile
|
||||
|
||||
prs = Presentation()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i, page in enumerate(doc):
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
page_img = Path(tmp) / f"page_{i}.png"
|
||||
pix.save(page_img)
|
||||
if i == 0:
|
||||
prs.slide_width, prs.slide_height = _slide_dims(pix.width, pix.height)
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
slide.shapes.add_picture(str(page_img), 0, 0, width=prs.slide_width, height=prs.slide_height)
|
||||
prs.save(dst)
|
||||
return
|
||||
|
||||
# single page -> single image; only first page is used for image targets
|
||||
page = doc[0]
|
||||
pix = page.get_pixmap(dpi=200)
|
||||
pix.save(dst)
|
||||
|
||||
|
||||
def _convert_pptx_to_pdf(src: Path, dst: Path):
|
||||
raise RuntimeError(
|
||||
"PPTX -> PDF/картинка требует LibreOffice или PowerPoint (рендер слайдов) — "
|
||||
"не поддерживается без тяжёлых внешних зависимостей."
|
||||
)
|
||||
|
||||
|
||||
def _convert_audio_video(src: Path, dst: Path, target: str):
|
||||
ffmpeg_dir = ffmpeg_path()
|
||||
exe = str(Path(ffmpeg_dir) / "ffmpeg.exe") if ffmpeg_dir else "ffmpeg"
|
||||
|
||||
args = [exe, "-y", "-i", str(src)]
|
||||
|
||||
if target == "gif":
|
||||
args += ["-vf", "fps=12,scale=480:-1:flags=lanczos"]
|
||||
elif target in AUDIO_FORMATS:
|
||||
args += ["-vn"]
|
||||
if target == "mp3":
|
||||
args += ["-codec:a", "libmp3lame", "-b:a", "320k"]
|
||||
elif target == "flac":
|
||||
args += ["-codec:a", "flac"]
|
||||
elif target == "wav":
|
||||
args += ["-codec:a", "pcm_s16le"]
|
||||
elif target == "aac":
|
||||
args += ["-codec:a", "aac", "-b:a", "256k"]
|
||||
|
||||
args.append(str(dst))
|
||||
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=600)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg error: {result.stderr[-800:]}")
|
||||
|
||||
|
||||
def convert(src: Path, dst: Path, target: str) -> tuple[bool, str | None]:
|
||||
try:
|
||||
kind = detect_type(src)
|
||||
|
||||
if kind == "image":
|
||||
if target == "pdf":
|
||||
_convert_image_to_pdf(src, dst)
|
||||
elif target == "pptx":
|
||||
_convert_image_to_pptx(src, dst)
|
||||
else:
|
||||
_convert_image(src, dst, target)
|
||||
|
||||
elif kind == "pdf":
|
||||
_convert_pdf_to_images_or_pptx(src, dst, target)
|
||||
|
||||
elif kind == "pptx":
|
||||
_convert_pptx_to_pdf(src, dst)
|
||||
|
||||
elif kind in ("audio", "video"):
|
||||
_convert_audio_video(src, dst, target)
|
||||
|
||||
else:
|
||||
return False, f"неизвестный формат: {src.suffix}"
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
699
app/backend/downloader.py
Normal file
699
app/backend/downloader.py
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
import sys
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
import yt_dlp
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def ffmpeg_path() -> str | None:
|
||||
exe = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
|
||||
local = PROJECT_DIR / "ffmpeg" / exe
|
||||
if local.exists():
|
||||
return str(PROJECT_DIR / "ffmpeg")
|
||||
return None
|
||||
|
||||
|
||||
AUDIO_EXTENSIONS = {"mp3", "flac", "wav", "m4a", "ogg", "opus", "mp4", "webm", "aac", "wma"}
|
||||
THUMB_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
|
||||
|
||||
_ANON_TOKEN: str | None = None
|
||||
_TOKEN_EXPIRY: float = 0.0
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip()
|
||||
|
||||
|
||||
def detect_platform(url: str) -> str:
|
||||
u = url.lower()
|
||||
if "spotify.com" in u:
|
||||
return "spotify"
|
||||
if "music.apple.com" in u:
|
||||
return "apple_music"
|
||||
if "soundcloud.com" in u:
|
||||
return "soundcloud"
|
||||
if "music.yandex." in u or "yandex.ru/music" in u or "yandex.com/music" in u:
|
||||
return "yandex_music"
|
||||
if "vk.com" in u or "vk.ru" in u or "vkvideo.ru" in u:
|
||||
return "vk"
|
||||
return "youtube"
|
||||
|
||||
|
||||
def _extract_spotify_id(url: str) -> tuple[str, str]:
|
||||
m = re.search(
|
||||
r"spotify\.com/(?:[a-z]{2}/)?(?:intl-[a-z]+/)?(track|album|playlist)/([A-Za-z0-9]+)",
|
||||
url,
|
||||
)
|
||||
return (m.group(1), m.group(2)) if m else ("", "")
|
||||
|
||||
|
||||
# --- spotify anonymous token path ---
|
||||
|
||||
def _get_anon_token() -> str:
|
||||
global _ANON_TOKEN, _TOKEN_EXPIRY
|
||||
if _ANON_TOKEN and time.time() < _TOKEN_EXPIRY:
|
||||
return _ANON_TOKEN
|
||||
resp = requests.get(
|
||||
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
|
||||
headers={"User-Agent": _UA, "app-platform": "WebPlayer"},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
_ANON_TOKEN = data["accessToken"]
|
||||
_TOKEN_EXPIRY = data.get("accessTokenExpirationTimestampMs", 0) / 1000 - 30
|
||||
return _ANON_TOKEN
|
||||
|
||||
|
||||
def _api(path: str) -> dict:
|
||||
token = _get_anon_token()
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/{path}",
|
||||
headers={"Authorization": f"Bearer {token}", "User-Agent": _UA},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _parse_api_track(track: dict, album_override: dict | None = None) -> dict:
|
||||
album = album_override or track.get("album") or {}
|
||||
images = album.get("images") or []
|
||||
cover = images[0]["url"] if images else None
|
||||
artists = track.get("artists") or []
|
||||
artist = ", ".join(a["name"] for a in artists)
|
||||
title = track.get("name", "")
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": album.get("name"),
|
||||
"year": (album.get("release_date") or "")[:4],
|
||||
"track_number": track.get("track_number"),
|
||||
"cover_url": cover,
|
||||
"search_query": f"{artist} - {title} audio",
|
||||
"duration": (track.get("duration_ms") or 0) // 1000,
|
||||
}
|
||||
|
||||
|
||||
def _get_via_api(kind: str, sid: str) -> list:
|
||||
if kind == "track":
|
||||
return [_parse_api_track(_api(f"tracks/{sid}"))]
|
||||
|
||||
if kind == "album":
|
||||
album = _api(f"albums/{sid}")
|
||||
results = []
|
||||
page = album.get("tracks", {})
|
||||
while True:
|
||||
for t in page.get("items", []):
|
||||
results.append(_parse_api_track(t, album))
|
||||
nxt = page.get("next")
|
||||
if not nxt:
|
||||
break
|
||||
token = _get_anon_token()
|
||||
page = requests.get(nxt, headers={"Authorization": f"Bearer {token}", "User-Agent": _UA}, timeout=10).json()
|
||||
return results
|
||||
|
||||
if kind == "playlist":
|
||||
results = []
|
||||
page = _api(f"playlists/{sid}/tracks?limit=50")
|
||||
while True:
|
||||
for item in page.get("items", []):
|
||||
t = item.get("track")
|
||||
if t:
|
||||
results.append(_parse_api_track(t))
|
||||
nxt = page.get("next")
|
||||
if not nxt:
|
||||
break
|
||||
token = _get_anon_token()
|
||||
page = requests.get(nxt, headers={"Authorization": f"Bearer {token}", "User-Agent": _UA}, timeout=10).json()
|
||||
return results
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _embed_url(kind: str, sid: str) -> str:
|
||||
return f"https://open.spotify.com/embed/{kind}/{sid}"
|
||||
|
||||
|
||||
def _scrape_embed(kind: str, sid: str) -> dict:
|
||||
resp = requests.get(
|
||||
_embed_url(kind, sid),
|
||||
headers={"User-Agent": _UA, "Accept-Language": "en-US,en;q=0.9"},
|
||||
timeout=12,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
m = re.search(
|
||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||
resp.text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
raise RuntimeError("Could not parse Spotify embed page")
|
||||
return json.loads(m.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
|
||||
|
||||
|
||||
def _best_cover(images: list) -> str | None:
|
||||
if not images:
|
||||
return None
|
||||
return sorted(images, key=lambda x: x.get("maxWidth", 0), reverse=True)[0]["url"]
|
||||
|
||||
|
||||
def _get_via_embed(kind: str, sid: str) -> list:
|
||||
entity = _scrape_embed(kind, sid)
|
||||
|
||||
if kind == "track":
|
||||
artists = entity.get("artists") or []
|
||||
artist = ", ".join(a["name"] for a in artists)
|
||||
title = entity.get("name") or entity.get("title", "")
|
||||
year = (entity.get("releaseDate") or {}).get("isoString", "")[:4]
|
||||
cover = _best_cover(entity.get("visualIdentity", {}).get("image", []))
|
||||
return [
|
||||
{
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": None,
|
||||
"year": year,
|
||||
"track_number": None,
|
||||
"cover_url": cover,
|
||||
"search_query": f"{artist} - {title} audio",
|
||||
"duration": (entity.get("duration") or 0) // 1000,
|
||||
}
|
||||
]
|
||||
|
||||
if kind in ("album", "playlist"):
|
||||
album_name = entity.get("name") or entity.get("title", "")
|
||||
album_artist = entity.get("subtitle", "")
|
||||
year = (entity.get("releaseDate") or {}).get("isoString", "")[:4]
|
||||
cover = _best_cover(entity.get("visualIdentity", {}).get("image", []))
|
||||
track_list = entity.get("trackList") or []
|
||||
results = []
|
||||
for t in track_list:
|
||||
title = t.get("title") or t.get("name", "")
|
||||
artist = t.get("subtitle") or album_artist
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": album_name if kind == "album" else None,
|
||||
"year": year,
|
||||
"track_number": None,
|
||||
"cover_url": cover,
|
||||
"search_query": f"{artist} - {title} audio",
|
||||
"duration": (t.get("duration") or 0) // 1000,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_spotify_metadata(url: str) -> list:
|
||||
kind, sid = _extract_spotify_id(url)
|
||||
if not sid:
|
||||
raise RuntimeError(f"Cannot parse Spotify URL: {url}")
|
||||
|
||||
try:
|
||||
return _get_via_api(kind, sid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _get_via_embed(kind, sid)
|
||||
|
||||
|
||||
# --- Apple Music (metadata via public itunes API, audio matched on YouTube) ---
|
||||
|
||||
def _itunes_track(row: dict, album_name=None, artist_fallback=None, cover_fallback=None, year_fallback=None) -> dict:
|
||||
cover = (row.get("artworkUrl100") or cover_fallback or "").replace("100x100bb", "600x600bb")
|
||||
artist = row.get("artistName") or artist_fallback or ""
|
||||
title = row.get("trackName") or row.get("collectionName") or ""
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": row.get("collectionName") or album_name,
|
||||
"year": (row.get("releaseDate") or year_fallback or "")[:4],
|
||||
"track_number": row.get("trackNumber"),
|
||||
"cover_url": cover,
|
||||
"search_query": f"{artist} - {title} audio",
|
||||
"duration": (row.get("trackTimeMillis") or 0) // 1000,
|
||||
}
|
||||
|
||||
|
||||
def _itunes_lookup(track_id: str) -> list:
|
||||
try:
|
||||
resp = requests.get(f"https://itunes.apple.com/lookup?id={track_id}", timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("resultCount"):
|
||||
return [_itunes_track(data["results"][0])]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _itunes_album(album_id: str) -> list:
|
||||
try:
|
||||
resp = requests.get(f"https://itunes.apple.com/lookup?id={album_id}&entity=song", timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
album_name = artist_name = cover_url = year = None
|
||||
results = []
|
||||
for r in data.get("results", []):
|
||||
if r.get("wrapperType") == "collection":
|
||||
album_name = r.get("collectionName")
|
||||
artist_name = r.get("artistName")
|
||||
cover_url = (r.get("artworkUrl100") or "").replace("100x100bb", "600x600bb")
|
||||
year = (r.get("releaseDate") or "")[:4]
|
||||
elif r.get("wrapperType") == "track":
|
||||
results.append(_itunes_track(r, album_name, artist_name, cover_url, year))
|
||||
return results
|
||||
|
||||
|
||||
def _itunes_search_from_url(url: str) -> list:
|
||||
path = re.sub(r"https?://[^/]+", "", url.split("?")[0])
|
||||
parts = [p for p in path.split("/") if p and not re.fullmatch(r"\d+", p) and p not in ("us","ru","gb","de","fr","jp","kr","cn","au","ca","it","es","pl","tr","ua","kz","by","am","ge","az","uz")]
|
||||
query = " ".join(parts[-3:]).replace("-", " ")
|
||||
if not query.strip():
|
||||
return []
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://itunes.apple.com/search?term={requests.utils.quote(query)}&media=music&limit=1",
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("resultCount"):
|
||||
return [_itunes_track(data["results"][0])]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def get_apple_music_metadata(url: str) -> list:
|
||||
m = re.search(r"[?&]i=(\d+)", url)
|
||||
if m:
|
||||
r = _itunes_lookup(m.group(1))
|
||||
if r:
|
||||
return r
|
||||
|
||||
for pattern in (r"/song/[^/?#]+/(\d+)", r"/music-video/[^/?#]+/(\d+)", r"/album/[^/?#]+/(\d+)"):
|
||||
m = re.search(pattern, url)
|
||||
if m:
|
||||
sid = m.group(1)
|
||||
if "/album/" in pattern:
|
||||
r = _itunes_album(sid)
|
||||
else:
|
||||
r = _itunes_lookup(sid)
|
||||
if r:
|
||||
return r
|
||||
|
||||
for m in re.finditer(r"/(\d{6,12})(?:[/?#]|$)", url):
|
||||
r = _itunes_lookup(m.group(1))
|
||||
if r:
|
||||
return r
|
||||
r = _itunes_album(m.group(1))
|
||||
if r:
|
||||
return r
|
||||
|
||||
return _itunes_search_from_url(url)
|
||||
|
||||
|
||||
# --- yt-dlp options ---
|
||||
|
||||
def _build_opts(fmt: str, quality: str, output_dir: str, meta: dict | None = None, embed_thumb: bool = True) -> dict:
|
||||
if meta and meta.get("title") and meta.get("artist"):
|
||||
stem = f"{_sanitize_filename(meta['artist'])} - {_sanitize_filename(meta['title'])}"
|
||||
else:
|
||||
stem = "%(title)s"
|
||||
|
||||
base = {
|
||||
"outtmpl": str(Path(output_dir) / f"{stem}.%(ext)s"),
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"ignoreerrors": False,
|
||||
}
|
||||
|
||||
ffmpeg = ffmpeg_path()
|
||||
if ffmpeg:
|
||||
base["ffmpeg_location"] = ffmpeg
|
||||
|
||||
if fmt == "mp4":
|
||||
if quality and quality != "best" and quality.isdigit():
|
||||
height = quality
|
||||
video_fmt = (
|
||||
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/"
|
||||
f"bestvideo[height<={height}]+bestaudio/best[height<={height}]"
|
||||
)
|
||||
else:
|
||||
video_fmt = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best"
|
||||
base.update(
|
||||
{
|
||||
"format": video_fmt,
|
||||
"merge_output_format": "mp4",
|
||||
"writethumbnail": embed_thumb,
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegVideoConvertor", "preferedformat": "mp4"},
|
||||
{"key": "FFmpegMetadata"},
|
||||
]
|
||||
+ ([{"key": "EmbedThumbnail"}] if embed_thumb else []),
|
||||
}
|
||||
)
|
||||
else:
|
||||
codec_map = {"mp3": "mp3", "flac": "flac", "wav": "wav", "m4a": "m4a", "alac": "alac", "ogg": "vorbis", "opus": "opus"}
|
||||
quality_map = {"320": "320", "256": "256", "192": "192", "128": "128", "best": "0", "lossless": "0"}
|
||||
codec = codec_map.get(fmt, "mp3")
|
||||
q = quality_map.get(quality, "0")
|
||||
base.update(
|
||||
{
|
||||
"format": "bestaudio/best",
|
||||
"writethumbnail": embed_thumb,
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegExtractAudio", "preferredcodec": codec, "preferredquality": q},
|
||||
{"key": "FFmpegMetadata"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# --- download helpers ---
|
||||
|
||||
def _snapshot_dir(directory: str) -> set:
|
||||
result = set()
|
||||
p = Path(directory)
|
||||
if not p.exists():
|
||||
return result
|
||||
for f in p.iterdir():
|
||||
if f.is_file() and f.suffix.lstrip(".").lower() in AUDIO_EXTENSIONS:
|
||||
result.add(str(f))
|
||||
return result
|
||||
|
||||
|
||||
def _apply_thumb_file(audio_file: str, thumb_path: str):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
img = Image.open(thumb_path).convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=95)
|
||||
cover_data = buf.getvalue()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
ext = Path(audio_file).suffix.lower()
|
||||
try:
|
||||
if ext in (".mp3", ".wav"):
|
||||
from mutagen.id3 import ID3, APIC
|
||||
from mutagen.id3 import ID3NoHeaderError
|
||||
try:
|
||||
tags = ID3(audio_file)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
tags.delall("APIC")
|
||||
tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_data)
|
||||
tags.save(audio_file)
|
||||
elif ext == ".flac":
|
||||
from mutagen.flac import FLAC, Picture
|
||||
audio = FLAC(audio_file)
|
||||
audio.clear_pictures()
|
||||
pic = Picture()
|
||||
pic.type = 3
|
||||
pic.mime = "image/jpeg"
|
||||
pic.data = cover_data
|
||||
audio.add_picture(pic)
|
||||
audio.save()
|
||||
elif ext in (".m4a", ".mp4"):
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
audio = MP4(audio_file)
|
||||
audio["covr"] = [MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)]
|
||||
audio.save()
|
||||
elif ext in (".ogg", ".opus"):
|
||||
import base64
|
||||
from mutagen.flac import Picture
|
||||
if ext == ".ogg":
|
||||
from mutagen.oggvorbis import OggVorbis as _Cls
|
||||
else:
|
||||
from mutagen.oggopus import OggOpus as _Cls
|
||||
audio = _Cls(audio_file)
|
||||
pic = Picture()
|
||||
pic.type = 3
|
||||
pic.mime = "image/jpeg"
|
||||
pic.data = cover_data
|
||||
audio["metadata_block_picture"] = [base64.b64encode(pic.write()).decode("ascii")]
|
||||
audio.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_thumbs(output_dir: str):
|
||||
p = Path(output_dir)
|
||||
if not p.exists():
|
||||
return
|
||||
for f in p.iterdir():
|
||||
if f.is_file() and f.suffix.lstrip(".").lower() in THUMB_EXTENSIONS:
|
||||
try:
|
||||
f.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _embed_saved_thumb(audio_file: str, output_dir: str):
|
||||
stem = Path(audio_file).stem
|
||||
for ext in THUMB_EXTENSIONS:
|
||||
thumb = Path(output_dir) / f"{stem}.{ext}"
|
||||
if thumb.exists():
|
||||
_apply_thumb_file(audio_file, str(thumb))
|
||||
try:
|
||||
thumb.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
def _download_url(url: str, opts: dict, output_dir: str, fmt: str = "") -> list:
|
||||
before = _snapshot_dir(output_dir)
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
try:
|
||||
ydl.download([url])
|
||||
except yt_dlp.utils.DownloadError as e:
|
||||
if "Postprocessing" not in str(e):
|
||||
raise RuntimeError(str(e)) from e
|
||||
after = _snapshot_dir(output_dir)
|
||||
new_files = list(after - before)
|
||||
|
||||
for f in new_files:
|
||||
_embed_saved_thumb(f, output_dir)
|
||||
|
||||
_cleanup_thumbs(output_dir)
|
||||
return new_files
|
||||
|
||||
|
||||
# --- yandex / vk / soundcloud fallback (metadata via yt-dlp extract_info) ---
|
||||
|
||||
def _meta_from_ytdlp_info(info: dict) -> dict:
|
||||
title = info.get("track") or info.get("title") or ""
|
||||
artist = info.get("artist") or info.get("creator") or info.get("uploader") or ""
|
||||
year = str(info.get("release_year") or (info.get("upload_date") or "")[:4] or "")
|
||||
query = f"{artist} - {title}".strip(" -") if (artist or title) else title
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": info.get("album"),
|
||||
"year": year,
|
||||
"track_number": info.get("track_number"),
|
||||
"cover_url": info.get("thumbnail"),
|
||||
"search_query": f"{query} audio",
|
||||
"duration": info.get("duration"),
|
||||
}
|
||||
|
||||
|
||||
def _ytdlp_extract_tracks(url: str) -> list:
|
||||
opts = {"quiet": True, "no_warnings": True, "ignoreerrors": True}
|
||||
ffmpeg = ffmpeg_path()
|
||||
if ffmpeg:
|
||||
opts["ffmpeg_location"] = ffmpeg
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception:
|
||||
return []
|
||||
if not info or not isinstance(info, dict):
|
||||
return []
|
||||
entries = info.get("entries")
|
||||
if entries:
|
||||
return [_meta_from_ytdlp_info(e) for e in entries if e and isinstance(e, dict)]
|
||||
return [_meta_from_ytdlp_info(info)]
|
||||
|
||||
|
||||
def _get_yandex_metadata(url: str) -> list:
|
||||
try:
|
||||
from yandex_music import Client
|
||||
client = Client().init()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
track = re.search(r"/track/(\d+)", url)
|
||||
album = re.search(r"/album/(\d+)", url)
|
||||
playlist_user = re.search(r"/users/([^/]+)/playlists/(\d+)", url)
|
||||
track_id = track.group(1) if track else ""
|
||||
album_id = album.group(1) if album else ""
|
||||
|
||||
def _to_meta(t):
|
||||
artists = ", ".join(a.name for a in (t.artists or []))
|
||||
alb = (t.albums or [None])[0]
|
||||
cover = None
|
||||
if alb and alb.cover_uri:
|
||||
cover = "https://" + alb.cover_uri.replace("%%", "600x600")
|
||||
return {
|
||||
"title": t.title or "",
|
||||
"artist": artists,
|
||||
"album": getattr(alb, "title", None),
|
||||
"year": str(getattr(alb, "year", "") or ""),
|
||||
"track_number": getattr(t, "track_position", None),
|
||||
"cover_url": cover,
|
||||
"search_query": f"{artists} - {t.title} audio",
|
||||
"duration": (t.duration_ms or 0) // 1000,
|
||||
}
|
||||
|
||||
if track_id:
|
||||
key = f"{track_id}:{album_id}" if album_id else track_id
|
||||
tracks = client.tracks([key])
|
||||
return [_to_meta(t) for t in tracks if t]
|
||||
|
||||
if album_id and not track_id:
|
||||
album_obj = client.albums_with_tracks(int(album_id))
|
||||
results = []
|
||||
for vol in (album_obj.volumes or []):
|
||||
for t in vol:
|
||||
results.append(_to_meta(t))
|
||||
return results
|
||||
|
||||
if playlist_user:
|
||||
user, kind = playlist_user.group(1), playlist_user.group(2)
|
||||
playlist = client.users_playlists(int(kind), user)
|
||||
results = []
|
||||
for item in (playlist.tracks or []):
|
||||
t = item.track
|
||||
if t:
|
||||
results.append(_to_meta(t))
|
||||
return results
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _download_with_fallback(url: str, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
|
||||
from backend.metadata import embed_cover_and_metadata
|
||||
|
||||
opts = _build_opts(fmt, quality, output_dir, embed_thumb=True)
|
||||
if progress_callback:
|
||||
opts["progress_hooks"] = [progress_callback]
|
||||
|
||||
try:
|
||||
files = _download_url(url, opts, output_dir, fmt)
|
||||
if files:
|
||||
return files
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
platform = detect_platform(url)
|
||||
tracks = []
|
||||
|
||||
if platform == "yandex_music":
|
||||
tracks = _get_yandex_metadata(url)
|
||||
|
||||
if not tracks:
|
||||
tracks = _ytdlp_extract_tracks(url)
|
||||
|
||||
if not tracks:
|
||||
raise RuntimeError("Could not extract track info. The URL may require login.")
|
||||
|
||||
downloaded = []
|
||||
for meta in tracks:
|
||||
if not meta.get("search_query"):
|
||||
continue
|
||||
sopts = _build_opts(fmt, quality, output_dir, meta=meta, embed_thumb=False)
|
||||
if progress_callback:
|
||||
sopts["progress_hooks"] = [progress_callback]
|
||||
files = _download_url(f"ytsearch1:{meta['search_query']}", sopts, output_dir, fmt)
|
||||
for f in files:
|
||||
embed_cover_and_metadata(f, meta)
|
||||
downloaded.append((f, meta))
|
||||
|
||||
return downloaded
|
||||
|
||||
|
||||
def _download_tracks_list(tracks: list, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
|
||||
from backend.metadata import embed_cover_and_metadata
|
||||
|
||||
downloaded = []
|
||||
for track in tracks:
|
||||
opts = _build_opts(fmt, quality, output_dir, meta=track, embed_thumb=False)
|
||||
if progress_callback:
|
||||
opts["progress_hooks"] = [progress_callback]
|
||||
new_files = _download_url(f"ytsearch1:{track['search_query']}", opts, output_dir, fmt)
|
||||
for f in new_files:
|
||||
embed_cover_and_metadata(f, track)
|
||||
downloaded.append((f, track))
|
||||
|
||||
return downloaded
|
||||
|
||||
|
||||
# --- public API ---
|
||||
# returns list of (file_path, meta_dict) tuples so callers can log title/artist/cover to history
|
||||
|
||||
def download_track(url: str, fmt: str, quality: str, output_dir: str, progress_callback=None) -> list:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
platform = detect_platform(url)
|
||||
|
||||
if platform == "spotify":
|
||||
tracks = get_spotify_metadata(url)
|
||||
if not tracks:
|
||||
raise RuntimeError("Could not retrieve Spotify metadata.")
|
||||
return _download_tracks_list(tracks, fmt, quality, output_dir, progress_callback)
|
||||
|
||||
if platform == "apple_music":
|
||||
tracks = get_apple_music_metadata(url)
|
||||
if not tracks:
|
||||
raise RuntimeError("Could not retrieve Apple Music metadata.")
|
||||
return _download_tracks_list(tracks, fmt, quality, output_dir, progress_callback)
|
||||
|
||||
if platform in ("yandex_music", "vk"):
|
||||
return _download_with_fallback(url, fmt, quality, output_dir, progress_callback)
|
||||
|
||||
opts = _build_opts(fmt, quality, output_dir, embed_thumb=True)
|
||||
if progress_callback:
|
||||
opts["progress_hooks"] = [progress_callback]
|
||||
files = _download_url(url, opts, output_dir, fmt)
|
||||
|
||||
results = []
|
||||
for f in files:
|
||||
try:
|
||||
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
if info and isinstance(info, dict):
|
||||
if info.get("entries"):
|
||||
info = info["entries"][0]
|
||||
meta = _meta_from_ytdlp_info(info)
|
||||
else:
|
||||
meta = {"title": Path(f).stem, "artist": "", "cover_url": None}
|
||||
except Exception:
|
||||
meta = {"title": Path(f).stem, "artist": "", "cover_url": None}
|
||||
results.append((f, meta))
|
||||
return results
|
||||
51
app/backend/history.py
Normal file
51
app/backend/history.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent
|
||||
HISTORY_FILE = PROJECT_DIR / "data" / "history.json"
|
||||
MAX_ENTRIES = 200
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _load() -> list:
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(entries: list):
|
||||
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def add_history_entry(file_path: str, meta: dict, fmt: str) -> dict:
|
||||
entry = {
|
||||
"title": meta.get("title") or Path(file_path).stem,
|
||||
"artist": meta.get("artist") or "",
|
||||
"cover_url": meta.get("cover_url"),
|
||||
"format": fmt,
|
||||
"path": file_path,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
with _LOCK:
|
||||
entries = _load()
|
||||
entries.insert(0, entry)
|
||||
entries = entries[:MAX_ENTRIES]
|
||||
_save(entries)
|
||||
return entry
|
||||
|
||||
|
||||
def get_history(limit: int = 50) -> list:
|
||||
with _LOCK:
|
||||
return _load()[:limit]
|
||||
|
||||
|
||||
def clear_history():
|
||||
with _LOCK:
|
||||
_save([])
|
||||
78
app/backend/jobs.py
Normal file
78
app/backend/jobs.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
_JOBS: dict[str, dict] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def create_job() -> str:
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
with _LOCK:
|
||||
_JOBS[job_id] = {
|
||||
"status": "pending",
|
||||
"progress": 0,
|
||||
"speed": "",
|
||||
"filename": "",
|
||||
"error": None,
|
||||
"result": None,
|
||||
"created": time.time(),
|
||||
}
|
||||
return job_id
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict | None:
|
||||
with _LOCK:
|
||||
job = _JOBS.get(job_id)
|
||||
return dict(job) if job else None
|
||||
|
||||
|
||||
def _update(job_id: str, **kwargs):
|
||||
with _LOCK:
|
||||
if job_id in _JOBS:
|
||||
_JOBS[job_id].update(kwargs)
|
||||
|
||||
|
||||
def _progress_hook(job_id: str):
|
||||
def hook(d):
|
||||
if d["status"] == "downloading":
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
done = d.get("downloaded_bytes") or 0
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
speed = d.get("_speed_str") or ""
|
||||
fname = Path(d.get("filename", "")).name
|
||||
_update(job_id, status="downloading", progress=pct, speed=speed, filename=fname)
|
||||
elif d["status"] == "finished":
|
||||
_update(job_id, status="processing", progress=100)
|
||||
return hook
|
||||
|
||||
|
||||
def run_download_job(job_id: str, url: str, fmt: str, quality: str, output_dir: str):
|
||||
from backend.downloader import download_track
|
||||
from backend.history import add_history_entry
|
||||
|
||||
_update(job_id, status="downloading")
|
||||
try:
|
||||
hook = _progress_hook(job_id)
|
||||
results = download_track(url, fmt, quality, output_dir, progress_callback=hook)
|
||||
|
||||
entries = []
|
||||
for file_path, meta in results:
|
||||
entry = add_history_entry(file_path, meta, fmt)
|
||||
entries.append(entry)
|
||||
|
||||
_update(job_id, status="done", progress=100, result=entries)
|
||||
except Exception as e:
|
||||
_update(job_id, status="error", error=str(e))
|
||||
|
||||
|
||||
def start_download(url: str, fmt: str, quality: str, output_dir: str) -> str:
|
||||
job_id = create_job()
|
||||
t = threading.Thread(
|
||||
target=run_download_job,
|
||||
args=(job_id, url, fmt, quality, output_dir),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return job_id
|
||||
251
app/backend/metadata.py
Normal file
251
app/backend/metadata.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import io
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
|
||||
from mutagen.id3 import ID3NoHeaderError
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.oggopus import OggOpus
|
||||
|
||||
SUPPORTED_EXTS = {".mp3", ".flac", ".m4a", ".mp4", ".wav", ".ogg", ".opus"}
|
||||
|
||||
|
||||
def _sanitize(value) -> str:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def _fetch_cover(url: str) -> bytes | None:
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
r = requests.get(url, timeout=15)
|
||||
r.raise_for_status()
|
||||
img = Image.open(io.BytesIO(r.content)).convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=95)
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cover_bytes_from_upload(data: bytes) -> bytes:
|
||||
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=95)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _extract_cover_bytes(path: Path) -> bytes | None:
|
||||
ext = path.suffix.lower()
|
||||
try:
|
||||
if ext in (".mp3", ".wav"):
|
||||
tags = ID3(path)
|
||||
for tag in tags.values():
|
||||
if tag.FrameID == "APIC":
|
||||
return tag.data
|
||||
elif ext == ".flac":
|
||||
audio = FLAC(path)
|
||||
if audio.pictures:
|
||||
return audio.pictures[0].data
|
||||
elif ext in (".m4a", ".mp4"):
|
||||
audio = MP4(path)
|
||||
covr = audio.get("covr")
|
||||
if covr:
|
||||
return bytes(covr[0])
|
||||
elif ext == ".ogg":
|
||||
audio = OggVorbis(path)
|
||||
pics = audio.get("metadata_block_picture")
|
||||
if pics:
|
||||
import base64
|
||||
pic = Picture(base64.b64decode(pics[0]))
|
||||
return pic.data
|
||||
elif ext == ".opus":
|
||||
audio = OggOpus(path)
|
||||
pics = audio.get("metadata_block_picture")
|
||||
if pics:
|
||||
import base64
|
||||
pic = Picture(base64.b64decode(pics[0]))
|
||||
return pic.data
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_tags(path: Path) -> dict:
|
||||
ext = path.suffix.lower()
|
||||
tags = {"title": "", "artist": "", "album": "", "year": "", "track_number": "", "has_cover": False}
|
||||
|
||||
try:
|
||||
if ext in (".mp3", ".wav"):
|
||||
id3 = ID3(path)
|
||||
tags["title"] = str(id3.get("TIT2", ""))
|
||||
tags["artist"] = str(id3.get("TPE1", ""))
|
||||
tags["album"] = str(id3.get("TALB", ""))
|
||||
tags["year"] = str(id3.get("TDRC", ""))
|
||||
tags["track_number"] = str(id3.get("TRCK", ""))
|
||||
elif ext == ".flac":
|
||||
audio = FLAC(path)
|
||||
tags["title"] = audio.get("title", [""])[0]
|
||||
tags["artist"] = audio.get("artist", [""])[0]
|
||||
tags["album"] = audio.get("album", [""])[0]
|
||||
tags["year"] = audio.get("date", [""])[0]
|
||||
tags["track_number"] = audio.get("tracknumber", [""])[0]
|
||||
elif ext in (".m4a", ".mp4"):
|
||||
audio = MP4(path)
|
||||
tags["title"] = (audio.get("\xa9nam") or [""])[0]
|
||||
tags["artist"] = (audio.get("\xa9ART") or [""])[0]
|
||||
tags["album"] = (audio.get("\xa9alb") or [""])[0]
|
||||
tags["year"] = (audio.get("\xa9day") or [""])[0]
|
||||
elif ext == ".ogg":
|
||||
audio = OggVorbis(path)
|
||||
tags["title"] = audio.get("title", [""])[0]
|
||||
tags["artist"] = audio.get("artist", [""])[0]
|
||||
tags["album"] = audio.get("album", [""])[0]
|
||||
tags["year"] = audio.get("date", [""])[0]
|
||||
elif ext == ".opus":
|
||||
audio = OggOpus(path)
|
||||
tags["title"] = audio.get("title", [""])[0]
|
||||
tags["artist"] = audio.get("artist", [""])[0]
|
||||
tags["album"] = audio.get("album", [""])[0]
|
||||
tags["year"] = audio.get("date", [""])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tags["has_cover"] = _extract_cover_bytes(path) is not None
|
||||
return tags
|
||||
|
||||
|
||||
def _write_mp3_like(path: Path, meta: dict, cover: bytes | None):
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
|
||||
if meta.get("title") is not None:
|
||||
tags["TIT2"] = TIT2(encoding=3, text=_sanitize(meta["title"]))
|
||||
if meta.get("artist") is not None:
|
||||
tags["TPE1"] = TPE1(encoding=3, text=_sanitize(meta["artist"]))
|
||||
if meta.get("album") is not None:
|
||||
tags["TALB"] = TALB(encoding=3, text=_sanitize(meta["album"]))
|
||||
if meta.get("track_number") is not None:
|
||||
tags["TRCK"] = TRCK(encoding=3, text=_sanitize(meta["track_number"]))
|
||||
if meta.get("year") is not None:
|
||||
tags["TDRC"] = TDRC(encoding=3, text=_sanitize(meta["year"]))
|
||||
if cover:
|
||||
tags.delall("APIC")
|
||||
tags["APIC"] = APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover)
|
||||
|
||||
tags.save(path)
|
||||
|
||||
|
||||
def _write_flac(path: Path, meta: dict, cover: bytes | None):
|
||||
audio = FLAC(path)
|
||||
|
||||
if meta.get("title") is not None:
|
||||
audio["title"] = _sanitize(meta["title"])
|
||||
if meta.get("artist") is not None:
|
||||
audio["artist"] = _sanitize(meta["artist"])
|
||||
if meta.get("album") is not None:
|
||||
audio["album"] = _sanitize(meta["album"])
|
||||
if meta.get("track_number") is not None:
|
||||
audio["tracknumber"] = _sanitize(meta["track_number"])
|
||||
if meta.get("year") is not None:
|
||||
audio["date"] = _sanitize(meta["year"])
|
||||
|
||||
if cover:
|
||||
audio.clear_pictures()
|
||||
pic = Picture()
|
||||
pic.type = 3
|
||||
pic.mime = "image/jpeg"
|
||||
pic.data = cover
|
||||
audio.add_picture(pic)
|
||||
|
||||
audio.save()
|
||||
|
||||
|
||||
def _write_m4a(path: Path, meta: dict, cover: bytes | None):
|
||||
audio = MP4(path)
|
||||
|
||||
if meta.get("title") is not None:
|
||||
audio["\xa9nam"] = [_sanitize(meta["title"])]
|
||||
if meta.get("artist") is not None:
|
||||
audio["\xa9ART"] = [_sanitize(meta["artist"])]
|
||||
if meta.get("album") is not None:
|
||||
audio["\xa9alb"] = [_sanitize(meta["album"])]
|
||||
if meta.get("year") is not None:
|
||||
audio["\xa9day"] = [_sanitize(meta["year"])]
|
||||
if meta.get("track_number"):
|
||||
try:
|
||||
audio["trkn"] = [(int(meta["track_number"]), 0)]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if cover:
|
||||
audio["covr"] = [MP4Cover(cover, imageformat=MP4Cover.FORMAT_JPEG)]
|
||||
|
||||
audio.save()
|
||||
|
||||
|
||||
def _write_ogg_like(path: Path, meta: dict, cover: bytes | None, cls):
|
||||
audio = cls(path)
|
||||
|
||||
if meta.get("title") is not None:
|
||||
audio["title"] = _sanitize(meta["title"])
|
||||
if meta.get("artist") is not None:
|
||||
audio["artist"] = _sanitize(meta["artist"])
|
||||
if meta.get("album") is not None:
|
||||
audio["album"] = _sanitize(meta["album"])
|
||||
if meta.get("year") is not None:
|
||||
audio["date"] = _sanitize(meta["year"])
|
||||
|
||||
if cover:
|
||||
import base64
|
||||
pic = Picture()
|
||||
pic.type = 3
|
||||
pic.mime = "image/jpeg"
|
||||
pic.data = cover
|
||||
audio["metadata_block_picture"] = [base64.b64encode(pic.write()).decode("ascii")]
|
||||
|
||||
audio.save()
|
||||
|
||||
|
||||
def write_tags(path: Path, meta: dict, cover_bytes: bytes | None = None):
|
||||
ext = path.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTS:
|
||||
raise ValueError(f"Unsupported format: {ext}")
|
||||
|
||||
if ext in (".mp3", ".wav"):
|
||||
_write_mp3_like(path, meta, cover_bytes)
|
||||
elif ext == ".flac":
|
||||
_write_flac(path, meta, cover_bytes)
|
||||
elif ext in (".m4a", ".mp4"):
|
||||
_write_m4a(path, meta, cover_bytes)
|
||||
elif ext == ".ogg":
|
||||
_write_ogg_like(path, meta, cover_bytes, OggVorbis)
|
||||
elif ext == ".opus":
|
||||
_write_ogg_like(path, meta, cover_bytes, OggOpus)
|
||||
|
||||
|
||||
def embed_cover_and_metadata(file_path: str, meta: dict):
|
||||
"""Used by the downloader right after a track finishes downloading."""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
cover_data = _fetch_cover(meta.get("cover_url"))
|
||||
try:
|
||||
write_tags(
|
||||
path,
|
||||
{
|
||||
"title": meta.get("title"),
|
||||
"artist": meta.get("artist"),
|
||||
"album": meta.get("album"),
|
||||
"track_number": meta.get("track_number"),
|
||||
"year": meta.get("year"),
|
||||
},
|
||||
cover_data,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
94
app/backend/scripts_format.py
Normal file
94
app/backend/scripts_format.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import ast
|
||||
import platform
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent
|
||||
BIN_DIR = PROJECT_DIR / "bin"
|
||||
|
||||
STYLUA_VERSION = "0.20.0"
|
||||
STYLUA_URL = (
|
||||
f"https://github.com/JohnnyMorganz/StyLua/releases/download/v{STYLUA_VERSION}/"
|
||||
f"stylua-windows-x86_64.zip"
|
||||
)
|
||||
|
||||
|
||||
def format_python(code: str) -> dict:
|
||||
try:
|
||||
ast.parse(code)
|
||||
except SyntaxError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"SyntaxError: {e.msg}",
|
||||
"line": e.lineno,
|
||||
"formatted": code,
|
||||
}
|
||||
|
||||
try:
|
||||
import black
|
||||
formatted = black.format_str(code, mode=black.Mode())
|
||||
return {"ok": True, "error": None, "line": None, "formatted": formatted}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "line": None, "formatted": code}
|
||||
|
||||
|
||||
def _stylua_exe() -> Path:
|
||||
return BIN_DIR / ("stylua.exe" if platform.system() == "Windows" else "stylua")
|
||||
|
||||
|
||||
def _ensure_stylua() -> Path | None:
|
||||
exe = _stylua_exe()
|
||||
if exe.exists():
|
||||
return exe
|
||||
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
|
||||
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with urllib.request.urlopen(STYLUA_URL, timeout=30) as resp:
|
||||
data = resp.read()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if Path(name).name.lower() == "stylua.exe":
|
||||
exe.write_bytes(zf.read(name))
|
||||
return exe
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def format_lua(code: str) -> dict:
|
||||
exe = _ensure_stylua()
|
||||
if not exe:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "stylua не удалось скачать автоматически — проверь интернет или положи stylua.exe в bin\\ вручную.",
|
||||
"line": None,
|
||||
"formatted": code,
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(exe), "-"],
|
||||
input=code,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "error": result.stderr.strip(), "line": None, "formatted": code}
|
||||
return {"ok": True, "error": None, "line": None, "formatted": result.stdout}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "line": None, "formatted": code}
|
||||
|
||||
|
||||
def format_code(code: str, language: str) -> dict:
|
||||
if language == "python":
|
||||
return format_python(code)
|
||||
if language == "lua":
|
||||
return format_lua(code)
|
||||
return {"ok": False, "error": f"unsupported language: {language}", "line": None, "formatted": code}
|
||||
102
app/backend/search.py
Normal file
102
app/backend/search.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import requests
|
||||
import yt_dlp
|
||||
from backend.downloader import ffmpeg_path
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def _fmt_duration(seconds) -> str:
|
||||
try:
|
||||
seconds = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
m, s = divmod(seconds, 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _ytdlp_search(prefix: str, query: str, limit: int) -> list:
|
||||
opts = {"quiet": True, "no_warnings": True, "ignoreerrors": True, "extract_flat": "in_playlist"}
|
||||
ffmpeg = ffmpeg_path()
|
||||
if ffmpeg:
|
||||
opts["ffmpeg_location"] = ffmpeg
|
||||
|
||||
results = []
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(f"{prefix}{limit}:{query}", download=False)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not info:
|
||||
return []
|
||||
|
||||
entries = info.get("entries") or []
|
||||
for e in entries:
|
||||
if not e:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"title": e.get("title") or "",
|
||||
"artist": e.get("uploader") or e.get("channel") or "",
|
||||
"cover_url": e.get("thumbnail") or (e.get("thumbnails") or [{}])[-1].get("url"),
|
||||
"duration": _fmt_duration(e.get("duration")),
|
||||
"url": e.get("url") or e.get("webpage_url") or e.get("id"),
|
||||
"source": "youtube" if prefix == "ytsearch" else "soundcloud",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def search_youtube(query: str, limit: int = 10) -> list:
|
||||
return _ytdlp_search("ytsearch", query, limit)
|
||||
|
||||
|
||||
def search_soundcloud(query: str, limit: int = 10) -> list:
|
||||
return _ytdlp_search("scsearch", query, limit)
|
||||
|
||||
|
||||
def search_apple_music(query: str, limit: int = 10) -> list:
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://itunes.apple.com/search",
|
||||
params={"term": query, "media": "music", "entity": "song", "limit": limit},
|
||||
headers={"User-Agent": _UA},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for row in data.get("results", []):
|
||||
cover = (row.get("artworkUrl100") or "").replace("100x100bb", "600x600bb")
|
||||
results.append(
|
||||
{
|
||||
"title": row.get("trackName") or "",
|
||||
"artist": row.get("artistName") or "",
|
||||
"cover_url": cover,
|
||||
"duration": _fmt_duration((row.get("trackTimeMillis") or 0) // 1000),
|
||||
"url": row.get("trackViewUrl") or "",
|
||||
"source": "apple_music",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
SEARCH_FUNCS = {
|
||||
"youtube": search_youtube,
|
||||
"soundcloud": search_soundcloud,
|
||||
"apple_music": search_apple_music,
|
||||
}
|
||||
|
||||
|
||||
def search(platform: str, query: str, limit: int = 10) -> list:
|
||||
fn = SEARCH_FUNCS.get(platform)
|
||||
if not fn:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
return fn(query, limit)
|
||||
9
app/requirements.txt
Normal file
9
app/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
flask>=3.0.0
|
||||
yt-dlp>=2024.3.10
|
||||
mutagen>=1.47.0
|
||||
Pillow>=10.3.0
|
||||
requests>=2.31.0
|
||||
black>=24.3.0
|
||||
pymupdf>=1.24.0
|
||||
python-pptx>=0.6.23
|
||||
img2pdf>=0.5.1
|
||||
4
app/start.bat
Normal file
4
app/start.bat
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python app.py
|
||||
pause
|
||||
353
app/static/css/style.css
Normal file
353
app/static/css/style.css
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
:root {
|
||||
--bg: #0a0a0d;
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--surface: #131317;
|
||||
--surface-2: #1a1a20;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #e8e8ec;
|
||||
--text-dim: #8b8b96;
|
||||
--accent: #5d8aef;
|
||||
--accent-dim: rgba(93, 138, 239, 0.14);
|
||||
--error: #ff6b6b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
color: var(--text);
|
||||
font-family: -apple-system, "Segoe UI", Roboto, Inter, system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
opacity: 0;
|
||||
transition: opacity .4s ease;
|
||||
}
|
||||
body.loaded { opacity: 1; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22px 36px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-accent { color: var(--accent); }
|
||||
|
||||
.gh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.gh-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
padding: 0 36px;
|
||||
margin-bottom: 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px 2px 12px;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
main {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 80px;
|
||||
}
|
||||
|
||||
.tab-panel { display: none; flex-direction: column; gap: 20px; }
|
||||
.tab-panel.active { display: flex; }
|
||||
.tab-panel.fade-out { opacity: 0; transform: translateY(5px); transition: opacity .18s ease, transform .18s ease; }
|
||||
.tab-panel.fade-in { animation: fadeInUp .38s cubic-bezier(.16,.8,.24,1); }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
transition: border-color .25s ease, transform .25s ease, box-shadow .25s ease;
|
||||
}
|
||||
.card:hover { border-color: rgba(255, 255, 255, 0.14); }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text-dim); }
|
||||
|
||||
.mode-switch {
|
||||
display: inline-flex;
|
||||
background: var(--surface-2);
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
margin-bottom: 18px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 7px 16px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background .2s cubic-bezier(.2,.7,.3,1), color .2s ease, transform .1s ease;
|
||||
}
|
||||
.mode-btn:active { transform: scale(.94); }
|
||||
.mode-btn.active { background: var(--accent); color: var(--bg); }
|
||||
|
||||
.mode-panel { display: none; }
|
||||
.mode-panel.active { display: block; }
|
||||
|
||||
.big-input, input[type=text], input:not([type]) {
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
margin-bottom: 12px;
|
||||
transition: border-color .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.big-input:focus, input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.row input { margin-bottom: 0; flex: 1; min-width: 120px; }
|
||||
|
||||
select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
transition: border-color .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: #05070f;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity .15s, transform .1s ease;
|
||||
}
|
||||
.primary-btn:hover { opacity: .88; }
|
||||
.primary-btn:active { transform: scale(.96); }
|
||||
.primary-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.ghost-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: color .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.ghost-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ghost-btn:active { transform: scale(.96); }
|
||||
|
||||
.file-btn { display: inline-flex; cursor: pointer; }
|
||||
|
||||
.progress-box { margin-top: 18px; }
|
||||
.progress-box.hidden { display: none; }
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width .3s cubic-bezier(.2,.7,.3,1);
|
||||
box-shadow: 0 0 10px 0 var(--accent-dim);
|
||||
}
|
||||
.progress-text { font-size: 13px; color: var(--text-dim); margin-top: 8px; transition: opacity .15s ease; }
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
animation: fadeInUp .3s cubic-bezier(.2,.7,.3,1) backwards;
|
||||
transition: border-color .15s ease, transform .15s ease;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.result-card img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: #222;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-card .meta { flex: 1; min-width: 0; }
|
||||
.result-card .title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.result-card .artist { font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.result-card:hover { border-color: var(--accent); }
|
||||
.result-card .dl-btn {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
transition: transform .1s ease, background .15s ease;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.result-card .dl-btn:active { transform: scale(.92); }
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
transition: border-color .2s ease, background .2s ease, transform .15s ease;
|
||||
}
|
||||
.drop-zone.dragover { border-color: var(--accent); background: var(--accent-dim); transform: scale(1.01); }
|
||||
.drop-zone p { color: var(--text-dim); margin: 8px 0; }
|
||||
.drop-zone .hint { font-size: 12px; }
|
||||
|
||||
.conv-result { margin-top: 20px; }
|
||||
.conv-result.hidden { display: none; }
|
||||
#conv-file-info { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; }
|
||||
#conv-output { margin-top: 14px; font-size: 14px; }
|
||||
#conv-output a { color: var(--accent); }
|
||||
|
||||
.meta-form { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.meta-form.hidden { display: none; }
|
||||
.meta-cover-row { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; }
|
||||
.cover-preview {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.batch-list { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.batch-item { font-size: 13px; color: var(--text-dim); display: flex; justify-content: space-between; }
|
||||
.batch-item .ok { color: #6ee7a8; }
|
||||
.batch-item .err { color: var(--error); }
|
||||
|
||||
.script-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.script-grid textarea {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: "Cascadia Code", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
.script-grid textarea:focus { border-color: var(--accent); }
|
||||
.script-error {
|
||||
margin-top: 10px;
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.script-error.hidden { display: none; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.18);
|
||||
pointer-events: none;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.script-grid { grid-template-columns: 1fr; }
|
||||
.topbar { padding: 18px 20px; }
|
||||
.tabs { padding: 0 12px; overflow-x: auto; }
|
||||
}
|
||||
BIN
app/static/img/favicon.ico
Normal file
BIN
app/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
app/static/img/icon.png
Normal file
BIN
app/static/img/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
467
app/static/js/app.js
Normal file
467
app/static/js/app.js
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
requestAnimationFrame(() => document.body.classList.add("loaded"));
|
||||
|
||||
// --- tabs ---
|
||||
function crossfade(fromPanel, toPanel) {
|
||||
if (fromPanel === toPanel) return;
|
||||
if (fromPanel) {
|
||||
fromPanel.classList.add("fade-out");
|
||||
setTimeout(() => {
|
||||
fromPanel.classList.remove("active", "fade-out");
|
||||
}, 170);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toPanel.classList.add("active", "fade-in");
|
||||
setTimeout(() => toPanel.classList.remove("fade-in"), 380);
|
||||
}, fromPanel ? 140 : 0);
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.classList.contains("active")) return;
|
||||
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const current = document.querySelector(".tab-panel.active");
|
||||
const next = document.getElementById(`tab-${btn.dataset.tab}`);
|
||||
crossfade(current, next);
|
||||
});
|
||||
});
|
||||
|
||||
function switchMode(switchEl, panelPrefix, attr) {
|
||||
switchEl.querySelectorAll(".mode-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.classList.contains("active")) return;
|
||||
switchEl.querySelectorAll(".mode-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const val = btn.dataset[attr];
|
||||
const current = switchEl.parentElement.querySelector(".mode-panel.active");
|
||||
const next = document.getElementById(`${panelPrefix}-${val}`);
|
||||
crossfade(current, next);
|
||||
return val;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".mode-switch").forEach((sw) => {
|
||||
if (sw.querySelector("[data-mode]")) switchMode(sw, "mode", "mode");
|
||||
if (sw.querySelector("[data-meta-mode]")) switchMode(sw, "meta", "metaMode");
|
||||
});
|
||||
|
||||
// =================== DOWNLOADER ===================
|
||||
|
||||
const dlUrl = document.getElementById("dl-url");
|
||||
const dlFormat = document.getElementById("dl-format");
|
||||
const dlQuality = document.getElementById("dl-quality");
|
||||
const dlGo = document.getElementById("dl-go");
|
||||
const dlProgress = document.getElementById("dl-progress");
|
||||
const dlProgressBar = document.getElementById("dl-progress-bar");
|
||||
const dlProgressText = document.getElementById("dl-progress-text");
|
||||
|
||||
const AUDIO_QUALITIES = [
|
||||
["320", "320 kbps"],
|
||||
["256", "256 kbps"],
|
||||
["192", "192 kbps"],
|
||||
["128", "128 kbps"],
|
||||
];
|
||||
const VIDEO_QUALITIES = [
|
||||
["best", "лучшее"],
|
||||
["2160", "2160p (4K)"],
|
||||
["1440", "1440p"],
|
||||
["1080", "1080p"],
|
||||
["720", "720p"],
|
||||
["480", "480p"],
|
||||
["360", "360p"],
|
||||
];
|
||||
|
||||
function refreshQualityOptions() {
|
||||
const isVideo = dlFormat.value === "mp4";
|
||||
const list = isVideo ? VIDEO_QUALITIES : AUDIO_QUALITIES;
|
||||
dlQuality.innerHTML = list.map(([v, label]) => `<option value="${v}">${label}</option>`).join("");
|
||||
}
|
||||
dlFormat.addEventListener("change", refreshQualityOptions);
|
||||
refreshQualityOptions();
|
||||
|
||||
function startDownload(url, fmt, quality) {
|
||||
dlProgress.classList.remove("hidden");
|
||||
dlProgressBar.style.width = "0%";
|
||||
dlProgressText.textContent = "запускаю...";
|
||||
|
||||
fetch("/api/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, format: fmt, quality }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
dlProgressText.textContent = "ошибка: " + data.error;
|
||||
return;
|
||||
}
|
||||
pollJob(data.job_id);
|
||||
})
|
||||
.catch((e) => (dlProgressText.textContent = "ошибка: " + e));
|
||||
}
|
||||
|
||||
function pollJob(jobId) {
|
||||
const iv = setInterval(() => {
|
||||
fetch(`/api/job/${jobId}`)
|
||||
.then((r) => r.json())
|
||||
.then((job) => {
|
||||
if (job.error) {
|
||||
clearInterval(iv);
|
||||
dlProgressText.textContent = "ошибка: " + job.error;
|
||||
return;
|
||||
}
|
||||
dlProgressBar.style.width = (job.progress || 0) + "%";
|
||||
|
||||
if (job.status === "downloading") {
|
||||
dlProgressText.textContent = `скачиваю... ${job.progress || 0}% ${job.speed || ""}`;
|
||||
} else if (job.status === "processing") {
|
||||
dlProgressText.textContent = "обрабатываю...";
|
||||
} else if (job.status === "done") {
|
||||
clearInterval(iv);
|
||||
dlProgressText.textContent = "готово ✓";
|
||||
loadHistory();
|
||||
} else if (job.status === "error") {
|
||||
clearInterval(iv);
|
||||
dlProgressText.textContent = "ошибка: " + job.error;
|
||||
}
|
||||
})
|
||||
.catch(() => clearInterval(iv));
|
||||
}, 800);
|
||||
}
|
||||
|
||||
dlGo.addEventListener("click", () => {
|
||||
const url = dlUrl.value.trim();
|
||||
if (!url) return;
|
||||
startDownload(url, dlFormat.value, dlQuality.value);
|
||||
});
|
||||
|
||||
// --- search ---
|
||||
|
||||
const searchQ = document.getElementById("search-q");
|
||||
const searchPlatform = document.getElementById("search-platform");
|
||||
const searchGo = document.getElementById("search-go");
|
||||
const searchResults = document.getElementById("search-results");
|
||||
|
||||
function renderResultCard(item, delayIndex = 0) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "result-card";
|
||||
div.style.animationDelay = `${Math.min(delayIndex * 35, 300)}ms`;
|
||||
div.innerHTML = `
|
||||
<img src="${item.cover_url || ""}" onerror="this.style.opacity=0">
|
||||
<div class="meta">
|
||||
<div class="title">${escapeHtml(item.title)}</div>
|
||||
<div class="artist">${escapeHtml(item.artist)} ${item.duration ? "· " + item.duration : ""}</div>
|
||||
</div>
|
||||
<button class="dl-btn">скачать</button>
|
||||
`;
|
||||
div.querySelector(".dl-btn").addEventListener("click", () => {
|
||||
startDownload(item.url, dlFormat.value, dlQuality.value);
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
searchGo.addEventListener("click", () => {
|
||||
const q = searchQ.value.trim();
|
||||
if (!q) return;
|
||||
searchResults.innerHTML = '<div class="progress-text">ищу...</div>';
|
||||
fetch(`/api/search?platform=${searchPlatform.value}&q=${encodeURIComponent(q)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
searchResults.innerHTML = "";
|
||||
if (data.error || !data.results || !data.results.length) {
|
||||
searchResults.innerHTML = '<div class="progress-text">ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
data.results.forEach((item, i) => searchResults.appendChild(renderResultCard(item, i)));
|
||||
});
|
||||
});
|
||||
|
||||
// --- history ---
|
||||
|
||||
const historyList = document.getElementById("history-list");
|
||||
|
||||
function loadHistory() {
|
||||
fetch("/api/history")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
historyList.innerHTML = "";
|
||||
(data.history || []).forEach((item, i) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "result-card";
|
||||
div.style.animationDelay = `${Math.min(i * 35, 300)}ms`;
|
||||
div.innerHTML = `
|
||||
<img src="${item.cover_url || ""}" onerror="this.style.opacity=0">
|
||||
<div class="meta">
|
||||
<div class="title">${escapeHtml(item.title)}</div>
|
||||
<div class="artist">${escapeHtml(item.artist)} · ${item.format.toUpperCase()}</div>
|
||||
</div>
|
||||
`;
|
||||
historyList.appendChild(div);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("history-clear").addEventListener("click", () => {
|
||||
fetch("/api/history/clear", { method: "POST" }).then(loadHistory);
|
||||
});
|
||||
|
||||
loadHistory();
|
||||
|
||||
// =================== CONVERTER ===================
|
||||
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const convFile = document.getElementById("conv-file");
|
||||
const convResult = document.getElementById("conv-result");
|
||||
const convFileInfo = document.getElementById("conv-file-info");
|
||||
const convTarget = document.getElementById("conv-target");
|
||||
const convGo = document.getElementById("conv-go");
|
||||
const convOutput = document.getElementById("conv-output");
|
||||
|
||||
let currentUpload = null;
|
||||
|
||||
["dragover", "dragenter"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
})
|
||||
);
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
})
|
||||
);
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) uploadForConvert(file);
|
||||
});
|
||||
convFile.addEventListener("change", () => {
|
||||
if (convFile.files[0]) uploadForConvert(convFile.files[0]);
|
||||
});
|
||||
|
||||
function uploadForConvert(file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
convOutput.innerHTML = "";
|
||||
fetch("/api/convert/upload", { method: "POST", body: fd })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
convFileInfo.textContent = "ошибка: " + data.error;
|
||||
convResult.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
currentUpload = data;
|
||||
convFileInfo.textContent = `${data.filename} · ${(data.size / 1024).toFixed(1)} KB · тип: ${data.type}`;
|
||||
convTarget.innerHTML = data.targets.map((t) => `<option value="${t}">${t.toUpperCase()}</option>`).join("");
|
||||
convResult.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
convGo.addEventListener("click", () => {
|
||||
if (!currentUpload) return;
|
||||
convOutput.textContent = "конвертирую...";
|
||||
fetch("/api/convert/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: currentUpload.id,
|
||||
saved: currentUpload.saved,
|
||||
filename: currentUpload.filename,
|
||||
target: convTarget.value,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
convOutput.textContent = "ошибка: " + data.error;
|
||||
return;
|
||||
}
|
||||
convOutput.innerHTML = `<a href="/api/convert/download/${data.result}" download="${data.filename}">скачать ${data.filename}</a>`;
|
||||
});
|
||||
});
|
||||
|
||||
// =================== METADATA ===================
|
||||
|
||||
const metaFile = document.getElementById("meta-file");
|
||||
const metaForm = document.getElementById("meta-form");
|
||||
const metaCoverPreview = document.getElementById("meta-cover-preview");
|
||||
const metaCoverInput = document.getElementById("meta-cover-input");
|
||||
const metaTitle = document.getElementById("meta-title");
|
||||
const metaArtist = document.getElementById("meta-artist");
|
||||
const metaAlbum = document.getElementById("meta-album");
|
||||
const metaYear = document.getElementById("meta-year");
|
||||
const metaTrack = document.getElementById("meta-track");
|
||||
const metaSave = document.getElementById("meta-save");
|
||||
const metaSaveResult = document.getElementById("meta-save-result");
|
||||
|
||||
let currentMetaFile = null;
|
||||
let newCoverFile = null;
|
||||
|
||||
metaFile.addEventListener("change", () => {
|
||||
const file = metaFile.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fetch("/api/metadata/upload", { method: "POST", body: fd })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
currentMetaFile = data;
|
||||
newCoverFile = null;
|
||||
metaTitle.value = data.tags.title || "";
|
||||
metaArtist.value = data.tags.artist || "";
|
||||
metaAlbum.value = data.tags.album || "";
|
||||
metaYear.value = data.tags.year || "";
|
||||
metaTrack.value = data.tags.track_number || "";
|
||||
metaCoverPreview.src = data.tags.has_cover ? `/api/metadata/cover/${data.saved}?t=${Date.now()}` : "";
|
||||
metaForm.classList.remove("hidden");
|
||||
metaSaveResult.textContent = "";
|
||||
});
|
||||
});
|
||||
|
||||
metaCoverInput.addEventListener("change", () => {
|
||||
const file = metaCoverInput.files[0];
|
||||
if (!file) return;
|
||||
newCoverFile = file;
|
||||
metaCoverPreview.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
metaSave.addEventListener("click", () => {
|
||||
if (!currentMetaFile) return;
|
||||
const fd = new FormData();
|
||||
fd.append("saved", currentMetaFile.saved);
|
||||
fd.append("title", metaTitle.value);
|
||||
fd.append("artist", metaArtist.value);
|
||||
fd.append("album", metaAlbum.value);
|
||||
fd.append("year", metaYear.value);
|
||||
fd.append("track_number", metaTrack.value);
|
||||
if (newCoverFile) fd.append("cover", newCoverFile);
|
||||
|
||||
fetch("/api/metadata/save", { method: "POST", body: fd })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
metaSaveResult.textContent = "ошибка: " + data.error;
|
||||
return;
|
||||
}
|
||||
metaSaveResult.innerHTML = `сохранено — <a href="/api/metadata/download/${data.download}" download>скачать</a>`;
|
||||
});
|
||||
});
|
||||
|
||||
// --- batch ---
|
||||
|
||||
const metaBatchFiles = document.getElementById("meta-batch-files");
|
||||
const metaBatchList = document.getElementById("meta-batch-list");
|
||||
const metaBatchForm = document.getElementById("meta-batch-form");
|
||||
const metaBatchCoverPreview = document.getElementById("meta-batch-cover-preview");
|
||||
const metaBatchCoverInput = document.getElementById("meta-batch-cover-input");
|
||||
const metaBatchArtist = document.getElementById("meta-batch-artist");
|
||||
const metaBatchAlbum = document.getElementById("meta-batch-album");
|
||||
const metaBatchYear = document.getElementById("meta-batch-year");
|
||||
const metaBatchSave = document.getElementById("meta-batch-save");
|
||||
const metaBatchResult = document.getElementById("meta-batch-result");
|
||||
|
||||
let batchUploads = [];
|
||||
let batchCoverSaved = null;
|
||||
|
||||
metaBatchFiles.addEventListener("change", async () => {
|
||||
batchUploads = [];
|
||||
metaBatchList.innerHTML = "";
|
||||
for (const file of metaBatchFiles.files) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/metadata/upload", { method: "POST", body: fd }).then((r) => r.json());
|
||||
if (!res.error) {
|
||||
batchUploads.push(res);
|
||||
const div = document.createElement("div");
|
||||
div.className = "batch-item";
|
||||
div.innerHTML = `<span>${escapeHtml(file.name)}</span><span class="ok">загружен</span>`;
|
||||
metaBatchList.appendChild(div);
|
||||
}
|
||||
}
|
||||
if (batchUploads.length) metaBatchForm.classList.remove("hidden");
|
||||
});
|
||||
|
||||
metaBatchCoverInput.addEventListener("change", async () => {
|
||||
const file = metaBatchCoverInput.files[0];
|
||||
if (!file) return;
|
||||
metaBatchCoverPreview.src = URL.createObjectURL(file);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, "cover.jpg");
|
||||
// reuse the audio upload-like storage isn't right for images; store cover directly via convert upload endpoint instead
|
||||
const res = await fetch("/api/convert/upload", { method: "POST", body: fd }).then((r) => r.json());
|
||||
if (!res.error) batchCoverSaved = res.saved;
|
||||
});
|
||||
|
||||
metaBatchSave.addEventListener("click", () => {
|
||||
fetch("/api/metadata/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
saved_files: batchUploads.map((u) => u.saved),
|
||||
artist: metaBatchArtist.value,
|
||||
album: metaBatchAlbum.value,
|
||||
year: metaBatchYear.value,
|
||||
cover_saved: batchCoverSaved,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
metaBatchResult.innerHTML = `обновлено: ${data.updated.length}` + (data.errors.length ? `, ошибок: ${data.errors.length}` : "");
|
||||
});
|
||||
});
|
||||
|
||||
// =================== SCRIPTS ===================
|
||||
|
||||
const scriptInput = document.getElementById("script-input");
|
||||
const scriptOutput = document.getElementById("script-output");
|
||||
const scriptFormat = document.getElementById("script-format");
|
||||
const scriptDownload = document.getElementById("script-download");
|
||||
const scriptError = document.getElementById("script-error");
|
||||
let currentLang = "python";
|
||||
|
||||
document.querySelectorAll("[data-lang]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
currentLang = btn.dataset.lang;
|
||||
});
|
||||
});
|
||||
|
||||
scriptFormat.addEventListener("click", () => {
|
||||
scriptError.classList.add("hidden");
|
||||
fetch("/api/scripts/format", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: scriptInput.value, language: currentLang }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
scriptOutput.value = data.formatted || "";
|
||||
if (!data.ok) {
|
||||
scriptError.textContent = (data.line ? `строка ${data.line}: ` : "") + data.error;
|
||||
scriptError.classList.remove("hidden");
|
||||
scriptDownload.disabled = true;
|
||||
} else {
|
||||
scriptDownload.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
scriptDownload.addEventListener("click", () => {
|
||||
const ext = currentLang === "python" ? "py" : "lua";
|
||||
const blob = new Blob([scriptOutput.value], { type: "text/plain" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `formatted.${ext}`;
|
||||
a.click();
|
||||
});
|
||||
198
app/templates/index.html
Normal file
198
app/templates/index.html
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>nexdex.space</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">nex<span class="brand-accent">dex</span>.space</div>
|
||||
<a class="gh-btn" href="https://github.com/imsavsis" target="_blank" rel="noopener" title="GitHub">
|
||||
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13
|
||||
-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66
|
||||
.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15
|
||||
-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27
|
||||
.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12
|
||||
.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48
|
||||
0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="downloader">Загрузчик</button>
|
||||
<button class="tab-btn" data-tab="converter">Конвертер</button>
|
||||
<button class="tab-btn" data-tab="metadata">Метаданные</button>
|
||||
<button class="tab-btn" data-tab="scripts">Скрипты</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ================= DOWNLOADER ================= -->
|
||||
<section id="tab-downloader" class="tab-panel active">
|
||||
<div class="card">
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" data-mode="link">Ссылка</button>
|
||||
<button class="mode-btn" data-mode="search">Поиск</button>
|
||||
</div>
|
||||
|
||||
<div id="mode-link" class="mode-panel active">
|
||||
<input id="dl-url" class="big-input" type="text" placeholder="Вставь ссылку — YouTube / SoundCloud / Apple Music / Spotify / Yandex Music">
|
||||
<div class="row">
|
||||
<select id="dl-format">
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="flac">FLAC (lossless)</option>
|
||||
<option value="wav">WAV (lossless)</option>
|
||||
<option value="m4a">M4A</option>
|
||||
<option value="alac">ALAC (lossless)</option>
|
||||
<option value="ogg">OGG</option>
|
||||
<option value="opus">OPUS</option>
|
||||
<option value="mp4">MP4 (видео)</option>
|
||||
</select>
|
||||
<select id="dl-quality"></select>
|
||||
<button id="dl-go" class="primary-btn">Скачать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mode-search" class="mode-panel">
|
||||
<div class="row">
|
||||
<input id="search-q" class="big-input" type="text" placeholder="Название трека или исполнитель...">
|
||||
<select id="search-platform">
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="soundcloud">SoundCloud</option>
|
||||
<option value="apple_music">Apple Music</option>
|
||||
</select>
|
||||
<button id="search-go" class="primary-btn">Искать</button>
|
||||
</div>
|
||||
<div id="search-results" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="dl-progress" class="progress-box hidden">
|
||||
<div class="progress-track"><div id="dl-progress-bar" class="progress-fill"></div></div>
|
||||
<div id="dl-progress-text" class="progress-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>История</h3>
|
||||
<button id="history-clear" class="ghost-btn">очистить</button>
|
||||
</div>
|
||||
<div id="history-list" class="card-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= CONVERTER ================= -->
|
||||
<section id="tab-converter" class="tab-panel">
|
||||
<div class="card">
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<p>Перетащи файл сюда или</p>
|
||||
<label class="primary-btn file-btn">
|
||||
выбрать файл
|
||||
<input id="conv-file" type="file" hidden>
|
||||
</label>
|
||||
<p class="hint">картинки (webp, png, jpg, ico...) · pdf · pptx · аудио (flac, wav, mp3...) · видео (mp4, mkv...)</p>
|
||||
</div>
|
||||
|
||||
<div id="conv-result" class="conv-result hidden">
|
||||
<div id="conv-file-info"></div>
|
||||
<div class="row">
|
||||
<select id="conv-target"></select>
|
||||
<button id="conv-go" class="primary-btn">Конвертировать</button>
|
||||
</div>
|
||||
<div id="conv-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= METADATA ================= -->
|
||||
<section id="tab-metadata" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" data-meta-mode="single">Один файл</button>
|
||||
<button class="mode-btn" data-meta-mode="batch">Массово</button>
|
||||
</div>
|
||||
|
||||
<div id="meta-single" class="mode-panel active">
|
||||
<label class="primary-btn file-btn">
|
||||
выбрать аудиофайл
|
||||
<input id="meta-file" type="file" accept=".mp3,.flac,.wav,.m4a,.mp4,.ogg,.opus" hidden>
|
||||
</label>
|
||||
|
||||
<div id="meta-form" class="meta-form hidden">
|
||||
<div class="meta-cover-row">
|
||||
<img id="meta-cover-preview" class="cover-preview" alt="обложка">
|
||||
<label class="ghost-btn file-btn">
|
||||
сменить обложку
|
||||
<input id="meta-cover-input" type="file" accept="image/*" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<input id="meta-title" placeholder="название">
|
||||
<input id="meta-artist" placeholder="исполнитель">
|
||||
<input id="meta-album" placeholder="альбом">
|
||||
<div class="row">
|
||||
<input id="meta-year" placeholder="год" style="max-width:120px">
|
||||
<input id="meta-track" placeholder="№ трека" style="max-width:120px">
|
||||
</div>
|
||||
<button id="meta-save" class="primary-btn">Сохранить</button>
|
||||
<div id="meta-save-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="meta-batch" class="mode-panel">
|
||||
<label class="primary-btn file-btn">
|
||||
выбрать несколько файлов
|
||||
<input id="meta-batch-files" type="file" accept=".mp3,.flac,.wav,.m4a,.mp4,.ogg,.opus" multiple hidden>
|
||||
</label>
|
||||
<div id="meta-batch-list" class="batch-list"></div>
|
||||
|
||||
<div id="meta-batch-form" class="meta-form hidden">
|
||||
<div class="meta-cover-row">
|
||||
<img id="meta-batch-cover-preview" class="cover-preview" alt="обложка">
|
||||
<label class="ghost-btn file-btn">
|
||||
обложка для всех
|
||||
<input id="meta-batch-cover-input" type="file" accept="image/*" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<input id="meta-batch-artist" placeholder="исполнитель (для всех)">
|
||||
<input id="meta-batch-album" placeholder="альбом (для всех)">
|
||||
<input id="meta-batch-year" placeholder="год (для всех)" style="max-width:160px">
|
||||
<button id="meta-batch-save" class="primary-btn">Применить ко всем</button>
|
||||
<div id="meta-batch-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= SCRIPTS ================= -->
|
||||
<section id="tab-scripts" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" data-lang="python">Python</button>
|
||||
<button class="mode-btn" data-lang="lua">Lua</button>
|
||||
</div>
|
||||
<div class="script-grid">
|
||||
<textarea id="script-input" placeholder="вставь код сюда..." spellcheck="false"></textarea>
|
||||
<textarea id="script-output" placeholder="результат появится здесь" spellcheck="false" readonly></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="script-format" class="primary-btn">Форматировать</button>
|
||||
<button id="script-download" class="ghost-btn" disabled>Скачать</button>
|
||||
</div>
|
||||
<div id="script-error" class="script-error hidden"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="footer">made by savsis with ♥</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
55
extras/batch/menu.bat
Normal file
55
extras/batch/menu.bat
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
@echo off
|
||||
REM extras/batch/menu.bat -- decorative, not part of the real installer.
|
||||
REM the real launcher is app/start.bat (just "python app.py"). this one is
|
||||
REM just a slightly fancier menu wrapper, kept here for show.
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
title nexdex.space -- dev menu
|
||||
|
||||
:menu
|
||||
cls
|
||||
echo ============================================
|
||||
echo nexdex.space -- dev menu
|
||||
echo made by savsis with ^<3
|
||||
echo ============================================
|
||||
echo.
|
||||
echo 1. run the app (python app.py)
|
||||
echo 2. install dependencies (pip install -r requirements.txt)
|
||||
echo 3. format a python file with black
|
||||
echo 4. open downloads folder
|
||||
echo 5. exit
|
||||
echo.
|
||||
set /p choice="choose 1-5: "
|
||||
|
||||
if "%choice%"=="1" goto run
|
||||
if "%choice%"=="2" goto deps
|
||||
if "%choice%"=="3" goto format
|
||||
if "%choice%"=="4" goto downloads
|
||||
if "%choice%"=="5" goto end
|
||||
goto menu
|
||||
|
||||
:run
|
||||
cd /d "%~dp0..\..\app"
|
||||
python app.py
|
||||
pause
|
||||
goto menu
|
||||
|
||||
:deps
|
||||
cd /d "%~dp0..\..\app"
|
||||
pip install -r requirements.txt
|
||||
pause
|
||||
goto menu
|
||||
|
||||
:format
|
||||
set /p pyfile="path to .py file: "
|
||||
python -m black "%pyfile%"
|
||||
pause
|
||||
goto menu
|
||||
|
||||
:downloads
|
||||
start "" "%~dp0..\..\app\downloads"
|
||||
goto menu
|
||||
|
||||
:end
|
||||
endlocal
|
||||
exit /b 0
|
||||
68
extras/cpp/audio_meta.hpp
Normal file
68
extras/cpp/audio_meta.hpp
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// extras/cpp — decorative, separate from installer/installer.cpp (the real,
|
||||
// functional C++ source). a tiny header-only audio-tag toy, mirroring
|
||||
// app/backend/metadata.py in spirit.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
namespace nexdex {
|
||||
|
||||
struct AudioTag {
|
||||
std::string title;
|
||||
std::string artist;
|
||||
std::optional<std::string> album;
|
||||
std::optional<int> year;
|
||||
std::optional<int> trackNumber;
|
||||
|
||||
bool isComplete() const {
|
||||
return !title.empty() && !artist.empty();
|
||||
}
|
||||
|
||||
std::string describe() const {
|
||||
std::ostringstream out;
|
||||
out << artist << " -- " << title;
|
||||
if (album) out << " [" << *album << "]";
|
||||
if (year) out << " (" << *year << ")";
|
||||
if (trackNumber) out << " track #" << *trackNumber;
|
||||
return out.str();
|
||||
}
|
||||
};
|
||||
|
||||
class AudioTagBuilder {
|
||||
public:
|
||||
AudioTagBuilder(std::string title, std::string artist) {
|
||||
tag_.title = std::move(title);
|
||||
tag_.artist = std::move(artist);
|
||||
}
|
||||
|
||||
AudioTagBuilder& album(std::string album) {
|
||||
tag_.album = std::move(album);
|
||||
return *this;
|
||||
}
|
||||
|
||||
AudioTagBuilder& year(int year) {
|
||||
tag_.year = year;
|
||||
return *this;
|
||||
}
|
||||
|
||||
AudioTagBuilder& trackNumber(int n) {
|
||||
tag_.trackNumber = n;
|
||||
return *this;
|
||||
}
|
||||
|
||||
AudioTag build() const {
|
||||
return tag_;
|
||||
}
|
||||
|
||||
private:
|
||||
AudioTag tag_;
|
||||
};
|
||||
|
||||
inline std::vector<std::string> knownExtensions() {
|
||||
return {"mp3", "flac", "wav", "m4a", "alac", "ogg", "opus"};
|
||||
}
|
||||
|
||||
} // namespace nexdex
|
||||
34
extras/cpp/demo.cpp
Normal file
34
extras/cpp/demo.cpp
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// extras/cpp/demo.cpp — decorative, not part of the build. compile with:
|
||||
// cl.exe /std:c++17 /EHsc demo.cpp /Fe:demo.exe
|
||||
// or: g++ -std=c++17 demo.cpp -o demo
|
||||
|
||||
#include <iostream>
|
||||
#include "audio_meta.hpp"
|
||||
#include "progress_bar.hpp"
|
||||
#include "playlist.hpp"
|
||||
|
||||
int main() {
|
||||
using nexdex::AudioTagBuilder;
|
||||
using nexdex::ProgressBar;
|
||||
using nexdex::Playlist;
|
||||
using nexdex::Track;
|
||||
|
||||
auto tag = AudioTagBuilder("MATADORA (Extended)", "SUKA.")
|
||||
.year(2026)
|
||||
.build();
|
||||
|
||||
std::cout << tag.describe() << "\n\n";
|
||||
|
||||
ProgressBar bar("MATADORA (Extended)", 30);
|
||||
for (int pct = 0; pct <= 100; pct += 20) {
|
||||
std::cout << bar.render(pct) << "\n";
|
||||
}
|
||||
|
||||
Playlist playlist;
|
||||
playlist.add(Track{tag, "downloads/MATADORA (Extended).flac", 222});
|
||||
std::cout << "\nplaylist: " << playlist.size() << " track(s), total "
|
||||
<< Playlist::formatDuration(playlist.totalDurationSecs()) << "\n";
|
||||
|
||||
std::cout << "\nmade by savsis with <3\n";
|
||||
return 0;
|
||||
}
|
||||
79
extras/cpp/playlist.hpp
Normal file
79
extras/cpp/playlist.hpp
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// extras/cpp — decorative. mirrors extras/rust/src/playlist.rs, just in
|
||||
// C++: a tiny in-memory playlist model with no real I/O.
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <numeric>
|
||||
#include "audio_meta.hpp"
|
||||
|
||||
namespace nexdex {
|
||||
|
||||
struct Track {
|
||||
AudioTag tag;
|
||||
std::string path;
|
||||
int durationSecs = 0;
|
||||
};
|
||||
|
||||
class Playlist {
|
||||
public:
|
||||
void add(Track track) {
|
||||
tracks_.push_back(std::move(track));
|
||||
}
|
||||
|
||||
size_t size() const { return tracks_.size(); }
|
||||
bool empty() const { return tracks_.empty(); }
|
||||
|
||||
int totalDurationSecs() const {
|
||||
return std::accumulate(
|
||||
tracks_.begin(), tracks_.end(), 0,
|
||||
[](int sum, const Track& t) { return sum + t.durationSecs; });
|
||||
}
|
||||
|
||||
std::vector<const Track*> byArtist(const std::string& artist) const {
|
||||
std::vector<const Track*> result;
|
||||
for (const auto& t : tracks_) {
|
||||
if (equalsIgnoreCase(t.tag.artist, artist)) {
|
||||
result.push_back(&t);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<const Track*> longest() const {
|
||||
if (tracks_.empty()) return std::nullopt;
|
||||
auto it = std::max_element(
|
||||
tracks_.begin(), tracks_.end(),
|
||||
[](const Track& a, const Track& b) { return a.durationSecs < b.durationSecs; });
|
||||
return &(*it);
|
||||
}
|
||||
|
||||
static std::string formatDuration(int totalSecs) {
|
||||
int hours = totalSecs / 3600;
|
||||
int minutes = (totalSecs % 3600) / 60;
|
||||
int seconds = totalSecs % 60;
|
||||
|
||||
char buf[32];
|
||||
if (hours > 0) {
|
||||
snprintf(buf, sizeof(buf), "%d:%02d:%02d", hours, minutes, seconds);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%d:%02d", minutes, seconds);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
private:
|
||||
static bool equalsIgnoreCase(const std::string& a, const std::string& b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
return std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
|
||||
return std::tolower(static_cast<unsigned char>(x)) ==
|
||||
std::tolower(static_cast<unsigned char>(y));
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<Track> tracks_;
|
||||
};
|
||||
|
||||
} // namespace nexdex
|
||||
31
extras/cpp/progress_bar.hpp
Normal file
31
extras/cpp/progress_bar.hpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// extras/cpp — decorative. a tiny console progress bar, echoing the shape
|
||||
// of the /api/job/<id> polling response from backend/jobs.py.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
|
||||
namespace nexdex {
|
||||
|
||||
class ProgressBar {
|
||||
public:
|
||||
ProgressBar(std::string label, int width) : label_(std::move(label)), width_(width) {}
|
||||
|
||||
std::string render(int percent) const {
|
||||
percent = std::clamp(percent, 0, 100);
|
||||
int filled = width_ * percent / 100;
|
||||
int empty = width_ - filled;
|
||||
|
||||
std::string bar;
|
||||
bar.append(filled, '#');
|
||||
bar.append(empty, '-');
|
||||
|
||||
return label_ + " [" + bar + "] " + std::to_string(percent) + "%";
|
||||
}
|
||||
|
||||
private:
|
||||
std::string label_;
|
||||
int width_;
|
||||
};
|
||||
|
||||
} // namespace nexdex
|
||||
98
extras/formats.ts
Normal file
98
extras/formats.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// extras/formats.ts — decorative, not wired into the Flask/JS frontend
|
||||
// (the real frontend logic lives in app/static/js/app.js, plain JS).
|
||||
// just a typed reference of the formats the downloader/converter understand.
|
||||
|
||||
type AudioFormat = "mp3" | "flac" | "wav" | "m4a" | "alac" | "ogg" | "opus";
|
||||
type VideoFormat = "mp4" | "avi" | "mov" | "mkv" | "webm" | "gif";
|
||||
type ImageFormat = "jpg" | "png" | "webp" | "bmp" | "gif" | "tiff" | "ico";
|
||||
type DocFormat = "pdf" | "pptx";
|
||||
|
||||
interface FormatInfo {
|
||||
ext: string;
|
||||
lossless: boolean;
|
||||
}
|
||||
|
||||
const AUDIO_FORMATS: Record<AudioFormat, FormatInfo> = {
|
||||
mp3: { ext: "mp3", lossless: false },
|
||||
flac: { ext: "flac", lossless: true },
|
||||
wav: { ext: "wav", lossless: true },
|
||||
m4a: { ext: "m4a", lossless: false },
|
||||
alac: { ext: "alac", lossless: true },
|
||||
ogg: { ext: "ogg", lossless: false },
|
||||
opus: { ext: "opus", lossless: false },
|
||||
};
|
||||
|
||||
const VIDEO_FORMATS: VideoFormat[] = ["mp4", "avi", "mov", "mkv", "webm", "gif"];
|
||||
const IMAGE_FORMATS: ImageFormat[] = ["jpg", "png", "webp", "bmp", "gif", "tiff", "ico"];
|
||||
const DOC_FORMATS: DocFormat[] = ["pdf", "pptx"];
|
||||
|
||||
function isLossless(fmt: AudioFormat): boolean {
|
||||
return AUDIO_FORMATS[fmt].lossless;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
artist: string;
|
||||
coverUrl: string | null;
|
||||
duration: string;
|
||||
source: "youtube" | "soundcloud" | "apple_music";
|
||||
}
|
||||
|
||||
function describeResult(r: SearchResult): string {
|
||||
const cover = r.coverUrl ? "with cover" : "no cover";
|
||||
return `[${r.source}] ${r.artist} -- ${r.title} (${r.duration}, ${cover})`;
|
||||
}
|
||||
|
||||
interface DownloadJob {
|
||||
jobId: string;
|
||||
status: "pending" | "downloading" | "processing" | "done" | "error";
|
||||
progress: number;
|
||||
speed: string;
|
||||
}
|
||||
|
||||
class ProgressBar {
|
||||
constructor(private label: string, private width: number) {}
|
||||
|
||||
render(percent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const filled = Math.round((this.width * clamped) / 100);
|
||||
const empty = this.width - filled;
|
||||
return `${this.label.padEnd(20)} [${"#".repeat(filled)}${"-".repeat(empty)}] ${clamped}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function simulateJob(jobId: string): DownloadJob[] {
|
||||
const steps: DownloadJob[] = [];
|
||||
for (let progress = 0; progress <= 100; progress += 25) {
|
||||
steps.push({
|
||||
jobId,
|
||||
status: progress < 100 ? "downloading" : "done",
|
||||
progress,
|
||||
speed: progress < 100 ? "1.2MiB/s" : "",
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
console.log("nexdex.space formats reference");
|
||||
console.log("audio formats:", Object.keys(AUDIO_FORMATS));
|
||||
console.log("lossless formats:", (Object.keys(AUDIO_FORMATS) as AudioFormat[]).filter(isLossless));
|
||||
console.log("video formats:", VIDEO_FORMATS);
|
||||
console.log("image formats:", IMAGE_FORMATS);
|
||||
console.log("doc formats:", DOC_FORMATS);
|
||||
|
||||
const sample: SearchResult = {
|
||||
title: "MATADORA (Extended)",
|
||||
artist: "SUKA.",
|
||||
coverUrl: null,
|
||||
duration: "3:42",
|
||||
source: "youtube",
|
||||
};
|
||||
console.log(describeResult(sample));
|
||||
|
||||
const bar = new ProgressBar("MATADORA (Extended)", 30);
|
||||
for (const job of simulateJob("demo-job")) {
|
||||
console.log(bar.render(job.progress), job.status);
|
||||
}
|
||||
|
||||
console.log("made by savsis with <3");
|
||||
14
extras/fortune.lua
Normal file
14
extras/fortune.lua
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- extras/fortune.lua — decorative, not wired into the app.
|
||||
-- a tiny fortune-cookie script, sitting here mostly so the Scripts/Lua tab
|
||||
-- has something native to point at. run with: lua fortune.lua
|
||||
|
||||
local fortunes = {
|
||||
"lossless или ничего.",
|
||||
"320kbps -- это не lossless, не обманывай себя.",
|
||||
"обложка важнее битрейта.",
|
||||
"stylua лучше форматирует lua, чем ты руками.",
|
||||
"made by savsis with <3",
|
||||
}
|
||||
|
||||
math.randomseed(os.time())
|
||||
print(fortunes[math.random(#fortunes)])
|
||||
82
extras/go/main.go
Normal file
82
extras/go/main.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// extras/go — decorative, not wired into the build. a toy CLI that mirrors
|
||||
// the shape of backend/search.py's result cards (title/artist/cover/duration)
|
||||
// without actually calling anything. run with: go run main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchResult mirrors the dicts returned by backend/search.py's
|
||||
// search_youtube / search_soundcloud / search_apple_music.
|
||||
type SearchResult struct {
|
||||
Title string
|
||||
Artist string
|
||||
CoverURL string
|
||||
Duration string
|
||||
Source string
|
||||
}
|
||||
|
||||
func (r SearchResult) String() string {
|
||||
return fmt.Sprintf("[%s] %s -- %s (%s)", strings.ToUpper(r.Source), r.Artist, r.Title, r.Duration)
|
||||
}
|
||||
|
||||
type AudioFormat struct {
|
||||
Ext string
|
||||
Lossless bool
|
||||
}
|
||||
|
||||
var formats = []AudioFormat{
|
||||
{"mp3", false},
|
||||
{"flac", true},
|
||||
{"wav", true},
|
||||
{"m4a", false},
|
||||
{"alac", true},
|
||||
{"ogg", false},
|
||||
{"opus", false},
|
||||
}
|
||||
|
||||
func losslessFormats() []string {
|
||||
var out []string
|
||||
for _, f := range formats {
|
||||
if f.Lossless {
|
||||
out = append(out, f.Ext)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderProgress(label string, percent int, width int) string {
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
filled := width * percent / 100
|
||||
bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
|
||||
return fmt.Sprintf("%-20s [%s] %3d%%", label, bar, percent)
|
||||
}
|
||||
|
||||
func main() {
|
||||
results := []SearchResult{
|
||||
{"MATADORA (Extended)", "SUKA.", "", "3:42", "youtube"},
|
||||
{"Aria Math (Epic Version)", "Logan Feece", "", "4:10", "soundcloud"},
|
||||
}
|
||||
|
||||
fmt.Println("nexdex.space -- go toy search demo")
|
||||
for _, r := range results {
|
||||
fmt.Println(r)
|
||||
}
|
||||
|
||||
fmt.Println("\nlossless formats:", strings.Join(losslessFormats(), ", "))
|
||||
|
||||
fmt.Println()
|
||||
for pct := 0; pct <= 100; pct += 20 {
|
||||
fmt.Println(renderProgress("MATADORA (Extended)", pct, 30))
|
||||
}
|
||||
|
||||
fmt.Println("\nmade by savsis with <3")
|
||||
}
|
||||
9
extras/rust/Cargo.toml
Normal file
9
extras/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "nexdex-tools"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "decorative rust toys, not part of the nexdex.space build"
|
||||
|
||||
[[bin]]
|
||||
name = "hello"
|
||||
path = "src/main.rs"
|
||||
46
extras/rust/src/checksum.rs
Normal file
46
extras/rust/src/checksum.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// a small dependency-free crc32 (used nowhere, just a rust toy sitting
|
||||
// next to the real python download/convert pipeline).
|
||||
|
||||
const POLY: u32 = 0xEDB88320;
|
||||
|
||||
fn table() -> [u32; 256] {
|
||||
let mut table = [0u32; 256];
|
||||
let mut i = 0u32;
|
||||
while i < 256 {
|
||||
let mut c = i;
|
||||
let mut k = 0;
|
||||
while k < 8 {
|
||||
c = if c & 1 != 0 { POLY ^ (c >> 1) } else { c >> 1 };
|
||||
k += 1;
|
||||
}
|
||||
table[i as usize] = c;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let table = table();
|
||||
let mut crc: u32 = 0xFFFFFFFF;
|
||||
for &byte in data {
|
||||
let idx = ((crc ^ byte as u32) & 0xFF) as usize;
|
||||
crc = table[idx] ^ (crc >> 8);
|
||||
}
|
||||
crc ^ 0xFFFFFFFF
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_vector() {
|
||||
// crc32("123456789") == 0xCBF43926, the standard check value.
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF43926);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
assert_eq!(crc32(b""), 0);
|
||||
}
|
||||
}
|
||||
91
extras/rust/src/formats.rs
Normal file
91
extras/rust/src/formats.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// mirrors the format list from app/backend/converter.py, just as a rust toy.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioFormat {
|
||||
Mp3,
|
||||
Flac,
|
||||
Wav,
|
||||
M4a,
|
||||
Alac,
|
||||
Ogg,
|
||||
Opus,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn all() -> &'static [AudioFormat] {
|
||||
use AudioFormat::*;
|
||||
&[Mp3, Flac, Wav, M4a, Alac, Ogg, Opus]
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
AudioFormat::Mp3 => "mp3",
|
||||
AudioFormat::Flac => "flac",
|
||||
AudioFormat::Wav => "wav",
|
||||
AudioFormat::M4a => "m4a",
|
||||
AudioFormat::Alac => "alac",
|
||||
AudioFormat::Ogg => "ogg",
|
||||
AudioFormat::Opus => "opus",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_lossless(&self) -> bool {
|
||||
matches!(self, AudioFormat::Flac | AudioFormat::Wav | AudioFormat::Alac)
|
||||
}
|
||||
|
||||
pub fn from_extension(ext: &str) -> Option<AudioFormat> {
|
||||
AudioFormat::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.extension().eq_ignore_ascii_case(ext))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageFormat {
|
||||
Jpg,
|
||||
Png,
|
||||
Webp,
|
||||
Bmp,
|
||||
Gif,
|
||||
Tiff,
|
||||
Ico,
|
||||
}
|
||||
|
||||
impl ImageFormat {
|
||||
pub fn all() -> &'static [ImageFormat] {
|
||||
use ImageFormat::*;
|
||||
&[Jpg, Png, Webp, Bmp, Gif, Tiff, Ico]
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
ImageFormat::Jpg => "jpg",
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Webp => "webp",
|
||||
ImageFormat::Bmp => "bmp",
|
||||
ImageFormat::Gif => "gif",
|
||||
ImageFormat::Tiff => "tiff",
|
||||
ImageFormat::Ico => "ico",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_extension() {
|
||||
for fmt in AudioFormat::all() {
|
||||
let ext = fmt.extension();
|
||||
assert_eq!(AudioFormat::from_extension(ext), Some(*fmt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_is_lossless() {
|
||||
assert!(AudioFormat::Flac.is_lossless());
|
||||
assert!(!AudioFormat::Mp3.is_lossless());
|
||||
}
|
||||
}
|
||||
59
extras/rust/src/main.rs
Normal file
59
extras/rust/src/main.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// extras/rust — decorative toy crate, not wired into the actual nexdex.space
|
||||
// build (that's the C++ installer + python app). kept here as a small rust
|
||||
// playground alongside the app. build with: cargo run --manifest-path Cargo.toml
|
||||
|
||||
mod formats;
|
||||
mod tags;
|
||||
mod checksum;
|
||||
mod progress;
|
||||
mod playlist;
|
||||
|
||||
fn banner() -> &'static str {
|
||||
r#"
|
||||
_ __ _____ ____| | _____ __
|
||||
| '_ \ / _ \ \/ / _` |/ _ \ \/ /
|
||||
| | | | __/> < (_| | __/> <
|
||||
|_| |_|\___/_/\_\__,_|\___/_/\_\
|
||||
.space
|
||||
"#
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("{}", banner());
|
||||
|
||||
let track = tags::Tag {
|
||||
title: "MATADORA (Extended)".to_string(),
|
||||
artist: "SUKA.".to_string(),
|
||||
album: None,
|
||||
year: Some(2026),
|
||||
track_number: None,
|
||||
};
|
||||
println!("{}", track.describe());
|
||||
|
||||
for fmt in formats::AudioFormat::all() {
|
||||
println!(
|
||||
"{:<6} lossless={}",
|
||||
fmt.extension(),
|
||||
fmt.is_lossless()
|
||||
);
|
||||
}
|
||||
|
||||
let data = b"nexdex.space made by savsis";
|
||||
println!("crc32({:?}) = {:08x}", String::from_utf8_lossy(data), checksum::crc32(data));
|
||||
|
||||
progress::simulate_download("MATADORA (Extended)");
|
||||
|
||||
let mut pl = playlist::Playlist::new();
|
||||
pl.add(playlist::Track {
|
||||
tag: track.clone(),
|
||||
path: "downloads/MATADORA (Extended).flac".to_string(),
|
||||
duration_secs: 222,
|
||||
});
|
||||
println!(
|
||||
"playlist: {} track(s), total {}",
|
||||
pl.len(),
|
||||
playlist::Playlist::format_duration(pl.total_duration_secs())
|
||||
);
|
||||
|
||||
println!("made by savsis with <3");
|
||||
}
|
||||
111
extras/rust/src/playlist.rs
Normal file
111
extras/rust/src/playlist.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// extras/rust — decorative toy: a tiny in-memory playlist model, echoing
|
||||
// the shape of data/history.json (backend/history.py) but with no I/O.
|
||||
|
||||
use crate::tags::Tag;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Track {
|
||||
pub tag: Tag,
|
||||
pub path: String,
|
||||
pub duration_secs: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Playlist {
|
||||
tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn new() -> Self {
|
||||
Playlist { tracks: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn add(&mut self, track: Track) {
|
||||
self.tracks.push(track);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tracks.is_empty()
|
||||
}
|
||||
|
||||
pub fn total_duration_secs(&self) -> u32 {
|
||||
self.tracks.iter().map(|t| t.duration_secs).sum()
|
||||
}
|
||||
|
||||
pub fn by_artist(&self, artist: &str) -> Vec<&Track> {
|
||||
self.tracks
|
||||
.iter()
|
||||
.filter(|t| t.tag.artist.eq_ignore_ascii_case(artist))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sorted_by_title(&self) -> Vec<&Track> {
|
||||
let mut sorted: Vec<&Track> = self.tracks.iter().collect();
|
||||
sorted.sort_by(|a, b| a.tag.title.cmp(&b.tag.title));
|
||||
sorted
|
||||
}
|
||||
|
||||
pub fn longest(&self) -> Option<&Track> {
|
||||
self.tracks.iter().max_by_key(|t| t.duration_secs)
|
||||
}
|
||||
|
||||
pub fn format_duration(total_secs: u32) -> String {
|
||||
let hours = total_secs / 3600;
|
||||
let minutes = (total_secs % 3600) / 60;
|
||||
let seconds = total_secs % 60;
|
||||
if hours > 0 {
|
||||
format!("{}:{:02}:{:02}", hours, minutes, seconds)
|
||||
} else {
|
||||
format!("{}:{:02}", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tags::TagBuilder;
|
||||
|
||||
fn sample_track(title: &str, artist: &str, secs: u32) -> Track {
|
||||
Track {
|
||||
tag: TagBuilder::new(title, artist).build(),
|
||||
path: format!("downloads/{}.flac", title),
|
||||
duration_secs: secs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_duration_sums_all_tracks() {
|
||||
let mut pl = Playlist::new();
|
||||
pl.add(sample_track("A", "X", 100));
|
||||
pl.add(sample_track("B", "Y", 200));
|
||||
assert_eq!(pl.total_duration_secs(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_artist_case_insensitively() {
|
||||
let mut pl = Playlist::new();
|
||||
pl.add(sample_track("A", "SUKA.", 100));
|
||||
pl.add(sample_track("B", "suka.", 50));
|
||||
pl.add(sample_track("C", "Someone Else", 10));
|
||||
assert_eq!(pl.by_artist("suka.").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_longest_track() {
|
||||
let mut pl = Playlist::new();
|
||||
pl.add(sample_track("Short", "X", 60));
|
||||
pl.add(sample_track("Long", "X", 600));
|
||||
assert_eq!(pl.longest().unwrap().tag.title, "Long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_duration_with_and_without_hours() {
|
||||
assert_eq!(Playlist::format_duration(65), "1:05");
|
||||
assert_eq!(Playlist::format_duration(3665), "1:01:05");
|
||||
}
|
||||
}
|
||||
61
extras/rust/src/progress.rs
Normal file
61
extras/rust/src/progress.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// a toy terminal progress bar, echoing the shape of the progress_hook
|
||||
// callbacks used by backend/jobs.py — just rendered with print! here
|
||||
// instead of pushed over the /api/job/<id> polling endpoint.
|
||||
|
||||
pub struct ProgressBar {
|
||||
width: usize,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl ProgressBar {
|
||||
pub fn new(label: impl Into<String>, width: usize) -> Self {
|
||||
ProgressBar {
|
||||
width,
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, percent: u8) -> String {
|
||||
let percent = percent.min(100) as usize;
|
||||
let filled = (self.width * percent) / 100;
|
||||
let empty = self.width - filled;
|
||||
|
||||
format!(
|
||||
"{:<20} [{}{}] {:>3}%",
|
||||
self.label,
|
||||
"#".repeat(filled),
|
||||
"-".repeat(empty),
|
||||
percent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simulate_download(label: &str) {
|
||||
let bar = ProgressBar::new(label, 30);
|
||||
for pct in (0..=100).step_by(20) {
|
||||
println!("{}", bar.render(pct));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_percent_is_all_empty() {
|
||||
let bar = ProgressBar::new("test", 10);
|
||||
assert_eq!(bar.render(0), format!("{:<20} [{}] {:>3}%", "test", "-".repeat(10), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_percent_is_all_filled() {
|
||||
let bar = ProgressBar::new("test", 10);
|
||||
assert_eq!(bar.render(100), format!("{:<20} [{}] {:>3}%", "test", "#".repeat(10), 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_above_100() {
|
||||
let bar = ProgressBar::new("test", 10);
|
||||
assert_eq!(bar.render(255), bar.render(100));
|
||||
}
|
||||
}
|
||||
86
extras/rust/src/tags.rs
Normal file
86
extras/rust/src/tags.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// a tiny stand-in for the tag struct app/backend/metadata.py works with —
|
||||
// decorative, the real read/write logic lives in python (mutagen).
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tag {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub track_number: Option<u32>,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn describe(&self) -> String {
|
||||
let mut parts = vec![format!("{} — {}", self.artist, self.title)];
|
||||
|
||||
if let Some(album) = &self.album {
|
||||
parts.push(format!("[{}]", album));
|
||||
}
|
||||
if let Some(year) = self.year {
|
||||
parts.push(format!("({})", year));
|
||||
}
|
||||
if let Some(track) = self.track_number {
|
||||
parts.push(format!("track #{}", track));
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.title.is_empty() && !self.artist.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TagBuilder {
|
||||
tag: Tag,
|
||||
}
|
||||
|
||||
impl TagBuilder {
|
||||
pub fn new(title: impl Into<String>, artist: impl Into<String>) -> Self {
|
||||
TagBuilder {
|
||||
tag: Tag {
|
||||
title: title.into(),
|
||||
artist: artist.into(),
|
||||
album: None,
|
||||
year: None,
|
||||
track_number: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn album(mut self, album: impl Into<String>) -> Self {
|
||||
self.tag.album = Some(album.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn year(mut self, year: u32) -> Self {
|
||||
self.tag.year = Some(year);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn track_number(mut self, n: u32) -> Self {
|
||||
self.tag.track_number = Some(n);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Tag {
|
||||
self.tag
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builder_produces_expected_tag() {
|
||||
let tag = TagBuilder::new("Aria Math", "Logan Feece")
|
||||
.album("C418 covers")
|
||||
.year(2024)
|
||||
.build();
|
||||
|
||||
assert!(tag.is_complete());
|
||||
assert_eq!(tag.describe(), "Logan Feece — Aria Math [C418 covers] (2024)");
|
||||
}
|
||||
}
|
||||
BIN
installer/icon.ico
Normal file
BIN
installer/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
176
installer/installer.cpp
Normal file
176
installer/installer.cpp
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// nexdex.space installer/launcher
|
||||
//
|
||||
// the app payload (portable python runtime + full app source, including
|
||||
// ffmpeg) is embedded inside this exe as a plain RCDATA resource (a zip).
|
||||
// on first run it dumps that resource to a temp zip file and extracts it
|
||||
// next to itself via the OS-native tar.exe, then launches the app with the
|
||||
// bundled python — no system python required at all. embedding as a real
|
||||
// PE resource (rather than appending the zip after the exe) keeps this a
|
||||
// normal, well-formed PE, so it can still be Authenticode-signed as usual.
|
||||
|
||||
#include <windows.h>
|
||||
#include <shlobj.h>
|
||||
#include <shobjidl.h>
|
||||
#include <objbase.h>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <filesystem>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static void createDesktopShortcut(const fs::path& targetExe) {
|
||||
wchar_t* desktopPath = nullptr;
|
||||
if (FAILED(SHGetKnownFolderPath(FOLDERID_Desktop, 0, nullptr, &desktopPath))) return;
|
||||
fs::path shortcutPath = fs::path(desktopPath) / L"nexdex.space.lnk";
|
||||
CoTaskMemFree(desktopPath);
|
||||
|
||||
if (fs::exists(shortcutPath)) return;
|
||||
|
||||
IShellLinkW* shellLink = nullptr;
|
||||
if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_IShellLinkW, reinterpret_cast<void**>(&shellLink))))
|
||||
return;
|
||||
|
||||
shellLink->SetPath(targetExe.c_str());
|
||||
shellLink->SetWorkingDirectory(targetExe.parent_path().c_str());
|
||||
shellLink->SetIconLocation(targetExe.c_str(), 0);
|
||||
shellLink->SetDescription(L"nexdex.space -- made by savsis");
|
||||
|
||||
IPersistFile* persistFile = nullptr;
|
||||
if (SUCCEEDED(shellLink->QueryInterface(IID_IPersistFile, reinterpret_cast<void**>(&persistFile)))) {
|
||||
persistFile->Save(shortcutPath.c_str(), TRUE);
|
||||
persistFile->Release();
|
||||
}
|
||||
shellLink->Release();
|
||||
}
|
||||
|
||||
static std::wstring quote(const std::wstring& s) {
|
||||
return L"\"" + s + L"\"";
|
||||
}
|
||||
|
||||
static bool runAndWait(const std::wstring& cmdLine) {
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
|
||||
std::vector<wchar_t> buf(cmdLine.begin(), cmdLine.end());
|
||||
buf.push_back(0);
|
||||
|
||||
if (!CreateProcessW(nullptr, buf.data(), nullptr, nullptr, FALSE,
|
||||
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) {
|
||||
return false;
|
||||
}
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
DWORD code = 0;
|
||||
GetExitCodeProcess(pi.hProcess, &code);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
return code == 0;
|
||||
}
|
||||
|
||||
static bool extractPayloadResource(const fs::path& outZipPath) {
|
||||
HMODULE self = GetModuleHandleW(nullptr);
|
||||
HRSRC res = FindResourceW(self, MAKEINTRESOURCEW(102), MAKEINTRESOURCEW(10) /* RT_RCDATA */);
|
||||
if (!res) return false;
|
||||
|
||||
HGLOBAL data = LoadResource(self, res);
|
||||
if (!data) return false;
|
||||
|
||||
DWORD size = SizeofResource(self, res);
|
||||
void* ptr = LockResource(data);
|
||||
if (!ptr || size == 0) return false;
|
||||
|
||||
std::ofstream out(outZipPath, std::ios::binary);
|
||||
if (!out) return false;
|
||||
out.write(static_cast<const char*>(ptr), static_cast<std::streamsize>(size));
|
||||
return out.good();
|
||||
}
|
||||
|
||||
static bool runDetached(const std::wstring& cmdLine, const std::wstring& cwd) {
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
|
||||
std::vector<wchar_t> buf(cmdLine.begin(), cmdLine.end());
|
||||
buf.push_back(0);
|
||||
|
||||
BOOL ok = CreateProcessW(nullptr, buf.data(), nullptr, nullptr, FALSE,
|
||||
DETACHED_PROCESS, nullptr, cwd.c_str(), &si, &pi);
|
||||
if (!ok) return false;
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
return true;
|
||||
}
|
||||
|
||||
int wmain() {
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
wchar_t exePathBuf[MAX_PATH];
|
||||
GetModuleFileNameW(nullptr, exePathBuf, MAX_PATH);
|
||||
fs::path exePath(exePathBuf);
|
||||
|
||||
wchar_t* localAppData = nullptr;
|
||||
SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &localAppData);
|
||||
fs::path installDir = fs::path(localAppData) / L"nexdex.space";
|
||||
CoTaskMemFree(localAppData);
|
||||
|
||||
fs::path appDir = installDir / L"app";
|
||||
fs::path pythonExe = installDir / L"python" / L"pythonw.exe";
|
||||
fs::path appPy = appDir / L"app.py";
|
||||
fs::path marker = installDir / L".installed";
|
||||
|
||||
std::wcout << L"nexdex.space\n";
|
||||
|
||||
if (!fs::exists(marker)) {
|
||||
std::wcout << L"первый запуск -- распаковываю (это займёт немного времени)...\n";
|
||||
std::error_code ec;
|
||||
fs::create_directories(installDir, ec);
|
||||
|
||||
fs::path tempZip = installDir / L"_payload.zip";
|
||||
if (!extractPayloadResource(tempZip)) {
|
||||
std::wcerr << L"не удалось прочитать встроенные данные приложения.\n";
|
||||
Sleep(4000);
|
||||
return 1;
|
||||
}
|
||||
|
||||
wchar_t sysDir[MAX_PATH];
|
||||
GetSystemDirectoryW(sysDir, MAX_PATH);
|
||||
std::wstring tarExe = std::wstring(sysDir) + L"\\tar.exe";
|
||||
|
||||
std::wstring cmd = quote(tarExe) + L" -xf " + quote(tempZip.wstring()) +
|
||||
L" -C " + quote(installDir.wstring());
|
||||
|
||||
bool ok = runAndWait(cmd);
|
||||
fs::remove(tempZip, ec);
|
||||
|
||||
if (!ok) {
|
||||
std::wcerr << L"ошибка распаковки. попробуй запустить ещё раз или проверь права доступа.\n";
|
||||
Sleep(4000);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::ofstream(marker.string()).put('1');
|
||||
createDesktopShortcut(exePath);
|
||||
std::wcout << L"готово -- ярлык добавлен на рабочий стол.\n";
|
||||
}
|
||||
|
||||
if (!fs::exists(appPy) || !fs::exists(pythonExe)) {
|
||||
std::wcerr << L"файлы приложения повреждены -- удали папку " << installDir.wstring()
|
||||
<< L" и запусти установщик заново.\n";
|
||||
Sleep(5000);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::wcout << L"запускаю...\n";
|
||||
std::wstring runCmd = quote(pythonExe.wstring()) + L" " + quote(appPy.wstring());
|
||||
if (!runDetached(runCmd, appDir.wstring())) {
|
||||
std::wcerr << L"не удалось запустить приложение.\n";
|
||||
Sleep(4000);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Sleep(1200);
|
||||
return 0;
|
||||
}
|
||||
31
installer/installer.rc
Normal file
31
installer/installer.rc
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
101 ICON "icon.ico"
|
||||
102 RCDATA "..\payload.zip"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 1,0,0,0
|
||||
PRODUCTVERSION 1,0,0,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x1L
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "savsis"
|
||||
VALUE "FileDescription", "nexdex.space launcher"
|
||||
VALUE "FileVersion", "1.0.0.0"
|
||||
VALUE "InternalName", "nexdex.space"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 savsis"
|
||||
VALUE "OriginalFilename","nexdex.space-setup.exe"
|
||||
VALUE "ProductName", "nexdex.space"
|
||||
VALUE "ProductVersion", "1.0.0.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
Loading…
Add table
Add a link
Reference in a new issue