refactor: remove YouTube download feature, extract TrackInfo dataclass, clean up config and hotkeys

This commit is contained in:
Maksim Totmin 2026-06-01 09:19:14 +07:00
parent 9cdb27dace
commit e569f8c731
16 changed files with 115 additions and 1082 deletions

1
.gitignore vendored
View File

@ -15,7 +15,6 @@ ENV/
# Temporary files # Temporary files
temp/ temp/
downloads/
converted/ converted/
# IDE files # IDE files

View File

@ -1,17 +1,16 @@
# neo-pod-desktop # neo-pod-desktop
Desktop application for downloading, converting, managing and transferring music to iPod Nano 7G. Includes YouTube audio extraction, format conversion, metadata management, local library with playback, cover art pipeline, and full iTunesDB/ArtworkDB generation. Desktop application for converting, managing and transferring music to iPod Nano 7G. Includes format conversion, metadata management, local library with playback, cover art pipeline, and full iTunesDB/ArtworkDB generation.
## Features ## Features
- **Download** audio from YouTube videos/playlists
- **Convert** to iPod-compatible formats (AAC/M4A, MP3) with `-map 0:t?` cover preservation - **Convert** to iPod-compatible formats (AAC/M4A, MP3) with `-map 0:t?` cover preservation
- **Metadata** handling title, artist, album, track number, genre, embedded cover art - **Metadata** handling -- title, artist, album, track number, genre, embedded cover art
- **Cover art pipeline** extract from audio files (APIC/covr/FLAC pictures), encode to iPod Nano 7G formats (RGB565), write ArtworkDB + ithmb - **Cover art pipeline** -- extract from audio files (APIC/covr/FLAC pictures), encode to iPod Nano 7G formats (RGB565), write ArtworkDB + ithmb
- **iTunes-style player** — play local library with cover art display (60×60), seek, volume - **iTunes-style player** -- play local library with cover art display (60x60), seek, volume
- **iPod Nano 7G database** full iTunesCDB sync, SQLite management, Locations.itdb with HASHAB checksums - **iPod Nano 7G database** -- full iTunesCDB sync, SQLite management, Locations.itdb with HASHAB checksums
- **Library management** sortable table, source/ready track separation, bulk transfer - **Library management** -- sortable table, source/ready track separation, bulk transfer
- **Portable AppImage** `scripts/build-appimage.sh` builds standalone x86_64 AppImage - **Portable AppImage** -- `scripts/build-appimage.sh` builds standalone x86_64 AppImage
- **Config** stored in `~/.config/neo-pod-desktop/config.ini` - **Config** stored in `~/.config/neo-pod-desktop/config.ini`
## Installation ## Installation
@ -39,12 +38,6 @@ Install FFmpeg:
python src/main.py python src/main.py
``` ```
### Command Line Interface
```bash
python src/cli.py --url "https://www.youtube.com/watch?v=VIDEO_ID" --output-dir "/path/to/ipod"
```
### AppImage (portable) ### AppImage (portable)
```bash ```bash
@ -61,42 +54,31 @@ Build from source:
``` ```
src/ src/
main.py — GUI application (PyQt6) main.py -- GUI application (PyQt6)
cli.py — CLI interface track_info.py -- TrackInfo data class
youtube_downloader.py — YouTube audio downloader audio_converter.py -- FFmpeg audio conversion
audio_converter.py — FFmpeg audio conversion metadata_handler.py -- ID3/MP4 tag reading and writing
metadata_handler.py — ID3/MP4 tag reading and writing metadata_editor.py -- GUI metadata editing widget
config_loader.py — Config in ~/.config/ config_loader.py -- Config in ~/.config/
ipod_device.py — iPod device detection and file management ipod_device.py -- iPod device detection and file management
ipod_nano7_db.py — Nano 7G SQLite + iTunesCDB + ArtworkDB ipod_nano7_db.py -- Nano 7G SQLite + iTunesCDB + ArtworkDB
hashab.py — HASHAB checksum generation ipod_mhbd_writer.py -- Binary iTunesDB writer
hashab.py -- HASHAB checksum generation
hotkeys.py -- Global keyboard shortcut management
library_cache.py -- Local library caching
artwork/ artwork/
__init__.py — Public API __init__.py -- Public API
presets.py — 50+ iPod artwork format definitions presets.py -- 50+ iPod artwork format definitions
types.py ArtworkEntry, EncodedFormatPayload, IthmbLocation types.py -- ArtworkEntry, EncodedFormatPayload, IthmbLocation
codecs.py RGB565/RGB555/UYVY/JPEG encode/decode (numpy) codecs.py -- RGB565/RGB555/UYVY/JPEG encode/decode (numpy)
extractor.py Cover extraction from audio files (mutagen) extractor.py -- Cover extraction from audio files (mutagen)
chunks.py — ArtworkDB binary parser/writer (MHFD→MHII→MHNI) chunks.py -- ArtworkDB binary parser/writer (MHFD->MHII->MHNI)
writer.py ithmb file + ArtworkDB writing writer.py -- ithmb file + ArtworkDB writing
cache.py Local JPEG cover cache for player UI cache.py -- Local JPEG cover cache for player UI
scripts/ scripts/
build-appimage.sh AppImage builder (PyInstaller + appimagetool) build-appimage.sh -- AppImage builder (PyInstaller + appimagetool)
``` ```
## Cover Art Re-embed
One-time script to restore cover art in already-converted files:
```bash
python /tmp/reembed_covers.py /path/to/source_music /path/to/output_dir
```
Matches output files to source files by artist+album, detects embedded covers and separate `cover.jpg`/`folder.jpg` files.
## Legal Disclaimer
This tool is intended for personal use only. Users are responsible for complying with YouTube's Terms of Service and copyright laws.
## License ## License
MIT License MIT License

View File

@ -1,7 +1,7 @@
@echo off @echo off
REM Installation script for YouTube Music to iPod Nano Transfer Tool (Windows) REM Installation script for neo-pod-desktop (Windows)
echo Installing YouTube Music to iPod Nano Transfer Tool... echo Installing neo-pod-desktop...
REM Check if Python 3 is installed REM Check if Python 3 is installed
python --version 2>NUL python --version 2>NUL
@ -32,7 +32,6 @@ pip install -r requirements.txt
REM Create necessary directories REM Create necessary directories
echo Creating necessary directories... echo Creating necessary directories...
if not exist temp mkdir temp if not exist temp mkdir temp
if not exist downloads mkdir downloads
if not exist converted mkdir converted if not exist converted mkdir converted
echo. echo.
@ -40,8 +39,7 @@ echo Installation complete!
echo. echo.
echo To run the application: echo To run the application:
echo 1. Activate the virtual environment: venv\Scripts\activate.bat echo 1. Activate the virtual environment: venv\Scripts\activate.bat
echo 2. Run the GUI application: python run.py echo 2. Run the application: python run.py
echo 3. Or run the CLI application: python run.py --cli --url "https://www.youtube.com/watch?v=VIDEO_ID"
echo. echo.
echo For more information, see the README.md file. echo For more information, see the README.md file.

View File

