A Windows desktop app that takes a Shazam export, downloads the songs, cleans up the filenames, tags the files, and files them into your music library. Written in Python with a PyQt6 interface.
I use Shazam to catch songs I hear, and all it gives you back is a CSV file. Getting those songs into my library meant downloading each one by hand, deleting the junk out of the filename, and typing the artist and title into a tag editor. That is fine for one song and miserable for two hundred. So I wrote the whole thing as one app.
It is not a general purpose music manager. It does one job properly. A Shazam export goes in, a clean and tagged library comes out.
Warning: this tool renames and re-tags MP3 files. Back up your library before you use it the first time.
- Downloads the songs in a Shazam CSV export straight from YouTube, using yt-dlp and FFmpeg.
- Keeps a manual My List for tracks that never went through Shazam.
- Strips junk out of filenames, things like
[Official Video]and site watermarks. - Writes ID3 tags in a batch: artist, album artist, and title.
- Downloads land in a staging folder first so you can look at them before they move into your library.
- Lets you review staged files before the move. You can fix the artist or title, pick a different source video, or download it again.
- Runs the whole pipeline with one button (download, clean and tag, move), or runs any step on its own.
- Queues failed downloads and retries them on the next run.
- Scans the library for duplicates, untagged files, suspicious names, and stray files that are not MP3s.
- Undoes the last batch of renames and moves.
The parts I would want to talk about in a code review.
Nothing writes to the library until you say so. Downloads and cleaning happen in a staging folder, and moving to the library is its own separate step. A bad download or a bad tag never reaches the real library, and the code treats the two folders as different things everywhere. It never mixes them.
Undo is built on an append only ledger. Every rename and move gets written to
Config/history.jsonl as it happens, so Undo Last Batch replays real recorded
operations instead of guessing what probably happened. See undo_last_batch in
Core/tag_cleaner.py:331.
Failures are loud, never silent. This is the rule I care most about here. A read or a parse that turns a failure into an absence is the worst bug shape in a program that moves files around, because the caller writes the empty result back and destroys real data. An unreadable music folder must not look like an empty one. A queue file that will not decode must not be rewritten without the lines it could not read. I did a sweep for this pattern and it turned up six real bugs, all fixed. Even a fallback that cannot fail, like decoding a CSV as latin-1, announces itself, because otherwise nothing downstream can tell it apart from a clean success.
Every file replacement is atomic, and it retries. Writes go to a temp file and then
get swapped in with os.replace. On Windows a replace is denied outright while
anything else holds a handle on the target, and an antivirus scanner or Explorer
preview will do exactly that for a moment, so the swap retries briefly instead of
failing the whole run. See atomic_replace in Core/utils.py:15.
The GUI never blocks and the worker threads never touch Qt. Long jobs run in daemon
threads through run_task in Core/main.py. Those threads talk to the
interface only through a signal bridge, _Bridge in Core/ui.py:41, so
every widget update happens back on the GUI thread where Qt requires it. Cancel is
cooperative. It stops between tracks and lets the track in flight finish.
The modules stay decoupled on purpose. input_handler.py and ui.py are not
allowed to import each other, and everything the user sees goes through one
print_output callback instead of scattered print calls. That is what makes the
logic testable without a GUI.
Dependencies are pinned exactly. The versions in requirements.txt came off a venv
that was actually tested. yt-dlp especially breaks when YouTube changes, so updating it
means retesting, not pip install -U.
The test suite is pure logic. Over two hundred tests, no GUI, no network, no FFmpeg, and they never touch my real config file. That is why they can run in CI on a clean Linux box in a few seconds.
/Audio_Program/
│
├── Core/ # All the application code
│ ├── main.py # GUI window and task orchestration
│ ├── downloader.py # yt-dlp downloads, retry queue, My List, ledger
│ ├── tag_cleaner.py # filename cleaner, ID3 tags, CSV parser, move to library, undo
│ ├── input_handler.py
│ ├── ui.py # widgets, dialogs, thread safe signal bridge
│ └── utils.py # config persistence, logging, history and ledger
│
├── Config/ # Created and managed by the app. All gitignored.
├── Setup_Scripts/ # install_setup.bat and run_audio_program.bat
├── tests/ # pytest suite
├── docs/ # open items and notes
└── requirements.txt
Config/ holds user_config.txt, the failed download queue, My List, the operation
history, the download ledger, and the last run's errors. None of it is committed.
Run Setup_Scripts\install_setup.bat. It finds a Python 3.11 through 3.14
interpreter, installs FFmpeg with winget (warn only, since it may already be there),
creates .venv, installs the exact pins from requirements.txt, and starts the app.
If you would rather do it yourself:
winget install Gyan.FFmpeg
py -3.11 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
Shazam CSV: export your library from shazam.com under My Library, download the CSV, and point at it in Settings. The CSV is optional. My List works without one.
Double click Setup_Scripts\run_audio_program.bat, or from the project root:
.venv\Scripts\pythonw.exe Core\main.py
Use the venv interpreter. A system wide python will not have PyQt6.
On the first run, open Settings and set the Library Folder, the Staging Folder, and the CSV File Path if you are downloading from Shazam.
Actions
- Run Full Pipeline downloads, cleans and tags, then moves, in that order.
- Run Shazam Downloader downloads the CSV entries that are newer than the last scanned date. Anything already in your library or staging folder is skipped, and it asks before starting a large batch.
- Clean & Tag MP3 Files renames and tags whatever is sitting in staging, which is useful for files you added yourself.
- Download My List downloads the tracks in
Config/my_list.txt. - Review Staged Files lets you go through staged tracks before they move. Fix the artist or title, download it again, or pick a different source.
- Move Staged Files to Library is the last step. It is also offered automatically after a download or clean run.
- Cancel stops the run between tracks. The track in progress finishes first.
Tools
- Add Song puts an artist and title straight into My List.
- My List opens
Config/my_list.txt. One track per line, tab separated. - Failed List opens
Config/failed_downloads.txt. Failed tracks retry on their own each run, and after three attempts they are held. Delete a line to drop the track for good, or set its attempt count back to 0 to try again. - Open Staging and Open Library open those folders in Explorer.
- Undo Last Batch reverses the most recent set of renames and moves.
- Library Health scans the library for duplicates, untagged files, suspicious names, and stray files that are not MP3s.
Settings
- Library Folder, Staging Folder, and CSV File Path.
- Song Tags List and Web Tags List, which are the junk strings stripped out of filenames.
- Re-scan Date lets you move the last scanned date back to redownload from an
earlier point. Use
YYYY-MM-DDorYYYY-MM-DDTHH:MM:SS. Fractions of a second are accepted and kept. - Loudness Normalize on or off, applied while downloading.
Config/user_config.txt is written by the app. Edit it through the GUI.
library_folder=C:/Users/You/Music
staging_folder=C:/Users/You/Music_Staging
csv_path=C:/Users/You/Desktop/shazam_library.csv
song_tags=(Lyrics)|(Official Video)
web_tags=[ytmp3.page] |yt5s.io -
last_scanned_date=2026-01-01T00:00:00
normalize_audio=0
ffmpeg_path=
ffmpeg_path is optional. Set it only if FFmpeg is not on your PATH.
Config/failed_downloads.txt and Config/my_list.txt are tab separated, one track per
line, and # starts a comment. If a line is malformed the app tells you and leaves the
file alone instead of quietly rewriting it without that line.
.venv\Scripts\python.exe -m pytest tests/ -q
Pure logic only. No GUI, no network, no FFmpeg. The tests never touch your real
user_config.txt.
- PyQt6 for the interface
- eyed3 for ID3 tagging
- yt-dlp for downloading audio from YouTube
- FFmpeg for MP3 conversion, installed on the system and on PATH
Python 3.11 through 3.14 is supported by the setup script. The pinned dependencies are verified on 3.11. Newer versions are not blocked if you launch the app yourself.
During setup, pip and winget talk to their package sources. During a search or a download, the artist and title queries go out through yt-dlp to YouTube, along with the video, media, and thumbnail requests that follow. The app itself has no telemetry and no update check. Your CSV, logs, configuration, queues, and library files stay on your machine.
It is worth knowing where your listening history lives locally:
- Logs are at
%LOCALAPPDATA%\AudioProgram\logs\app.log, rotating at 1 MB across three files. They contain track names, local file paths, and error details. - The Config folder sits next to the app inside the project folder. It is gitignored, but it holds track names and local paths, so keep that in mind before you share the folder or a checkout of the repo.
MIT. See LICENSE.