Having local audio files well organised in a directory tree has the advantaherege that no matter what application is used there is a consistent navigation.
LMS
Kodi
Ampache
Subsonic
UPnP / DLNA
Mopidy / MPD
Yamaha Musicast
The best way to achieve this is a commandline tool named beets
This is the documentation i wish i had when i started organizing with beets in addition to the very detailed documentation you find here
Use package manager, python package installer or docker to install beets.
When using docker path definition for tagging can be cumbersome.
I started out to organize my music by genre. The result was some overcrowded genres and a lot of obscure genre directories with just a few songs in there. I tried setting up a genre tree with subgenres etc, yet this proved to be to complicated as genres are more like a matrix than a tree. ( a specific subgenre often has more than one parent) Finally i came up with the idea to organize the top level by the field album_grouping.
In the config.yaml i have:
paths:
default: %if{%ifdef{album_grouping},_${album_grouping}%if{%ifdef{subgroup},/-${subgroup}},$genre}/%asciify{%if{$albumartist_sort,$albumartist_sort,$albumartist}}/%if{$original_year,${original_year}-,%if{$year,${year}-}}%asciify{$album}/%if{$disc,$disc-}%if{$track,${track}_-_}%asciify{${artist}_-_${album}_-_$title}
This gives a nice tree to navigate a large collection
Import new albums like so
beet import -A /data/music/music_data/beets/1/101_Strings --set album_grouping=World
Larger Groups can be organised in subgroups. This can be done when a group has grown to large bz reorganising some of the files
beet modify -a albumartist::^CAN$ album_grouping=Rock subgroup=Krautrock
Two things are noteworthy here. When you query in the album context (-a) it is albumartist not artist to query for. And second, if i would use the query albumartist:CAN instead of the regular expression albumartist::^CAN$ i would also reorganize Laura Cantrell, Dead Can Dance and Boards of Canada.
Better check a query with beet list before a modify.
You may have songs of an artist, that you dont want organised in an album, but directly under the artist instead.
i use the album name 'no-album' for that case like so
album:no-album: %if{%ifdef{album_grouping},_${album_grouping}%if{%ifdef{subgroup},/-${subgroup}},$genre}/%asciify{$albumartist}/%asciify{${artist}_-_$title}
A singleton is a file, that is not associated to an album. By default beet works on whole albums.
Some artists i have just 1 or 2 songs of. Having this songs organised under the artists name like described above using no-album would result in a very cluttered directory tree. I organise these songs in a directory '00_Singletons' under their grouping (and optionally subgroup) directory.
When you want to work on a single song without a reference to an album use (-s) parameter.
Now things become a bit confusing.
Instead of albumartist the field to query is artist and album_grouping is grouping.
Also the genre of a song and the genre of an album. Easy to get confused when writing path configurations.
singleton: %if{${grouping},_${grouping}/%if{%ifdef{subgroup},-${subgroup}/}00_Singletons,00_Singletons/-${genre}}/%asciify{${artist}_-_$title}
When importing the name of the field to be used is grouping.
beet import -As /data/music/music_data/beets/1/17_trouser_press_\(saturday_cl/11_-_17_trouser_press_.mp3 --set grouping=Rock
I record radioplays with tvheadend. Radioplays need a special path configuration different from the way music is organised.
genre:"Radio Play": 00_HOERSPIELE/%if{${grouping},${grouping},%if{${album},${album},Hoerspiel}}/%asciify{${title}%if{${artist},_-_${artist}}}%if{$original_year,-${original_year},%if{$year,-${year}}}%if{$track,-Teil_$track}%if{$tracktotal,_von_$tracktotal}
When changing the path definition in config.yaml you can reimport the files
beet import -As $PWD/Hungern_un_Freten-2023.mp3
Note the '$PWD' .This gives beets the complete path to work with.
With the alternatives Plugin you can create additional Directory Trees linked to your Collection. Say you want the highres files of your collection accessible through an alternative navigation tree.
alternatives:
HighRes:
directory: /data/music/music_data/alternatives/highres
query: bitdepth:16..
paths:
default:
'%if{%ifdef{album_grouping},_${album_grouping},NO_GROUP}/%asciify{%if{$albumartist_sort,$albumartist_sort,$albumartist}}/%if{$original_year,${original_year}_,%if{$year,${year}-}}%asciify{$album}/%if{$disc,$disc-}%if{$track,${track}_-_}%asciify{${artist}_-_${album}_-_$title}'
comp: '%if{%ifdef{album_grouping},_${album_grouping},NO_GROUP}/00_Compilations/%asciify{$album}/%if{$disc,${disc}-}%if{$track,${track}_-_}%asciify{${artist}_-_$title}'
formats: link
You can change a files tags with modify
beet modify $PWD/Hungern_un_Freten-2023.mp3 album='Niederdeutsches_Hoerspiel'
You could also query for the old value of the tag zou want to change like this:
beet modify album:'Plattdeutsches Hoerspiel' album='Niederdeutsches_Hoerspiel'
modify by default works on single files. To work on album use -a
beet modify -a genre:balkan genre='Balkan Pop'
Now you are working on the genre of the album not the single file.
Note: In the context of album the artist field is albumartist.
The advantage of using modify vs import -A and –set is the Overview of changes which can be cancelled. On the other hand a path does not work well in the album context.
Another great way change some tags is the edit command
beet edit $PWD/Seker_is_seker-Teil_04_von_04.mp3
Even better you can edit multiple files in one go
beet edit $PWD
Also you can edit all songs of an Album. You can specify field you want to edit with -f.
beet edit -a -f albumartist_sort -f genre -f album_grouping albumartist:A.R.E
What tags to edit by default can be configured in config.yaml like so
edit:
itemfields: genre grouping track tracktotal title artist album disc year
albumfields: albumartist albumartist_sort album_groupin subgroup genre year original_year
ignore_fields: id path
By default beet removes files only from its library, leaving the file on disk. to also remove from disk, use the -d option. With -f you will not be asked for confirmation.
beet remove -d /data/music/music_data/beets/2/20_humanoid_boogie_\(radio_1_c/11_-_20_humanoid_boogie_.mp3
Sometimes i find it easier to delete files on disk first and update beets library later.
ls *.[1-9].mp3 #better save than sorry
rm *.[1-9].mp3
beet update $PWD
When importing files that are not well tagged, different values for original_year, year, genre etc can cause beet to distribute files in separate albums. These albums can have the same name yet have different ids.
In a listing they appear twice. Specify the album?id in the listing format.
To remedy
Remove Files from existing albums
beet remove album_id:5797
Move Files to same Dir.
Delete Duplicates
Write consistent Tags
mid3v2 -A "Peel Session" -a "cLOUDDEAD" -y 2002 /path/*
Import Album
beet import -A /data/music/music_data/beets/-Indie/cLOUDDEAD/2002-Peel_Session --set album_grouping=Indie --set genre='Hip Hop' --set original_year=2002
You can add the result of a beets query to mopidy or mpd tracklist like so:
beet ls -f 'file://$path' artist:Brian Eno album:Before And After Science | mpc add
To play the contents of a Directory organised bz beets
mpc clear
find $1 -type f -exec mpc add file://{} \;
mpc play
Passing songs from a beets query to a Logitech Media Server is a bit more complex. First we need a cmdline Programm to control LMS.
Here is a little python script to get you started:
#!/usr/bin/env python3
# cmd: "play" or "load" - replace current playlist (default)
# cmd: "play_now" - adds to current spot in playlist
# cmd: "insert" - adds next in playlist
# cmd: "add" - adds to end of playlist
from pysqueezebox import Server, Player
import aiohttp
import asyncio
from argparse import ArgumentParser
SERVER = '192.168.178.4' # ip address of Logitech Media Server
PATH_ON_HOST = "/data/music/music_data"
PATH_IN_DOCKER = "/music"
PLAYER = "Moode"
parser = ArgumentParser()
parser.add_argument("-c", "--cmd", dest="cmd", default="insert")
parser.add_argument("-s", "--server", dest="server", default=SERVER)
parser.add_argument("-p", "--player", dest="player", default=PLAYER)
parser.add_argument("files", nargs="+", type = str)
args = parser.parse_args()
#print(f"file: {args.files} cmd:{args.cmd}")
async def main():
async with aiohttp.ClientSession() as session:
lms = Server(session, args.server)
player = await lms.async_get_player(name=args.player)
for file in args.files:
url = f"file://{file.replace(PATH_ON_HOST,PATH_IN_DOCKER)}"
await player.async_load_url(url, cmd=args.cmd)
await player.async_update()
if player.mode != 'play':
await player.async_play()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
Note: I run LMS in a docker container, so I need to translate the given file path. YMMV. We can call this script like so:
beet ls -f '$path' artist:Brian Eno album:Before And After Science | xargs bin/lms-play.py -c add
Delete empty dirs:
find . -type d -empty -delete
Delete hidden files
find . -type f -name ".*" -delete
Delete @eaDir :
find . -type d -name "@eaDir" -exec rm -rf {} \;
list Dirs and No. of files comtained:
find . -type 'f' -printf '%h\n' | sort | uniq -c | sort -g
Script to import as album when Dir has more than 3 files (using parent dir) .. code-block:: bash
#!/bin/bash
find “$PWD/$1” -type ‘f’ -printf ‘%hn’ | sort | uniq -c | sort -g | awk -F ” ” ‘$1>3{ print $NF “/..”}’ | xargs -n 1 beet import -A
Need to use absolute path otherwise beets creates duplicate entries.
import as Singleton when Dir contains only 1 file:
#!/bin/bash
find “$PWD/$1” -type ‘f’ -printf ‘%hn’ | sort | uniq -c | sort -g | awk -F ” ” ‘$1<2{ print $NF }’ | xargs -n 1 beet import -As
Sometimes it would be great if beets had a GUI. I use lf to fill that gap.
For that to happen some Shortcuts are added to lfrc.
map b # organise music with beets
map bd !beet remove -d "$f"
#map bn !beet modify album=no-album $f
map bt !eyeD3 -P yaml "$f" | bat -p
map bm manage_tags
map bu !beet update $PWD
map bi !beet info "$f" | bat -p
map bj !beet info -a "$f"
map be !/data/music/music_var/bin/beet-edit "$f"
map bs push :import_singleton
map ba push :import_album
map bo push :set_subgroup
cmd list_artist ${{
RES_JSON=$(eyeD3 -P json "$f")
ARTIST=$(echo $RES_JSON | jq '.artist')
ARTIST="${ARTIST//\"/}"
beet list artist:$ARTIST | bat -p
read -n1 -p "continue..."
}}
cmd manage_tags ${{
echo "$f"
RES_JSON=$(eyeD3 -P json "$f")
GENRE=$(echo $RES_JSON | jq '.genre')
GENRE="${GENRE//\"/}"
TITLE=$(echo $RES_JSON | jq '.title')
TITLE="${TITLE//\"/}"
ALBUM=$(echo $RES_JSON | jq '.album')
ALBUM="${ALBUM//\"/}"
ARTIST=$(echo $RES_JSON | jq '.artist')
ARTIST="${ARTIST//\"/}"
read -p "title: $TITLE > " ans
if [[ $ans ]]
then
TITLE=$ans
fi
read -p "album: $ALBUM > " ans
if [[ $ans ]]
then
ALBUM=$ans
fi
read -p "artist: $ARTIST > " ans
if [[ $ans ]]
then
ARTIST=$ans
fi
read -p "genre: $GENRE > " ans
if [[ $ans ]]
then
GENRE=$ans
fi
#echo "eyeD3 --to-v2.4 --non-std-genres -a '$ARTIST' -A '$ALBUM' -t '$TITLE' -G 'GENRE' $f"
eyeD3 --to-v2.4 --non-std-genres -a "$ARTIST" -A "$ALBUM" -t "$TITLE" -G "GENRE" "$f"
eyeD3 -P yaml "$f" | bat
read -n1 -p "continue.."
}}
cmd import_singleton ${{
#GROUPING=$1
read -p "grouping:$GROUPING " ans
#if [[ $ans ]]
#then
GROUPING=$ans
#else
# exit(1)
#fi
beet import -As "$f" --set grouping=$GROUPING &
read -n1 -p "continue..."
}}
cmd import_album ${{
#ALBUM_GROUPING=$1
read -p "album_grouping $ALBUM_GROUPING:" ans
#if [[ $ans ]]
#then
# if [[ $ans =~ ^(quit|q) ]]
# then
# exit(0)
# fi
ALBUM_GROUPING=$ans
#fi
beet import -A "$f" --set album_grouping=$ALBUM_GROUPING
read -n1 -p "continue..."
}}
cmd set_subgroup ${{
SUBGROUP=$1
read -p "Subggroup: $SUBGROUP " ans
#beet modify subgroup=$ans $1
echo $SUBGROUP
read -n1 -p "continue.."
}}
map Ba album_import
map BA !beet-music edit -a $f #!echo "$fx" | xargs beet-music edit -a
map Bs single_import
map BS !beet-music edit $f
map Bf list_artist
map Bn !echo "$fx" | xargs -n 1 beet-music modify -a -y style=no-album
cmd album_import ${{
read -p "album_grouping? " AGROUP
read -p "subgroup? " SGROUP
read -n 1 -p "Compilation? [y|N]" ans
read -n 1 -p "No Album? [y|N]" noalbum
CMD="echo \"$fx\" | xargs -n 1 beet-music import -A --set album_grouping=\"$AGROUP\""
if [[ $AGROUP ]]
then
[[ -n $SGROUP ]] && CMD="$CMD --set subgroup=\"$SGROUP\""
[[ $ans = y ]] && CMD="$CMD --set comp=true"
[[ $noalbum = y ]] && CMD="$CMD --set style=no-album"
eval $CMD
else
echo "$fx" | xargs -n1 -I % printf "% \n\n"
fi
read -n1 -p "continue..."
}}
cmd single_import ${{
read -p "grouping? " GROUP
read -p "subgroup? " SGROUP
if [[ $GROUP ]]
then
if [[ $SGROUP ]]
then
beet-music import -As --set grouping=$GROUP --set subgroup=$SGROUP "$f"
else
beet-music import -As --set grouping=$GROUP "$f"
fi
else
echo "$fx"
fi
read -n1 -p "continue..."
}}
Next lf as a music player