@ -1,9 +1,9 @@
#!/bin/bash #!/bin/bash
# Installation script for YouTube Music to iPod Nano Transfer Tool # Installation script for neo-pod-desktop
set -e set -e
echo "Installing YouTube Music to iPod Nano Transfer Tool..." echo "Installing neo-pod-desktop..."
# Check if Python 3 is installed # Check if Python 3 is installed
if ! command -v python3 &> /dev/null; then if ! command -v python3 &> /dev/null; then
@ -44,7 +44,6 @@ echo "Installation complete!"
echo "" echo ""
echo "To run the application:" echo "To run the application:"
echo " 1. Activate the virtual environment: source venv/bin/activate" echo " 1. Activate the virtual environment: source venv/bin/activate"
echo " 2. Run the GUI application: python run.py" echo " 2. Run the application: python run.py"
echo " 3. Or run the CLI application: python run.py --cli --url \"https://www.youtube.com/watch?v=VIDEO_ID\""
echo "" echo ""
echo "For more information, see the README.md file." echo "For more information, see the README.md file."

View File

@ -1,12 +1,9 @@
yt-dlp>=2023.3.4
pytube>=12.1.3
ffmpeg-python>=0.2.0 ffmpeg-python>=0.2.0
mutagen>=1.46.0 mutagen>=1.46.0
eyeD3>=0.9.7 eyeD3>=0.9.7
pyusb>=1.2.1 pyusb>=1.2.1
pillow>=9.5.0 pillow>=9.5.0
requests>=2.28.2 requests>=2.28.2
tqdm>=4.65.0
musicbrainzngs>=0.7.1 musicbrainzngs>=0.7.1
PyQt6>=6.5.0 PyQt6>=6.5.0
numpy>=1.24.0 numpy>=1.24.0

27
run.py
View File

