Skip to content

Repository files navigation

Steam Library Organizer

Scans your Steam library and sorts every game into a Steam collection based on what the game actually is — FPS, Soulslike, Metroidvania, Roguelite, Farming, Visual Novel, and 30-odd more.

Your games are never touched. The only thing this writes is the collections document inside your Steam user config. Every write is preceded by an automatic backup, and the default mode is a dry run that changes nothing.

Download

Get the latest release for Windows — unzip it anywhere and run SteamLibraryOrganizer.exe. No Python needed.

Windows will show a blue "Windows protected your PC" box the first time, because the executable is not code-signed (signing certificates cost money). Click More info → Run anyway. If you would rather not take that on trust, run it from source instead — it is the same code, three commands below.

Then: close Steam, press Preview, look at what it plans to do, press Apply.


Contents


What it does

  1. Finds Steam via the registry (Windows) or the standard install paths, then reads every library folder from libraryfolders.vdf.
  2. Builds the game list from four sources: installed games from appmanifest_*.acf, owned games from the Steam Web API (optional key), family-shared games from IFamilyGroupsService (optional access token), and apps this account has launched, from localconfig.vdf.
  3. Fetches metadata from the Steam Store API (genres, store features, app type), SteamSpy (Steam user tags with vote counts) and the store page itself for the games SteamSpy does not know, cached in SQLite.
  4. Scores each game against a rule set and picks the best-fitting category.
  5. Groups series and folds thin categories so the result is a sidebar you can actually use rather than forty near-empty rows.
  6. Previews the plan, then — only when you say so — merges it into your Steam collections, backing up first.

Highlights:

  • Handles 1000+ game libraries; rate-limited, cached, and resumable.
  • Detects franchises from titles — one Resident Evil collection instead of eighteen games diluting four genres.
  • Never creates near-duplicate collections — "First Person Shooter", "Shooter" and "FPS" all normalise to FPS.
  • Adds to your existing collections rather than replacing them (configurable).
  • Never modifies dynamic collections (the ones with a saved filter), because rewriting their membership would destroy the filter.
  • Second run over an unchanged library issues zero network requests.

Requirements

  • Python 3.9+ (developed and tested on 3.14)
  • Tkinter for the GUI — bundled with the python.org installer on Windows and macOS. On Debian/Ubuntu: sudo apt install python3-tk
  • requests — the only third-party runtime dependency
  • Windows, macOS or Linux. Steam integration is fully implemented on all three; Windows is what it has been tested against.

Installation

From source (recommended)

git clone https://github.com/okabeeer/SteamLibraryOrganizer.git
cd SteamLibraryOrganizer
pip install -r requirements.txt

Launch the GUI:

python -m steam_organizer

On Windows you can also double-click Run Steam Library Organizer.bat.

As an installed package

pip install .
steam-organizer            # GUI
steam-organizer preview    # CLI

Standalone executable

No Python needed on the target machine — see Building an executable.


Quick start

  1. Close Steam completely. Check the system tray — Steam rewrites its config when it exits, so anything written while it is running gets discarded. The app refuses to write while Steam is up.
  2. Launch the GUI.
  3. Confirm the account picker shows the right profile.
  4. (Optional) Paste a Steam Web API key to include games you own but have not installed.
  5. Click Preview (dry run). The first run on a large library takes a while — the Steam store API allows roughly 200 requests per 5 minutes, so budget about 25 minutes for 1000 games. Results are cached, so later runs are near instant.
  6. Review the Preview tab: collections are listed with their games and the signals behind each decision.
  7. Click Apply to Steam, then start Steam.

To undo everything: Backups… → Restore selected.


How categorisation works

Each game is scored against every category rule. Three signal groups feed in:

Signal Source Role
User tags SteamSpy, then the store page Primary evidence. Weighted by vote share, so a game's headline tag counts for far more than a fringe one.
Genres Steam Store API Corroboration. Broad and reliable, but Steam lists "RPG" as a genre for most action games.
Store features Steam Store API Single-player, Co-op, VR Supported, MMO — the only sensible source for play-mode categories.

