Since I’ve switched to owning my music, I’ve listened a lot more, and more intentionally, than before. I also have the pleasure to organize my music how I want.

As of Dec 2024, I have a music library of ~9200 songs, most of which are in FLAC format. I’ve manually tagged and organized almost all of them, with embedded lyrics, proper genres etc.

Where to get your music

I get my music from a few sources:

  • Bandcamp (preferred). Usually doesn’t have hi-res.
  • Qobuz and 7Digital — check both to see which is cheaper. Might have hi-res if you pay extra.
  • Amazon Music store (not preferred, they only have v0 mp3s, but it might be the only place to get stuff)
  • Physical CDs
  • The artist’s website/page
  • archive.org for really rare stuff

Useful software

For Windows, the software you’ll need:

  • mp3tag for fixing the tags
  • lrcget to add lyrics
  • Exact Audio Copy to rip CDs. This is the only software you should ever use for CD ripping, and you should always rip losslessly.
  • Image editor of your choice (e.g. Gimp), to edit/resize/etc. album art.

Recommended players:

  • MusicBee for Windows
  • Swinsian for Mac (the only Mac player that supports multiple genres per song)
  • PowerAmp for Android

Organizing your library

Step 1. Prep

Install the recommended software above.

Back up your entire library.

Create two folders. One for your organized music, and a staging folder where you temporarily put your new music while you’re fixing the tags.

Step 2. Fixing tags

Put your music in your staging folder. For the first time, put all your music here. Subsequent times you just put new music.

Open mp3tag, load your library, and basically go through your entire music library by hand. Add song and album titles where they’re missing, fix typos etc.

In particular:

  • Always have the following tags: Title, Artist, Album Artist, Genre, Track Number, Disc Number (if multiple discs). If those tags are missing, add them.
  • Always have album art embedded in the file (make your own art if you need to). I recommend as large as possible, but not bigger than 500kb or 1500px. Keep your cover.png for the original, large image. Don’t crop or stretch if the original isn’t square.
  • The Album Artist should be the exact same for the entire album and the exact same for all albums by a specific artist. If there are multiple artists who worked on the album, pick one (or just all of them together) as the Album Artist and use the Artist field to distinguish between each track. Avoid using “Various Artists”.
  • Make sure you also fix capitalization and leading/trailing whitespace in tags.
  • The Genre tag is the most inconsistent tag out there. You should manually go through all your artists and albums and make the genre tag consistent. I generally recommend album-level granularity; don’t use different genres for different tracks in the same album. For multiple genres, separate them with ; (Folk;Folk Rock;Metal).

Now that you’ve fixed all your tags, use mp3tag’s Tag to Filename feature to make your filenames consistent (be careful with this! there may be a good reason why the file names are the way they are, and this part isn’t super crucial), and at least structure your files into folders by artist and album.

Step 3. Lyrics

We will be embedding lyrics directly into the file.

Make a backup of any .txt files you may have in your music library.

First, use LRCGET with the “embed in file” option ENABLED and the “skip tracks that already have lyrics” DISABLED to get all your lyrics embedded in the file automatically. Run it on your whole library; this may take a while. (If you encounter API errors, run it again.)

Once that’s done, it’s time for the tricky part. LRCGET has embedded unsynced lyrics into the UNSYNCEDLYRICS tag and synced lyrics into the LYRICS tag. If a song has synced lyrics, it will add both unsynced and synced. You don’t want this. (Musicbee will never show synced lyrics if UNSYNCEDLYRICS exists, and some players don’t recognize UNSYNCEDLYRICS at all.) Instead, you only want lyrics in the LYRICS tag.

So what we’re doing is copying over UNSYNCEDLYRICS into LYRICS if there aren’t already synced lyrics in there.

Open mp3tag. In the main window, create a new column with the name “Has lyrics?” and the following value:

$if($eql($len(%unsyncedlyrics%),0),$if($eql($len(%lyrics%),0),'no','LYRICS'),$if($eql($len(%lyrics%),0),'UNSYNCEDLYRICS','both'))
  1. Sort by “Has lyrics”.
  2. Select all files that have UNSYNCEDLYRICS only, and use mp3tag’s tag to tag feature to copy the tag over to LYRICS.
  3. Refresh file view (important).
  4. Select all files that have both, right click → Extended Tags. Delete the UNSYNCEDLYRICS tag and save.

