Maksim Totmin 885a401ad0 Rebrand project to neo-pod-desktop with updated description and improvements
- Rename project from 'youtube-to-ipod-nano' to 'neo-pod-desktop' across all files
- Update description: 'Desktop application for downloading, converting,
  managing and transferring music to iPod Nano devices'
- Update setup.py: name, description, url, entry_points, author
- Update run.py: docstring and argparse description
- Update src/__init__.py: module docstring
- Update src/main.py: docstring, window title, about label
- Update src/cli.py: docstring, class docstring, argparse description
- Update README.md: title and project description
- Improve duplicate track detection in ipod_nano7_db.py:
  case-insensitive matching, bulk removal with file cleanup
2026-05-31 16:19:15 +07:00

338 lines
11 KiB
Python

#!/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 = "m4a",
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())