init
This commit is contained in:
commit
ed8bb86bbd
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
22
INSTALL.md
Normal 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
59
README.md
Normal 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
19
pyproject.toml
Normal 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"
|
||||||
264
src/music_manager/autopop.py
Normal file
264
src/music_manager/autopop.py
Normal 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()
|
||||||
299
src/music_manager/organizer.py
Normal file
299
src/music_manager/organizer.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user