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
|
# Temporary files
|
||||||
temp/
|
temp/
|
||||||
downloads/
|
|
||||||
converted/
|
converted/
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
|
|||||||
74
README.md
74
README.md
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -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
27
run.py
@ -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())
|
||||||
|
|||||||
4
setup.py
4
setup.py
@ -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=[
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
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'):
|
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',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
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.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()
|
||||||
|
|||||||
@ -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
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
|
#!/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)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user