Files
just-music-premium/justmusic/bridge.py
Barış Keser db1d0e6b5c v1.6.0: paralel indirme (3 escanli) + responsive player bar
- Paralel indirme: tek tek yerine MAX_DL_CONCURRENT=3 escanli indirme havuzu.
  active_downloads {thread: yuzde}, download_done sayaci, _pump_downloads havuzu
  doldurur, _emit_dl_progress toplam ilerleme + "X/Y (N etkin)" pill. THREAD-GUVENLI:
  sinyaller QObject-bagli slotlara gider (self.sender() ile thread bulunur) -> tum
  kuyruk/havuz mutasyonu + QThread olusturma ana thread'de; 0 Qt cross-thread uyarisi.
  Dogrulandi: sahte-thread testi peak=3, dogru sayac + reset.
- Responsive player bar: ikincil kontroller (klip/karaoke/ambiyans/kuyruk/soz) varsayilan
  olarak ⋯ tasma menusune katlanir (>=1500px satir ici acilir). Kontrol GIZLENMEZ.
  Boylece bar her genislikte sigar, tasip sayfayi yatay kaydirip sol kenar cubugunu
  ekrandan itmez ("sapitmaz"). Dogrulandi: 1600/1100/820px'te overflowX=0, sidebarX=8,
  ⋯ toggle, 0 JS hatasi. #pHeroBtn eklenince (v1.5.0) bar tasmisti; bu onu cozer.
2026-09-20 19:55:53 +03:00

