Building a Multi-Platform Music Player with PyQt and Web Scraping

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.

Tags: python PyQt Music Player web scraping multi-threading

Posted on Mon, 10 Aug 2026 16:54:36 +0000 by harris97