an idea worth tens of dollars total
it's like the 80s, but for the 2020s
what on earth is music streaming
Declarative MP3 File Management for passionate listeners.
What if you could copy a file?
Yes, this has been invented a long time ago.
File copying gets so boring. You have to go and pick which file you want to copy, without thinking about what kinds of files you might copy.
Mixtape Matrix bridges this gap. It's desired-state config for MP3 files. When 2005 finds out about this.... It's going to be huge.
uv tool install is recommended for install.
uv tool install git+https://github.com/karldreher/mixtapematrix.git
Once installed, you need to generate a config file.
matrix:
# absolute paths always appreciated, relative paths supported
- source_path: ./my-music
# don't copy these folders (or files)
exclude_paths:
- ./my-music/Podcasts
- "./my-music/Huey Luis"
destination_path: ./my-mixtape
mp3_files:
# all funk, except tracks by one artist or from one album
- genre: funk
exclude:
artist: Artist B
album: Album Name 2
- artist: "Fear Factory"
# every tagged MP3 beneath a folder (an absolute path inside source_path, here /home/me/my-music)
- folder: /home/me/my-music/MixesYou can create a default config file with mixtape init. This also writes matrix.schema.json next to it, and the config references it with a # yaml-language-server: $schema=./matrix.schema.json comment. With the Red Hat YAML extension installed, VS Code then highlights errors and offers completions and type hints for the config. Pass --no-json-schema to skip the schema file and the comment, and --force to overwrite an existing matrix.yaml. Pass --schema-only to write just matrix.schema.json (for example to refresh it after upgrading) without touching matrix.yaml.
Based on the config file, Mixtape Matrix will find any files in source_path, which match the directives in mp3_files. If you want to keep funk and Fear Factory in your mixtape, the config file above is half-done for you!
folder is a match key like artist or album, but it matches on location: every tagged MP3 beneath that directory, subdirectories included, whatever its tags say. It must be an absolute path to an existing directory inside source_path, or the run stops with an error before copying anything. It is matched on whole path components, so /music/rock never matches /music/rockabilly. Like exclude_paths, it may end in ** to match by prefix: /music/rock** matches everything whose path starts with /music/rock (including rockabilly), and /music/rock/** everything beneath it. Glob entries need not exist but must still be inside source_path. Both fields share one validator, so no other wildcard is supported in either.
mp3_files:
- folder: /music/library/Mixes
- folder: /music/library/Various
exclude:
genre: Podcast # a folder entry may exclude tags
- artist: Aphrodite
exclude:
folder: /music/library/Aphrodite/Bootlegs # and a tag entry may exclude a folderfolder does not change what is scanned. Only tagged .mp3 files are discovered and copied, so untagged MP3s and other files in the folder (images, .cue sheets, .DS_Store) are left behind. An entry with several keys matches when any one of them does, and exclude_paths still removes locations first.
Two options leave files out, and they work at different levels:
-
exclude_paths(per matrix) works on locations. Each entry is a directory or file, and everything beneath it is skipped without being read. A plain entry matches whole path components, sorocknever excludesCrockett, and relative paths work howeversource_pathis written. Plain entries must exist. An entry may end in**to match by prefix:/music/rock**skips everything whose path starts with/music/rock(includingrockabilly), and/music/rock/**skips everything beneath/music/rock.**is only supported at the end of an entry, and glob entries need not exist. -
exclude:(inside anmp3_filesentry) works on tags. It takes the same keys as an entry (artist,album,genre,album_artist,folder, and a nestedexclude). Any tag may be used, whichever tag the entry itself lists. Each tag key takes one value or a list, and a file matches when it matches any value in the list:exclude: album: Cheese # one value exclude: album: # or several - Pizza - Cheese
Excludes are applied in order, path first and then tag:
- Path: a file under an
exclude_pathsentry is dropped for the whole matrix. Nomp3_filesentry can bring it back, whatever its tags say. Use this to keep something out for good, such as aHuey Luisfolder whose tracks would otherwise be caught bygenre: pop. - Tag: each remaining file is tested against every
mp3_filesentry. An entry orexclude:block matches when any tag it lists matches (case-insensitive). An entry'sexclude:only carves exceptions out of that entry, so one entry's exception never removes a file another entry matches.
A file is copied when it matches at least one entry and no exclude applies (by path or by tag). An entry needs at least one tag of its own, so "everything except X" is not supported.
include: is the inverse of exclude:. A file that matches the entry is kept only if it also matches the include: block, so the two are ANDed:
- album_artist: Bush
include:
album: Razorblade Suitcase # Bush, and only that albumIt takes the same keys as an entry (artist, album, genre, album_artist, folder, scalar or list values, and nested include/exclude), is case-insensitive, and may be combined with exclude: on the same entry. Notes:
- Like
exclude:, an entry with aninclude:needs at least one tag or a folder of its own. - Several keys inside one
include:are OR'd, as everywhere else. To require several keys, nest:include: {artist: X, include: {genre: Y}}. - An include that contradicts its entry (
artist: Awithinclude: {artist: B}) is valid and matches nothing. - An
include:inside anexclude:narrows the exclusion:exclude: {artist: X, include: {album: Y}}removes only X's album Y. exclude_pathsstill removes locations first, and an entry'sinclude:only narrows that entry, so another entry can still match the file.
include: and exclude: are applied to cached tags, so adding or changing one never invalidates the tag cache.
mixtape run
# Or, the long form "mixtapematrix run"
# Use --config to point at a file other than ./matrix.yaml
# Running with no subcommand prints help and exits 1.
# Use --no-cache to ignore the tag cache for one run.
# Use --prune to delete destination files that no matrix copied (off by default).
source_path and destination_path must be existing directories; a missing path or a file stops the command with a one-line error before any work starts. Mixtape Matrix does not create the destination for you.
After running, this will send the files from source_path to destination_path accordingly.
Running a second mixtape command while one is active prints a warning and carries on. Concurrent runs are discouraged, since they can overwrite each other's cache. Detection is held in memory by the OS, scoped to your user, and creates no files: on macOS and Linux it is a kernel lock on your home directory, and on Windows a named mutex. It is released automatically if a run is killed, so it cannot go stale.
See which values exist in your library before writing mp3_files entries:
mixtape list tag artist
mixtape list tag genre
# Also: album, album_artist
# Narrow with --artist, --album, --genre, --album-artist (exact, case-insensitive; all must match):
mixtape list tag album --artist Alpha
mixtape list tag artist --genre funk --album-artist Alpha
# Use --refresh to rescan the library instead of reading the cache as is.
# Prints the distinct values from every matrix source, one per line, sorted and case-insensitive.
# Use --config to point at a file other than ./matrix.yaml
# Use --no-cache to ignore the tag cache for one run.
list tag ignores mp3_files. It reads a valid tag cache as is, without scanning the library, which makes repeat runs fast. Files added, deleted or retagged since the cache was written do not show up until you pass --refresh (rescan and rewrite the cache), run mixtape run, or the cache expires. Without a valid cache it scans the library and fills the cache. --no-cache reads every file and leaves the cache alone.
describe tag shows the same library as list tag, with more detail. The tag you choose is the top level, and below it comes whatever is left of artist > album > song. A song is shown as the full path of its file, such as /music/rock/one.mp3.
describe tag ... |
Tree |
|---|---|
artist |
artist > album > song |
album |
album > song |
genre |
genre > artist > album > song |
album_artist |
album_artist > artist > album > song |
genre and album_artist are not part of the artist > album > song chain, so the whole chain sits below them. There is one section per distinct value, sorted. Add a VALUE to show only that section.
mixtape describe tag album
First
├── /music/rock/four.mp3
├── /music/rock/one.mp3
└── /music/rock/two.mp3
Third
└── /music/pop/three.mp3
mixtape describe tag genre funk
Funk
├── Alpha
│ └── First
│ ├── /music/rock/one.mp3
│ └── /music/rock/two.mp3
└── Beta
└── First
└── /music/rock/four.mp3
- Matching is exact and case-insensitive, like
mp3_files. The--artist,--album,--genreand--album-artistfilters work as inlist tagand narrow which files are described. - Names that differ only by case share one node and use the spelling that sorts first.
- A missing artist or album shows as
(unknown artist)or(unknown album). Files with no ID3 tag are skipped, as are files with no genre or album_artist when that is the top level. - Like
list tag, it ignoresmp3_filesand reads a valid tag cache as is, so results can be stale until you pass--refresh.--config,--no-cache,--verboseand--debugare available.
mixtape describe untagged
Prints the full path of every MP3 with no ID3 tag, one per line, sorted. It takes no tag filters, since an untagged file has no tags to filter on. --config, --no-cache, --verbose and --debug are available.
describe untagged always scans the library, because it needs files the cache does not list. The tag cache is used only to skip files it records as tagged and unchanged. Files missing from the cache, and files it records as untagged, are read again. The cache is never written, so this command cannot leave a partial cache behind.
Reading the ID3 tag of every MP3 is the slow part of a run. Add a cache block to cache the discovered tags between runs:
cache:
ttl: 2d
matrix:
- source_path: ./my-music
...ttl is a whole number followed by one unit: m (minutes), h (hours), d (days), w (weeks), or mo (months, fixed at 30 days). Examples: 30m, 12h, 2d, 1w, 1mo. Without a cache block, nothing is cached.
- What is cached: the artist, album, genre and album artist of every MP3 under
source_path, so changing themp3_filesentries never needs a cache reset. A file whose modification time changed is re-read, new files are added, and deleted files are dropped. - Expiry: the first run after
ttlhas passed deletes the cache and rescans the whole library. - The cache belongs to the config file: each cache is keyed by the config file's path and the
source_path, so two config files never share or overwrite a cache. Moving or renaming a config file starts a new cache, and the old one is left behind until you clean it up. - Where it lives:
$XDG_CACHE_HOME/mixtapematrix/(~/.cache/mixtapematrix/by default), never next to your config or music. The files are compressed and small: roughly 27 bytes per track, so a 5,000-track library takes about 150 KiB.
mixtape cache list shows each cache with its status (ok, or why cache clean would remove it), size, source path and config file. Clean up with mixtape cache clean, which removes expired caches, caches whose config file or source path no longer exists, and unreadable ones. Use mixtape cache clean --all to remove every cache.
