Add files via upload
This commit is contained in:
parent
07f2ed525b
commit
6e7f0c1505
37 changed files with 4084 additions and 20 deletions
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue