This commit is contained in:
Dmitri 2025-10-02 17:38:03 +02:00
commit ed8bb86bbd
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
6 changed files with 709 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@ -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/

22
INSTALL.md Normal file
View File

@ -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

59
README.md Normal file
View File

@ -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

19
pyproject.toml Normal file
View File

@ -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"

View File

@ -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()

View File

@ -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()