Tags decide almost everything, so a game without them is being guessed at from two or three broad genres. SteamSpy has no entry for roughly one game in seven — recent releases, niche titles, regional ones — and that is where the bad assignments come from. SILENT HILL f lists its genres as Action and Adventure and nothing else, which files a survival horror game under Adventure.

store_page_tags closes the gap by reading the tag list straight off the store page, where Steam embeds it with vote counts. On a 770-game library this took tag coverage from 84% to 99% and fixed, among others:

Game Genres only With store-page tags
SILENT HILL f Adventure Horror
Hollow Knight: Silksong Adventure Metroidvania
Deadlock Action MOBA
Fear & Hunger Adventure Horror
SUPER PEOPLE Action Battle Royale
Ori and the Blind Forest Action Metroidvania

The default ("missing") scrapes only games SteamSpy could not answer for, which costs one extra request each. "always" prefers the store page for every game — its counts are live rather than SteamSpy's estimates — at the price of a much longer first run. Store pages and the store API share one rate-limit bucket, because they are the same host.

After changing this setting, run steam-organizer clear-cache --tagless to refetch just the games that lack tags instead of the whole library.

Four rules make the output good rather than merely plausible:

  1. No synonym stacking. Within a signal group the strongest match sets the score; the rest only add a small corroboration bonus. Without this, a broad rule that lists many synonyms ("rpg", "action rpg", "crpg", …) out-accumulates a specific rule that legitimately matches once — and every Soulslike lands in RPG.
  2. Genres are damped when tags exist. A decisive user tag should beat a generic genre listing. With no tags at all (unreleased games), genres carry full weight, since they are then the only thing known.
  3. Suppression, not just competition. Metroidvania removes Platformer from contention entirely; Roguelite removes Roguelike; a third-person shooter is not also an FPS.
  4. Specificity breaks ties. Sub-genres (Soulslike, JRPG, Battle Royale) outrank genres (FPS, Horror), which outrank broad buckets (RPG, Adventure), which outrank catch-alls (Action, Indie). Indie is damped hardest — nearly half of Steam carries that tag.

Worked example, DARK SOULS III:

tags: Souls-like=9604, Dark Fantasy=7905, Difficult=6967, RPG=5966,
      Action RPG=3576, Co-op=3499, …

Soulslike  4.62   tag:souls-like (top tag, full strength)
RPG        2.55   tag:rpg + damped tag:action rpg + damped genre
Open World 1.99
→ Soulslike

Games where nothing scores above min_confidence go to fallback_category (default Uncategorized), or are skipped if you blank that setting.


Franchises and keeping the list short

Correct categories are not automatically useful ones. A large library scores into forty-odd collections, several holding two games each, while ten Assassin's Creed titles sit scattered across Open World, Action and Stealth telling you nothing their names did not already say. Two passes run over the whole library after scoring to fix that.

Franchise collections

A series with franchise_min_games entries (default 5) gets a collection of its own, and by default its games leave their genre collections — one Assassin's Creed row instead of ten games diluting three genres.

Detection is name-based, because the title is the only signal present for every game, including family-shared, delisted and never-launched ones. Titles are stripped of edition noise (Definitive Edition, GOTY, Remastered), split into words, and every shared prefix becomes a candidate. What makes it usable is what it rejects:

Rule Prevents
A name may not end on a sequence number Assassin's Creed II as a series name
A name may not end on a possessive Tom Clancy's swallowing Splinter Cell, Ghost Recon and Rainbow Six
A one-word name needs a game actually called that, or that word plus a number, or seven distinctive characters Sniper Elite merging with Sniper Ghost Warrior
A name may not end on an auxiliary verb We Were instead of We Were Here
The longest name covering the same games wins Resident instead of Resident Evil
Otherwise the shortest name wins Yakuza Kiwami splitting off from Yakuza