Now delete all lrc and txt files in your library folder. In Windows Explorer, the search query is ext:txt OR ext:lrc.

Restore any txt files you had before.

Step 4. Organize your files

Pick your folder structure for your organized music. I have a very loose categorization system where I group loosely-related genres into a folder.

  • Loose Genre > Artist > Album
  • Soundtracks > Game Title > Album
  • etc.

Music library organized by category

Move your files over. Hard-refresh your library in your player of choice.

You’re done. Yay!

For any new files you add to your library, first add them to the staging folder and then do these steps before moving them over to your organized library.

Step 5 (optional). Create a lossy copy of your library

My library is too huge (~170 GB) to fit on my phone. I have a lossy version of it, encoded using Ogg Opus (the current best lossy format) at 256kbps. I always keep the original FLAC library intact and backed up.

Here’s the script I use to create a lossy version of your library. It will keep the directory structure intact and copy over (unchanged) any files that aren’t .flac. It will also update any m3u and m3u8 playlists to reference the new file names. Feel free to share this script. Link to gist

You need WSL if you’re on windows, as well as sudo apt install parallel flac opus-tools.

To use, simply edit the source and destination directories and run the script. It will take FOREVER, so take a nap or something. Once done, it reduced my 170 GB library down to 70 GB.

Notes:

  • You should really back up your library before running this script. It doesn’t touch the original files, but don’t chance it.
  • This breaks on files with backticks in the file name (ask me how I know…).
#!/usr/bin/env bash
# Make a portable, lossy copy of your music library. https://elfakyn.com/music/organize-your-library
SOURCE_DIR="/source/music/folder"
DEST_DIR="/destination/music/folder"
JOB_FILE="./conversion_jobs.txt"
ERROR_LOG="./conversion_errors.log"
 
> "$JOB_FILE"
> "$ERROR_LOG"
 
declare -a partial_files
 
convert_flac_to_opus() {
    local file="$1"
    local dest_opus_path="$2"
 
    partial_files+=("$dest_opus_path")
 
    if ! opusenc --quiet --vbr --bitrate 256 "$file" "$dest_opus_path" 2>&1 | tee -a "$ERROR_LOG"; then
        # If conversion failed, clean up the partially written file
        rm -f "$dest_opus_path" 2>/dev/null
    fi
 
    partial_files=("${partial_files[@]/$dest_opus_path}")
}
 
export -f convert_flac_to_opus
export ERROR_LOG
 
total_files=$(find "$SOURCE_DIR" -type f | wc -l)
 
files_processed=0
files_skipped=0
files_copied=0
flac_files_queued=0
 
trap 'echo -e "\nProcess interrupted. Exiting..."; clean_up_partial_files; kill 0' SIGINT
 
clean_up_partial_files() {
    echo "Cleaning up partially written files..."
    for file in "${partial_files[@]}"; do
        rm -f "$file"
        echo "Removed partial file: $file"
    done
}
 
echo -n "Processing files: "
 
find "$SOURCE_DIR" -type f | while IFS= read -r file; do
    relative_path="${file#$SOURCE_DIR/}"
 
    dest_path="$DEST_DIR/$relative_path"
    dest_folder=$(dirname "$dest_path")
    mkdir -p "$dest_folder"
 
    dest_opus_path="${dest_path%.flac}.opus"
 
    if [ "${file##*.}" == "flac" ]; then
        if [ -f "$dest_opus_path" ] && [ "$dest_opus_path" -nt "$file" ]; then
            # Skip the file if the destination file is newer
            files_skipped=$((files_skipped + 1))
        else
            echo "convert_flac_to_opus \"$file\" \"$dest_opus_path\"" >> "$JOB_FILE"
            flac_files_queued=$((flac_files_queued + 1))
        fi
    else
        # Check for non-FLAC files
        if [ -f "$dest_path" ] && [ "$dest_path" -nt "$file" ]; then
            # Skip the file if the destination file is newer
            files_skipped=$((files_skipped + 1))
        else
            partial_files+=("$dest_path")
            cp "$file" "$dest_path"
            partial_files=("${partial_files[@]/$dest_path}")
            files_copied=$((files_copied + 1))
        fi
    fi
 
    files_processed=$((files_processed + 1))
    echo -ne "\rProcessing files: $files_processed/$total_files (Copied: $files_copied, Skipped: $files_skipped, FLAC queued: $flac_files_queued)"
