initial commit
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: music-library-management
|
||||
description: Manage local music libraries — tag, rename, and organize files with beets and mutagen. Absorbed music-renamer (mutagen-based in-place rename workflow).
|
||||
category: media
|
||||
---
|
||||
|
||||
# Music Library Management
|
||||
|
||||
Tag, rename, and organize local music files. Covers beets (music library manager) for metadata-aware
|
||||
workflows and mutagen for fast bulk operations when files already have clean ID3 tags.
|
||||
|
||||
## Triggers
|
||||
|
||||
- User wants to rename/tag/organize music files
|
||||
- User asks about beets, mutagen, or music file metadata
|
||||
- User has a collection of mp3/flac/m4a files that need cleanup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install beets (includes mutagen as a dependency):
|
||||
|
||||
```bash
|
||||
pip install --break-system-packages beets
|
||||
```
|
||||
|
||||
Config lives at `~/.config/beets/config.yaml`.
|
||||
|
||||
## Workflow Decision
|
||||
|
||||
### Use beets when:
|
||||
- Files need MusicBrainz tagging (missing or wrong metadata)
|
||||
- You want to reorganize files into a standard Artist/Album/Track structure
|
||||
- You need beets' query and library features long-term
|
||||
- Small batch (<100 files) — even with MusicBrainz timeout, it's manageable
|
||||
|
||||
### Use mutagen directly when:
|
||||
- Files already have clean ID3 tags (artist, title, album present)
|
||||
- Bulk renaming is the only goal (format change, not metadata enrichment)
|
||||
- Large batch (100+ files) — beets import is too slow per-file
|
||||
- In-place renaming (keep directory structure, just fix filenames)
|
||||
|
||||
## Beets Workflow
|
||||
|
||||
### Configuration (`~/.config/beets/config.yaml`)
|
||||
|
||||
```yaml
|
||||
directory: /path/to/music/root
|
||||
library: /path/to/music/root/musiclibrary.db
|
||||
|
||||
import:
|
||||
copy: no
|
||||
move: no # no for import-only; yes when ready to reorganize
|
||||
write: yes
|
||||
quiet: yes # skip confirmation prompts
|
||||
|
||||
# Treat loose singles as a compilation
|
||||
singletons:
|
||||
album: Singles
|
||||
albumartist: Various Artists
|
||||
compilation: yes
|
||||
|
||||
paths:
|
||||
singleton: %(artist)s - %(title)s
|
||||
comp: Compilations/%(album)s/%(artist)s - %(title)s
|
||||
default: %(albumartist)s/%(album)s/%(track)02d - %(title)s
|
||||
```
|
||||
|
||||
### Import files into the library
|
||||
|
||||
```bash
|
||||
# Slow — hits MusicBrainz per file. Use -A (no autotag) if tags are already good.
|
||||
beet import -A -q --singletons /path/to/music/dir/
|
||||
```
|
||||
|
||||
### Query and rename already-imported files
|
||||
|
||||
```bash
|
||||
# Dry run: see what would change
|
||||
beet ls -f '$path || $artist - $title' 'query'
|
||||
|
||||
# Actually rename (moves per path template in config)
|
||||
beet move 'query'
|
||||
```
|
||||
|
||||
For in-place renaming with beets, the path template must match the directory
|
||||
structure. Beets always uses `directory + path_template` — it cannot rename
|
||||
within arbitrary subdirectories without moving.
|
||||
|
||||
## Mutagen Bulk Rename
|
||||
|
||||
When beets import is too slow and files already have ID3 tags, use the script at
|
||||
`scripts/bulk-rename.py`. It walks a directory tree, reads artist/title from tags
|
||||
via mutagen, and renames in-place to `Artist - Title.ext`.
|
||||
|
||||
## Linked files
|
||||
|
||||
- `scripts/bulk-rename.py` — Bulk in-place rename using mutagen ID3 tags
|
||||
- `scripts/rename_by_tags.py` — Alternative rename script (absorbed from music-renamer)
|
||||
|
||||
The script handles:
|
||||
- Collisions (appends `(1)`, `(2)` to duplicates in the same directory)
|
||||
- Special characters (strips `/` and null bytes)
|
||||
- Files already correctly named (skips)
|
||||
|
||||
## Quick Rename Workflow (mutagen, absorbed from `music-renamer`)
|
||||
|
||||
For the specific use case of renaming files in-place using embedded ID3 tags
|
||||
(files already have good tags, just need cleaner filenames), use the script at
|
||||
`scripts/rename_by_tags.py`. It walks a directory tree, reads artist/title from
|
||||
mutagen, and renames to `Artist - Title.ext`.
|
||||
|
||||
### Trigger for this workflow
|
||||
|
||||
User asks to rename/organize music files, clean up filenames, strip number prefixes
|
||||
from downloaded music.
|
||||
|
||||
### Step 1 — Determine scope
|
||||
|
||||
**CRITICAL**: Confirm which directories the user wants renamed. Never assume "all music."
|
||||
Use `find` with a `-regex` pattern to count files with number prefixes:
|
||||
|
||||
```bash
|
||||
find /path/to/music -type f -regex ".*/[0-9]+\. .*"
|
||||
```
|
||||
|
||||
Files with number prefixes (e.g., `123. Title.mp3`) are the unrenamed ones.
|
||||
|
||||
### Step 2 — Run the rename script
|
||||
|
||||
Use `scripts/rename_by_tags.py` via `execute_code`. The script:
|
||||
- Walks a base directory recursively
|
||||
- Reads artist/title from ID3 tags via mutagen
|
||||
- Renames files to `Artist - Title.ext` in-place (same directory)
|
||||
- Skips files already in the correct format
|
||||
- Handles collisions by appending `(1)`, `(2)` etc.
|
||||
- Handles slashes in artist/title by replacing with `-`
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
```bash
|
||||
ls /path/to/music/some-folder/ | head -10
|
||||
find /path/to/music -regex ".*/[0-9]+\. .*" | wc -l # should be 0
|
||||
```
|
||||
|
||||
### When to use mutagen vs beets
|
||||
|
||||
| Scenario | Tool |
|
||||
|---|---|
|
||||
| Files already have good ID3 tags, just need renaming | **mutagen** (scripts/rename_by_tags.py) |
|
||||
| Files have NO tags, need to be matched against MusicBrainz | **beets import** (with autotag) |
|
||||
| Small batch (< 50 files) needing autotag | **beets import** is fine |
|
||||
| Large batch (> 100 files) | **mutagen** — beets import will time out |
|
||||
|
||||
### User preferences
|
||||
|
||||
- Ray prefers mutagen over beets for bulk renaming
|
||||
- Always confirm directory scope — don't expand beyond what was asked
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Beets import is slow**: Even with `-A` (no autotag), beets still does per-file
|
||||
MusicBrainz lookups. Expect ~1-3 seconds per file. For 1000+ files, mutagen is
|
||||
the right choice.
|
||||
- **Beets `move` vs in-place**: `beet move` respects `directory + path_template`.
|
||||
It cannot do truly in-place renames within arbitrary subdirectories. For in-place
|
||||
renames, use mutagen or a script as above.
|
||||
- **`beet update`**: Updates metadata FROM files TO library — not the reverse.
|
||||
Does not update stored paths after external renames.
|
||||
- **Config YAML quoting**: Beets path templates use `%(var)s` syntax. The YAML
|
||||
linter may warn about unquoted `%` characters but beets parses them correctly.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Beets config — in-place (no-move) setup
|
||||
|
||||
directory: /mnt/seagate8tb/Music
|
||||
library: /mnt/seagate8tb/Music/musiclibrary.db
|
||||
|
||||
import:
|
||||
copy: no
|
||||
move: no
|
||||
write: yes
|
||||
quiet: yes
|
||||
|
||||
# Singleton mode: treat all files as individual tracks (not albums).
|
||||
# Use this when you have compilation/playlist folders, not proper albums.
|
||||
singletons:
|
||||
album: Singles
|
||||
albumartist: Various Artists
|
||||
compilation: yes
|
||||
|
||||
paths:
|
||||
singleton: %(artist)s - %(title)s
|
||||
comp: Compilations/%(album)s/%(artist)s - %(title)s
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bulk-rename music files in-place using ID3 tags via mutagen.
|
||||
|
||||
Reads artist/title from file metadata and renames to `Artist - Title.ext`.
|
||||
Handles collisions, special characters, and files already correctly named.
|
||||
|
||||
Usage:
|
||||
python3 bulk-rename.py /path/to/music/dir
|
||||
|
||||
Place in ~/.hermes/skills/media/music-library-management/scripts/
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from mutagen import File
|
||||
|
||||
def sanitize(name):
|
||||
return name.replace('/', '-').replace('\x00', '')
|
||||
|
||||
def rename_tree(base):
|
||||
renamed = 0
|
||||
skipped_tag = 0
|
||||
skipped_ok = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(base):
|
||||
for fname in files:
|
||||
if not fname.lower().endswith(('.mp3', '.flac', '.m4a', '.ogg')):
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(root, fname)
|
||||
|
||||
try:
|
||||
audio = File(fullpath, easy=True)
|
||||
except Exception as e:
|
||||
errors.append(f"READ {fullpath}: {e}")
|
||||
continue
|
||||
|
||||
if audio is None or not audio.tags:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
artist = audio.tags.get('artist', [None])[0]
|
||||
title = audio.tags.get('title', [None])[0]
|
||||
|
||||
if not artist or not title:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(fname)[1]
|
||||
new_name = f"{sanitize(artist)} - {sanitize(title)}{ext}"
|
||||
new_path = os.path.join(root, new_name)
|
||||
|
||||
if fullpath == new_path:
|
||||
skipped_ok += 1
|
||||
continue
|
||||
|
||||
# Handle collisions
|
||||
counter = 1
|
||||
while os.path.exists(new_path) and new_path != fullpath:
|
||||
name_no_ext = f"{sanitize(artist)} - {sanitize(title)} ({counter})"
|
||||
new_path = os.path.join(root, name_no_ext + ext)
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
os.rename(fullpath, new_path)
|
||||
renamed += 1
|
||||
except OSError as e:
|
||||
errors.append(f"RENAME {fullpath}: {e}")
|
||||
|
||||
print(f"Renamed: {renamed}")
|
||||
print(f"Skipped (no tags): {skipped_tag}")
|
||||
print(f"Skipped (already correct): {skipped_ok}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
for e in errors[:20]:
|
||||
print(f" {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} /path/to/music/dir")
|
||||
sys.exit(1)
|
||||
rename_tree(sys.argv[1])
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Rename music files in-place using embedded ID3 tags.
|
||||
Format: Artist - Title.ext
|
||||
Handles collisions by appending (1), (2), etc.
|
||||
Run via execute_code in an Hermes session — mutagen is available in the agent environment.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from mutagen import File
|
||||
|
||||
|
||||
def rename_by_tags(base_dir: str, extensions: tuple = ('.mp3', '.flac', '.m4a', '.ogg')) -> dict:
|
||||
"""Walk base_dir and rename all music files to 'Artist - Title.ext' in-place."""
|
||||
renamed = 0
|
||||
skipped_tag = 0
|
||||
skipped_ok = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for fname in files:
|
||||
if not fname.lower().endswith(extensions):
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(root, fname)
|
||||
|
||||
try:
|
||||
audio = File(fullpath, easy=True)
|
||||
except Exception as e:
|
||||
errors.append(f"READ {fullpath}: {e}")
|
||||
continue
|
||||
|
||||
if audio is None or not audio.tags:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
artist = audio.tags.get('artist', [None])[0]
|
||||
title = audio.tags.get('title', [None])[0]
|
||||
|
||||
if not artist or not title:
|
||||
skipped_tag += 1
|
||||
continue
|
||||
|
||||
new_name = f"{artist} - {title}".replace('/', '-').replace('\x00', '')
|
||||
ext = os.path.splitext(fname)[1]
|
||||
new_name += ext
|
||||
new_path = os.path.join(root, new_name)
|
||||
|
||||
if fullpath == new_path:
|
||||
skipped_ok += 1
|
||||
continue
|
||||
|
||||
# Handle collisions
|
||||
counter = 1
|
||||
while os.path.exists(new_path) and new_path != fullpath:
|
||||
name_no_ext = f"{artist} - {title} ({counter})".replace('/', '-')
|
||||
new_path = os.path.join(root, name_no_ext + ext)
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
os.rename(fullpath, new_path)
|
||||
renamed += 1
|
||||
except OSError as e:
|
||||
errors.append(f"RENAME {fullpath}: {e}")
|
||||
|
||||
return {
|
||||
'renamed': renamed,
|
||||
'skipped_tag': skipped_tag,
|
||||
'skipped_ok': skipped_ok,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python rename_by_tags.py /path/to/music")
|
||||
sys.exit(1)
|
||||
result = rename_by_tags(sys.argv[1])
|
||||
print(f"Renamed: {result['renamed']}")
|
||||
print(f"Skipped (no tags): {result['skipped_tag']}")
|
||||
print(f"Skipped (already OK): {result['skipped_ok']}")
|
||||
print(f"Errors: {len(result['errors'])}")
|
||||
for e in result['errors'][:10]:
|
||||
print(f" {e}")
|
||||
Reference in New Issue
Block a user