Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Building a Cross-Platform Music Player with PyQt5, Pygame, and urllib

Tech Sep 30 10

This project implements a desktop music application capable of searching, streaming, and downloading tracks from multiple music platforms including NetEase Cloud Music, KuGou, QQ Music, and Kuwo. The application combines a PyQt5-based GUI with Pygame for audio playback and urllib for network requests.

Project Structure

The architecture separatse concerns into three main components: a search worker thread for retrieving music metadata, a download worker thread for fetching audio files and artwork, and the main UI thread handling user interactions and playback control.

Search Thread Implementation

The search functionality operates in a background thread to prevent UI blocking during network requests. The thread emits signals upon completion to notify the main application.

from PyQt5.QtCore import QThread, pyqtSignal
import requests
import jsonpath

class MusicSearchThread(QThread):
    """Background thread for music search operations"""
    search_completed = pyqtSignal(list, list, list, list)
    
    def __init__(self, query, platform_type, page_count=2):
        super().__init__()
        self.query = query
        self.platform = platform_type
        self.pages = page_count if page_count > 0 else 2
        
    def run(self):
        search_url = 'https://defcon.cn/dmusic/'
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'X-Requested-With': 'XMLHttpRequest'
        }
        
        titles = []
        artists = []
        download_urls = []
        cover_images = []
        lyrics_data = []
        
        for page_num in range(1, self.pages):
            payload = {
                'input': self.query,
                'filter': 'name',
                'type': self.platform,
                'page': page_num
            }
            
            try:
                response = requests.post(search_url, data=payload, headers=headers)
                json_data = response.json()
                
                for idx in range(10):
                    try:
                        title = jsonpath.jsonpath(json_data, f'$..title[{idx}]')[0]
                        artist = jsonpath.jsonpath(json_data, f'$..author[{idx}]')[0]
                        url = jsonpath.jsonpath(json_data, f'$..url[{idx}]')[0]
                        cover = jsonpath.jsonpath(json_data, f'$..pic[{idx}]')[0]
                        lrc = jsonpath.jsonpath(json_data, f'$..lrc[{idx}]')[0]
                        
                        titles.append(title)
                        artists.append(artist)
                        download_urls.append(url)
                        cover_images.append(cover)
                        lyrics_data.append(lrc)
                    except (IndexError, TypeError):
                        continue
                        
                self.search_completed.emit(titles, artists, download_urls, cover_images)
            except requests.RequestException as e:
                print(f"Search failed: {e}")
                self.search_completed.emit([], [], [], [])

Download Worker Thread

The download thread handles both audio file retrieval and cover image fetching. It includes progress callback functionality for download status updates.

from PyQt5.QtCore import QThread, pyqtSignal
from urllib.request import urlretrieve
from shutil import copy2
import os
import requests

class DownloadWorker(QThread):
    """Handles music file downloads with progress tracking"""
    download_progress = pyqtSignal(float)
    download_complete = pyqtSignal(bool, str)
    lyrics_loaded = pyqtSignal(list)
    
    def __init__(self, audio_url, song_title, cover_url, lyrics, target_dir):
        super().__init__()
        self.audio_url = audio_url
        self.song_title = song_title
        self.cover_url = cover_url
        self.lyrics_source = lyrics
        self.output_dir = target_dir
        
    def _progress_callback(self, downloaded, total_size, progress_signal):
        """Callback function for download progress updates"""
        if total_size > 0:
            percentage = min(100.0 * downloaded * 100000 / total_size, 100)
            progress_signal.emit(percentage)
    
    def _fetch_cover_image(self, image_url):
        """Download and process album artwork"""
        try:
            if 'kugou' in self.cover_url and self.cover_url.count('/') == 6:
                parts = self.cover_url.split('/')
                resized_url = f"{parts[0]}//{parts[2]}/{parts[3]}/400/{parts[5]}/{parts[6]}"
            elif 'netease' in self.cover_url and '?' in self.cover_url:
                base_url = self.cover_url.split('?')[0]
                resized_url = f"{base_url}?param=400x400"
            else:
                resized_url = self.cover_url
                
            response = requests.get(resized_url, timeout=10)
            cover_path = os.path.join(self.output_dir, 'temp_cover.png')
            
            with open(cover_path, 'wb') as f:
                for chunk in response.iter_content(8192):
                    f.write(chunk)
            return cover_path
        except Exception:
            return None
    
    def _parse_lyrics(self, raw_lyrics):
        """Extract plain text from LRC format lyrics"""
        parsed_lines = []
        if not raw_lyrics:
            return parsed_lines
            
        for line in raw_lyrics.split('\n'):
            if line.strip():
                try:
                    content = line.split(']')[1].strip()
                    if content:
                        parsed_lines.append(content)
                except IndexError:
                    continue
        return parsed_lines
    
    def run(self):
        try:
            os.makedirs(self.output_dir, exist_ok=True)
            
            self._fetch_cover_image(self.cover_url)
            
            temp_path = os.path.join(self.output_dir, f'temp_{hash(self.audio_url)}.mp3')
            urlretrieve(self.audio_url, temp_path)
            
            final_path = os.path.join(self.output_dir, f'{self.song_title}.mp3')
            copy2(temp_path, final_path)
            
            if os.path.exists(temp_path):
                os.remove(temp_path)
            
            parsed_lrc = self._parse_lyrics(self.lyrics_source)
            self.lyrics_loaded.emit(parsed_lrc)
            
            self.download_complete.emit(True, final_path)
        except Exception as e:
            print(f"Download error: {e}")
            self.download_complete.emit(False, str(e))