done
 
echo -e "\nDone copying files."
echo "Running conversions in parallel..."
 
cat "$JOB_FILE" | parallel --progress --eta --bar
 
# Update m3u and m3u8 playlists to reference .opus files instead of .flac
find "$DEST_DIR" -type f \( -iname "*.m3u" -o -iname "*.m3u8" \) -print0 | while IFS= read -r -d '' playlist; do
  sed -i 's/\.flac/\.opus/g' "$playlist"
  echo "Updated playlist: $playlist"
done

Final thoughts

That’s a lot of work! But the more attention to detail you put into your library, the more pleasant your browsing and listening experience will be. (And it’s way less work after the first time.)

In particular, having consistent genre names will be very important in a huge library, especially if you like to shuffle genres.

This is my system; your library is your own — feel free to adapt as you see fit. The goal is to have a joyous listening experience. Yay!

Troubleshooting

Here are some other notes.

Multiple genres

If a track has multiple genres (which I recommend), you need to separate them somehow.

The problem: there’s no standard for this.

The most compatible is ; (Folk;Folk Rock;Metal). MusicBee only accepts ;. Swinsian only accepts / (Folk/Folk Rock/Metal) or , (Folk,Folk Rock,Metal). It is with great joy to announce that Swinsian now supports semicolon separators as of 3.0 Beta 20 (to get the beta, hold the Option key while selecting Swinsian Check for Updates). Many thanks to the developer for adding this feature!

The “recommended” way for FLAC and Ogg is multiple metadata fields (mp3tag denotes them with \\: Folk\\Folk Rock\\Metal but that’s just a visual thing, and internally it’s multiple genre tags). mp3 doesn’t support multiple genre tags at all, so if your library has any mp3s this is out of the question (you want consistency).

Therefore, use ; as your genre separator. If you have multiple genre tags, merge them.

Buggy FLAC files

FLAC and Ogg files use Vorbis tags; mp3 files use ID3 tags. In rare situations, such as if you got your files from shady sources, your FLAC files will have ID3 tags instead of Vorbis tags. 99% of the time this won’t be a problem, but some players and file converters will throw weird errors (the script above definitely won’t work with janky files…)

You can check using mp3tag: if you have .flac files with ID3 tags, you have this problem. Back up y our files before doing this. Select those files. Right-click → Tag copy. Right-click → Remove tag. Right-click → Tag paste. This should fix it.

Copy issues on Mac

If you’re having trouble copying files on Mac with error -43, it could be an issue with file names. See here: mac-copy-error-43

Alternative to LRCGET’s embed feature

LRCPUT by TheRedSpy15 (originally on GitHub, now unavailable), a python3 script, will embed your lyrics into files.

This script embeds .lrc files into the LYRICS tag. You should try LRCGET’s built-in embed feature (and fixing it in mp3tag later) instead. But if you need this, here it is.

This won’t work with unsynced lyrics, instead you should make a copy of the script, and edit the copy to put .txt files into the LYRICS tag.

Use it in this order:

  1. Embed synced lyrics (.lrc).
  2. Embed unsynced lyrics (.txt) with the --skip option so it doesn’t overwrite any synced lyrics).
  3. Check that everything is ok
  4. Delete your lrc and txt files

lrcput.py

import os
import shutil
import argparse
from mutagen.flac import FLAC
import eyed3
from tqdm import tqdm
 
def has_embedded_lyrics(audio):
    if isinstance(audio, FLAC):
        return 'LYRICS' in audio
    elif isinstance(audio, eyed3.core.AudioFile):
        return audio.tag.lyrics is not None
    return False
 
