Add files via upload

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

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