1662 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""JS ↔ Python köprüsü (QWebChannel) — uygulamanın kontrolörü.
Tüm arayüz web'de; bu sınıf ses motorunu, kütüphaneyi, indirmeyi, kapakları
yönetir. Slotlar JS'ten çağrılır, sinyaller JS'e yayınlanır.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import QApplication, QFileDialog
from . import config, engine as engine_mod, eqpresets, naming, richpresence, separation, updater
from .covers import CoverFetcher
from .downloader import DownloadThread
from .engine import DspEngine
from .library import Library, make_song
POLL_MS = 60
_NO_WINDOW = 0x08000000
MAX_DL_CONCURRENT = 3 # aynı anda kaç indirme (paralel toplu indirme)
class SearchThread(QThread):
results = pyqtSignal(list)
failed = pyqtSignal(str)
def __init__(self, query: str, limit: int = 20, parent=None,
playlist_url: str = "") -> None:
super().__init__(parent)
self.query = query
self.limit = limit
self.playlist_url = playlist_url # dolu ise: playlist'i aç (arama değil)
self._stopped = False
def stop(self) -> None:
self._stopped = True
def run(self) -> None:
try:
import yt_dlp
except ImportError:
self.failed.emit("yt-dlp kütüphanesi bulunamadı.")
return
if self.playlist_url:
# PLAYLIST modu: URL'yi aynen aç, noplaylist=False ile tüm parçaları listele
opts = {"quiet": True, "no_warnings": True, "skip_download": True,
"extract_flat": True, "noplaylist": False, "playlistend": 200}
target = self.playlist_url
else:
opts = {"quiet": True, "no_warnings": True, "skip_download": True,
"extract_flat": True, "noplaylist": True}
target = f"ytsearch{self.limit}:{self.query}"
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(target, download=False)
except Exception as exc:
self.failed.emit(str(exc))
return
rows = []
for e in ((info or {}).get("entries") or []):
if not e:
continue
vid = e.get("id")
if not vid:
continue
dur = e.get("duration") or 0
rows.append({
"id": vid,
"title": e.get("title") or vid,
"uploader": e.get("channel") or e.get("uploader") or "",
"duration_ms": int(dur * 1000) if dur else 0,
"thumbnail": f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
"url": f"https://www.youtube.com/watch?v={vid}",
})
self.results.emit(rows)
class ScanThread(QThread):
done = pyqtSignal(list)
def run(self) -> None:
try:
collected = Library.collect_music_folder()
except Exception:
collected = []
self.done.emit(collected)
class DurationProber(QThread):
"""Gömülü ffmpeg ile şarkı sürelerini arka planda okur (tablo süreleri için)."""
probed = pyqtSignal(str, int) # song_id, duration_ms
def __init__(self, items, parent=None) -> None:
super().__init__(parent)
self.items = items # [(id, path), ...]
self._stop = False
def stop(self) -> None:
self._stop = True
def run(self) -> None:
import re
pat = re.compile(rb"Duration: (\d+):(\d+):(\d+)\.(\d+)")
for sid, path in self.items:
if self._stop:
break
if not path or not os.path.exists(path):
continue
try:
proc = subprocess.run(
[config.FFMPEG, "-i", path, "-hide_banner"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
creationflags=_NO_WINDOW, timeout=20)
m = pat.search(proc.stderr or b"")
if m and not self._stop:
h, mn, s, cs = (int(x) for x in m.groups())
self.probed.emit(sid, ((h * 3600 + mn * 60 + s) * 100 + cs) * 10)
except Exception:
pass
class VideoStreamThread(QThread):
"""yt-dlp ile klip akış URL'lerini çözer (İNDİRME YOK — doğrudan stream).
QtWebEngine H.264/AAC oynatamaz; YouTube da artık muxed webm vermiyor.
Bu yüzden WebM VP9 (video-only) + Opus (audio-only) URL'leri ayrı döner,
arayüz bunları senkron oynatır.
"""
ready = pyqtSignal(str) # json {id, video, audio, title, duration}
failed = pyqtSignal(str)
def __init__(self, sid: str, query: str, parent=None) -> None:
super().__init__(parent)
self.sid = sid
self.query = (query or "").strip()
def run(self) -> None:
try:
import yt_dlp
except ImportError:
self.failed.emit("yt-dlp kütüphanesi bulunamadı.")
return
is_url = self.query.startswith(("http://", "https://"))
target = self.query if is_url else f"ytsearch1:{self.query}"
# QtWebEngine yalnızca webm (VP9/VP8 + Opus/Vorbis) oynatır
fmt = ("bestvideo[ext=webm][height<=720]+bestaudio[ext=webm]/"
"best[ext=webm][vcodec!=none][acodec!=none]/"
"bestvideo[height<=720][ext=webm]+bestaudio")
opts = {"quiet": True, "no_warnings": True, "skip_download": True,
"noplaylist": True, "format": fmt}
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(target, download=False)
if info and "entries" in info:
entries = [e for e in info["entries"] if e]
if not entries:
self.failed.emit("Klip bulunamadı.")
return
info = entries[0]
except Exception as exc:
self.failed.emit(f"Klip akışı çözülemedi: {exc}")
return
video_url = audio_url = ""
rf = info.get("requested_formats")
if rf:
for f in rf:
if f.get("vcodec") and f.get("vcodec") != "none":
video_url = f.get("url") or video_url
if f.get("acodec") and f.get("acodec") != "none":
audio_url = f.get("url") or audio_url
else:
video_url = info.get("url") or ""
if not video_url:
self.failed.emit("Oynatılabilir klip akışı bulunamadı.")
return
self.ready.emit(json.dumps({
"id": self.sid, "video": video_url, "audio": audio_url,
"title": info.get("title") or "", "duration": info.get("duration") or 0,
}))
class LyricsThread(QThread):
"""Lyrica servisinden zaman kodlu (LRC) şarkı sözü getirir."""
ready = pyqtSignal(str, str, str) # song_id, lyrics, source
failed = pyqtSignal(str)
def __init__(self, sid: str, artist: str, title: str, parent=None) -> None:
super().__init__(parent)
self.sid = sid
self.artist = (artist or "").strip()
self.title = (title or "").strip()
@staticmethod
def _get(url: str):
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "JustMusic/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def run(self) -> None:
import urllib.parse
artist, title = self.artist, self.title
if not title:
return
try:
if not artist: # sanatçı yoksa önce öneriyle bul
try:
s = self._get(f"{config.LYRICS_API}/suggestion?q={urllib.parse.quote(title)}&limit=1")
items = s.get("data") or s.get("results") or s.get("suggestions") or []
if isinstance(items, list) and items and isinstance(items[0], dict):
artist = items[0].get("artist") or ""
title = items[0].get("title") or items[0].get("song") or title
except Exception:
pass
url = (f"{config.LYRICS_API}/lyrics/?artist={urllib.parse.quote(artist or title)}"
f"&song={urllib.parse.quote(title)}&timestamps=true&fast=true")
d = self._get(url)
except Exception as exc:
self.failed.emit(f"Söz servisine ulaşılamadı: {exc}")
return
data = (d or {}).get("data") or {}
if (d or {}).get("status") != "success" or not data.get("lyrics"):
self.failed.emit("Bu şarkı için söz bulunamadı.")
return
self.ready.emit(self.sid, data.get("lyrics") or "", str(data.get("source") or ""))
class AnalyzeThread(QThread):
"""Şarkı DNA'sı (dalga formu) + ruh hali analizini arka planda yapar."""
ready = pyqtSignal(str)
def __init__(self, audio, sid, parent=None) -> None:
super().__init__(parent)
self.audio = audio
self.sid = sid
def run(self) -> None:
try:
wf = engine_mod.waveform_peaks(self.audio, 480)
label, color = engine_mod.analyze_mood(self.audio)
bpm = engine_mod.estimate_bpm(self.audio)
gain = engine_mod.loudness_gain(self.audio)
spec = engine_mod.spectrogram(self.audio)
except Exception:
wf, label, color, bpm, gain, spec = [], "", "", 0, 1.0, []
self.ready.emit(json.dumps({"id": self.sid, "waveform": wf, "mood": label,
"color": color, "bpm": bpm, "gain": gain, "spectrogram": spec}))
class Bridge(QObject):
# Py -> JS
stateChanged = pyqtSignal()
trackChanged = pyqtSignal(str)
playingChanged = pyqtSignal(bool)
positionChanged = pyqtSignal(float, float)
spectrumSignal = pyqtSignal(str)
coverReadySignal = pyqtSignal(str, str)
toastSignal = pyqtSignal(str)
downloadProgressSignal = pyqtSignal(float, str, int)
downloadDoneSignal = pyqtSignal(str)
searchResultsSignal = pyqtSignal(str)
scanDoneSignal = pyqtSignal(int, int)
durationSignal = pyqtSignal(str, int) # song_id, ms
queueChanged = pyqtSignal(str) # json kuyruk
videoReadySignal = pyqtSignal(str) # json {id, video, audio, title}
analysisSignal = pyqtSignal(str) # json {id, waveform, mood, color}
loopSignal = pyqtSignal(float, float) # A, B (sn); -1 = yok
updateAvailableSignal = pyqtSignal(str, str) # sürüm, notlar
updateProgressSignal = pyqtSignal(float) # indirme yüzdesi
updateReadySignal = pyqtSignal(str) # sürüm (indirildi, çıkışta kurulur)
separationProgressSignal = pyqtSignal(float, str) # yüzde, durum (htdemucs)
karaokeModeSignal = pyqtSignal(str) # off/quick/instrumental/vocals
def __init__(self, window=None, parent=None) -> None:
super().__init__(parent)
self.window = window
self.library = Library()
self.library.load()
self.engine = DspEngine()
self.engine.start()
self.cover_fetcher = CoverFetcher(self)
self.cover_fetcher.coverReady.connect(self._on_cover)
self.cover_fetcher.coverFailed.connect(lambda sid: self.cover_requested.discard(sid))
self.cover_fetcher.start()
self.play_list_name = self.library.current
self.play_index = -1
self.active_song_id = None
self.shuffle = False
self.repeat_one = False
self.repeat_mode = 1 # 0=kapalı, 1=liste, 2=tek
self._base_volume = float(self.library.settings.get("volume", 80))
self._cur_gain = 1.0
self.normalize = bool(self.library.settings.get("normalize", False))
self.download_queue: list[str] = []
self.active_downloads: dict = {} # {DownloadThread: son_yüzde} — paralel indirme havuzu
self.download_total = 0 # "X/Y" pill sayacı (toplu indirmede)
self.download_done = 0 # tamamlanan (ok+hata) sayısı
self.scan_thread = None
self._search = None
self.user_queue: list[dict] = [] # {"playlist","id"}
self._probers: list = []
self._analyzers: list = []
self._lyric_threads: list = []
self._video_threads: list = []
self._video_handoff = 0.0 # klip<->müzik konum devri (sn); 0 = devir yok
self._analyzed_id = None
self._loop_a = None
self._loop_b = None
self._probed_ids: set[str] = set()
self.cover_requested: set[str] = set()
self._last_dur = -1.0
self._last_playing = False
self._tick = 0
self._listen_accum = 0.0
self._cover_dirty = False
self._closing = False
self._staged_update = None # kurulacak .msi yolu (indirildiyse)
self._staged_version = ""
self._update_thread = None
self._sep_thread = None # htdemucs ayırma iş parçacığı
self._karaoke_mode = "off" # off/quick/instrumental/vocals
self._sep_song_id = None # ayırmanın hedef parçası
self._sep_target = "off" # ayırma bitince uygulanacak mod
# Discord Rich Presence (opsiyonel)
self.discord = richpresence.DiscordPresence(
str(self.library.settings.get("discord_client_id", "")))
self._discord_last = 0.0
if self.library.settings.get("discord_rpc"):
self.discord.enable()
self._apply_audio_settings()
self.poll = QTimer(self)
self.poll.timeout.connect(self._on_poll)
self.poll.start(POLL_MS)
if self._is_library_empty():
QTimer.singleShot(600, self.scanMusic)
# Açılış yükünü etkilememek için güncelleme kontrolü gecikmeli başlar
QTimer.singleShot(3500, self._start_update_check)
# ================= yardımcılar =================
def _is_library_empty(self) -> bool:
return all(len(v) == 0 for v in self.library.playlists.values())
def _cover_url(self, song_id: str) -> str:
return "app://app/cover/" + hashlib.md5(song_id.encode("utf-8")).hexdigest() + ".jpg"
def _song_view(self, song: dict) -> dict:
thumb = song.get("thumbnail", "")
return {
"id": song["id"], "title": song.get("title", ""),
"artist": song.get("artist", ""), "favorite": song.get("favorite", False),
"duration_ms": song.get("duration_ms", 0), "play_count": song.get("play_count", 0),
"source": song.get("source", "local"),
"cover": self._cover_url(song["id"]),
"hasCover": bool(thumb and os.path.exists(thumb)),
"mood": song.get("mood", ""),
"bpm": song.get("bpm", 0),
}
def _track_json(self) -> str:
song = (self.library.find_song(self.play_list_name, self.active_song_id)
if self.active_song_id else None)
if not song:
return json.dumps({"none": True})
v = self._song_view(song)
v.update({"playlist": self.play_list_name, "lyrics": song.get("lyrics", ""),
"shuffle": self.shuffle, "repeat": self.repeat_one,
"repeat_mode": self.repeat_mode})
return json.dumps(v)
def _apply_audio_settings(self) -> None:
s = self.library.settings
self._base_volume = float(s.get("volume", 80))
self.normalize = bool(s.get("normalize", False))
self._apply_norm_gain()
self.engine.set_speed(float(s.get("speed", 1.0)))
gains = s.get("eq_gains") or eqpresets.PRESETS["🎚 Flat"]
self.engine.set_eq([float(g) for g in gains])
self.engine.set_eq_enabled(bool(s.get("eq_enabled", True)))
fx = dict(eqpresets.DEFAULT_EFFECTS)
fx.update(s.get("effects", {}))
for k, val in fx.items():
self.engine.set_effect(k, val)
sc = s.get("soundscape")
if isinstance(sc, list) and len(sc) == 2:
self.engine.set_soundscape(sc[0], sc[1])
self.engine.set_crossfade(float(s.get("crossfade", 0)))
self.engine.set_gapless(bool(s.get("gapless", False)))
# ================= durum =================
@pyqtSlot(result=str)
def getState(self) -> str: # noqa: N802
if os.environ.get("JM_GATE") and not getattr(self, "_proofed", False):
self._proofed = True
try:
import tempfile
# donmuş derlemede torch/demucs gerçekten yüklenebiliyor mu da kanıtla
with open(os.path.join(tempfile.gettempdir(), "jm_web_proof.txt"), "w") as f:
f.write(f"ui-loaded demucs={separation.demucs_available()}")
except Exception:
pass
playlists = {name: [self._song_view(s) for s in songs]
for name, songs in self.library.playlists.items()}
return json.dumps({
"playlists": playlists,
"protected": list(config.PROTECTED_PLAYLISTS),
"version": config.APP_VERSION,
"current": self.library.current,
"settings": self.library.settings,
"presets": eqpresets.PRESETS,
"presetNames": eqpresets.PRESET_NAMES,
"bandLabels": eqpresets.BAND_LABELS,
"effectDefaults": eqpresets.DEFAULT_EFFECTS,
"effectDefsOrder": [k for k in eqpresets.DEFAULT_EFFECTS],
"active": {"playlist": self.play_list_name, "song_id": self.active_song_id,
"playing": self.engine.is_playing(),
"shuffle": self.shuffle, "repeat": self.repeat_one,
"repeat_mode": self.repeat_mode},
"stats": self.library.stats,
"recent": self._recent_views(),
"favoritesName": config.FAVORITES_PLAYLIST,
"demucs": separation.demucs_available(), # stüdyo karaoke motoru var mı
"karaokeMode": self._karaoke_mode,
"discordAvail": richpresence.pypresence_available(),
})
def _recent_views(self) -> list:
out = []
seen = set()
for r in self.library.settings.get("recent", []):
key = r.get("id")
if key in seen:
continue
s = self.library.find_song(r.get("playlist", ""), r.get("id", ""))
if s:
seen.add(key)
v = self._song_view(s)
v["playlist"] = r.get("playlist", "")
out.append(v)
return out
# ================= oynatma =================
@pyqtSlot(str, str)
def play(self, song_id: str, playlist: str) -> None:
songs = self.library.playlists.get(playlist, [])
for i, s in enumerate(songs):
if s["id"] == song_id:
self.play_list_name = playlist
self._play(i)
return
def _play(self, index: int) -> None:
songs = self.library.playlists.get(self.play_list_name, [])
if not (0 <= index < len(songs)):
return
song = songs[index]
if not os.path.exists(song["path"]):
self.toastSignal.emit("Dosya bulunamadı (taşınmış olabilir).")
return
self.play_index = index
self.active_song_id = song["id"]
song["play_count"] = song.get("play_count", 0) + 1
self._last_dur = -1.0
self._loop_a = self._loop_b = None # yeni parça -> A-B döngü sıfırla
self.loopSignal.emit(-1.0, -1.0)
self._cur_gain = float(song.get("lgain", 1.0)) # ses eşitleme (varsa önbellek)
self._apply_norm_gain()
self.engine.load(song["path"], autoplay=True)
# yeni parça: karaoke/stem modunu sıfırla (orijinal yüklendi)
if self._sep_thread and self._sep_thread.isRunning():
self._sep_thread.cancel()
if self._karaoke_mode != "off":
self._karaoke_mode = "off"
self._sep_target = "off"
self.engine.set_effect("karaoke", 0)
self.karaokeModeSignal.emit("off")
self._maybe_fetch_cover(song)
if not song.get("lyrics"):
self._start_lyrics(song) # Lyrica'dan otomatik (zaman kodlu) söz
self._record_recent(self.play_list_name, song["id"])
self.trackChanged.emit(self._track_json())
self._cover_dirty = True
self._discord_update(force=True)
def _record_recent(self, playlist: str, sid: str) -> None:
rec = self.library.settings.setdefault("recent", [])
rec[:] = [r for r in rec if r.get("id") != sid]
rec.insert(0, {"playlist": playlist, "id": sid})
del rec[24:]
@pyqtSlot()
def toggle(self) -> None:
if not self.engine.is_loaded():
songs = self.library.playlists.get(self.library.current, [])
if songs:
self.play_list_name = self.library.current
self._play(0)
return
self.engine.toggle()
@pyqtSlot()
def playPause(self) -> None:
self.toggle()
@pyqtSlot()
def next(self) -> None:
songs = self.library.playlists.get(self.play_list_name, [])
if not songs:
return
if self.shuffle and len(songs) > 1:
import random
idx = self.play_index
while idx == self.play_index:
idx = random.randint(0, len(songs) - 1)
self._play(idx)
else:
self._play((self.play_index + 1) % len(songs))
@pyqtSlot()
def prev(self) -> None:
songs = self.library.playlists.get(self.play_list_name, [])
if not songs:
return
if self.play_index < 0:
self._play(0)
return
if self.engine.position() > 3.0:
self.engine.seek(0)
return
if self.shuffle and len(songs) > 1:
import random
self._play(random.randint(0, len(songs) - 1))
else:
self._play((self.play_index - 1) % len(songs))
def _on_media_ended(self) -> None:
if self.repeat_mode == 2:
self.engine.seek(0)
self.engine.play()
elif self.user_queue:
item = self.user_queue.pop(0)
self.queueChanged.emit(self.getQueue())
self.play(item["id"], item["playlist"])
elif self.repeat_mode == 0:
songs = self.library.playlists.get(self.play_list_name, [])
if self.play_index < len(songs) - 1:
self.next() # kapalı: son parçada durur
else:
self.next()
@pyqtSlot(int)
def setRepeatMode(self, mode: int) -> None:
self.repeat_mode = mode % 3
self.repeat_one = (self.repeat_mode == 2)
@pyqtSlot(str, str)
def openFileLocation(self, sid: str, playlist: str) -> None:
song = self.library.find_song(playlist, sid)
if not song or not os.path.exists(song.get("path", "")):
self.toastSignal.emit("Dosya bulunamadı.")
return
try:
subprocess.Popen(["explorer", "/select,", os.path.normpath(song["path"])],
creationflags=_NO_WINDOW)
except Exception:
pass
# ---- kuyruk (Sıradaki) ----
@pyqtSlot(str, str)
def addToQueue(self, sid: str, playlist: str) -> None:
self.user_queue.append({"playlist": playlist, "id": sid})
self.queueChanged.emit(self.getQueue())
self.toastSignal.emit("Sıraya eklendi.")
@pyqtSlot(str, str)
def playNext(self, sid: str, playlist: str) -> None:
self.user_queue.insert(0, {"playlist": playlist, "id": sid})
self.queueChanged.emit(self.getQueue())
self.toastSignal.emit("Sıradaki olarak eklendi.")
@pyqtSlot(result=str)
def getQueue(self) -> str: # noqa: N802
out = []
for item in self.user_queue:
s = self.library.find_song(item["playlist"], item["id"])
if s:
v = self._song_view(s)
v["playlist"] = item["playlist"]
out.append(v)
return json.dumps(out)
@pyqtSlot()
def clearQueue(self) -> None:
self.user_queue = []
self.queueChanged.emit("[]")
@pyqtSlot(int)
def removeFromQueue(self, index: int) -> None:
if 0 <= index < len(self.user_queue):
self.user_queue.pop(index)
self.queueChanged.emit(self.getQueue())
@pyqtSlot(int)
def playQueueIndex(self, index: int) -> None:
if 0 <= index < len(self.user_queue):
item = self.user_queue.pop(index)
self.queueChanged.emit(self.getQueue())
self.play(item["id"], item["playlist"])
@pyqtSlot(str, str)
def playPlaylistShuffled(self, playlist: str, _unused: str = "") -> None:
import random
songs = self.library.playlists.get(playlist, [])
if not songs:
return
self.shuffle = True
self.play_list_name = playlist
self._play(random.randint(0, len(songs) - 1))
# ---- ÇAĞ AÇICI: analiz, A-B döngü, ses manzarası ----
def _on_analysis(self, payload: str) -> None:
try:
d = json.loads(payload)
except json.JSONDecodeError:
self.analysisSignal.emit(payload)
return
sid = d.get("id")
for songs in self.library.playlists.values():
for s in songs:
if s.get("id") == sid:
s["mood"] = d.get("mood", "")
s["bpm"] = d.get("bpm", 0)
s["lgain"] = d.get("gain", 1.0)
self._cur_gain = d.get("gain", 1.0)
self._apply_norm_gain()
self._cover_dirty = True
self.analysisSignal.emit(payload)
def _apply_norm_gain(self) -> None:
g = self._cur_gain if self.normalize else 1.0
self.engine.set_volume(self._base_volume * g)
@pyqtSlot(bool)
def setNormalize(self, on: bool) -> None:
self.normalize = on
self.library.settings["normalize"] = on
self._apply_norm_gain()
self.toastSignal.emit("Ses eşitleme açık" if on else "Ses eşitleme kapalı")
@pyqtSlot()
def setLoopA(self) -> None:
self._loop_a = self.engine.position()
self._apply_loop()
@pyqtSlot()
def setLoopB(self) -> None:
self._loop_b = self.engine.position()
self._apply_loop()
@pyqtSlot()
def clearLoop(self) -> None:
self._loop_a = self._loop_b = None
self.engine.clear_loop()
self.loopSignal.emit(-1.0, -1.0)
def _apply_loop(self) -> None:
if self._loop_a is not None and self._loop_b is not None and self._loop_b > self._loop_a:
self.engine.set_loop(self._loop_a, self._loop_b)
self.loopSignal.emit(self._loop_a, self._loop_b)
elif self._loop_a is not None:
self.loopSignal.emit(self._loop_a, -1.0)
@pyqtSlot(str, float)
def setSoundscape(self, kind: str, level: float) -> None:
self.engine.set_soundscape(kind, level)
self.library.settings["soundscape"] = [kind, level]
# ---- geçiş: crossfade / gapless ----
@pyqtSlot(float)
def setCrossfade(self, sec: float) -> None:
sec = max(0.0, min(12.0, float(sec)))
self.engine.set_crossfade(sec)
self.library.settings["crossfade"] = sec
self.library.save()
self.toastSignal.emit(f"Crossfade: {sec:g} sn" if sec > 0 else "Crossfade kapalı")
@pyqtSlot(bool)
def setGapless(self, on: bool) -> None:
self.engine.set_gapless(bool(on))
self.library.settings["gapless"] = bool(on)
self.library.save()
self.toastSignal.emit("Boşluksuz çalma açık" if on else "Boşluksuz çalma kapalı")
# ---- Discord Rich Presence ----
@pyqtSlot(bool)
def setDiscordRpc(self, on: bool) -> None:
self.library.settings["discord_rpc"] = bool(on)
self.library.save()
if on:
if not self.discord.client_id:
self.toastSignal.emit("Discord için önce Client ID girin (Ayarlar).")
return
self.discord.enable()
self._discord_update(force=True)
self.toastSignal.emit("Discord durumu açık")
else:
self.discord.disable()
self.toastSignal.emit("Discord durumu kapalı")
@pyqtSlot(str)
def setDiscordClientId(self, cid: str) -> None:
cid = (cid or "").strip()
self.library.settings["discord_client_id"] = cid
self.library.save()
self.discord.set_client_id(cid)
if cid and self.library.settings.get("discord_rpc"):
self.discord.enable()
self._discord_update(force=True)
def _discord_update(self, force: bool = False) -> None:
"""Çalan parçayı Discord'a yansıtır (poll'dan ~5 sn'de bir ya da parça değişince)."""
if not self.library.settings.get("discord_rpc"):
return
song = (self.library.find_song(self.play_list_name, self.active_song_id)
if self.active_song_id else None)
if not song:
self.discord.clear()
return
try:
self.discord.update(
title=song.get("title", ""),
artist=song.get("artist", ""),
playing=self.engine.is_playing(),
position=self.engine.position(),
duration=self.engine.duration(),
)
except Exception:
pass
def _on_crossfade_advanced(self, path: str) -> None:
"""Motor crossfade/gapless ile sıradaki parçaya kendiliğinden geçti —
arayüzü yeniden yükleme yapmadan (boşluksuz) güncelle."""
songs = self.library.playlists.get(self.play_list_name, [])
if not songs:
return
idx = (self.play_index + 1) % len(songs)
if not (0 <= idx < len(songs) and songs[idx].get("path") == path):
idx = next((i for i, s in enumerate(songs) if s.get("path") == path), -1)
if idx < 0:
return
song = songs[idx]
self.play_index = idx
self.active_song_id = song["id"]
song["play_count"] = song.get("play_count", 0) + 1
self._last_dur = -1.0
self._loop_a = self._loop_b = None
self.loopSignal.emit(-1.0, -1.0)
self._cur_gain = float(song.get("lgain", 1.0))
self._apply_norm_gain()
self._maybe_fetch_cover(song)
if not song.get("lyrics"):
self._start_lyrics(song)
self._record_recent(self.play_list_name, song["id"])
self.trackChanged.emit(self._track_json())
self._cover_dirty = True
self._discord_update(force=True)
# ---- RADİKAL: radyo, kırpma, sıralama, kopya, m3u, liste kapağı ----
@pyqtSlot(str, str)
def startRadio(self, sid: str, playlist: str) -> None:
import random
base = self.library.find_song(playlist, sid)
base_mood = (base or {}).get("mood", "")
pool = [(pl, s) for pl, songs in self.library.playlists.items()
for s in songs if s.get("id") != sid]
random.shuffle(pool)
pool.sort(key=lambda ps: 0 if (base_mood and ps[1].get("mood") == base_mood) else 1)
n = 0
for pl, s in pool[:20]:
self.user_queue.append({"playlist": pl, "id": s["id"]})
n += 1
self.queueChanged.emit(self.getQueue())
self.toastSignal.emit(f"📻 Radyo: {n} benzer parça kuyruğa eklendi.")
@pyqtSlot(str)
def addAllToQueue(self, playlist: str) -> None:
for s in self.library.playlists.get(playlist, []):
self.user_queue.append({"playlist": playlist, "id": s["id"]})
self.queueChanged.emit(self.getQueue())
self.toastSignal.emit("Tüm liste kuyruğa eklendi.")
@pyqtSlot(str, int, int)
def reorderSong(self, playlist: str, frm: int, to: int) -> None:
songs = self.library.playlists.get(playlist, [])
if 0 <= frm < len(songs) and 0 <= to < len(songs) and frm != to:
songs.insert(to, songs.pop(frm))
if playlist == self.play_list_name and self.active_song_id:
self.play_index = next((i for i, x in enumerate(songs)
if x["id"] == self.active_song_id), self.play_index)
self.library.save()
self.stateChanged.emit()
@pyqtSlot(str, str)
def setPlaylistCover(self, playlist: str, url: str) -> None:
self.library.settings.setdefault("playlist_covers", {})[playlist] = url
self.library.save()
self.stateChanged.emit()
@pyqtSlot(str, str)
def setPlaylistDescription(self, playlist: str, text: str) -> None:
self.library.settings.setdefault("playlist_desc", {})[playlist] = text
self.library.save()
self.stateChanged.emit()
@pyqtSlot(str)
def removeDuplicates(self, playlist: str) -> None:
songs = self.library.playlists.get(playlist, [])
seen, out = set(), []
for s in songs:
key = os.path.normcase(os.path.normpath(s.get("path", "")))
if key in seen:
continue
seen.add(key)
out.append(s)
removed = len(songs) - len(out)
self.library.playlists[playlist] = out
self.library.save()
self.stateChanged.emit()
self.toastSignal.emit(f"{removed} tekrar kaldırıldı." if removed else "Tekrar bulunamadı.")
@pyqtSlot(str)
def exportM3U(self, playlist: str) -> None:
songs = self.library.playlists.get(playlist, [])
if not songs:
return
path, _ = QFileDialog.getSaveFileName(self.window, "M3U dışa aktar",
str(config.MUSIC_DIR / (playlist + ".m3u")), "M3U (*.m3u)")
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for s in songs:
f.write(f"#EXTINF:-1,{s.get('artist','')} - {s.get('title','')}\n{s.get('path','')}\n")
self.toastSignal.emit("M3U kaydedildi.")
except OSError as exc:
self.toastSignal.emit(f"Hata: {exc}")
@pyqtSlot(str, str, float, float)
def exportTrim(self, sid: str, playlist: str, a: float, b: float) -> None:
song = self.library.find_song(playlist, sid)
if not song or not os.path.exists(song.get("path", "")) or b <= a:
self.toastSignal.emit("Kırpma için geçerli A-B noktaları ve dosya gerekli.")
return
out, _ = QFileDialog.getSaveFileName(self.window, "Kırpılan parçayı kaydet",
str(config.DOWNLOADS_DIR / (song["title"] + "_kirpik.mp3")), "MP3 (*.mp3)")
if not out:
return
try:
subprocess.run([config.FFMPEG, "-y", "-ss", str(a), "-to", str(b), "-i", song["path"],
"-c:a", "libmp3lame", "-q:a", "2", out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=_NO_WINDOW, timeout=90)
self.toastSignal.emit("✂️ Kırpılan parça kaydedildi.")
except Exception as exc:
self.toastSignal.emit(f"Kırpma hatası: {exc}")
@pyqtSlot(float)
def seek(self, seconds: float) -> None:
self.engine.seek(seconds)
@pyqtSlot(bool)
def setShuffle(self, on: bool) -> None:
self.shuffle = on
@pyqtSlot(bool)
def setRepeat(self, on: bool) -> None:
self.repeat_one = on
@pyqtSlot(float)
def setVolume(self, v: float) -> None:
self._base_volume = v
if v > 0:
self.library.settings["volume"] = v
self._apply_norm_gain()
@pyqtSlot(float)
def setSpeed(self, r: float) -> None:
self.engine.set_speed(r)
self.library.settings["speed"] = r
# ================= EQ / efekt =================
@pyqtSlot(str)
def setEq(self, gains_json: str) -> None:
gains = json.loads(gains_json)
self.engine.set_eq([float(g) for g in gains])
self.library.settings["eq_gains"] = gains
@pyqtSlot(str)
def setEqPreset(self, name: str) -> None:
self.library.settings["eq_preset"] = name
@pyqtSlot(bool)
def setEqEnabled(self, on: bool) -> None:
self.engine.set_eq_enabled(on)
self.library.settings["eq_enabled"] = on
@pyqtSlot(result=str)
def autoEq(self) -> str: # noqa: N802
audio = self.engine.audio
if audio is None:
self.toastSignal.emit("Önce bir şarkı çalın (Oto EQ için).")
return json.dumps([])
gains = engine_mod.auto_eq_gains(audio)
self.engine.set_eq(gains)
self.library.settings["eq_gains"] = gains
self.library.settings["eq_preset"] = "🤖 Oto"
self.toastSignal.emit("🤖 Oto EQ uygulandı.")
return json.dumps(gains)
@pyqtSlot(str, float)
def setEffect(self, name: str, val: float) -> None:
self.engine.set_effect(name, val)
self.library.settings.setdefault("effects", {})[name] = val
# ================= karaoke / vokal ayırma (htdemucs) =================
@pyqtSlot(str)
def setKaraoke(self, mode: str) -> None:
"""Karaoke/akapella modu:
off — normal (stem yok, mid-side kapalı)
quick — anında mid-side vokal azaltma (motor gerektirmez)
instrumental — htdemucs ile vokalsiz karışım (stüdyo karaoke)
vocals — htdemucs ile sadece vokal (akapella)
htdemucs CPU'da yavaştır (birkaç dk); stem'ler önbelleğe alınır → sonra anında."""
song = (self.library.find_song(self.play_list_name, self.active_song_id)
if self.active_song_id else None)
# stem modunda mid-side'ı üst üste bindirme
if mode != "quick":
self.engine.set_effect("karaoke", 0)
if mode in ("off", "quick"):
self._karaoke_mode = mode
self._sep_target = "off"
if song and self.engine.current_path != song["path"]:
self.engine.swap_source(song["path"]) # stem'den orijinale dön
if mode == "quick":
self.engine.set_effect("karaoke", 85)
self.toastSignal.emit("⚡ Hızlı karaoke açık (mid-side)")
self.karaokeModeSignal.emit(mode)
return
# instrumental / vocals -> htdemucs
if not song:
self.toastSignal.emit("Önce bir şarkı çal.")
return
if not separation.demucs_available():
self.engine.set_effect("karaoke", 85) # motor yoksa hızlıya düş
self._karaoke_mode = "quick"
self.karaokeModeSignal.emit("quick")
self.toastSignal.emit("Stüdyo karaoke motoru yok — hızlı karaoke açıldı.")
return
self._sep_song_id = self.active_song_id
self._sep_target = mode
cached = separation.cached_stems(song["path"])
if cached:
self._apply_stem(cached["no_vocals"] if mode == "instrumental" else cached["vocals"], mode)
return
self.toastSignal.emit("🎤 Vokaller ayrılıyor… (CPU, birkaç dk sürebilir)")
self.separationProgressSignal.emit(1.0, "Başlıyor…")
if self._sep_thread and self._sep_thread.isRunning():
self._sep_thread.cancel()
t = separation.SeparationThread(song["path"], self)
t.progress.connect(self.separationProgressSignal)
t.done.connect(self._on_separation_done)
t.failed.connect(self._on_separation_failed)
self._sep_thread = t
t.start()
def _apply_stem(self, stem_path: str, mode: str) -> None:
self.engine.swap_source(stem_path)
self._karaoke_mode = mode
self.karaokeModeSignal.emit(mode)
self.toastSignal.emit("🎙 Akapella — sadece vokal" if mode == "vocals"
else "🎤 Karaoke — enstrümantal (vokal ayrıldı)")
def _on_separation_done(self, no_vocals: str, vocals: str) -> None:
self.separationProgressSignal.emit(100.0, "Hazır")
if self.active_song_id != self._sep_song_id:
self.toastSignal.emit("🎤 Vokal ayırma hazır (parça değişmiş).")
return
mode = self._sep_target
if mode in ("instrumental", "vocals"):
self._apply_stem(no_vocals if mode == "instrumental" else vocals, mode)
def _on_separation_failed(self, msg: str) -> None:
self.separationProgressSignal.emit(100.0, "")
self.toastSignal.emit(msg)
self._karaoke_mode = "off"
self.karaokeModeSignal.emit("off")
# ================= çalma listeleri =================
@pyqtSlot(str)
def selectPlaylist(self, name: str) -> None:
if name in self.library.playlists:
self.library.current = name
@pyqtSlot(str)
def addPlaylist(self, name: str) -> None:
if self.library.add_playlist(name):
self.library.current = name
self.library.save()
self.stateChanged.emit()
self.toastSignal.emit(f"“{name}” listesi oluşturuldu.")
else:
self.toastSignal.emit("Bu isimde bir liste zaten var.")
@pyqtSlot(str)
def deletePlaylist(self, name: str) -> None:
if self.library.remove_playlist(name):
if name == self.play_list_name:
self.engine.clear()
self.active_song_id = None
self.play_index = -1
self.play_list_name = self.library.current
self.trackChanged.emit(self._track_json())
self.library.save()
self.stateChanged.emit()
self.toastSignal.emit("Liste silindi.")
@pyqtSlot(str, str)
def renamePlaylist(self, old: str, new: str) -> None:
new = new.strip()
if not new or new == old or new in self.library.playlists or old in config.PROTECTED_PLAYLISTS:
return
self.library.playlists = {(new if k == old else k): v
for k, v in self.library.playlists.items()}
if self.library.current == old:
self.library.current = new
if self.play_list_name == old:
self.play_list_name = new
self.library.save()
self.stateChanged.emit()
# ================= şarkı işlemleri =================
@pyqtSlot(str, str)
def toggleFavorite(self, song_id: str, playlist: str) -> None:
song = self.library.find_song(playlist, song_id)
if not song:
return
new_val = not song.get("favorite", False)
self.library.set_favorite(song, new_val)
if not new_val and self.play_list_name == config.FAVORITES_PLAYLIST and song_id == self.active_song_id:
self.engine.clear()
self.active_song_id = None
self.play_index = -1
self.trackChanged.emit(self._track_json())
self.library.save()
self.stateChanged.emit()
@pyqtSlot(str, str)
def removeSong(self, song_id: str, playlist: str) -> None:
songs = self.library.playlists.get(playlist, [])
idx = next((i for i, s in enumerate(songs) if s["id"] == song_id), -1)
if idx < 0:
return
songs.pop(idx)
if song_id == self.active_song_id and playlist == self.play_list_name:
self.engine.clear()
self.active_song_id = None
self.play_index = -1
self.trackChanged.emit(self._track_json())
elif playlist == self.play_list_name and idx < self.play_index:
self.play_index -= 1
self.library.save()
self.stateChanged.emit()
@pyqtSlot(str, str, str)
def copyToPlaylist(self, song_id: str, playlist: str, target: str) -> None:
song = self.library.find_song(playlist, song_id)
if not song:
return
if self.library.add_song(dict(song), target):
self.library.save()
self.stateChanged.emit()
self.toastSignal.emit(f"“{target}” listesine eklendi.")
else:
self.toastSignal.emit("Bu parça hedef listede zaten var.")
@pyqtSlot(str, str, str, str)
def editMetadata(self, song_id: str, playlist: str, title: str, artist: str) -> None:
song = self.library.find_song(playlist, song_id)
if not song:
return
song["title"] = title.strip() or song["title"]
song["artist"] = artist.strip()
self.library.save()
self.stateChanged.emit()
if song_id == self.active_song_id:
self.trackChanged.emit(self._track_json())
# ---- Klip modu: video AKIŞI (indirme yok) ----
@pyqtSlot(str, str)
def playVideo(self, sid: str, playlist: str) -> None:
song = self.library.find_song(playlist, sid)
if not song:
return
query = song.get("video_url") or naming.search_query(
song.get("artist", ""), song.get("title", ""))
if not query:
self.toastSignal.emit("Klip için şarkı adı gerekli.")
return
self.toastSignal.emit("🎬 Klip görüntüsü hazırlanıyor…")
# YENİ MİMARİ: ses DSP motorundan (mp3) gelir; video SESSİZ ve motora
# senkron oynar. Böylece tek ses kaynağı + tüm DSP efektleri klibe de uygulanır
# (çift playback yok). Motoru DURDURMA; gerekiyorsa çaldır.
if sid == self.active_song_id and not self.engine.is_playing():
self.engine.play()
self._video_handoff = self.engine.position() # videonun başlangıç saniyesi
t = VideoStreamThread(sid, query, self)
t.ready.connect(self._on_video_ready)
t.failed.connect(self._on_video_failed)
t.finished.connect(lambda x=t: self._video_threads.remove(x) if x in self._video_threads else None)
self._video_threads.append(t)
t.start()
def _on_video_ready(self, payload: str) -> None:
try:
data = json.loads(payload)
except ValueError:
return
data["start"] = round(self._video_handoff, 2)
data["muted"] = True # ses motordan gelir; video her zaman sessiz
self.videoReadySignal.emit(json.dumps(data))
def _on_video_failed(self, msg: str) -> None:
self.toastSignal.emit(msg) # motor zaten çalıyor, ekstra işlem yok
@pyqtSlot(float)
def resumeMusic(self, pos: float = -1.0) -> None:
"""Klip kapatılınca çağrılır. Motor hiç durmadı — sadece çaldığından emin ol."""
self._video_handoff = 0.0
if not self.engine.is_playing():
self.engine.play()
# ---- Otomatik güncelleme (GitHub Releases → .msi) ----
def _start_update_check(self) -> None:
if self._closing:
return
t = updater.UpdateCheckThread(config.APP_VERSION, updater.is_frozen(), self)
t.available.connect(self._on_update_available)
t.progress.connect(self.updateProgressSignal)
t.ready.connect(self._on_update_ready)
t.error.connect(lambda m: None) # ağ yok/rate-limit → sessiz
t.noUpdate.connect(lambda: None)
self._update_thread = t
t.start()
def _on_update_available(self, version: str, notes: str) -> None:
self.updateAvailableSignal.emit(version, notes)
if not updater.is_frozen():
self.toastSignal.emit(f"🔔 Yeni sürüm mevcut: v{version} (geliştirme modunda indirilmez).")
def _on_update_ready(self, msi_path: str, version: str) -> None:
self._staged_update = msi_path
self._staged_version = version
self.updateReadySignal.emit(version)
self.toastSignal.emit(f"🎉 Güncelleme indirildi (v{version}) — çıkışta otomatik kurulacak.")
def _perform_update(self) -> bool:
"""Ayrık kurucuyu başlatır. Donmuşta çalışan exe = MSI'nin güncellediği yol."""
if not self._staged_update:
return False
exe = sys.executable
ok = updater.install_and_relaunch(self._staged_update, exe)
if ok:
self._staged_update = None
return ok
@pyqtSlot()
def installUpdateNow(self) -> None:
"""Kullanıcı 'şimdi yeniden başlat' derse: hemen kur + yeniden aç."""
if self._staged_update and self._perform_update():
QApplication.quit()
# ---- Lyrica: otomatik (zaman kodlu) şarkı sözü ----
@pyqtSlot(str, str)
def fetchLyrics(self, sid: str, playlist: str) -> None:
song = self.library.find_song(playlist, sid)
if not song:
return
self.toastSignal.emit("🎵 Sözler aranıyor…")
self._start_lyrics(song, manual=True)
def _start_lyrics(self, song: dict, manual: bool = False) -> None:
t = LyricsThread(song["id"], song.get("artist", ""), song.get("title", ""), self)
t.ready.connect(self._on_lyrics)
if manual:
t.failed.connect(self.toastSignal)
t.finished.connect(lambda x=t: self._lyric_threads.remove(x) if x in self._lyric_threads else None)
self._lyric_threads.append(t)
t.start()
def _on_lyrics(self, sid: str, lyrics: str, source: str) -> None:
found = False
for songs in self.library.playlists.values():
for s in songs:
if s.get("id") == sid:
s["lyrics"] = lyrics
found = True
if not found:
return
self.library.save()
if sid == self.active_song_id:
self.trackChanged.emit(self._track_json())
self.toastSignal.emit(f"🎵 Sözler bulundu ({source})")
@pyqtSlot(str, str, str)
def saveLyrics(self, song_id: str, playlist: str, text: str) -> None:
song = self.library.find_song(playlist, song_id)
if not song:
return
song["lyrics"] = text
self.library.save()
if song_id == self.active_song_id:
self.trackChanged.emit(self._track_json())
self.toastSignal.emit("Sözler kaydedildi.")
# ================= içe aktarma / tarama / senkron =================
@pyqtSlot()
def importFiles(self) -> None:
exts = " ".join(f"*{e}" for e in config.AUDIO_EXTENSIONS)
files, _ = QFileDialog.getOpenFileNames(
self.window, "Müzik dosyalarını seç", str(config.MUSIC_DIR),
f"Ses Dosyaları ({exts});;Tüm Dosyalar (*.*)")
self.import_paths(files)
def import_paths(self, files: list[str]) -> None:
added = 0
for path in files:
if path.lower().endswith(config.AUDIO_EXTENSIONS):
song = make_song(path)
if self.library.add_song(song, self.library.current):
added += 1
self._maybe_fetch_cover(song)
if added:
self.library.save()
self.stateChanged.emit()
self.toastSignal.emit(f"{added} şarkı eklendi.")
else:
self.toastSignal.emit("Uygun/yeni dosya bulunamadı.")
@pyqtSlot()
def scanMusic(self) -> None:
if self.scan_thread and self.scan_thread.isRunning():
return
self.toastSignal.emit("Müzik klasörü taranıyor…")
self.scan_thread = ScanThread(self)
self.scan_thread.done.connect(self._on_scan_done)
self.scan_thread.start()
def _on_scan_done(self, collected: list) -> None:
added, new_pl = self.library.merge_scanned(collected)
if added or new_pl:
self.library.save()
self.stateChanged.emit()
self.requestSidebarCovers() # taranan listelerin kapaklarını çekmeye başla
self.scanDoneSignal.emit(added, new_pl)
self.toastSignal.emit(f"Tarama bitti: {added} şarkı, {new_pl} liste."
if (added or new_pl) else "Yeni şarkı bulunamadı.")
@pyqtSlot(str)
def syncNamesCovers(self, playlist: str) -> None:
songs = self.library.playlists.get(playlist, [])
if not songs:
self.toastSignal.emit("Bu listede şarkı yok.")
return
for song in songs:
stem = os.path.splitext(os.path.basename(song["path"]))[0]
artist, title = naming.from_filename(stem)
if title:
song["title"] = title
if artist:
song["artist"] = artist
self._force_cover_refetch(song)
self.library.save()
self.stateChanged.emit()
if self.active_song_id:
self.trackChanged.emit(self._track_json())
self.toastSignal.emit(f"{len(songs)} parça senkronize ediliyor; kapaklar geliyor…")
# ================= kapaklar =================
@pyqtSlot(str)
def requestCovers(self, playlist: str) -> None:
"""Bir liste açıldığında kapakları ve süreleri arka planda çeker."""
songs = self.library.playlists.get(playlist, [])
for s in songs:
self._maybe_fetch_cover(s)
self._probe_durations(songs)
def _probe_durations(self, songs: list) -> None:
items = [(s["id"], s["path"]) for s in songs
if not s.get("duration_ms") and s["id"] not in self._probed_ids
and os.path.exists(s.get("path", ""))]
if not items:
return
for sid, _ in items:
self._probed_ids.add(sid)
pr = DurationProber(items[:300], self)
pr.probed.connect(self._on_duration_probed)
pr.finished.connect(lambda p=pr: self._probers.remove(p) if p in self._probers else None)
self._probers.append(pr)
pr.start()
def _on_duration_probed(self, sid: str, ms: int) -> None:
for songs in self.library.playlists.values():
for s in songs:
if s.get("id") == sid:
s["duration_ms"] = ms
self.durationSignal.emit(sid, ms)
self._cover_dirty = True
@pyqtSlot()
def requestSidebarCovers(self) -> None:
"""Kenar çubuğu/ana sayfa küçük resimleri için her listenin ilk
(kapaksız) birkaç şarkısının kapağını çeker."""
for songs in self.library.playlists.values():
for s in songs[:3]:
self._maybe_fetch_cover(s)
def _maybe_fetch_cover(self, song: dict) -> None:
thumb = song.get("thumbnail")
if thumb and os.path.exists(thumb):
return
if song["id"] in self.cover_requested:
return
self.cover_requested.add(song["id"])
query = naming.search_query(song.get("artist", ""), song.get("title", ""))
self.cover_fetcher.enqueue(song["id"], query or song.get("title", ""),
song.get("thumbnail_url") or None)
def _force_cover_refetch(self, song: dict) -> None:
try:
cache = config.COVERS_DIR / (hashlib.md5(song["id"].encode("utf-8")).hexdigest() + ".jpg")
if cache.exists():
cache.unlink()
except Exception:
pass
song["thumbnail"] = ""
self.cover_requested.discard(song["id"])
self._maybe_fetch_cover(song)
def _on_cover(self, song_id: str, path: str) -> None:
for songs in self.library.playlists.values():
for s in songs:
if s.get("id") == song_id:
s["thumbnail"] = path
self.coverReadySignal.emit(song_id, self._cover_url(song_id))
self._cover_dirty = True
# ================= YouTube ara / indir =================
@pyqtSlot(str)
def searchYouTube(self, query: str) -> None:
query = query.strip()
if not query:
return
self._search = SearchThread(query, 20, self)
self._search.results.connect(lambda rows: self.searchResultsSignal.emit(json.dumps(rows)))
self._search.failed.connect(lambda m: self.searchResultsSignal.emit(json.dumps({"error": m})))
self._search.start()
@pyqtSlot(str)
def importPlaylist(self, url: str) -> None:
"""YouTube playlist URL'sini aç: tüm parçaları arama sonucu satırları gibi
listeler → renderSearchRows checkbox'larıyla önizle-ve-seç, toplu indir."""
url = url.strip()
if not url:
return
self.toastSignal.emit("📃 Playlist yükleniyor…")
self._search = SearchThread("", 20, self, playlist_url=url)
self._search.results.connect(self._on_playlist_rows)
self._search.failed.connect(lambda m: self.searchResultsSignal.emit(json.dumps({"error": m})))
self._search.start()
def _on_playlist_rows(self, rows: list) -> None:
self.searchResultsSignal.emit(json.dumps(rows))
if rows:
self.toastSignal.emit(f"📃 Playlist: {len(rows)} parça — seç ve indir.")
else:
self.toastSignal.emit("Playlist boş veya çözülemedi.")
def _dl_counter(self) -> str:
if self.download_total > 1:
return f"{self.download_done}/{self.download_total}"
return ""
def _emit_dl_progress(self) -> None:
"""Paralel indirmelerin toplam ilerlemesini tek pill'e yansıtır."""
remaining = len(self.download_queue) + len(self.active_downloads)
if self.download_total <= 0 and remaining == 0:
self.downloadProgressSignal.emit(0, "", 0)
return
agg = (self.download_done * 100 + sum(self.active_downloads.values())) / max(1, self.download_total)
c = self._dl_counter()
label = f"İndiriliyor… {c}".strip()
n = len(self.active_downloads)
if n > 1:
label = f"{label} ({n} etkin)"
self.downloadProgressSignal.emit(min(100.0, agg), label, remaining)
@pyqtSlot(str)
def downloadUrls(self, urls_json: str) -> None:
try:
urls = json.loads(urls_json)
except json.JSONDecodeError:
return
if isinstance(urls, str):
urls = [urls]
urls = [u for u in urls if u]
if not urls:
return
self.download_queue.extend(urls)
self.download_total += len(urls) # toplu sayaç birikir
self._pump_downloads()
def _pump_downloads(self) -> None:
"""Havuz doluncaya (MAX_DL_CONCURRENT) kadar kuyruktan paralel indirme başlatır.
Yalnız ana (Qt) thread'inde çağrılır: kuyruk/havuz mutasyonu ve QThread
oluşturma burada olur. Sinyaller QObject-bağlı slotlara gider → Qt onları
ana thread'e queue'lar (aşağıda self.sender() ile hangi thread bulunur).
"""
if self._closing:
return
while self.download_queue and len(self.active_downloads) < MAX_DL_CONCURRENT:
url = self.download_queue.pop(0)
t = DownloadThread(url, self)
t.progress.connect(self._on_dl_progress)
t.finished_ok.connect(self._on_download_done)
t.failed.connect(self._on_dl_failed)
t.finished.connect(self._download_finished)
self.active_downloads[t] = 0.0
t.start()
if not self.download_queue and not self.active_downloads:
self.download_total = 0 # kuyruk + havuz boş: sayaçları sıfırla
self.download_done = 0
self._emit_dl_progress()
@pyqtSlot(float, str)
def _on_dl_progress(self, pct, status) -> None: # noqa: ARG002
t = self.sender()
if t in self.active_downloads:
self.active_downloads[t] = float(pct)
self._emit_dl_progress()
@pyqtSlot(str)
def _on_dl_failed(self, msg) -> None:
self.toastSignal.emit(msg)
@pyqtSlot()
def _download_finished(self) -> None:
t = self.sender()
self.active_downloads.pop(t, None)
self.download_done += 1
self._pump_downloads()
def _on_download_done(self, song: dict) -> None:
# İndirilenler her zaman belli bir (korumalı) listeye gider
target = config.DOWNLOADS_PLAYLIST
if target not in self.library.playlists:
self.library.playlists[target] = []
if self.library.add_song(song, target):
self._maybe_fetch_cover(song)
self.library.save()
self.stateChanged.emit()
self.downloadDoneSignal.emit(json.dumps(self._song_view(song)))
self.toastSignal.emit(f"İndirildi: {song['title']} → İndirilenler")
else:
self.toastSignal.emit("Bu parça zaten indirilmiş.")
# ================= tema / yedek =================
@pyqtSlot(str)
def setTheme(self, name: str) -> None:
self.library.settings["theme"] = name
self.library.settings.pop("accent", None) # tema seçilince özel renk sıfırlanır
self.library.save()
@pyqtSlot(str)
def setAccent(self, hex_color: str) -> None:
self.library.settings["accent"] = hex_color
self.library.save()
@pyqtSlot(str)
def setLanguage(self, code: str) -> None:
self.library.settings["lang"] = "en" if code == "en" else "tr"
self.library.save()
@pyqtSlot()
def backup(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self.window, "Yedeği kaydet", str(config.BASE_DIR / "just_music_yedek.json"), "JSON (*.json)")
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.library.to_json())
self.toastSignal.emit("Yedek kaydedildi.")
except OSError as exc:
self.toastSignal.emit(f"Yedek başarısız: {exc}")
# ================= yoklama =================
def _on_poll(self) -> None:
eng = self.engine
playing = eng.is_playing()
if playing != self._last_playing:
self._last_playing = playing
self.playingChanged.emit(playing)
self._discord_update(force=True) # oynat/duraklat -> Discord'u hemen tazele
self.spectrumSignal.emit(json.dumps([round(float(x), 3) for x in eng.spectrum()]))
if eng.is_loaded():
adv = eng.consume_advanced()
if adv:
self._on_crossfade_advanced(adv) # motor kendiliğinden sıradakine geçti
pos, dur = eng.position(), eng.duration()
self.positionChanged.emit(pos, dur)
self._maybe_preload_next(pos, dur)
if dur > 0 and self.active_song_id:
song = self.library.find_song(self.play_list_name, self.active_song_id)
if song and not song.get("duration_ms"):
song["duration_ms"] = int(dur * 1000)
# Şarkı DNA'sı + ruh hali: yeni parça çözümlenince bir kez
if self.active_song_id and self.active_song_id != self._analyzed_id and eng.audio is not None:
song = self.library.find_song(self.play_list_name, self.active_song_id)
if song and eng.current_path == song["path"]:
self._analyzed_id = self.active_song_id
at = AnalyzeThread(eng.audio, self.active_song_id, self)
at.ready.connect(self._on_analysis)
at.finished.connect(lambda a=at: self._analyzers.remove(a) if a in self._analyzers else None)
self._analyzers.append(at)
at.start()
if eng.consume_end():
self._on_media_ended()
if eng.load_error:
self.toastSignal.emit(eng.load_error)
eng.load_error = ""
if playing:
self._listen_accum += POLL_MS / 1000.0
if self._listen_accum >= 1.0:
add = int(self._listen_accum)
self._listen_accum -= add
self.library.stats["total_seconds"] = self.library.stats.get("total_seconds", 0) + add
self._tick += 1
if self.library.settings.get("discord_rpc") and self._tick % 80 == 0:
self._discord_update() # ~5 sn'de bir Discord durumunu tazele
if self._tick % 250 == 0 or (self._cover_dirty and self._tick % 50 == 0):
self.library.save()
self._cover_dirty = False
def _maybe_preload_next(self, pos: float, dur: float) -> None:
"""Crossfade/gapless açıkken sıradaki (sıralı) parçayı önden çözmeye başlar.
Yalnız öngörülebilir sıralı çalmada: shuffle/kuyruk/tek-tekrar kapalı.
"""
eng = self.engine
if not (eng.crossfade_sec > 0 or eng.gapless):
return
if self.shuffle or self.user_queue or self.repeat_mode == 2:
return
if dur <= 0 or self.play_index < 0:
return
lead = (eng.crossfade_sec or 0.0) + 8.0
if (dur - pos) > lead:
return
songs = self.library.playlists.get(self.play_list_name, [])
if not songs:
return
if self.repeat_mode == 0 and self.play_index >= len(songs) - 1:
return # liste sonu, tekrar yok -> sıradaki yok
nxt = songs[(self.play_index + 1) % len(songs)]
p = nxt.get("path", "")
if p and os.path.exists(p):
eng.preload_next(p)
# ================= kapanış =================
def shutdown(self) -> None:
self._closing = True
self.download_queue.clear()
self.poll.stop()
try:
self.discord.close()
except Exception:
pass
try:
self.engine.close()
except Exception:
pass
self.library.save()
try:
self.cover_fetcher.stop()
if not self.cover_fetcher.wait(6000):
self.cover_fetcher.wait()
except Exception:
pass
if self._search and self._search.isRunning():
self._search.stop()
self._search.wait(2000)
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.wait(3000)
for pr in list(self._probers):
try:
pr.stop()
pr.wait(2500)
except Exception:
pass
for a in list(self._analyzers):
try:
a.wait(2500)
except Exception:
pass
for lt in list(self._lyric_threads):
try:
lt.wait(3000)
except Exception:
pass
if self._sep_thread and self._sep_thread.isRunning():
try:
self._sep_thread.cancel()
self._sep_thread.wait(8000)
except Exception:
pass
for vt in list(self._video_threads):
try:
vt.wait(3000)
except Exception:
pass
for t in list(self.active_downloads):
try:
if t.isRunning():
t.cancel()
t.wait(5000)
except Exception:
pass
self.active_downloads.clear()
# Güncelleme iş parçacığı hâlâ indiriyorsa kısa bekle, sonra bırak
if self._update_thread and self._update_thread.isRunning():
if not self._update_thread.wait(1500):
self._update_thread.terminate()
self._update_thread.wait(1000)
# İndirilmiş güncelleme varsa: ayrık kurucuyu başlat (uygulama şimdi kapanıyor)
if self._staged_update:
self._perform_update()