def embed_lrc(directory, skip_existing, reduce_lrc, recursive):
    total_audio_files = 0
    embedded_lyrics_files = 0
    failed_files = []
    
    audio_files = []
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith('.flac') or file.endswith('.mp3'):
                audio_files.append(os.path.join(root, file))
    
    with tqdm(total=len(audio_files), desc='Embedding LRC files', unit='file') as pbar:
        for audio_path in audio_files:
            file = os.path.basename(audio_path)
            lrc_file = os.path.splitext(file)[0] + '.lrc'
            lrc_path = os.path.join(os.path.dirname(audio_path), lrc_file)
            
            if os.path.exists(lrc_path):
                if skip_existing:
                    audio = None
                    if file.endswith('.flac'):
                        audio = FLAC(audio_path)
                    elif file.endswith('.mp3'):
                        audio = eyed3.load(audio_path)
                    if has_embedded_lyrics(audio):
                        pbar.set_postfix({"status": "skipped"})
                        pbar.update(1)
                        continue
                
                try:
                    if file.endswith('.flac'):
                        audio = FLAC(audio_path)
                        audio['LYRICS'] = open(lrc_path, 'r', encoding='utf-8').read()
                        audio.save()
                    elif file.endswith('.mp3'):
                        audio = eyed3.load(audio_path)
                        tag = audio.tag
                        tag.lyrics.set(open(lrc_path, 'r', encoding='utf-8').read())
                        tag.save(version=eyed3.id3.ID3_V2_3)
                    
                    embedded_lyrics_files += 1
                    pbar.set_postfix({"status": f"embedded: {file}"})
                    pbar.update(1)
                    pbar.refresh()
                    
                    if reduce_lrc:
                        os.remove(lrc_path)
                        pbar.set_postfix({"status": f"embedded, LRC reduced: {file}"})
                        pbar.update(1)
                        pbar.refresh()
                
                except Exception as e:
                    print(f"Error embedding LRC for {file}: {str(e)}")
                    pbar.set_postfix({"status": f"error: {file}"})
                    pbar.update(1)
                    pbar.refresh()
                    failed_files.append(file)
                    if os.path.exists(lrc_path):
                        shutil.move(lrc_path, lrc_path + ".failed")
                    continue
 
    return len(audio_files), embedded_lyrics_files, failed_files
 
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Embed LRC files into audio files (FLAC and MP3) and optionally reduce LRC files.')
    parser.add_argument('-d', '--directory', required=True, help='Directory containing audio and LRC files')
    parser.add_argument('-s', '--skip', action='store_true', help='Skip files that already have embedded lyrics')
    parser.add_argument('-r', '--reduce', action='store_true', help='Reduce (delete) LRC files after embedding')
    parser.add_argument('-R', '--recursive', action='store_true', help='Recursively process subdirectories')
    args = parser.parse_args()
    
    banner = """
██╗     ██████╗  ██████╗██████╗ ██╗   ██╗████████╗
██║     ██╔══██╗██╔════╝██╔══██╗██║   ██║╚══██╔══╝
██║     ██████╔╝██║     ██████╔╝██║   ██║   ██║   
██║     ██╔══██╗██║     ██╔═══╝ ██║   ██║   ██║   
███████╗██║  ██║╚██████╗██║     ╚██████╔╝   ██║   
╚══════╝╚═╝  ╚═╝ ╚═════╝╚═╝      ╚═════╝    ╚═╝   
Scripted by TheRedSpy15"""
    print(banner)
 
    directory_path = args.directory
    skip_existing = args.skip
    reduce_lrc = args.reduce
    recursive = args.recursive
    total, embedded, failed = embed_lrc(directory_path, skip_existing, reduce_lrc, recursive)
    percentage = (embedded / total) * 100 if total > 0 else 0
    
    print(f"Total audio files: {total}")
    print(f"Embedded lyrics in {embedded} audio files.")
    print(f"Percentage of audio files with embedded lyrics: {percentage:.2f}%")
    
    if failed:
        print("\nFailed to embed LRC for the following files:")
        for file in failed:
            print(file)