From ed8bb86bbd0bc98fd9652658a5d17e2286d06d85 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 2 Oct 2025 17:38:03 +0200 Subject: [PATCH] init --- .gitignore | 46 +++++ INSTALL.md | 22 +++ README.md | 59 +++++++ pyproject.toml | 19 +++ src/music_manager/autopop.py | 264 +++++++++++++++++++++++++++++ src/music_manager/organizer.py | 299 +++++++++++++++++++++++++++++++++ 6 files changed, 709 insertions(+) create mode 100644 .gitignore create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/music_manager/autopop.py create mode 100644 src/music_manager/organizer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a6985f --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.mp3 +*.m4a +*.flac +music/ +Music/ +test_music/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..8ec0379 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,22 @@ +# Quick Install & Test + +# 1. Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 2. Setup project +cd music-manager +uv venv +source .venv/bin/activate +uv pip install -e . +uv pip install spotdl + +# 3. Set Spotify credentials (optional) +export SPOTIFY_CLIENT_ID="your_id" +export SPOTIFY_CLIENT_SECRET="your_secret" + +# 4. Test organize +music-organize ~/Downloads/test-music ~/Music/Organized --dry-run + +# 5. Test autopop +mkdir -p "~/Music/Test/Daft Punk/Discovery" +music-autopop ~/Music/Test --dry-run diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b2f116 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Music Manager + +Organize messy music files and auto-populate albums using spotdl (YouTube downloads with Spotify metadata). Works great with Nextcloud Music. + +## Features +- Organize files into Artist/Album +- Auto-download full albums by folder names +- Complete partial albums (optional) +- Reads tags or parses "Artist - Title" filenames +- Skips existing files, embeds metadata/art + +## Requirements +- Python 3.9+ +- uv (package/venv) +- spotdl + +## Install (uv) +```bash +# In project root +uv venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +uv pip install -e . +uv pip install spotdl +``` + +Optional (improves matching): +```bash +export SPOTIFY_CLIENT_ID="your_id" +export SPOTIFY_CLIENT_SECRET="your_secret" +``` + +## Commands + +Organize existing files: +```bash +music-organize SOURCE DEST # organize +music-organize SOURCE DEST --dry-run # preview +``` + +Auto-populate from folders: +```bash +# Create folders: Music/Artist/Album (empty or partial) +music-autopop LIBRARY # download empty albums +music-autopop LIBRARY --dry-run # preview +music-autopop LIBRARY --include-partial # complete partial albums +``` + +## Tips +- Use exact artist/album names for best results +- Singles (no album) go to Artist/Singles +- Always try --dry-run first + +## Troubleshooting +- spotdl not found: `uv pip install spotdl` +- Recreate venv: `rm -rf .venv && uv venv && source .venv/bin/activate && uv pip install -e .` +- Wrong matches: rename folder to exact album name (add year if needed) + +## License +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..90b518c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "music-manager" +version = "1.0.0" +description = "Organize and auto-populate music library for Nextcloud Music" +requires-python = ">=3.9" +dependencies = [ + "mutagen>=1.47.0", + "spotipy>=2.24.0", +] +readme = "README.md" +license = { text = "MIT" } + +[project.scripts] +music-organize = "music_manager.organizer:main" +music-autopop = "music_manager.autopop:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/src/music_manager/autopop.py b/src/music_manager/autopop.py new file mode 100644 index 0000000..4b64cbc --- /dev/null +++ b/src/music_manager/autopop.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Auto-populate Music Library +Populate empty album folders by downloading from YouTube/Spotify +""" + +import subprocess +import sys +from pathlib import Path + + +def check_spotdl(): + """Check if spotdl is installed""" + try: + result = subprocess.run( + ['spotdl', '--version'], + capture_output=True, + text=True, + check=True + ) + print(f"āœ“ spotdl found: {result.stdout.strip()}\n") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + print("āœ— spotdl not found") + print("\nInstall with:") + print(" uv pip install spotdl") + return False + + +def scan_library(library_path, include_partial=True): + """Scan library for albums to download""" + library = Path(library_path) + if not library.exists(): + print(f"Error: {library_path} does not exist") + return [] + + albums_to_download = [] + audio_extensions = { + '.mp3', '.m4a', '.flac', '.ogg', '.opus', '.wav', '.aac' + } + + print("Scanning library structure...\n") + + for artist_dir in sorted(library.iterdir()): + if not artist_dir.is_dir() or artist_dir.name.startswith('.'): + continue + + artist_name = artist_dir.name + + for album_dir in sorted(artist_dir.iterdir()): + if not album_dir.is_dir() or album_dir.name.startswith('.'): + continue + + album_name = album_dir.name + + # Skip Singles + if album_name.lower() in ['singles', 'single']: + continue + + # Count existing audio files + audio_files = [ + f for f in album_dir.iterdir() + if f.is_file() and f.suffix.lower() in audio_extensions + ] + + if len(audio_files) == 0: + print(f"šŸ“ {artist_name} / {album_name} - Empty") + albums_to_download.append({ + 'artist': artist_name, + 'album': album_name, + 'path': album_dir, + 'existing_tracks': 0 + }) + elif include_partial: + print(f"šŸ“‚ {artist_name} / {album_name} - {len(audio_files)} tracks") + albums_to_download.append({ + 'artist': artist_name, + 'album': album_name, + 'path': album_dir, + 'existing_tracks': len(audio_files) + }) + else: + print(f"āœ“ {artist_name} / {album_name} - {len(audio_files)} tracks (skipping)") + + return albums_to_download + + +def download_album(artist, album, album_path, dry_run=False): + """Download album using spotdl""" + + search_query = f"{artist} - {album}" + + if dry_run: + print(f"\n[DRY RUN] Would download: {search_query}") + print(f" Destination: {album_path}") + return True + + print(f"\n⬇ Downloading: {artist} - {album}") + print(f" To: {album_path}") + + try: + cmd = [ + 'spotdl', + 'download', + search_query, + '--output', str(album_path), + '--format', 'mp3', + '--bitrate', '320k', + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + if result.returncode == 0: + output_lines = result.stdout.split('\n') + downloaded = sum( + 1 for line in output_lines + if 'Downloaded' in line or 'Downloading' in line + ) + skipped = sum(1 for line in output_lines if 'Skipping' in line) + + if downloaded > 0: + print(f" āœ“ Downloaded {downloaded} tracks") + if skipped > 0: + print(f" ⊘ Skipped {skipped} existing tracks") + + return True + else: + print(f" āœ— Error downloading") + if result.stderr: + error_msg = result.stderr[:200] + print(f" {error_msg}") + return False + + except Exception as e: + print(f" āœ— Error: {e}") + return False + + +def create_example_structure(library_path): + """Create example folder structure""" + library = Path(library_path) + + examples = [ + "Daft Punk/Random Access Memories", + "Tame Impala/Currents", + "Kendrick Lamar/good kid, m.A.A.d city", + ] + + print(f"Creating example structure in {library_path}...\n") + + for path in examples: + full_path = library / path + full_path.mkdir(parents=True, exist_ok=True) + print(f" Created: {path}") + + print("\nExample structure created!") + print("Edit the folders to add your own artists/albums,") + print("then run without --create-example") + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='Auto-populate music library from folder structure', + epilog=""" +Examples: + # Create folder structure + mkdir -p "Music/Artist Name/Album Name" + mkdir -p "Music/Another Artist/Another Album" + + # Dry run to see what would be downloaded + music-autopop ~/Music --dry-run + + # Download all empty albums + music-autopop ~/Music + + # Download including partial albums (complete them) + music-autopop ~/Music --include-partial + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + 'library', + help='Path to music library root folder' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be downloaded without downloading' + ) + parser.add_argument( + '--include-partial', + action='store_true', + help='Also download for folders with existing tracks' + ) + parser.add_argument( + '--create-example', + action='store_true', + help='Create example folder structure' + ) + + args = parser.parse_args() + + # Create example structure if requested + if args.create_example: + create_example_structure(args.library) + return + + # Check spotdl + if not check_spotdl(): + sys.exit(1) + + # Scan library + albums = scan_library(args.library, args.include_partial) + + if not albums: + print("\n" + "=" * 70) + print("No albums to download!") + print("=" * 70) + print("\nCreate folders like this:") + print(" Artist Name/Album Name/") + print("\nOr use --create-example to generate sample structure") + return + + print("\n" + "=" * 70) + print(f"Found {len(albums)} albums to download") + print("=" * 70) + + if args.dry_run: + print("\n[DRY RUN MODE - No files will be downloaded]\n") + + successful = 0 + failed = 0 + + for album_info in albums: + if download_album( + album_info['artist'], + album_info['album'], + album_info['path'], + args.dry_run + ): + successful += 1 + else: + failed += 1 + + print("\n" + "=" * 70) + if args.dry_run: + print(f"Dry run complete! Would download {len(albums)} albums") + else: + print(f"Done!") + print(f" āœ“ Successful: {successful}") + if failed > 0: + print(f" āœ— Failed: {failed}") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/src/music_manager/organizer.py b/src/music_manager/organizer.py new file mode 100644 index 0000000..4d2b3a1 --- /dev/null +++ b/src/music_manager/organizer.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Music Library Organizer +Organizes messy music files into Artist/Album structure +""" + +import os +import re +import shutil +from pathlib import Path +from mutagen import File +from mutagen.id3 import ID3NoHeaderError + +try: + import spotipy + from spotipy.oauth2 import SpotifyClientCredentials + SPOTIFY_AVAILABLE = True +except ImportError: + SPOTIFY_AVAILABLE = False + + +def sanitize_filename(name): + """Remove invalid characters from filenames""" + if not name or name.strip() == "": + return "Unknown" + name = re.sub(r'[<>:"/\\|?*]', '', name) + name = name.strip('. ') + return name if name else "Unknown" + + +def parse_filename(filename): + """Parse 'Artist - Title.mp3' format""" + name = Path(filename).stem + name = re.sub(r'^[\d/\s]+', '', name).strip() + + if ' - ' in name: + parts = name.split(' - ', 1) + artist = parts[0].strip() + title = parts[1].strip() if len(parts) > 1 else name + return artist, title + + return None, None + + +def clean_artist_name(artist): + """Remove featuring artists, keep only main artist""" + if not artist: + return artist + + patterns = [ + r',.*', + r'\s+feat\..*', + r'\s+ft\..*', + r'\s+featuring.*', + r'\s+&.*', + ] + + for pattern in patterns: + artist = re.split(pattern, artist, flags=re.IGNORECASE)[0] + + return artist.strip() + + +def get_metadata(filepath): + """Extract artist, album, and title from audio file or filename""" + artist, album, title, track_num = None, None, None, None + + try: + audio = File(filepath, easy=True) + if audio is not None: + artist = audio.get('artist', [None])[0] + album = audio.get('album', [None])[0] + title = audio.get('title', [None])[0] + + track_data = audio.get('tracknumber', [None]) + if track_data and track_data[0]: + track_str = str(track_data[0]).split('/')[0] + try: + track_num = int(track_str) + except: + pass + except (ID3NoHeaderError, Exception): + pass + + if not artist or not title: + filename_artist, filename_title = parse_filename(filepath) + if filename_artist: + artist = artist or filename_artist + title = title or filename_title + album = album or "Singles" + + if artist: + artist = clean_artist_name(artist) + + artist = artist or "Unknown Artist" + album = album or "Unknown Album" + title = title or Path(filepath).stem + + return artist, album, title, track_num + + +def organize_music(source_folder, dest_folder, dry_run=False): + """Organize music files into Artist/Album structure""" + source_path = Path(source_folder) + dest_path = Path(dest_folder) + + if not source_path.exists(): + print(f"Error: Source folder {source_folder} does not exist") + return {} + + if not dry_run: + dest_path.mkdir(parents=True, exist_ok=True) + + albums = {} + audio_extensions = { + '.mp3', '.m4a', '.flac', '.ogg', '.opus', '.wav', '.aac' + } + + print("Scanning audio files...") + file_count = 0 + + for file_path in source_path.rglob('*'): + if file_path.suffix.lower() in audio_extensions and file_path.is_file(): + file_count += 1 + artist, album, title, track_num = get_metadata(file_path) + + key = (artist, album) + if key not in albums: + albums[key] = [] + albums[key].append({ + 'path': file_path, + 'title': title, + 'track_num': track_num + }) + + print(f"Found {file_count} files in {len(albums)} albums\n") + + for (artist, album), files in sorted(albums.items()): + artist_clean = sanitize_filename(artist) + album_clean = sanitize_filename(album) + album_path = dest_path / artist_clean / album_clean + + print(f"{'[DRY RUN] ' if dry_run else ''}{artist} / {album}") + print(f" └─ {len(files)} tracks") + + if not dry_run: + album_path.mkdir(parents=True, exist_ok=True) + + for file_info in sorted(files, key=lambda x: x['track_num'] or 0): + source_file = file_info['path'] + dest_file = album_path / source_file.name + + counter = 1 + original_dest = dest_file + while dest_file.exists() and not dry_run: + dest_file = album_path / ( + f"{original_dest.stem}_{counter}{original_dest.suffix}" + ) + counter += 1 + + if dry_run: + print(f" • {source_file.name}") + else: + try: + shutil.move(str(source_file), str(dest_file)) + except Exception as e: + print(f" āœ— Error: {source_file.name} - {e}") + + return albums + + +def find_missing_tracks( + albums, dest_folder, spotify_client_id=None, spotify_client_secret=None +): + """Use Spotify API to find missing tracks""" + if not SPOTIFY_AVAILABLE: + print("\n⚠ Skipping missing track detection (spotipy not installed)") + return + + if not spotify_client_id or not spotify_client_secret: + print("\n⚠ Skipping missing track detection (no Spotify credentials)") + return + + try: + sp = spotipy.Spotify( + auth_manager=SpotifyClientCredentials( + client_id=spotify_client_id, + client_secret=spotify_client_secret + ) + ) + except Exception as e: + print(f"\nāœ— Error connecting to Spotify: {e}") + return + + dest_path = Path(dest_folder) + + print("\n" + "=" * 60) + print("Finding missing tracks...") + print("=" * 60) + + for (artist, album), files in albums.items(): + if album == "Singles": + continue + + artist_clean = sanitize_filename(artist) + album_clean = sanitize_filename(album) + album_path = dest_path / artist_clean / album_clean + + try: + query = f"artist:{artist} album:{album}" + results = sp.search(q=query, type='album', limit=3) + + if not results['albums']['items']: + continue + + spotify_album = results['albums']['items'][0] + tracks = sp.album_tracks(spotify_album['id']) + all_tracks = tracks['items'] + + while tracks['next']: + tracks = sp.next(tracks) + all_tracks.extend(tracks['items']) + + existing_titles = { + f['title'].lower().strip() for f in files if f['title'] + } + + missing_tracks = [] + for track in all_tracks: + if track['name'].lower().strip() not in existing_titles: + missing_tracks.append({ + 'number': track['track_number'], + 'name': track['name'], + 'duration_ms': track['duration_ms'] + }) + + if missing_tracks: + missing_file = album_path / "missing_tracks.txt" + with open(missing_file, 'w', encoding='utf-8') as f: + f.write(f"Album: {artist} - {album}\n") + f.write(f"You have: {len(files)}/{len(all_tracks)} tracks\n") + f.write(f"Missing: {len(missing_tracks)}\n\n") + + for track in sorted(missing_tracks, key=lambda x: x['number']): + mins = track['duration_ms'] // 60000 + secs = (track['duration_ms'] % 60000) // 1000 + f.write(f"{track['number']:2d}. {track['name']} " + f"({mins}:{secs:02d})\n") + + f.write(f"\nDownload: spotdl \"{artist} - {album}\"\n") + + print(f"\n{artist} - {album}:") + print(f" {len(files)}/{len(all_tracks)} tracks " + f"({len(missing_tracks)} missing)") + + except Exception: + pass + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='Organize music files into Artist/Album structure' + ) + parser.add_argument('source', help='Source folder with music files') + parser.add_argument('destination', help='Destination organized folder') + parser.add_argument('--dry-run', action='store_true', + help='Preview without moving files') + parser.add_argument('--skip-missing', action='store_true', + help='Skip missing track detection') + parser.add_argument('--spotify-client-id', help='Spotify Client ID') + parser.add_argument('--spotify-client-secret', help='Spotify Client Secret') + + args = parser.parse_args() + + client_id = args.spotify_client_id or os.getenv('SPOTIFY_CLIENT_ID') + client_secret = args.spotify_client_secret or os.getenv('SPOTIFY_CLIENT_SECRET') + + print("=" * 60) + print("Music Library Organizer") + print("=" * 60 + "\n") + + albums = organize_music(args.source, args.destination, args.dry_run) + + if not albums: + print("\nNo music files found") + return + + if not args.skip_missing and not args.dry_run: + find_missing_tracks(albums, args.destination, client_id, client_secret) + + print("\n" + "=" * 60) + print("Done!") + print("=" * 60) + + +if __name__ == "__main__": + main()