Runs of single letters collapse, so S.T.A.L.K.E.R. and F.E.A.R. survive intact, and the display name is cut from the original title, keeping punctuation (Marvel's Spider-Man, Watch_Dogs).

Tune it with franchise_min_games; force or split groups it got wrong with franchise_overrides; drop one with franchise_ignore. Set franchise_mode to "extra" to keep the genre as well, or "off" to disable.

Steam sorts collections alphabetically, so series names interleave with genres. Set franchise_prefix to something like "* " to keep them together.

Folding thin categories

Any category with fewer than min_games_per_category games (default 5) folds into a broader parent — RTS and Turn-Based Strategy into Strategy, City Builder and Farming into Simulation, Story Rich into Adventure — repeating until it is either big enough or a root category. Roots are left alone however small they are; VR has nowhere sensible to go.

When a fold empties a collection an earlier run created, that collection is deleted (retire_merged_collections). This only ever applies to collections named after one of the built-in categories, so a collection you made yourself is never removed, whatever it is called. Dynamic collections are always skipped.

The hard cap

Folding is a threshold, which means you pick a number of games and find out how many collections you got afterwards. It also cannot go below about twenty-five on a large library, because root categories like Racing and VR have no parent to fold into.

max_categories is the direct control. The smallest category is dropped repeatedly until the budget is met, and each of its games moves to its own next-best category — the same scoring that picked the original, not a blanket redirect.

It is best-effort, not a guarantee, because three rules can stop a drop:

  • The replacement must already exist. A budget that shrinks the list by inventing a new collection to hold the refugees has shrunk nothing.
  • The fit must hold up. If the typical game would land in a category scoring below BUDGET_QUALITY_RATIO (0.7) of its current fit, the category stays. This is what stops football games scoring Sports 5.21 being filed under Action at 0.85 — a real result from an earlier version, and the reason the guard exists.
  • No game is left stranded. A category holding a game with nowhere at all to go is spared rather than dropping it out of every collection.

So asking for 18 on a library with 28 genuine genres will give you more than 18. That is the honest answer: accuracy and a short list are in direct tension, and past a point the budget is buying tidiness with wrong categories. Leave it at 0 unless you specifically want that trade.

Series and the fallback do not count towards the budget. Cap series separately with franchise_max_collections, which keeps the largest.

What the numbers actually do

Measured on a 770-game library:

Settings Collections Series Genre Uncategorized
Both passes off 36 0 36 5
Defaults (series at 5, fold at 5) 44 16 28 5
max_categories: 18 35 16 19 7
max_categories: 18, series cap 10 29 10 19 9
max_categories: 15, series cap 8 24 8 16 10
max_categories: 12, series cap 6 19 6 13 14
max_categories: 15, no series 16 0 16 11

Those counts predate the fit guard, so a tight budget now lands above the number asked for rather than mangling categories to reach it. Preview before applying — the summary lists every merge, and a merge like Co-op -> Puzzle is the signal that the budget is set too low.


Categories

Sub-genre    Soulslike  Metroidvania  Roguelike  Roguelite  JRPG  MOBA
             Battle Royale  City Builder  Colony Sim  Farming
             Visual Novel  Rhythm  Fighting  Racing  MMO  VR

Genre        FPS  Third-Person Shooter  Horror  Stealth  Platformer
             Puzzle  RTS  Turn-Based Strategy  Survival  Open World
             Sandbox  Building  Sports  Party Games

Broad        RPG  Strategy  Simulation  Adventure  Story Rich

Catch-all    Action  Indie  Co-op  Multiplayer  Singleplayer

steam-organizer categories prints these with their weights.

Missing categories are created automatically. Existing collections with a matching name (case-insensitive) are reused, never duplicated.


Configuration

Settings live in a JSON file. Locations:

Platform Path
Windows %APPDATA%\SteamLibraryOrganizer\config.json
macOS ~/Library/Application Support/SteamLibraryOrganizer/config.json
Linux ~/.config/steam-library-organizer/config.json

You never have to write it by hand: Save settings in the GUI creates it, and steam-organizer init-config writes one with the defaults. Every option is listed below.

The options worth knowing:

Option Default What it does
dry_run true Preview without writing.
multi_category false One collection per game. true also adds strong runners-up.
max_categories_per_game 3 Cap when multi_category is on.
min_confidence 0.8 Raise it to push more games to the fallback.
fallback_category "Uncategorized" Blank it to skip unmatched games instead.
ignore_categories [] Names to never create, e.g. ["Indie", "Singleplayer"].
custom_mappings {} Force a category by app ID or exact game name.
replace_existing_membership false true rewrites managed collections instead of adding to them.
franchise_mode "primary" "extra" keeps the genre too; "off" disables series detection.
franchise_min_games 5 Games a series needs to earn a collection.
franchise_prefix "" Prepended to series names so they sort together.
franchise_overrides {} Force a game into a series by app ID or name.
franchise_ignore [] Series never turned into a collection.
min_games_per_category 5 Thinner categories fold into their parent. 0 keeps them all.
max_categories 0 Best-effort cap on genre collections. 0 means no cap, and is the accurate setting.
reconcile_membership true Move a game out of a collection it no longer belongs to.
franchise_max_collections 0 Hard cap on series collections. 0 means no cap.
retire_merged_collections true Delete built-in-named collections a fold emptied.
store_page_tags "missing" Scrape tags from the store page when SteamSpy has none. "always" / "off".
steam_web_api_key "" Include owned-but-not-installed games.
steam_access_token "" Include games shared by a Steam Family.
include_family_shared true Use the family library (needs the token).
backup_before_write true Leave this on.
require_steam_closed true Leave this on.

Custom mappings override all scoring:

"custom_mappings": {
  "570": "MOBA",
  "stardew valley": "Comfy",
  "1245620": ["Soulslike", "Open World"]
}

Category names in custom_mappings and ignore_categories are normalised too, so "first person shooter" and "FPS" mean the same thing.


Command line

steam-organizer [--config PATH] [--steam-path PATH] [--account ID] [-v|-q] <command>
Command Purpose
gui Launch the desktop interface (default when no command is given).
preview Show what would change. --json for machine-readable output.
apply Write the collections. --yes skips the prompt, --no-backup skips the backup.
accounts List local Steam accounts and which one is used by default.
collections List the collections currently in Steam.
probe Dump the raw collection store and the detected schema.
categories List every category the rule set can assign.
backup Take a backup right now.
list-backups List saved backups.
restore [PATH|latest] Restore a backup.
remove-collections [--categories] [--include-dynamic] [--yes] Delete collections in bulk. Previews unless --yes.
clean-legacy Clear collections from the legacy localconfig.vdf store.
clear-cache [--tagless] Delete cached metadata. --tagless drops only entries with no user tags.
init-config Write a config file with the defaults.
steam-organizer preview                 # see the plan
steam-organizer preview --json > plan.json
steam-organizer apply --yes             # write it
steam-organizer restore latest          # undo

Backups and restore

Backups are written to the app-data folder under backups/, keeping the most recent max_backups (default 20). Each backup is two files:

  • collections-<timestamp>.json — the parsed collections plus the exact raw namespace document, which is what a restore replays.
  • collections-<timestamp>.localconfig.vdf — a verbatim copy of the source file, for manual rollback.

Restoring takes a fresh backup of the current state first, so a mistaken restore is itself reversible.

A backup failure aborts the run that requested it — it never degrades to a warning and writes anyway.

Starting over

Remove all collections… in the GUI, or steam-organizer remove-collections, deletes collections in bulk so you can re-run from a clean library instead of right-clicking forty of them in Steam.

What it will not touch:

  • Steam's own collections — Favorites, Hidden and the from-tag-* ones. They are recognised by their fixed IDs, not their names, so renaming one cannot expose it. Everything a person creates gets a uc- ID.
  • Dynamic collections, unless you pass --include-dynamic. Their membership comes from a saved filter, which is real work.
  • Games. A collection is a list of app IDs; deleting one changes nothing about what is installed or owned.

--categories narrows it to the built-in category names, leaving series collections and anything you named yourself alone. The CLI previews by default and needs --yes to actually delete; the GUI names every collection in the confirmation. A backup is always taken first — though note a restore replays the whole namespace, so it brings back everything from that moment, not just the part you regret.


Where Steam keeps collections

Steam has used three locations over the years. The tool auto-detects which one your client actually uses — run steam-organizer probe to see.

cloudstorage — current clients

<Steam>/userdata/<account_id>/config/cloudstorage/
    cloud-storage-namespace-1.json            the key-value store
    cloud-storage-namespace-1.modified.json   keys awaiting upload
    cloud-storage-namespaces.json             highest version per namespace

cloud-storage-namespace-1.json is compact JSON: an array of [key, entry] pairs holding Steam's whole cloud key-value store, of which collections are only a slice. A collection entry looks like this:

["user-collections.uc-AbC123", {
  "key": "user-collections.uc-AbC123",
  "timestamp": 1785986592,
  "value": "{\"id\":\"uc-AbC123\",\"name\":\"FPS\",\"added\":[730,550],\"removed\":[]}",
  "version": "4546",
  "conflictResolutionMethod": "custom",
  "strMethodId": "union-collections"
}]

Three things make writing this correctly non-trivial, and all are handled:

  • version is a namespace-global counter. New and changed entries must take versions above every existing one, and cloud-storage-namespaces.json has to record the new high-water mark.
  • Changed keys must be listed in *.modified.json, or the client may never upload the change and can later overwrite it with the cloud copy.
  • Deleted collections leave tombstones — entries with is_deleted: true and no value. They are preserved exactly; resurrecting one would bring back a collection the user deleted.

Everything that is not a collection passes through byte-for-byte, and collections whose membership did not actually change keep their original version and timestamp, so re-running does not churn your sync state.

localconfig.vdf — legacy

UserLocalConfigStore → WebStorage → user-collections in <Steam>/userdata/<account_id>/config/localconfig.vdf, holding the same document escaped into a single VDF string. Current clients still keep this key but ignore its contents — writing here has no visible effect. If a previous tool left data there, steam-organizer clean-legacy clears it (after backing it up).

Chromium LevelDB — legacy

%LOCALAPPDATA%\Steam\htmlcache\...\Local Storage\leveldb, key U<steamid64>-cloud-storage-namespace-1. Reading works out of the box; writing needs the optional plyvel package.

Schema detection

For the legacy backends the envelope shape varies between client versions, so the tool infers it from whatever is already on disk and writes new entries to match, rather than hard-coding one version's format.


Building an executable

python packaging/build.py

This creates an isolated .venv-build environment so PyInstaller never touches your system Python, then produces dist/SteamLibraryOrganizer/ containing:

  • SteamLibraryOrganizer.exe — windowed GUI, no console flash
  • steam-organizer-cli.exe — console build for the CLI subcommands

The whole folder is the application; zip it to distribute.

python packaging/build.py --system   # use the current interpreter instead
python packaging/build.py --clean    # remove build artefacts

Project layout

steam_organizer/
  vdf.py                 Dependency-free VDF (KeyValues) parser and writer
  config.py              Configuration model, JSON persistence, app-data paths
  logging_setup.py       Rotating file + console logging
  organizer.py           The run pipeline; the GUI and CLI are shells over this
  cli.py                 Command-line interface
  steam/
    paths.py             Install, library folder and account discovery
    library.py           Merged game list from manifests, Web API and localconfig
    collections.py       Collection store: read, write, schema detection, merge
    backup.py            Backup, prune and restore
  metadata/
    models.py            The normalised GameMetadata record
    sources.py           Steam Store, SteamSpy, store-page and Web API clients
    ratelimit.py         Token bucket and retry policy
    cache.py             SQLite metadata cache and resumable job state
    provider.py          Source merging, concurrency, progress reporting
  categorize/
    rules.py             Category vocabulary, matchers, aliases, parent chains
    engine.py            Scoring, category selection, franchise and merge passes
    franchise.py         Series detection from game titles
  ui/
    app.py               Main window
    settings_dialog.py   Advanced settings
    dialogs.py           Backup browser
    tooltip.py           Hover help
    theme.py             Dark ttk theme
tests/                   Unit tests — no network, no Steam install needed
packaging/               PyInstaller spec, entry script and build driver

The layering is strict: steam/ knows nothing about metadata, metadata/ knows nothing about Steam files, categorize/ is pure logic over a metadata record, and ui/ only talks to organizer.py.


Troubleshooting

"Steam is running" — Close it fully, including the system tray icon. Steam rewrites localconfig.vdf on exit, so it would overwrite anything written while it is up. If you must override this, set require_steam_closed: false; expect your changes to be lost.

Collections do not appear after applying — Run steam-organizer probe and check the Backend: line:

  • cloudstorage is what current clients read. If the probe lists your collections but Steam does not show them, make sure Steam was fully closed when you applied — it rewrites this file on exit.
  • localconfig.vdf on a modern client means the cloudstorage folder was missing. Launch Steam once, sign in, close it, and try again.
  • leveldb needs pip install plyvel to be writable.

"No Steam Web API key configured" — Only installed games and games this account has launched will be found. Get a key at https://steamcommunity.com/dev/apikey and make sure the profile's Game details privacy setting is Public.

Hundreds of games still show under Steam's "Uncategorized" — Steam's "Uncategorized" is its own built-in bucket: every library game that is in no collection at all. Games end up there when the tool never saw them, which has two usual causes:

  1. No Web API key, so games you own but have never installed or launched are invisible. Local files alone typically know about only a third of a large library.
  2. Steam Family sharing. Shared games are owned by another account, so they appear in none of the other sources — not appmanifest, not GetOwnedGames, not localconfig.vdf. Set steam_access_token to pick them up.

Run steam-organizer preview and compare "Games scanned" against the size of your library in Steam. If it is much smaller, one of the two above applies.

A library folder is reported unreachable — That drive is not currently connected. Games installed there are skipped; reconnect the drive and re-run.

The first run is slow — Expected. The Steam store API allows roughly 200 requests per 5 minutes. Results are cached for 30 days, so subsequent runs take seconds. An interrupted run resumes where it stopped.

A game is in the wrong collection — Add a custom_mappings entry. Metadata quality varies, and unreleased games often have no user tags at all, leaving only broad genres to go on.

Rate limited / connection reset — The tool backs off and retries automatically. Persistent failures are reported per game and do not abort the run.

Logssteam-organizer.log in the app-data folder, or the Log tab.


Testing

pip install ".[dev]"
python -m pytest tests -q

138 tests, none of which need the network or a Steam install. They cover VDF parsing and round-trip fidelity; the cloudstorage backend (version counters, sync bookkeeping, tombstone preservation, and that unrelated cloud data is never touched); which collections may be deleted in bulk; schema detection; the merge rules including dynamic-collection protection; franchise detection against real Steam titles; category consolidation and the budget's fit guard; and scoring against real tag data captured from the live APIs.

They run on every push — see .github/workflows/tests.yml.


Safety summary

  • Games are never installed, uninstalled, moved or deleted.
  • Only user-collections.* entries are written. Tests confirm every other entry in the cloud namespace — and every other key in localconfig.vdf — is byte-identical afterwards.
  • Existing collections are added to, not replaced. reconcile_membership additionally removes a game from a collection it is no longer assigned to, but only for games the run actually examined — a library that shrank cannot strip collections of what it failed to see.
  • Bulk removal never touches Steam's own Favorites, Hidden or tag-derived collections, nor dynamic ones unless explicitly asked. It previews first and backs up before writing.
  • A collection is deleted only when a fold has emptied it, only when its name is one of the built-in categories, and only with retire_merged_collections on. Collections you named yourself are never candidates, and dynamic collections never are either. Every deletion is preceded by a backup and reported in the summary.
  • A backup taken from one storage backend can never be restored into another.
  • Writes go to a temp file and are swapped in atomically, so a crash mid-write cannot truncate your config.
  • Backups are automatic, and restore is one click.
  • Dry run is the default everywhere.

License

MIT

About

Automatically sorts your Steam library into collections from what each game actually is — genres, store features and user tags. Never touches your games.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages