initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
---
name: music-renamer
description: Rename local music files using ID3 tags (mutagen), and optionally manage with beets. For when the user wants to clean up numbered/poorly-named music files using their embedded artist/title metadata.
---
# Music Renamer
Rename music files in-place using embedded ID3 tags. The primary path uses `mutagen` directly — fast, no network calls, works on any file with good tags. Beets is available as a secondary path for library management but its import step (MusicBrainz matching) is too slow for bulk renames.
## Trigger
User asks to rename/organize music files, clean up filenames, strip number prefixes from downloaded music, or set up beets for local music.
## Support files
- `scripts/rename_by_tags.py` — mutagen-based in-place renamer (run via execute_code)
- `references/beets-config.yaml` — minimal beets config for no-move setup
## 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) |
| Need full library management / queries / stats | **beets** |
| Small batch (< 50 files) needing autotag | **beets import** is fine |
| Large batch (> 100 files) | **mutagen** — beets import will time out |
## Step 1 — Determine scope
**CRITICAL**: Confirm which directories the user wants renamed. Never assume "all music." If they say "just the Albanian folder," do NOT touch English or other folders. Use `find` with a `-regex` pattern to count files with number prefixes to identify what's unrenamed:
```
find /path/to/music -type f -regex ".*/[0-9]+\. .*"
```
Files with number prefixes (e.g., `123. Title.mp3`) are the unrenamed ones. Files already in `Artist - Title.ext` format are done.
## Step 2 — Run the rename script
Use `scripts/rename_by_tags.py` (copy the code from `execute_code` — it uses mutagen via the same Python environment). 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 `-`
Key code pattern:
```python
from mutagen import File
audio = File(fullpath, easy=True)
artist = audio.tags.get('artist', [None])[0]
title = audio.tags.get('title', [None])[0]
new_name = f"{artist} - {title}".replace('/', '-') + ext
```
## Step 3 — Verify
Check a few directories to confirm the rename:
```
ls /path/to/music/some-folder/ | head -10
find /path/to/music -regex ".*/[0-9]+\. .*" | wc -l # should be 0
```
## Beets config (fallback)
Beets config lives at `~/.config/beets/config.yaml`. Minial config for in-place (no-move) setup:
```yaml
directory: /path/to/music
library: /path/to/music/musiclibrary.db
import:
copy: no
move: no
write: yes
quiet: yes
paths:
singleton: %(artist)s - %(title)s
comp: Compilations/%(album)s/%(artist)s - %(title)s
```
### Known pitfalls with beets
- `beet import` without `-A` hits MusicBrainz for every file — very slow for 100+ files, will time out
- `beet import -A -q --singletons` still slow for 1000+ files due to per-file overhead
- `beet move` uses `directory + path_template` — cannot rename truly in-place within subdirectories
- Only use beets for library management / queries, not bulk renames
## User preferences
- Ray prefers mutagen over beets for bulk renaming
- Always confirm directory scope — don't expand beyond what was asked
@@ -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,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}")