This implementation describes a Python desktop application that searches, streams, and downloads music from several online sources, featuring album artwork, synchronized lyrics, and playlist management.
Core Search Engine
The music retrieval module runs inside a dedicated thread to avoid UI freezing. It queries public music aggregation APIs and parses JSON responses containing song metadata, streaming URLs, lyrics, and cover images.
import requests
from jsonpath_ng import jsonpath
from PyQt5.QtCore import QThread, pyqtSignal
class SearchWorker(QThread):
result_ready = pyqtSignal(str)
def __init__(self, keyword, source_type, page_count=1):
super().__init__()
self.keyword = keyword
self.source_type = source_type
self.pages = max(int(page_count), 1) if page_count else 2
self.api_endpoints = [
'http://music.9q4.cn/',
'https://defcon.cn/dmusic/',
'http://www.xmsj.org/',
'http://music.laomao.me/',
]
self.fallback_index = 0
def run(self):
collected_urls, collected_titles, collected_covers, collected_lyrics = [], [], [], []
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest'
}
endpoint = self.api_endpoints[self.fallback_index % len(self.api_endpoints)]
for page_num in range(1, self.pages + 1):
payload = {
'input': self.keyword,
'filter': 'name',
'type': self.source_type,
'page': page_num
}
try:
response = requests.post(endpoint, data=payload, headers=headers, timeout=10)
data = response.json()
items = data.get('data', []) if isinstance(data, dict) else []
for entry in items[:10]:
track_name = entry.get('title', 'Unknown')
artist = entry.get('author', 'Unknown')
audio_url = entry.get('url')
cover_url = entry.get('pic')
lyric_text = entry.get('lrc')
if audio_url:
collected_urls.append(audio_url)
collected_covers.append(cover_url or '')
collected_lyrics.append(lyric_text or '')
collected_titles.append(f'{track_name} - {artist}')
except Exception as exc:
self.fallback_index += 1
if self.fallback_index < len(self.api_endpoints):
endpoint = self.api_endpoints[self.fallback_index]
global cached_urls, cached_songs, cached_pics, cached_lrc
cached_urls = collected_urls
cached_songs = collected_titles
cached_pics = collected_covers
cached_lrc = collected_lyrics
self.result_ready.emit('finish')
Download and Playback Handler
A separate thread handles file downloads, cover image processing, and lyric extraction before handing control to pygame.mixer.
import os
import shutil
from pathlib import Path
import requests
from PyQt5.QtCore import QThread, pyqtSignal
from mutagen.mp3 import MP3
import pygame.mixer
DATA_DIR = Path('app_data')
DOWNLOAD_DIR = Path('downloadmusic')
DOWNLOAD_DIR.mkdir(exist_ok=True)
class AssetDownloader(QThread):
progress = pyqtSignal(str)
artwork_ready = pyqtSignal(str)
def __init__(self, track_index, catalog):
super().__init__()
self.index = track_index
self.catalog = catalog
self.audio_url = catalog['urls'][track_index]
self.cover_url = catalog['covers'][track_index]
self.lyric_raw = catalog['lyrics'][track_index]
self.song_title = catalog['titles'][track_index]
self.stop_flag = False
def fetch_cover(self):
try:
resp = requests.get(self.cover_url, timeout=10)
if resp.status_code == 200:
raw_path = DATA_DIR / 'cover_raw.jpg'
with open(raw_path, 'wb') as f:
for chunk in resp.iter_content(8192):
f.write(chunk)
return raw_path
except Exception:
pass
return None
def parse_lyrics(self):
blocks = []
if not self.lyric_raw:
return None
for line in self.lyric_raw.splitlines():
if line.strip():
parts = line.split(']')
content = parts[-1].strip() if ']' in line else line.strip()
if content:
blocks.append(content)
return blocks if blocks else None
def run(self):
temp_path = DATA_DIR / f'download_{self.index}.tmp'
try:
with requests.get(self.audio_url, stream=True, timeout=20) as r:
total = int(r.headers.get('content-length', 0))
downloaded = 0
with open(temp_path, 'wb') as file:
for chunk in r.iter_content(chunk_size=4096):
if self.stop_flag:
break
file.write(chunk)
downloaded += len(chunk)
if total:
pct = int(downloaded / total * 100)
self.progress.emit(f'{pct}%')
final_name = DOWNLOAD_DIR / f'{self.song_title}.mp3'
shutil.move(temp_path, final_name)
# load into mixer
pygame.mixer.init()
pygame.mixer.music.load(str(final_name))
pygame.mixer.music.play()
# artwork and lyrics
cover_path = self.fetch_cover()
if cover_path:
self.artwork_ready.emit(str(cover_path))
lyrics = self.parse_lyrics()
if lyrics:
self.progress.emit('lyrics_ready')
self.progress.emit('finish')
except Exception:
self.progress.emit('error')
Playlist and Queue Manaegment
A PyQt list widget displays the search results. Double-clicking an entry starts playback and moves the song into a "now playing" queue. Navigation controls support sequential, random, and single-loop modes.
from PyQt5.QtWidgets import QListWidget, QListWidgetItem, QPushButton, QLabel
from PyQt5.QtCore import Qt, QTimer
import random
class PlayerControls:
def __init__(self, playlist_widget: QListWidget, status_label: QLabel, cover_label: QLabel):
self.playlist = playlist_widget
self.status = status_label
self.cover = cover_label
self.queue = []
self.current_index = -1
self.mode = 'sequential' # sequential, random, loop_one
self.monitor = QTimer()
self.monitor.timeout.connect(self.check_end)
self.monitor.start(1000)
def add_to_queue(self, title):
self.queue.append(title)
QListWidgetItem(title, self.playlist)
def play_at(self, row):
if 0 <= row < len(self.queue):
self.current_index = row
self.launch_playback()
def next_track(self):
if self.mode == 'loop_one':
self.launch_playback()
return
self.current_index = (self.current_index + 1) % len(self.queue)
self.launch_playback()
def previous_track(self):
self.current_index = (self.current_index - 1) % len(self.queue)
self.launch_playback()
def shuffle(self):
if self.queue:
self.current_index = random.randint(0, len(self.queue) - 1)
self.launch_playback()
def launch_playback(self):
title = self.queue[self.current_index]
self.status.setText(f'Playing: {title}')
# trigger AssetDownloader with current_index and cached catalog
def change_mode(self):
cycle = {'sequential': 'random', 'random': 'loop_one', 'loop_one': 'sequential'}
self.mode = cycle[self.mode]
self.status.setText(f'Mode: {self.mode}')
def check_end(self):
if not pygame.mixer.music.get_busy():
if self.mode == 'sequential':
self.next_track()
elif self.mode == 'random':
self.shuffle()
elif self.mode == 'loop_one':
self.launch_playback()
Cover and UI Integration
Downloaded artwork is displayed in dedicated QLabel widgets with proper aspect ratio scaling. A fallback image is used when the cover cannot be fetched.