@ -5,30 +5,19 @@ Launcher script for neo-pod-desktop
import os import os
import sys import sys
import argparse
def main(): def main():
"""Main entry point""" """Main entry point"""
parser = argparse.ArgumentParser(description="neo-pod-desktop")
parser.add_argument("--cli", action="store_true", help="Run in command-line mode")
args = parser.parse_args()
# Add src directory to path
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src") src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
sys.path.insert(0, src_dir) sys.path.insert(0, src_dir)
if args.cli: from src.main import QApplication, MainWindow
# Run CLI version app = QApplication(sys.argv)
from src.cli import YouTubeToIPodCLI window = MainWindow()
cli = YouTubeToIPodCLI() window.show()
return cli.run() return app.exec()
else:
# Run GUI version
from src.main import QApplication, MainWindow
app = QApplication(sys.argv)
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@ -17,7 +17,7 @@ with open('README.md', encoding='utf-8') as f:
setup( setup(
name="neo-pod-desktop", name="neo-pod-desktop",
version="1.0.0", version="1.0.0",
description="Desktop application for downloading, converting, managing and transferring music to iPod Nano devices", description="Desktop application for converting, managing and transferring music to iPod Nano devices",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
author="neo-pod-desktop", author="neo-pod-desktop",
@ -27,7 +27,7 @@ setup(
install_requires=requirements, install_requires=requirements,
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'neo-pod-desktop=src.cli:main', 'neo-pod-desktop=src.main:main',
], ],
}, },
classifiers=[ classifiers=[

View File

@ -10,7 +10,7 @@ import subprocess
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pathlib import Path from pathlib import Path
from youtube_downloader import TrackInfo from track_info import TrackInfo
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

View File

@ -1,337 +0,0 @@
#!/usr/bin/env python3
"""
Command Line Interface for neo-pod-desktop
Provides a CLI for downloading, converting, managing and transferring music to iPod Nano devices
"""
import os
import sys
import logging
import argparse
import time
from typing import List, Dict, Optional, Tuple
from pathlib import Path
from tqdm import tqdm
from youtube_downloader import YouTubeDownloader, TrackInfo
from audio_converter import AudioConverter
from metadata_handler import MetadataHandler
from ipod_device import IPodDevice
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class YouTubeToIPodCLI:
"""Command Line Interface for neo-pod-desktop"""
def __init__(self):
"""Initialize the CLI"""
self.downloader = None
self.converter = None
self.metadata_handler = None
self.ipod_device = None
self.downloaded_tracks = []
self.converted_tracks = []
self.temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "temp")
os.makedirs(self.temp_dir, exist_ok=True)
def parse_arguments(self):
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="neo-pod-desktop",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# URL argument
parser.add_argument(
"--url", "-u",
help="YouTube video or playlist URL",
required=True
)
# Output directory
parser.add_argument(
"--output-dir", "-o",
help="Output directory (iPod mount point)",
default=os.path.join(os.path.expanduser("~"), "Music", "iPod")
)
# Format
parser.add_argument(
"--format", "-f",
help="Audio format",
choices=["m4a", "mp3"],
default="m4a"
)
# Quality
parser.add_argument(
"--quality", "-q",
help="Audio quality in kbps",
type=int,
choices=[128, 192, 256, 320],
default=256
)
# Video support
parser.add_argument(
"--video",
help="Enable video download (for compatible iPod models)",
action="store_true"
)
# Video resolution
parser.add_argument(
"--resolution", "-r",
help="Video resolution (for video download)",
choices=["640x480", "480x360", "320x240"],
default="640x480"
)
# iPod device
parser.add_argument(
"--device", "-d",
help="iPod device mount point (if not specified, auto-detection will be attempted)",
default=None
)
# Skip steps
parser.add_argument(
"--skip-download",
help="Skip download step (use previously downloaded files)",
action="store_true"
)
parser.add_argument(
"--skip-convert",
help="Skip conversion step (use previously converted files)",
action="store_true"
)
parser.add_argument(
"--skip-transfer",
help="Skip transfer step (only download and convert)",
action="store_true"
)
# Clean temp files
parser.add_argument(
"--clean-temp",
help="Clean temporary files after transfer",
action="store_true"
)
return parser.parse_args()
def download(self, url: str, format: str = "m4a", quality: int = 256, video: bool = False) -> List[TrackInfo]:
"""
Download audio/video from YouTube
Args:
url: YouTube URL
format: Audio format
quality: Audio quality in kbps
video: Whether to download video
Returns:
List of TrackInfo objects
"""
logger.info(f"Downloading from URL: {url}")
# Initialize downloader
self.downloader = YouTubeDownloader(output_dir=self.temp_dir)
# Process URL
tracks = self.downloader.process_url(url, format, quality)
logger.info(f"Downloaded {len(tracks)} tracks")
return tracks
def convert(self,
tracks: List[TrackInfo],
output_dir: str,
format: str = "mp3",
quality: int = 256,
video: bool = False,
resolution: str = "640x480") -> List[Tuple[TrackInfo, str]]:
"""
Convert downloaded tracks to iPod-compatible format
Args:
tracks: List of TrackInfo objects
output_dir: Output directory
format: Audio format
quality: Audio quality in kbps
video: Whether to convert video
resolution: Video resolution
Returns:
List of (TrackInfo, output_path) tuples
"""
logger.info(f"Converting {len(tracks)} tracks to iPod-compatible format")
# Initialize converter and metadata handler
self.converter = AudioConverter(output_dir=output_dir)
self.metadata_handler = MetadataHandler(temp_dir=self.temp_dir)
# Convert each track
converted_tracks = []
for track in tqdm(tracks, desc="Converting", unit="track"):
if track.download_path and os.path.exists(track.download_path):
# Convert to iPod format
output_path = self.converter.convert_to_ipod_format(
track, track.download_path, format, quality
)
# Embed metadata
if output_path:
self.metadata_handler.process_file(output_path, track)
converted_tracks.append((track, output_path))
logger.info(f"Converted {len(converted_tracks)} tracks")
return converted_tracks
def transfer(self,
tracks: List[Tuple[TrackInfo, str]],
mount_point: Optional[str] = None) -> int:
"""
Transfer tracks to iPod
Args:
tracks: List of (TrackInfo, path) tuples
mount_point: iPod mount point (optional)
Returns:
Number of tracks transferred
"""
logger.info("Transferring tracks to iPod")
# Initialize iPod device
self.ipod_device = IPodDevice(mount_point=mount_point)
# If no mount point specified, try to detect and mount
if not mount_point:
logger.info("No mount point specified, attempting to detect iPod")
devices = self.ipod_device.detect_devices()
if not devices:
logger.error("No iPod devices found")
return 0
logger.info(f"Found iPod: {devices[0]['name']} ({devices[0]['model']})")
mount_point = self.ipod_device.mount_device(devices[0]['id'])
if not mount_point:
logger.error(f"Failed to mount iPod: {devices[0]['name']}")
return 0
logger.info(f"Mounted iPod at: {mount_point}")
# Get device info
device_info = self.ipod_device.get_device_info()
free_space = device_info.get("free_space", 0)
# Calculate total size of tracks
total_size = 0
for _, path in tracks:
if os.path.exists(path):
total_size += os.path.getsize(path)
# Check if there's enough space
if total_size > free_space:
logger.error(
f"Not enough space on iPod. Need {total_size / 1024**2:.1f} MB, " +
f"but only {free_space / 1024**2:.1f} MB available"
)
return 0
# Transfer each track
transferred_count = 0
for track, path in tqdm(tracks, desc="Transferring", unit="track"):
if os.path.exists(path):
# Transfer file
success = self.ipod_device.transfer_file(path)
if success:
transferred_count += 1
logger.info(f"Transferred {transferred_count} tracks to iPod")
return transferred_count
def clean_temp_files(self):
"""Clean temporary files"""
logger.info("Cleaning temporary files")
# Remove all files in temp directory
for file in os.listdir(self.temp_dir):
file_path = os.path.join(self.temp_dir, file)
try:
if os.path.isfile(file_path):
os.unlink(file_path)
except Exception as e:
logger.error(f"Error deleting {file_path}: {e}")
def run(self):
"""Run the CLI"""
# Parse arguments
args = self.parse_arguments()
try:
# Download step
if not args.skip_download:
self.downloaded_tracks = self.download(
args.url,
args.format,
args.quality,
args.video
)
else:
logger.info("Skipping download step")
# TODO: Load previously downloaded tracks
# Convert step
if not args.skip_convert:
self.converted_tracks = self.convert(
self.downloaded_tracks,
args.output_dir,
args.format,
args.quality,
args.video,
args.resolution
)
else:
logger.info("Skipping conversion step")
# TODO: Load previously converted tracks
# Transfer step
if not args.skip_transfer:
transferred_count = self.transfer(
self.converted_tracks,
args.device
)
if transferred_count > 0:
logger.info(f"Successfully transferred {transferred_count} tracks to iPod")
else:
logger.error("Failed to transfer tracks to iPod")
else:
logger.info("Skipping transfer step")
# Clean temp files
if args.clean_temp:
self.clean_temp_files()
return 0
except Exception as e:
logger.exception(f"Error: {e}")
return 1
if __name__ == "__main__":
cli = YouTubeToIPodCLI()
sys.exit(cli.run())

View File

@ -134,14 +134,8 @@ class ConfigLoader:
if not self.config.has_option('Advanced', 'temp_dir'): if not self.config.has_option('Advanced', 'temp_dir'):
self.config.set('Advanced', 'temp_dir', 'temp') self.config.set('Advanced', 'temp_dir', 'temp')
if not self.config.has_option('Advanced', 'concurrent_downloads'):
self.config.set('Advanced', 'concurrent_downloads', '2')
if not self.config.has_option('Advanced', 'debug'):
self.config.set('Advanced', 'debug', 'false')
if not self.config.has_option('Advanced', 'clean_temp'): if not self.config.has_option('Advanced', 'clean_temp'):
self.config.set('Advanced', 'clean_temp', 'true') self.config.set('Advanced', 'clean_temp', 'true')
if not self.config.has_option('Advanced', 'show_download'):
self.config.set('Advanced', 'show_download', 'false')
if not self.config.has_option('Advanced', 'hide_library_buttons'): if not self.config.has_option('Advanced', 'hide_library_buttons'):
self.config.set('Advanced', 'hide_library_buttons', 'false') self.config.set('Advanced', 'hide_library_buttons', 'false')
@ -157,15 +151,13 @@ class ConfigLoader:
'volume_down': 'Ctrl+Down', 'volume_down': 'Ctrl+Down',
'seek_forward': 'Right', 'seek_forward': 'Right',
'seek_backward': 'Left', 'seek_backward': 'Left',
'tab_download': 'Ctrl+1', 'tab_library': 'Ctrl+1',
'tab_library': 'Ctrl+2', 'tab_ipod': 'Ctrl+2',
'tab_ipod': 'Ctrl+3', 'tab_settings': 'Ctrl+3',
'tab_settings': 'Ctrl+4',
'search_focus': 'Ctrl+F', 'search_focus': 'Ctrl+F',
'select_all': 'Ctrl+A', 'select_all': 'Ctrl+A',
'library_refresh': 'F5', 'library_refresh': 'F5',
'ipod_refresh': 'Ctrl+R', 'ipod_refresh': 'Ctrl+R',
'download': 'Ctrl+Enter',
'delete_selected': 'Delete', 'delete_selected': 'Delete',
'library_add_files': 'Ctrl+O', 'library_add_files': 'Ctrl+O',
} }

View File

@ -84,15 +84,13 @@ DEFAULTS = [
("volume_down", "Ctrl+Down", "Volume Down", "global"), ("volume_down", "Ctrl+Down", "Volume Down", "global"),
("seek_forward", "Right", "Seek Forward 5s", "global"), ("seek_forward", "Right", "Seek Forward 5s", "global"),
("seek_backward", "Left", "Seek Backward 5s", "global"), ("seek_backward", "Left", "Seek Backward 5s", "global"),
("tab_download", "Ctrl+1", "Switch to Download", "global"), ("tab_library", "Ctrl+1", "Switch to Library", "global"),
("tab_library", "Ctrl+2", "Switch to Library", "global"), ("tab_ipod", "Ctrl+2", "Switch to iPod", "global"),
("tab_ipod", "Ctrl+3", "Switch to iPod", "global"), ("tab_settings", "Ctrl+3", "Switch to Settings", "global"),
("tab_settings", "Ctrl+4", "Switch to Settings", "global"),
("search_focus", "Ctrl+F", "Focus Search", "global"), ("search_focus", "Ctrl+F", "Focus Search", "global"),
("select_all", "Ctrl+A", "Select All", "global"), ("select_all", "Ctrl+A", "Select All", "global"),
("library_refresh", "F5", "Refresh Library", "global"), ("library_refresh", "F5", "Refresh Library", "global"),
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"), ("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
("download", "Ctrl+Enter", "Start Download", "global"),
("delete_selected", "Delete", "Delete Selected", "global"), ("delete_selected", "Delete", "Delete Selected", "global"),
("edit_metadata", "F2", "Edit Metadata", "global"), ("edit_metadata", "F2", "Edit Metadata", "global"),
("library_add_files", "Ctrl+O", "Library: Add Files", "global"), ("library_add_files", "Ctrl+O", "Library: Add Files", "global"),

View File

@ -22,7 +22,7 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer
from PyQt6.QtGui import QPixmap, QColor from PyQt6.QtGui import QPixmap, QColor
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from youtube_downloader import YouTubeDownloader, TrackInfo from track_info import TrackInfo
from audio_converter import AudioConverter from audio_converter import AudioConverter
from metadata_handler import MetadataHandler from metadata_handler import MetadataHandler
from metadata_editor import MetadataEditorDialog from metadata_editor import MetadataEditorDialog
@ -72,7 +72,6 @@ class WorkerThread(QThread):
def run(self): def run(self):
try: try:
handlers = { handlers = {
"download": self._run_download,
"convert": self._run_convert, "convert": self._run_convert,
"transfer": self._run_transfer, "transfer": self._run_transfer,
"detect_devices": self._run_detect_devices, "detect_devices": self._run_detect_devices,
@ -89,29 +88,6 @@ class WorkerThread(QThread):
logger.exception(f"Error in worker thread: {e}") logger.exception(f"Error in worker thread: {e}")
self.finished_signal.emit(False, str(e), None) self.finished_signal.emit(False, str(e), None)
def _run_download(self):
url = self.kwargs.get("url")
output_dir = self.kwargs.get("output_dir", "downloads")
fmt = self.kwargs.get("format", "m4a")
quality = self.kwargs.get("quality", 256)
self.progress_signal.emit(0, f"Initializing download from {url}")
downloader = YouTubeDownloader(
output_dir=output_dir,
progress_callback=self._download_progress_callback
)
tracks = downloader.process_url(url, fmt, quality)
if not tracks:
self.finished_signal.emit(False, "No tracks were successfully downloaded", [])
return
self.finished_signal.emit(True, f"Downloaded {len(tracks)} tracks", tracks)
def _download_progress_callback(self, progress: int, status: str):
self.progress_signal.emit(progress, status)
def _run_convert(self): def _run_convert(self):
tracks = self.kwargs.get("tracks", []) tracks = self.kwargs.get("tracks", [])
local_files = self.kwargs.get("local_files", []) local_files = self.kwargs.get("local_files", [])
@ -303,16 +279,14 @@ class MainWindow(QMainWindow):
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.worker_thread = None self.worker_thread = None
self.downloaded_tracks: List[TrackInfo] = []
self.library_ready: List[Dict] = [] self.library_ready: List[Dict] = []
self.library_source_files: List[str] = [] self.library_source_files: List[str] = []
self.library_scanned_sources: List[Dict] = [] self.library_scanned_sources: List[Dict] = []
self.ipod_devices: List[Dict] = [] self.ipod_devices: List[Dict] = []
self.current_mount_point: Optional[str] = None self.current_mount_point: Optional[str] = None
self.show_download_tab = False
self.hide_library_buttons = False self.hide_library_buttons = False
self._library_toolbar_buttons: list = [] self._library_toolbar_buttons: list = []
self.ipod_tab_index = 2 self.ipod_tab_index = 1
self.config_loader = ConfigLoader() self.config_loader = ConfigLoader()
self.hotkey_manager: Optional[HotkeyManager] = None self.hotkey_manager: Optional[HotkeyManager] = None
@ -358,12 +332,6 @@ class MainWindow(QMainWindow):
) )
self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a")) self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a"))
self.quality_spin.setValue(config.get_int("General", "quality", fallback=256)) self.quality_spin.setValue(config.get_int("General", "quality", fallback=256))
self.enable_video_check.setChecked(
config.get_boolean("Video", "enable_video", fallback=False)
)
self.video_resolution_combo.setCurrentText(
config.get("Video", "resolution", fallback="640x480")
)
self.embed_artwork_check.setChecked( self.embed_artwork_check.setChecked(
config.get_boolean("Metadata", "embed_artwork", fallback=True) config.get_boolean("Metadata", "embed_artwork", fallback=True)
) )
@ -376,11 +344,6 @@ class MainWindow(QMainWindow):
self.auto_detect_check.setChecked( self.auto_detect_check.setChecked(
config.get_boolean("Device", "auto_detect", fallback=True) config.get_boolean("Device", "auto_detect", fallback=True)
) )
show_download = config.get_boolean("Advanced", "show_download", fallback=False)
self.show_download_check.setChecked(show_download)
self.show_download_tab = show_download
self.tab_widget.setTabVisible(self.download_tab_index, show_download)
hide_library_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False) hide_library_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
self.hide_library_buttons_check.setChecked(hide_library_buttons) self.hide_library_buttons_check.setChecked(hide_library_buttons)
self._on_hide_library_buttons_toggled(hide_library_buttons) self._on_hide_library_buttons_toggled(hide_library_buttons)
@ -395,13 +358,10 @@ class MainWindow(QMainWindow):
config.set("General", "output_dir", self.output_dir_input.text()) config.set("General", "output_dir", self.output_dir_input.text())
config.set("General", "format", self.format_combo.currentText()) config.set("General", "format", self.format_combo.currentText())
config.set("General", "quality", str(self.quality_spin.value())) config.set("General", "quality", str(self.quality_spin.value()))
config.set("Video", "enable_video", str(self.enable_video_check.isChecked()).lower())
config.set("Video", "resolution", self.video_resolution_combo.currentText())
config.set("Metadata", "embed_artwork", str(self.embed_artwork_check.isChecked()).lower()) config.set("Metadata", "embed_artwork", str(self.embed_artwork_check.isChecked()).lower())
config.set("Metadata", "use_musicbrainz", str(self.use_musicbrainz_check.isChecked()).lower()) config.set("Metadata", "use_musicbrainz", str(self.use_musicbrainz_check.isChecked()).lower())
config.set("Advanced", "clean_temp", str(self.clean_temp_check.isChecked()).lower()) config.set("Advanced", "clean_temp", str(self.clean_temp_check.isChecked()).lower())
config.set("Device", "auto_detect", str(self.auto_detect_check.isChecked()).lower()) config.set("Device", "auto_detect", str(self.auto_detect_check.isChecked()).lower())
config.set("Advanced", "show_download", str(self.show_download_check.isChecked()).lower())
config.set("Advanced", "hide_library_buttons", str(self.hide_library_buttons_check.isChecked()).lower()) config.set("Advanced", "hide_library_buttons", str(self.hide_library_buttons_check.isChecked()).lower())
config.set("Playback", "last_volume", str(self.volume_slider.value())) config.set("Playback", "last_volume", str(self.volume_slider.value()))
@ -433,15 +393,13 @@ class MainWindow(QMainWindow):
"volume_down": lambda: self._adjust_volume(-5), "volume_down": lambda: self._adjust_volume(-5),
"seek_forward": self._seek_forward, "seek_forward": self._seek_forward,
"seek_backward": self._seek_backward, "seek_backward": self._seek_backward,
"tab_download": lambda: self.tab_widget.setCurrentIndex(self.download_tab_index), "tab_library": lambda: self.tab_widget.setCurrentIndex(0),
"tab_library": lambda: self.tab_widget.setCurrentIndex(1),
"tab_ipod": lambda: self.tab_widget.setCurrentIndex(self.ipod_tab_index), "tab_ipod": lambda: self.tab_widget.setCurrentIndex(self.ipod_tab_index),
"tab_settings": lambda: self.tab_widget.setCurrentIndex(3), "tab_settings": lambda: self.tab_widget.setCurrentIndex(2),
"search_focus": self._focus_search, "search_focus": self._focus_search,
"select_all": self._select_all_current, "select_all": self._select_all_current,
"library_refresh": self._on_refresh_library_clicked, "library_refresh": self._on_refresh_library_clicked,
"ipod_refresh": self._on_refresh_devices_clicked, "ipod_refresh": self._on_refresh_devices_clicked,
"download": self._on_download_clicked,
"delete_selected": self._delete_selected_current, "delete_selected": self._delete_selected_current,
"edit_metadata": self._on_edit_metadata, "edit_metadata": self._on_edit_metadata,
"library_add_files": self._on_library_add_files, "library_add_files": self._on_library_add_files,
@ -459,11 +417,6 @@ class MainWindow(QMainWindow):
self.tab_widget = QTabWidget() self.tab_widget = QTabWidget()
main_layout.addWidget(self.tab_widget) main_layout.addWidget(self.tab_widget)
download_tab = QWidget()
self.tab_widget.addTab(download_tab, "Download")
self._setup_download_tab(download_tab)
self.download_tab_index = 0
library_tab = QWidget() library_tab = QWidget()
self.tab_widget.addTab(library_tab, "Library") self.tab_widget.addTab(library_tab, "Library")
self._setup_library_tab(library_tab) self._setup_library_tab(library_tab)
@ -476,56 +429,11 @@ class MainWindow(QMainWindow):
self.tab_widget.addTab(settings_tab, "Settings") self.tab_widget.addTab(settings_tab, "Settings")
self._setup_settings_tab(settings_tab) self._setup_settings_tab(settings_tab)
self.tab_widget.setTabVisible(self.download_tab_index, self.show_download_tab)
self.tab_widget.setTabVisible(self.ipod_tab_index, False) self.tab_widget.setTabVisible(self.ipod_tab_index, False)
self.statusBar().showMessage("Ready") self.statusBar().showMessage("Ready")
self.setCentralWidget(main_widget) self.setCentralWidget(main_widget)
def _setup_download_tab(self, tab):
layout = QVBoxLayout(tab)
url_layout = QHBoxLayout()
url_label = QLabel("YouTube URL:")
self.url_input = QLineEdit()
self.url_input.setPlaceholderText("https://www.youtube.com/watch?v=...")
url_layout.addWidget(url_label)
url_layout.addWidget(self.url_input)
layout.addLayout(url_layout)
format_layout = QHBoxLayout()
format_label = QLabel("Format:")
self.format_combo = QComboBox()
self.format_combo.addItems(["mp3", "m4a"])
quality_label = QLabel("Quality (kbps):")
self.quality_spin = QSpinBox()
self.quality_spin.setRange(128, 320)
self.quality_spin.setValue(256)
self.quality_spin.setSingleStep(32)
format_layout.addWidget(format_label)
format_layout.addWidget(self.format_combo)
format_layout.addWidget(quality_label)
format_layout.addWidget(self.quality_spin)
format_layout.addStretch()
layout.addLayout(format_layout)
self.download_button = QPushButton("Download")
self.download_button.clicked.connect(self._on_download_clicked)
layout.addWidget(self.download_button)
self.download_progress = QProgressBar()
self.download_progress.setRange(0, 100)
layout.addWidget(self.download_progress)
self.download_status = QLabel("Ready to download")
layout.addWidget(self.download_status)
track_list_label = QLabel("Downloaded Tracks:")
layout.addWidget(track_list_label)
self.track_list = QListWidget()
layout.addWidget(self.track_list)
def _setup_library_tab(self, tab): def _setup_library_tab(self, tab):
layout = QVBoxLayout(tab) layout = QVBoxLayout(tab)
@ -783,28 +691,25 @@ class MainWindow(QMainWindow):
output_layout.addWidget(self.output_dir_input) output_layout.addWidget(self.output_dir_input)
output_layout.addWidget(browse_btn) output_layout.addWidget(browse_btn)
library_layout.addLayout(output_layout) library_layout.addLayout(output_layout)
convert_layout = QHBoxLayout()
convert_format_label = QLabel("Convert Format:")
self.format_combo = QComboBox()
self.format_combo.addItems(["mp3", "m4a"])
convert_quality_label = QLabel("Quality (kbps):")
self.quality_spin = QSpinBox()
self.quality_spin.setRange(128, 320)
self.quality_spin.setValue(256)
self.quality_spin.setSingleStep(32)
convert_layout.addWidget(convert_format_label)
convert_layout.addWidget(self.format_combo)
convert_layout.addWidget(convert_quality_label)
convert_layout.addWidget(self.quality_spin)
convert_layout.addStretch()
library_layout.addLayout(convert_layout)
layout.addWidget(library_group) layout.addWidget(library_group)
video_group = QGroupBox("Video Support")
video_layout = QVBoxLayout(video_group)
self.enable_video_check = QCheckBox("Enable Video Download (iPod Nano 5th-7th gen)")
video_layout.addWidget(self.enable_video_check)
video_resolution_layout = QHBoxLayout()
video_resolution_label = QLabel("Video Resolution:")
self.video_resolution_combo = QComboBox()
self.video_resolution_combo.addItems(["640x480", "480x360", "320x240"])
self.video_resolution_combo.setEnabled(False)
self.enable_video_check.toggled.connect(self.video_resolution_combo.setEnabled)
video_resolution_layout.addWidget(video_resolution_label)
video_resolution_layout.addWidget(self.video_resolution_combo)
video_resolution_layout.addStretch()
video_layout.addLayout(video_resolution_layout)
layout.addWidget(video_group)
metadata_group = QGroupBox("Metadata Options") metadata_group = QGroupBox("Metadata Options")
metadata_layout = QVBoxLayout(metadata_group) metadata_layout = QVBoxLayout(metadata_group)
@ -828,11 +733,6 @@ class MainWindow(QMainWindow):
self.auto_detect_check.setChecked(True) self.auto_detect_check.setChecked(True)
advanced_layout.addWidget(self.auto_detect_check) advanced_layout.addWidget(self.auto_detect_check)
self.show_download_check = QCheckBox("Show YouTube Download Tab")
self.show_download_check.setChecked(False)
self.show_download_check.toggled.connect(self._on_show_download_toggled)
advanced_layout.addWidget(self.show_download_check)
self.hide_library_buttons_check = QCheckBox("Hide Library Toolbar Buttons") self.hide_library_buttons_check = QCheckBox("Hide Library Toolbar Buttons")
self.hide_library_buttons_check.setChecked(False) self.hide_library_buttons_check.setChecked(False)
self.hide_library_buttons_check.toggled.connect(self._on_hide_library_buttons_toggled) self.hide_library_buttons_check.toggled.connect(self._on_hide_library_buttons_toggled)
@ -872,10 +772,6 @@ class MainWindow(QMainWindow):
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter) about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(about_label) layout.addWidget(about_label)
def _on_show_download_toggled(self, checked: bool):
self.show_download_tab = checked
self.tab_widget.setTabVisible(self.download_tab_index, checked)
def _on_hide_library_buttons_toggled(self, checked: bool): def _on_hide_library_buttons_toggled(self, checked: bool):
self.hide_library_buttons = checked self.hide_library_buttons = checked
for btn in self._library_toolbar_buttons: for btn in self._library_toolbar_buttons:
@ -1433,7 +1329,7 @@ class MainWindow(QMainWindow):
if not is_ready: if not is_ready:
continue continue
track_info = TrackInfo( track_info = TrackInfo(
video_id=f"local_{hash(data['path'])}", source_id=f"local_{hash(data['path'])}",
title=data['title'], title=data['title'],
artist=data['artist'], artist=data['artist'],
album=data.get("album", ""), album=data.get("album", ""),
@ -1680,66 +1576,13 @@ class MainWindow(QMainWindow):
else: else:
subprocess.Popen(["xdg-open", os.path.dirname(path)]) subprocess.Popen(["xdg-open", os.path.dirname(path)])
def _on_download_clicked(self):
url = self.url_input.text().strip()
if not url:
QMessageBox.warning(self, "Error", "Please enter a YouTube URL")
return
if not (url.startswith("http://") or url.startswith("https://")):
QMessageBox.warning(self, "Error", "Please enter a valid URL")
return
if self.worker_thread and self.worker_thread.isRunning():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
self.download_button.setEnabled(False)
self.download_status.setText("Downloading...")
self.download_progress.setValue(0)
self.track_list.clear()
self.worker_thread = WorkerThread(
task_type="download",
url=url,
format=self.format_combo.currentText(),
quality=self.quality_spin.value()
)
self.worker_thread.progress_signal.connect(self._on_download_progress)
self.worker_thread.finished_signal.connect(self._on_download_finished)
self.worker_thread.start()
def _on_download_progress(self, progress, status):
self.download_progress.setValue(progress)
self.download_status.setText(status)
def _on_download_finished(self, success, message, result):
self.download_button.setEnabled(True)
if success and result:
self.download_status.setText(message)
self.downloaded_tracks = result
self.track_list.clear()
for track in self.downloaded_tracks:
if hasattr(track, 'download_path') and track.download_path:
item = QListWidgetItem(f"{track.artist} - {track.title}")
self.track_list.addItem(item)
self._scan_library()
else:
self.download_status.setText(f"Error: {message}")
QMessageBox.warning(self, "Download Error", message)
self.downloaded_tracks = []
self.track_list.clear()
self._cleanup_worker()
def _on_library_convert_clicked(self): def _on_library_convert_clicked(self):
source_files = self._get_selected_source_files() source_files = self._get_selected_source_files()
has_downloaded = bool(self.downloaded_tracks)
if not source_files and not has_downloaded: if not source_files:
QMessageBox.warning( QMessageBox.warning(
self, "Error", self, "Error",
"No tracks to convert. Add local source files or download tracks first." "No tracks to convert. Add local source files first."
) )
return return
@ -1757,7 +1600,7 @@ class MainWindow(QMainWindow):
self.library_progress.setValue(0) self.library_progress.setValue(0)
kwargs = { kwargs = {
"tracks": self.downloaded_tracks if has_downloaded else [], "tracks": [],
"local_files": source_files, "local_files": source_files,
"output_dir": output_dir, "output_dir": output_dir,
"format": self.format_combo.currentText(), "format": self.format_combo.currentText(),
@ -2332,15 +2175,18 @@ def _setup_linux_theming():
logger.debug("No GTK platform theme plugin found, using Qt default style") logger.debug("No GTK platform theme plugin found, using Qt default style")
if __name__ == "__main__": def main():
_setup_linux_theming() _setup_linux_theming()
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Apply Fusion as fallback style if no platform theme was applied
if not os.environ.get("QT_QPA_PLATFORMTHEME"): if not os.environ.get("QT_QPA_PLATFORMTHEME"):
app.setStyle("Fusion") app.setStyle("Fusion")
window = MainWindow() window = MainWindow()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@ -20,7 +20,7 @@ from mutagen.mp4 import MP4, MP4Cover
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from youtube_downloader import TrackInfo from track_info import TrackInfo
from artwork.cache import cover_cache, xdg_cache_path from artwork.cache import cover_cache, xdg_cache_path
# Configure logging # Configure logging
@ -356,7 +356,7 @@ class MetadataHandler:
cover_path = cover_cache.put(artist or "Unknown Artist", album or "", cover_data) cover_path = cover_cache.put(artist or "Unknown Artist", album or "", cover_data)
return TrackInfo( return TrackInfo(
video_id=f"local_{hash(file_path)}", source_id=f"local_{hash(file_path)}",
title=title or base_name, title=title or base_name,
artist=artist or "Unknown Artist", artist=artist or "Unknown Artist",
album=album or "", album=album or "",
@ -533,7 +533,7 @@ class MetadataHandler:
artist, title = filename.split(" - ", 1) artist, title = filename.split(" - ", 1)
return TrackInfo( return TrackInfo(
video_id=f"local_{hash(file_path or filename)}", source_id=f"local_{hash(file_path or filename)}",
title=title, title=title,
artist=artist, artist=artist,
album="", album="",

23
src/track_info.py Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""
Track Info data class shared across the iPod transfer pipeline.
"""
from typing import Optional
from dataclasses import dataclass
@dataclass
class TrackInfo:
"""Data class to store track information."""
source_id: str
title: str
artist: str
album: str
thumbnail_url: str
duration: int
track_number: Optional[int] = None
playlist_title: Optional[str] = None
download_path: Optional[str] = None
genre: Optional[str] = None
cover_path: Optional[str] = None

View File

@ -1,417 +0,0 @@
#!/usr/bin/env python3
"""
YouTube Downloader Module for iPod Nano Transfer Tool
Handles downloading audio from YouTube videos and playlists
"""
import os
import re
import logging
import time
from typing import List, Dict, Optional, Tuple, Callable
from dataclasses import dataclass
import yt_dlp
from tqdm import tqdm
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@dataclass
class TrackInfo:
"""Data class to store track information"""
video_id: str
title: str
artist: str
album: str
thumbnail_url: str
duration: int
track_number: Optional[int] = None
playlist_id: Optional[str] = None
playlist_title: Optional[str] = None
playlist_index: Optional[int] = None
download_path: Optional[str] = None
genre: Optional[str] = None
cover_path: Optional[str] = None
class YouTubeDownloader:
"""Class to handle YouTube video and playlist downloads"""
def __init__(self, output_dir: str = "downloads", temp_dir: str = "temp", progress_callback: Optional[Callable] = None):
"""
Initialize the YouTube downloader
Args:
output_dir: Directory to save downloaded files
temp_dir: Directory for temporary files
progress_callback: Optional callback function for progress updates
"""
self.output_dir = output_dir
self.temp_dir = temp_dir
self.progress_callback = progress_callback
# Create directories if they don't exist
os.makedirs(output_dir, exist_ok=True)
os.makedirs(temp_dir, exist_ok=True)
# Playlist regex pattern
self.playlist_regex = r'(?:list=)([a-zA-Z0-9_-]+)'
# Track download progress
self.download_start_time = 0
self.current_track = None
def _parse_artist_title(self, video_title: str) -> Tuple[str, str]:
"""
Parse artist and title from video title
Args:
video_title: YouTube video title
Returns:
Tuple of (artist, title)
"""
# Common patterns: "Artist - Title", "Artist: Title", "Artist | Title"
patterns = [
r'^(.*?)\s*-\s*(.*?)$', # Artist - Title
r'^(.*?)\s*:\s*(.*?)$', # Artist: Title
r'^(.*?)\s*\|\s*(.*?)$' # Artist | Title
]
for pattern in patterns:
match = re.match(pattern, video_title)
if match:
artist, title = match.groups()
return artist.strip(), title.strip()
# If no pattern matches, return empty artist and full title
return "", video_title.strip()
def extract_video_info(self, video_url: str) -> Optional[TrackInfo]:
"""
Extract information from a YouTube video
Args:
video_url: YouTube video URL
Returns:
TrackInfo object with video metadata or None if video is unavailable
"""
logger.info(f"Extracting info from video: {video_url}")
ydl_opts = {
'quiet': True,
'no_warnings': True,
'skip_download': True,
'extract_flat': True,
'ignoreerrors': True, # Continue on download errors
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
if not info:
logger.warning(f"Could not extract info from video: {video_url}")
return None
video_id = info.get('id')
video_title = info.get('title', '')
artist, title = self._parse_artist_title(video_title)
# Get best thumbnail
thumbnails = info.get('thumbnails', [])
thumbnail_url = next((t['url'] for t in reversed(thumbnails)
if 'url' in t), None) if thumbnails else None
return TrackInfo(
video_id=video_id,
title=title,
artist=artist,
album=artist, # Default album to artist name
thumbnail_url=thumbnail_url,
duration=info.get('duration', 0)
)
except Exception as e:
logger.warning(f"Error extracting video info: {e}")
return None
def extract_playlist_info(self, playlist_url: str) -> List[TrackInfo]:
"""
Extract information from a YouTube playlist
Args:
playlist_url: YouTube playlist URL
Returns:
List of TrackInfo objects for each video in the playlist
"""
logger.info(f"Extracting info from playlist: {playlist_url}")
# Extract playlist ID
playlist_id_match = re.search(self.playlist_regex, playlist_url)
if not playlist_id_match:
raise ValueError(f"Invalid playlist URL: {playlist_url}")
playlist_id = playlist_id_match.group(1)
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'skip_download': True,
'ignoreerrors': True, # Continue on download errors
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
playlist_info = ydl.extract_info(
f'https://www.youtube.com/playlist?list={playlist_id}',
download=False
)
if not playlist_info or 'entries' not in playlist_info:
logger.warning(f"Could not extract playlist info: {playlist_url}")
return []
playlist_title = playlist_info.get('title', 'Unknown Playlist')
tracks = []
track_number = 1 # Keep track of actual track numbers
# Update progress if callback is available
if self.progress_callback:
self.progress_callback(0, f"Analyzing playlist: {playlist_title}")
total_entries = len(playlist_info.get('entries', []))
for idx, entry in enumerate(playlist_info.get('entries', []), 1):
# Update progress for playlist analysis
if self.progress_callback:
progress = int((idx / total_entries) * 10) # Use first 10% for playlist analysis
self.progress_callback(progress, f"Analyzing playlist: {idx}/{total_entries} tracks")
if not entry or entry.get('_type') != 'url':
continue
try:
video_info = self.extract_video_info(f"https://www.youtube.com/watch?v={entry['id']}")
if video_info:
video_info.playlist_id = playlist_id
video_info.playlist_title = playlist_title
video_info.playlist_index = idx
video_info.track_number = track_number
video_info.album = playlist_title # Set album to playlist title
tracks.append(video_info)
track_number += 1
else:
logger.warning(f"Skipping unavailable video in playlist: {entry.get('id')}")
except Exception as e:
logger.warning(f"Error processing playlist entry {idx}: {e}")
return tracks
except Exception as e:
logger.error(f"Error extracting playlist info: {e}")
return []
def download_audio(self, track_info: TrackInfo, format: str = "m4a", quality: int = 256) -> Optional[str]:
"""
Download audio from YouTube video
Args:
track_info: TrackInfo object with video metadata
format: Audio format (m4a, mp3)
quality: Audio quality in kbps
Returns:
Path to downloaded file or None if download failed
"""
logger.info(f"Downloading audio for: {track_info.title}")
self.current_track = track_info
self.download_start_time = time.time()
# Create output filename
safe_title = re.sub(r'[^\w\-_\. ]', '_', track_info.title)
if track_info.track_number:
filename = f"{track_info.track_number:02d}. {safe_title}.{format}"
else:
filename = f"{safe_title}.{format}"
output_path = os.path.join(self.temp_dir, filename)
# Set up yt-dlp options
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': output_path,
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': format,
'preferredquality': str(quality),
}],
'quiet': False,
'progress_hooks': [self._download_progress_hook],
'ignoreerrors': True, # Continue on download errors
}
try:
# Download the file
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={track_info.video_id}"])
# Check if file was actually downloaded
expected_path = f"{output_path}.{format}"
if os.path.exists(expected_path):
# Update track_info with download path
track_info.download_path = expected_path
return track_info.download_path
else:
logger.warning(f"Download failed for: {track_info.title}")
return None
except Exception as e:
logger.error(f"Error downloading audio: {e}")
return None
def _download_progress_hook(self, d: Dict):
"""Progress hook for yt-dlp"""
if d['status'] == 'downloading':
# Calculate download progress
if 'total_bytes' in d and d['total_bytes'] > 0:
percent = d['downloaded_bytes'] / d['total_bytes'] * 100
# Calculate download speed
elapsed_time = time.time() - self.download_start_time
if elapsed_time > 0:
download_speed = d['downloaded_bytes'] / elapsed_time / 1024 # KB/s
# Format download speed
speed_str = f"{download_speed:.1f} KB/s"
if download_speed > 1024:
speed_str = f"{download_speed/1024:.1f} MB/s"
# Format file size
total_size_mb = d['total_bytes'] / (1024 * 1024)
downloaded_mb = d['downloaded_bytes'] / (1024 * 1024)
# Create status message
status_msg = f"Downloading {self.current_track.title}: {downloaded_mb:.1f}/{total_size_mb:.1f} MB ({percent:.1f}%) at {speed_str}"
# Log progress
logger.info(status_msg)
# Call progress callback if available
if self.progress_callback:
# Scale to 10-95% range to leave room for playlist analysis and completion
scaled_percent = 10 + (percent * 0.85)
self.progress_callback(int(scaled_percent), status_msg)
# If we don't have total_bytes, show indeterminate progress
elif 'downloaded_bytes' in d:
elapsed_time = time.time() - self.download_start_time
if elapsed_time > 0:
download_speed = d['downloaded_bytes'] / elapsed_time / 1024 # KB/s
# Format download speed
speed_str = f"{download_speed:.1f} KB/s"
if download_speed > 1024:
speed_str = f"{download_speed/1024:.1f} MB/s"
# Format file size
downloaded_mb = d['downloaded_bytes'] / (1024 * 1024)
# Create status message
status_msg = f"Downloading {self.current_track.title}: {downloaded_mb:.1f} MB at {speed_str}"
# Log progress
logger.info(status_msg)
# Call progress callback if available
if self.progress_callback:
# Use a pulsing progress between 10-90% for indeterminate progress
pulse_progress = 10 + (int(time.time() * 10) % 80)
self.progress_callback(pulse_progress, status_msg)
elif d['status'] == 'finished':
logger.info(f"Download complete: {self.current_track.title}")
if self.progress_callback:
self.progress_callback(95, f"Download complete: {self.current_track.title}")
elif d['status'] == 'error':
logger.warning(f"Download error: {self.current_track.title}")
if self.progress_callback:
self.progress_callback(95, f"Download error: {self.current_track.title}")
def process_url(self, url: str, format: str = "m4a", quality: int = 256) -> List[TrackInfo]:
"""
Process a YouTube URL (video or playlist)
Args:
url: YouTube URL
format: Audio format
quality: Audio quality in kbps
Returns:
List of TrackInfo objects with download paths
"""
successful_tracks = []
try:
# Check if URL is a playlist
if 'list=' in url:
# Process as playlist
tracks = self.extract_playlist_info(url)
if not tracks:
logger.warning("No valid tracks found in playlist")
return []
# Download each track
for i, track in enumerate(tracks):
if self.progress_callback:
status_msg = f"Preparing to download {i+1}/{len(tracks)}: {track.title}"
self.progress_callback(10, status_msg)
try:
download_path = self.download_audio(track, format, quality)
if download_path:
successful_tracks.append(track)
except Exception as e:
logger.error(f"Error downloading track {track.title}: {e}")
else:
# Process as single video
track = self.extract_video_info(url)
if track:
try:
download_path = self.download_audio(track, format, quality)
if download_path:
successful_tracks.append(track)
except Exception as e:
logger.error(f"Error downloading track {track.title}: {e}")
else:
logger.warning("Could not extract video information")
# Final progress update
if self.progress_callback:
self.progress_callback(100, f"Downloaded {len(successful_tracks)} tracks successfully")
return successful_tracks
except Exception as e:
logger.error(f"Error processing URL: {e}")
if self.progress_callback:
self.progress_callback(100, f"Error: {str(e)}")
return successful_tracks
if __name__ == "__main__":
# Example usage
downloader = YouTubeDownloader()
# Test with a single video
video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
video_info = downloader.extract_video_info(video_url)
print(f"Video: {video_info.artist} - {video_info.title}")
# Uncomment to test download
# downloader.download_audio(video_info)

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Basic tests for YouTube Music to iPod Nano Transfer Tool Basic tests for neo-pod-desktop
""" """
import os import os
@ -8,55 +8,22 @@ import sys
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
# Add src directory to path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
from youtube_downloader import YouTubeDownloader, TrackInfo
from audio_converter import AudioConverter from audio_converter import AudioConverter
from metadata_handler import MetadataHandler from metadata_handler import MetadataHandler
from ipod_device import IPodDevice
class TestYouTubeDownloader(unittest.TestCase):
"""Test YouTube downloader functionality"""
def test_parse_artist_title(self):
"""Test parsing artist and title from video title"""
downloader = YouTubeDownloader()
# Test case 1: Artist - Title
artist, title = downloader._parse_artist_title("Rick Astley - Never Gonna Give You Up")
self.assertEqual(artist, "Rick Astley")
self.assertEqual(title, "Never Gonna Give You Up")
# Test case 2: Artist: Title
artist, title = downloader._parse_artist_title("Rick Astley: Never Gonna Give You Up")
self.assertEqual(artist, "Rick Astley")
self.assertEqual(title, "Never Gonna Give You Up")
# Test case 3: Artist | Title
artist, title = downloader._parse_artist_title("Rick Astley | Never Gonna Give You Up")
self.assertEqual(artist, "Rick Astley")
self.assertEqual(title, "Never Gonna Give You Up")
# Test case 4: No pattern match
artist, title = downloader._parse_artist_title("Never Gonna Give You Up")
self.assertEqual(artist, "")
self.assertEqual(title, "Never Gonna Give You Up")
class TestAudioConverter(unittest.TestCase): class TestAudioConverter(unittest.TestCase):
"""Test audio converter functionality""" """Test audio converter functionality"""
def test_sanitize_filename(self): def test_sanitize_filename(self):
"""Test filename sanitization""" """Test filename sanitization"""
converter = AudioConverter() converter = AudioConverter()
# Test case 1: Invalid characters
sanitized = converter._sanitize_filename('File: with "invalid" chars?') sanitized = converter._sanitize_filename('File: with "invalid" chars?')
self.assertEqual(sanitized, 'File_ with _invalid_ chars_') self.assertEqual(sanitized, 'File_ with _invalid_ chars_')
# Test case 2: Long filename
long_name = "A" * 150 long_name = "A" * 150
sanitized = converter._sanitize_filename(long_name) sanitized = converter._sanitize_filename(long_name)
self.assertEqual(len(sanitized), 100) self.assertEqual(len(sanitized), 100)
@ -65,26 +32,23 @@ class TestAudioConverter(unittest.TestCase):
class TestMetadataHandler(unittest.TestCase): class TestMetadataHandler(unittest.TestCase):
"""Test metadata handler functionality""" """Test metadata handler functionality"""
@patch('requests.get') @patch('requests.get')
def test_download_thumbnail(self, mock_get): def test_download_thumbnail(self, mock_get):
"""Test thumbnail download""" """Test thumbnail download"""
# Mock response
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.return_value = None mock_response.raise_for_status.return_value = None
mock_response.content = b'fake_image_data' mock_response.content = b'fake_image_data'
mock_get.return_value = mock_response mock_get.return_value = mock_response
# Mock PIL Image
with patch('PIL.Image.open') as mock_image_open: with patch('PIL.Image.open') as mock_image_open:
mock_image = MagicMock() mock_image = MagicMock()
mock_image.save.return_value = None mock_image.save.return_value = None
mock_image_open.return_value = mock_image mock_image_open.return_value = mock_image
handler = MetadataHandler() handler = MetadataHandler()
result = handler.download_thumbnail("https://example.com/image.jpg") result = handler.download_thumbnail("https://example.com/image.jpg")
# Check that the function returned a path
self.assertIsNotNone(result) self.assertIsNotNone(result)
mock_get.assert_called_once_with("https://example.com/image.jpg", timeout=10) mock_get.assert_called_once_with("https://example.com/image.jpg", timeout=10)