Playback Control Module

The playback module integrates Pygame's mixer for audio playback with Qt's timer for UI updates and progress tracking.

from PyQt5.QtWidgets import QListWidgetItem, QMessageBox
from PyQt5.QtCore import QTimer
import pygame
import time

class MusicPlayer:
    """Main music playback controller"""
    
    def __init__(self):
        self.is_playing = False
        self.current_track_index = 0
        self.playlist = []
        self.download_in_progress = False
        
    def play_track(self, track_index):
        """Initialize playback for selected track"""
        try:
            pygame.mixer.stop()
            pygame.mixer.init()
            
            self.current_track_index = track_index
            self.is_playing = False
            self.download_in_progress = True
            
            self._update_ui_status("Downloading...")
            download_thread = DownloadWorker(
                self.playlist[track_index]['url'],
                self.playlist[track_index]['title'],
                self.playlist[track_index]['cover'],
                self.playlist[track_index]['lyrics'],
                'downloads'
            )
            download_thread.download_complete.connect(self._on_download_finished)
            download_thread.start()
        except Exception as e:
            print(f"Playback initialization failed: {e}")
            
    def _on_download_finished(self, success, file_path):
        """Handle download completion and start playback"""
        self.download_in_progress = False
        if success:
            try:
                pygame.mixer.music.load(file_path)
                pygame.mixer.music.play()
                self.is_playing = True
                self._update_ui_status(self.playlist[self.current_track_index]['title'])
                self._start_playback_monitor()
            except Exception as e:
                print(f"Playback failed: {e}")
        else:
            self._update_ui_status("Download failed")
    
    def _start_playback_monitor(self):
        """Start timer-based playback monitoring"""
        self.monitor_timer = QTimer()
        self.monitor_timer.timeout.connect(self._check_playback_status)
        self.monitor_timer.start(1000)
    
    def _check_playback_status(self):
        """Monitor playback state and trigger next track if needed"""
        if not pygame.mixer.music.get_busy() and not self.download_in_progress:
            self._advance_playlist()
    
    def _advance_playlist(self):
        """Move to next track based on current play mode"""
        if self.play_mode == 'sequential':
            self.next_track()
        elif self.play_mode == 'shuffle':
            self.random_track()
        elif self.play_mode == 'repeat_one':
            self.replay_current()

Playlist Navigation

The application supports multiple playback modes with corresponding navigation functions.

class PlaylistController:
    """Handles playlist operations and navigation"""
    
    def __init__(self):
        self.current_index = 0
        self.play_modes = ['sequential', 'shuffle', 'repeat_one']
        self.mode_index = 0
        
    @property
    def play_mode(self):
        return self.play_modes[self.mode_index]
    
    def cycle_play_mode(self):
        """Cycle through available playback modes"""
        self.mode_index = (self.mode_index + 1) % len(self.play_modes)
        mode_names = {
            'sequential': 'Sequential',
            'shuffle': 'Shuffle',
            'repeat_one': 'Repeat One'
        }
        return mode_names[self.play_mode]
    
    def get_next_index(self):
        """Calculate next track index based on play mode"""
        total = len(self.playlist)
        if self.play_mode == 'shuffle':
            import random
            return random.randint(0, total - 1)
        return (self.current_index + 1) % total
    
    def get_previous_index(self):
        """Calculate previous track index"""
        total = len(self.playlist)
        return (self.current_index - 1 + total) % total
    
    def handle_list_click(self, clicked_item):
        """Process playlist item selection"""
        row = self.parent_widget.row(clicked_item)
        self.current_index = row
        return row

UI Integration

The QListWidget double-click event triggers playback initialization for the selected track.

# Connect list widget double-click signal to playback handler
self.playlist_widget.itemDoubleClicked.connect(self._on_track_selected)

def _on_track_selected(self, item):
    """Handle track selection from playlist"""
    selected_row = self.playlist_widget.row(item)
    print(f"Selected track: {item.text()} at row {selected_row}")
    
    self.current_track_label.setText(item.text())
    self.play_track(selected_row)

The play mode toggle cycles through sequential, shuffle, and repeat-one modes with appropriate visual feedback.

def toggle_playback_mode(self):
    """Cycle through playback modes and update UI"""
    mode_icons = {
        'sequential': 'fa.align-center',
        'shuffle': 'fa.random',
        'repeat_one': 'fa.retweet'
    }
    
    current_mode = self.playlist_controller.cycle_play_mode()
    self.mode_indicator.setText(f"Mode: {current_mode}")
    
    icon_name = mode_icons[self.playlist_controller.play_mode]
    self.mode_button.setIcon(qtawesome.icon(icon_name, color='#4A90D9'))

Architecture Summary

The application demonstrates effective multi-threading with PyQt signals for thread-safe UI updates. The separation between network operations (search and download) and UI operations prevents interface freezing during lengthy operations. Pygame handles low-latency audio playback while Qt's QTimer provides reliable status monitoring at one-second intervals.

Key design patterns include the worker thread pattern for background tasks, observer pattern via Qt signals for loose coupling, and state management for tracking playback and download status across the application lifecycle.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.