refactor: remove YouTube download feature, extract TrackInfo dataclass, clean up config and hotkeys
This commit is contained in:
parent
9cdb27dace
commit
e569f8c731
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,7 +15,6 @@ ENV/
|
||||
|
||||
# Temporary files
|
||||
temp/
|
||||
downloads/
|
||||
converted/
|
||||
|
||||
# IDE files
|
||||
|
||||
74
README.md
74
README.md
@ -1,17 +1,16 @@
|
||||
# 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
|
||||
|
||||
- **Download** audio from YouTube videos/playlists
|
||||
- **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
|
||||
- **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
|
||||
- **iPod Nano 7G database** — full iTunesCDB sync, SQLite management, Locations.itdb with HASHAB checksums
|
||||
- **Library management** — sortable table, source/ready track separation, bulk transfer
|
||||
- **Portable AppImage** — `scripts/build-appimage.sh` builds standalone x86_64 AppImage
|
||||
- **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
|
||||
- **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
|
||||
- **Library management** -- sortable table, source/ready track separation, bulk transfer
|
||||
- **Portable AppImage** -- `scripts/build-appimage.sh` builds standalone x86_64 AppImage
|
||||
- **Config** stored in `~/.config/neo-pod-desktop/config.ini`
|
||||
|
||||
## Installation
|
||||
@ -39,12 +38,6 @@ Install FFmpeg:
|
||||
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)
|
||||
|
||||
```bash
|
||||
@ -61,42 +54,31 @@ Build from source:
|
||||
|
||||
```
|
||||
src/
|
||||
main.py — GUI application (PyQt6)
|
||||
cli.py — CLI interface
|
||||
youtube_downloader.py — YouTube audio downloader
|
||||
audio_converter.py — FFmpeg audio conversion
|
||||
metadata_handler.py — ID3/MP4 tag reading and writing
|
||||
config_loader.py — Config in ~/.config/
|
||||
ipod_device.py — iPod device detection and file management
|
||||
ipod_nano7_db.py — Nano 7G SQLite + iTunesCDB + ArtworkDB
|
||||
hashab.py — HASHAB checksum generation
|
||||
main.py -- GUI application (PyQt6)
|
||||
track_info.py -- TrackInfo data class
|
||||
audio_converter.py -- FFmpeg audio conversion
|
||||
metadata_handler.py -- ID3/MP4 tag reading and writing
|
||||
metadata_editor.py -- GUI metadata editing widget
|
||||
config_loader.py -- Config in ~/.config/
|
||||
ipod_device.py -- iPod device detection and file management
|
||||
ipod_nano7_db.py -- Nano 7G SQLite + iTunesCDB + ArtworkDB
|
||||
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/
|
||||
__init__.py — Public API
|
||||
presets.py — 50+ iPod artwork format definitions
|
||||
types.py — ArtworkEntry, EncodedFormatPayload, IthmbLocation
|
||||
codecs.py — RGB565/RGB555/UYVY/JPEG encode/decode (numpy)
|
||||
extractor.py — Cover extraction from audio files (mutagen)
|
||||
chunks.py — ArtworkDB binary parser/writer (MHFD→MHII→MHNI)
|
||||
writer.py — ithmb file + ArtworkDB writing
|
||||
cache.py — Local JPEG cover cache for player UI
|
||||
__init__.py -- Public API
|
||||
presets.py -- 50+ iPod artwork format definitions
|
||||
types.py -- ArtworkEntry, EncodedFormatPayload, IthmbLocation
|
||||
codecs.py -- RGB565/RGB555/UYVY/JPEG encode/decode (numpy)
|
||||
extractor.py -- Cover extraction from audio files (mutagen)
|
||||
chunks.py -- ArtworkDB binary parser/writer (MHFD->MHII->MHNI)
|
||||
writer.py -- ithmb file + ArtworkDB writing
|
||||
cache.py -- Local JPEG cover cache for player UI
|
||||
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
|
||||
|
||||
MIT License
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
@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
|
||||
python --version 2>NUL
|
||||
@ -32,7 +32,6 @@ pip install -r requirements.txt
|
||||
REM Create necessary directories
|
||||
echo Creating necessary directories...
|
||||
if not exist temp mkdir temp
|
||||
if not exist downloads mkdir downloads
|
||||
if not exist converted mkdir converted
|
||||
|
||||
echo.
|
||||
@ -40,8 +39,7 @@ echo Installation complete!
|
||||
echo.
|
||||
echo To run the application:
|
||||
echo 1. Activate the virtual environment: venv\Scripts\activate.bat
|
||||
echo 2. Run the GUI 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 2. Run the application: python run.py
|
||||
echo.
|
||||
echo For more information, see the README.md file.
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Installation script for YouTube Music to iPod Nano Transfer Tool
|
||||
# Installation script for neo-pod-desktop
|
||||
|
||||
set -e
|
||||
|
||||
echo "Installing YouTube Music to iPod Nano Transfer Tool..."
|
||||
echo "Installing neo-pod-desktop..."
|
||||
|
||||
# Check if Python 3 is installed
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
@ -44,7 +44,6 @@ echo "Installation complete!"
|
||||
echo ""
|
||||
echo "To run the application:"
|
||||
echo " 1. Activate the virtual environment: source venv/bin/activate"
|
||||
echo " 2. Run the GUI 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 " 2. Run the application: python run.py"
|
||||
echo ""
|
||||
echo "For more information, see the README.md file."
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
yt-dlp>=2023.3.4
|
||||
pytube>=12.1.3
|
||||
ffmpeg-python>=0.2.0
|
||||
mutagen>=1.46.0
|
||||
eyeD3>=0.9.7
|
||||
pyusb>=1.2.1
|
||||
pillow>=9.5.0
|
||||
requests>=2.28.2
|
||||
tqdm>=4.65.0
|
||||
musicbrainzngs>=0.7.1
|
||||
PyQt6>=6.5.0
|
||||
numpy>=1.24.0
|
||||
|
||||
27
run.py
27
run.py
@ -5,30 +5,19 @@ Launcher script for neo-pod-desktop
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
|
||||
def main():
|
||||
"""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")
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
if args.cli:
|
||||
# Run CLI version
|
||||
from src.cli import YouTubeToIPodCLI
|
||||
cli = YouTubeToIPodCLI()
|
||||
return cli.run()
|
||||
else:
|
||||
# Run GUI version
|
||||
from src.main import QApplication, MainWindow
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
from src.main import QApplication, MainWindow
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
4
setup.py
4
setup.py
@ -17,7 +17,7 @@ with open('README.md', encoding='utf-8') as f:
|
||||
setup(
|
||||
name="neo-pod-desktop",
|
||||
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_content_type="text/markdown",
|
||||
author="neo-pod-desktop",
|
||||
@ -27,7 +27,7 @@ setup(
|
||||
install_requires=requirements,
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'neo-pod-desktop=src.cli:main',
|
||||
'neo-pod-desktop=src.main:main',
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
|
||||
@ -10,7 +10,7 @@ import subprocess
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from youtube_downloader import TrackInfo
|
||||
from track_info import TrackInfo
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
337
src/cli.py
337
src/cli.py
@ -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())
|
||||
@ -134,14 +134,8 @@ class ConfigLoader:
|
||||
|
||||
if not self.config.has_option('Advanced', 'temp_dir'):
|
||||
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'):
|
||||
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'):
|
||||
self.config.set('Advanced', 'hide_library_buttons', 'false')
|
||||
|
||||
@ -157,15 +151,13 @@ class ConfigLoader:
|
||||
'volume_down': 'Ctrl+Down',
|
||||
'seek_forward': 'Right',
|
||||
'seek_backward': 'Left',
|
||||
'tab_download': 'Ctrl+1',
|
||||
'tab_library': 'Ctrl+2',
|
||||
'tab_ipod': 'Ctrl+3',
|
||||
'tab_settings': 'Ctrl+4',
|
||||
'tab_library': 'Ctrl+1',
|
||||
'tab_ipod': 'Ctrl+2',
|
||||
'tab_settings': 'Ctrl+3',
|
||||
'search_focus': 'Ctrl+F',
|
||||
'select_all': 'Ctrl+A',
|
||||
'library_refresh': 'F5',
|
||||
'ipod_refresh': 'Ctrl+R',
|
||||
'download': 'Ctrl+Enter',
|
||||
'delete_selected': 'Delete',
|
||||
'library_add_files': 'Ctrl+O',
|
||||
}
|
||||
|
||||
@ -84,15 +84,13 @@ DEFAULTS = [
|
||||
("volume_down", "Ctrl+Down", "Volume Down", "global"),
|
||||
("seek_forward", "Right", "Seek Forward 5s", "global"),
|
||||
("seek_backward", "Left", "Seek Backward 5s", "global"),
|
||||
("tab_download", "Ctrl+1", "Switch to Download", "global"),
|
||||
("tab_library", "Ctrl+2", "Switch to Library", "global"),
|
||||
("tab_ipod", "Ctrl+3", "Switch to iPod", "global"),
|
||||
("tab_settings", "Ctrl+4", "Switch to Settings", "global"),
|
||||
("tab_library", "Ctrl+1", "Switch to Library", "global"),
|
||||
("tab_ipod", "Ctrl+2", "Switch to iPod", "global"),
|
||||
("tab_settings", "Ctrl+3", "Switch to Settings", "global"),
|
||||
("search_focus", "Ctrl+F", "Focus Search", "global"),
|
||||
("select_all", "Ctrl+A", "Select All", "global"),
|
||||
("library_refresh", "F5", "Refresh Library", "global"),
|
||||
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
|
||||
("download", "Ctrl+Enter", "Start Download", "global"),
|
||||
("delete_selected", "Delete", "Delete Selected", "global"),
|
||||
("edit_metadata", "F2", "Edit Metadata", "global"),
|
||||
("library_add_files", "Ctrl+O", "Library: Add Files", "global"),
|
||||
|
||||
214
src/main.py
214
src/main.py
@ -22,7 +22,7 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer
|
||||
from PyQt6.QtGui import QPixmap, QColor
|
||||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||
|
||||
from youtube_downloader import YouTubeDownloader, TrackInfo
|
||||
from track_info import TrackInfo
|
||||
from audio_converter import AudioConverter
|
||||
from metadata_handler import MetadataHandler
|
||||
from metadata_editor import MetadataEditorDialog
|
||||
@ -72,7 +72,6 @@ class WorkerThread(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
handlers = {
|
||||
"download": self._run_download,
|
||||
"convert": self._run_convert,
|
||||
"transfer": self._run_transfer,
|
||||
"detect_devices": self._run_detect_devices,
|
||||
@ -89,29 +88,6 @@ class WorkerThread(QThread):
|
||||
logger.exception(f"Error in worker thread: {e}")
|
||||
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):
|
||||
tracks = self.kwargs.get("tracks", [])
|
||||
local_files = self.kwargs.get("local_files", [])
|
||||
@ -303,16 +279,14 @@ class MainWindow(QMainWindow):
|
||||
self.setMinimumSize(800, 600)
|
||||
|
||||
self.worker_thread = None
|
||||
self.downloaded_tracks: List[TrackInfo] = []
|
||||
self.library_ready: List[Dict] = []
|
||||
self.library_source_files: List[str] = []
|
||||
self.library_scanned_sources: List[Dict] = []
|
||||
self.ipod_devices: List[Dict] = []
|
||||
self.current_mount_point: Optional[str] = None
|
||||
self.show_download_tab = False
|
||||
self.hide_library_buttons = False
|
||||
self._library_toolbar_buttons: list = []
|
||||
self.ipod_tab_index = 2
|
||||
self.ipod_tab_index = 1
|
||||
|
||||
self.config_loader = ConfigLoader()
|
||||
self.hotkey_manager: Optional[HotkeyManager] = None
|
||||
@ -358,12 +332,6 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a"))
|
||||
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(
|
||||
config.get_boolean("Metadata", "embed_artwork", fallback=True)
|
||||
)
|
||||
@ -376,11 +344,6 @@ class MainWindow(QMainWindow):
|
||||
self.auto_detect_check.setChecked(
|
||||
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)
|
||||
self.hide_library_buttons_check.setChecked(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", "format", self.format_combo.currentText())
|
||||
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", "use_musicbrainz", str(self.use_musicbrainz_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("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("Playback", "last_volume", str(self.volume_slider.value()))
|
||||
@ -433,15 +393,13 @@ class MainWindow(QMainWindow):
|
||||
"volume_down": lambda: self._adjust_volume(-5),
|
||||
"seek_forward": self._seek_forward,
|
||||
"seek_backward": self._seek_backward,
|
||||
"tab_download": lambda: self.tab_widget.setCurrentIndex(self.download_tab_index),
|
||||
"tab_library": lambda: self.tab_widget.setCurrentIndex(1),
|
||||
"tab_library": lambda: self.tab_widget.setCurrentIndex(0),
|
||||
"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,
|
||||
"select_all": self._select_all_current,
|
||||
"library_refresh": self._on_refresh_library_clicked,
|
||||
"ipod_refresh": self._on_refresh_devices_clicked,
|
||||
"download": self._on_download_clicked,
|
||||
"delete_selected": self._delete_selected_current,
|
||||
"edit_metadata": self._on_edit_metadata,
|
||||
"library_add_files": self._on_library_add_files,
|
||||
@ -459,11 +417,6 @@ class MainWindow(QMainWindow):
|
||||
self.tab_widget = QTabWidget()
|
||||
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()
|
||||
self.tab_widget.addTab(library_tab, "Library")
|
||||
self._setup_library_tab(library_tab)
|
||||
@ -476,56 +429,11 @@ class MainWindow(QMainWindow):
|
||||
self.tab_widget.addTab(settings_tab, "Settings")
|
||||
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.statusBar().showMessage("Ready")
|
||||
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):
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
@ -783,28 +691,25 @@ class MainWindow(QMainWindow):
|
||||
output_layout.addWidget(self.output_dir_input)
|
||||
output_layout.addWidget(browse_btn)
|
||||
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)
|
||||
|
||||
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_layout = QVBoxLayout(metadata_group)
|
||||
|
||||
@ -828,11 +733,6 @@ class MainWindow(QMainWindow):
|
||||
self.auto_detect_check.setChecked(True)
|
||||
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.setChecked(False)
|
||||
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)
|
||||
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):
|
||||
self.hide_library_buttons = checked
|
||||
for btn in self._library_toolbar_buttons:
|
||||
@ -1433,7 +1329,7 @@ class MainWindow(QMainWindow):
|
||||
if not is_ready:
|
||||
continue
|
||||
track_info = TrackInfo(
|
||||
video_id=f"local_{hash(data['path'])}",
|
||||
source_id=f"local_{hash(data['path'])}",
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
album=data.get("album", ""),
|
||||
@ -1680,66 +1576,13 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
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):
|
||||
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(
|
||||
self, "Error",
|
||||
"No tracks to convert. Add local source files or download tracks first."
|
||||
"No tracks to convert. Add local source files first."
|
||||
)
|
||||
return
|
||||
|
||||
@ -1757,7 +1600,7 @@ class MainWindow(QMainWindow):
|
||||
self.library_progress.setValue(0)
|
||||
|
||||
kwargs = {
|
||||
"tracks": self.downloaded_tracks if has_downloaded else [],
|
||||
"tracks": [],
|
||||
"local_files": source_files,
|
||||
"output_dir": output_dir,
|
||||
"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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main():
|
||||
_setup_linux_theming()
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Apply Fusion as fallback style if no platform theme was applied
|
||||
if not os.environ.get("QT_QPA_PLATFORMTHEME"):
|
||||
app.setStyle("Fusion")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@ -20,7 +20,7 @@ from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
from youtube_downloader import TrackInfo
|
||||
from track_info import TrackInfo
|
||||
from artwork.cache import cover_cache, xdg_cache_path
|
||||
|
||||
# Configure logging
|
||||
@ -356,7 +356,7 @@ class MetadataHandler:
|
||||
cover_path = cover_cache.put(artist or "Unknown Artist", album or "", cover_data)
|
||||
|
||||
return TrackInfo(
|
||||
video_id=f"local_{hash(file_path)}",
|
||||
source_id=f"local_{hash(file_path)}",
|
||||
title=title or base_name,
|
||||
artist=artist or "Unknown Artist",
|
||||
album=album or "",
|
||||
@ -533,7 +533,7 @@ class MetadataHandler:
|
||||
artist, title = filename.split(" - ", 1)
|
||||
|
||||
return TrackInfo(
|
||||
video_id=f"local_{hash(file_path or filename)}",
|
||||
source_id=f"local_{hash(file_path or filename)}",
|
||||
title=title,
|
||||
artist=artist,
|
||||
album="",
|
||||
|
||||
23
src/track_info.py
Normal file
23
src/track_info.py
Normal 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
|
||||
@ -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)
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic tests for YouTube Music to iPod Nano Transfer Tool
|
||||
Basic tests for neo-pod-desktop
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -8,55 +8,22 @@ import sys
|
||||
import unittest
|
||||
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"))
|
||||
|
||||
from youtube_downloader import YouTubeDownloader, TrackInfo
|
||||
from audio_converter import AudioConverter
|
||||
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):
|
||||
"""Test audio converter functionality"""
|
||||
|
||||
|
||||
def test_sanitize_filename(self):
|
||||
"""Test filename sanitization"""
|
||||
converter = AudioConverter()
|
||||
|
||||
# Test case 1: Invalid characters
|
||||
|
||||
sanitized = converter._sanitize_filename('File: with "invalid" chars?')
|
||||
self.assertEqual(sanitized, 'File_ with _invalid_ chars_')
|
||||
|
||||
# Test case 2: Long filename
|
||||
|
||||
long_name = "A" * 150
|
||||
sanitized = converter._sanitize_filename(long_name)
|
||||
self.assertEqual(len(sanitized), 100)
|
||||
@ -65,26 +32,23 @@ class TestAudioConverter(unittest.TestCase):
|
||||
|
||||
class TestMetadataHandler(unittest.TestCase):
|
||||
"""Test metadata handler functionality"""
|
||||
|
||||
|
||||
@patch('requests.get')
|
||||
def test_download_thumbnail(self, mock_get):
|
||||
"""Test thumbnail download"""
|
||||
# Mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.content = b'fake_image_data'
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Mock PIL Image
|
||||
|
||||
with patch('PIL.Image.open') as mock_image_open:
|
||||
mock_image = MagicMock()
|
||||
mock_image.save.return_value = None
|
||||
mock_image_open.return_value = mock_image
|
||||
|
||||
|
||||
handler = MetadataHandler()
|
||||
result = handler.download_thumbnail("https://example.com/image.jpg")
|
||||
|
||||
# Check that the function returned a path
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
mock_get.assert_called_once_with("https://example.com/image.jpg", timeout=10)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user