stashfs gives you a folder, such as /mnt/stash, that shows your
stash video library sorted by tag,
performer, studio or your saved filters: for example tags/Beach/,
performers/Alice/ or saved/Favourites/. You can browse it with any file
manager, play from it with any video player, or point a media server at it.
- Nothing is copied and no extra disk space is used: the files are read from where stash already keeps them.
- It's read-only, so nothing you do in the folder changes stash or your files.
- It follows your stash library: new scenes, tags and other changes show up within a few minutes.
stashfs runs directly on a Linux machine and asks stash for the library details over its API. Stash itself needs no changes, and can run in Docker or not.
Where it runs: Linux only; Windows and macOS aren't supported. It's built and tested on ordinary Linux distributions that use systemd (Debian, Ubuntu, Fedora, Arch and so on). NAS systems such as Unraid or Synology are untested. There's no Docker image: a FUSE folder created inside a container needs extra privileges and doesn't easily reach the host, so stashfs is installed on the host instead.
You need:
- Linux on x86-64 or ARM64, with FUSE (what lets a normal program
provide a folder like this). Check with
which fusermount3: if it prints nothing, install thefuse3package (sudo apt install fuse3,sudo dnf install fuse3orsudo pacman -S fuse3). - The machine where your video files are. stashfs reads the files directly, so run it on the machine that has them (or has them mounted, for example from a NAS).
- Read access to the files for your user. See below if you can't read them.
Download the latest release (most people should use this). These commands work in a temporary folder, so nothing is left behind:
cd "$(mktemp -d)"
arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
base=https://github.com/slurpeestraw/stashfs/releases/latest/download
curl -LO "$base/stashfs-linux-$arch" -LO "$base/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS
install -Dm755 "stashfs-linux-$arch" ~/.local/bin/stashfs
cdThe sha256sum line should print OK. Then check that the command is found:
which stashfs should print a path. If it prints nothing, log out and back in
(some systems only add ~/.local/bin to your PATH at login), or run it as
~/.local/bin/stashfs.
If you have Go installed, go install github.com/slurpeestraw/stashfs/cmd/stashfs@latest
works too, and puts stashfs in ~/go/bin.
Run ls -l on one of your videos. If it belongs to another user and isn't
readable by everyone (the last r in -rw-r--r-- is missing), either:
- add yourself to the file's group, if that group can read it (the group is
the second name
ls -lshows):sudo usermod -aG <group> "$USER", then log out and back in; or - make the videos readable by everyone:
sudo chmod -R a+rX /path/to/your/videos.
Don't run stashfs with sudo to get around this: a folder mounted by root
can't be used by your automatic start or by media servers.
If your stash has a username and password, open stash, go to Settings → Security → API Key, and click Generate if there's no key yet. If stash has no login, skip this step.
In stash, open Settings → Library. The folders listed there are your
video folders, with the paths as stash sees them. stashfs has to find the
same folders on the machine it runs on. You'll need a path_map entry for
each folder whose path is different there.
If stash runs in Docker, those paths are inside its container. To see
where each one really is, run (replace stash with your container's name,
from docker ps):
docker inspect stash --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'Each line shows a real folder on the left and the path stash sees on the right, for example:
/srv/media/videos -> /data
/srv/media/clips -> /clips
/opt/stash/config -> /root/.stash
Ignore the lines that aren't your video folders (config, cache, metadata and
so on). For each video folder, add an entry with stash's path as from
and the real folder as to:
path_map:
- from: /data
to: /srv/media/videos
- from: /clips
to: /srv/media/clipsNote that this is the opposite order to Docker's host:container.
If stash runs on another machine (for example a NAS) and the videos are
mounted on this one, add an entry for each folder: from is the path in
stash's Settings → Library, to is where the same folder is mounted here.
If stash runs on this machine and not in Docker, the paths are already
right: you don't need path_map.
mkdir -p ~/.config/stashfs
nano ~/.config/stashfs/config.yamlPaste this in, change the values, and save:
stash:
url: http://localhost:9999
api_key: <your API key>
mount:
path: /mnt/stash
path_map: # from step 2; delete these lines if you don't need it
- from: /data
to: /srv/media/videosurl: the address you open stash at. If stash runs in Docker on this machine, use the port you published (the left side of9999:9999). If stash is on another machine, use its address, for examplehttp://192.168.1.10:9999.api_key: replace<your API key>, including the<and>, with your key. Remove the line if stash has no login.mount.path: where the stashfs folder will appear.path_map: your entries from step 2.
The file now contains your API key, so make it readable only by you:
chmod 600 ~/.config/stashfs/config.yamlThat's all you need to start. With no views in the file, you get the
default folders: tags, performers (each with a _with folder of
co-performers), studios and saved. To change them, see
Configuration reference.
Create the mount folder and make yourself its owner (use the same path as
mount.path), then start stashfs as yourself, not with sudo:
sudo mkdir -p /mnt/stash && sudo chown "$USER": /mnt/stash
stashfs -config ~/.config/stashfs/config.yamlstashfs stays running in the terminal and writes what it's doing there. A good start looks like this:
stashfs: loading library from http://localhost:9999
stashfs: loaded 1234 scenes, 210 performers, 480 tags
stashfs: mounted at /mnt/stash
Once you see mounted at, open a second terminal and check:
ls /mnt/stashYou should see performers saved studios tags, and inside them your
library. Videos keep their original file names; to show titles or dates
instead, see File names.
If stash isn't reachable yet, or a check fails, stashfs logs a
not ready yet: … retrying in … line explaining why, and tries again: after 2
seconds, then 4, 8 and so on, up to once a minute. So it's fine to start
stashfs before stash. The one error it doesn't wait on is a refused API key:
then it exits with a message saying so.
To stop, press Ctrl-C. stashfs unmounts the folder before it exits. If a video is still playing from it, the player gets an error.
While it runs, changes in stash appear within about 5½ minutes (every
refresh_interval, 5 minutes by default, plus 30 seconds of caching). If
stash goes away, the folder keeps showing the last known library.
To run stashfs in the background and start it at login, use a systemd user service.
-
If stashfs is running in a terminal, stop it with Ctrl-C.
-
Create the file
~/.config/systemd/user/stashfs.service:mkdir -p ~/.config/systemd/user nano ~/.config/systemd/user/stashfs.service
and paste:
[Unit] Description=stashfs: stash library as a filesystem [Service] ExecStart=%h/.local/bin/stashfs -config %h/.config/stashfs/config.yaml # Clean up the mount if stashfs ever stops without unmounting. ExecStopPost=-fusermount3 -u -z /mnt/stash Restart=on-failure RestartSec=10 [Install] WantedBy=default.target
Change
/mnt/stashif yourmount.pathis different. If you installed withgo install, change%h/.local/binto%h/go/bin. -
Start it now and at every login, then watch its log:
systemctl --user daemon-reload systemctl --user enable --now stashfs journalctl --user -u stashfs -fPress Ctrl-C to stop watching the log; stashfs keeps running in the background. To stop stashfs itself, use
systemctl --user stop stashfs.
To also start it at boot without logging in, run loginctl enable-linger "$USER"
once. There's no need to make it wait for stash: stashfs waits on its own.
Without systemd, run the same stashfs -config … command from your
system's startup scripts, as your own user (not root).
A media server usually runs as a different user, or in a container, and FUSE folders are only readable by the user who created them unless you allow otherwise.
-
Allow it, once per machine:
echo user_allow_other | sudo tee -a /etc/fuse.conf
(If
/etc/fuse.confalready has a#user_allow_otherline, you can remove the#instead.) -
In your stashfs config, add the line
allow_other: trueinside the existingmount:section, so that it looks like this:mount: path: /mnt/stash allow_other: true
Then restart stashfs:
systemctl --user restart stashfsif you use the service, or Ctrl-C and start it again. -
If the media server runs in Docker, add the folder to its volumes read-only, with
rslaveso it still sees the files after stashfs restarts, then restart the media server's container:volumes: - /mnt/stash:/stash:ro,rslave
In the media server, add libraries from
/stash/…(for example/stash/studios). If it isn't in Docker, use/mnt/stash/…directly.
Each video appears in several folders: once per tag, per performer and so on. If you add several of these folders as libraries, the media server lists the same video once per place. Pick one folder per library, or make a view just for the media server (see Configuration reference).
| What you see | What to do |
|---|---|
not ready yet: … library root /data (host path …) is not a directory on this host; check path_map |
The path_map is wrong or the disk isn't mounted. See step 2. |
not ready yet: … is N bytes, stash says M; check path_map |
The mapping points to a different copy of your files. Check to:. |
not ready yet: … connection refused |
stash isn't running or url is wrong. stashfs keeps retrying. |
stash refused access (HTTP 401); check the API key |
The API key is wrong or missing. |
mount point /mnt/stash is not empty |
The mount folder must be empty. Use an empty folder. |
transport endpoint is not connected |
stashfs was killed without cleaning up. Run fusermount3 -u /mnt/stash, then start it again. |
fusermount3: command not found, or mounting fails |
Install the fuse3 package (see Install). |
| Files show up but won't play: "permission denied" | Your user can't read the video files. See If you can't read your video files. |
| A media server or another user sees an empty folder | Set allow_other (see media servers). In Docker, add rslave to the volume and restart the container. |
A saved filter is missing from saved/ |
stashfs couldn't convert it; the log names the filter. See Filters. |
| A new tag or scene doesn't appear | Wait up to about 5½ minutes, or restart stashfs. |
To upgrade, run the download commands from Install again,
then restart stashfs (systemctl --user restart stashfs if you use the
service).
To uninstall:
systemctl --user disable --now stashfs # if you set up the service
rm -f ~/.config/systemd/user/stashfs.service ~/.local/bin/stashfs
rm -rf ~/.config/stashfs
sudo rmdir /mnt/stashIf you also did these, undo them:
- remove the
user_allow_otherline from/etc/fuse.conf; - run
loginctl disable-linger "$USER", if you enabled it only for stashfs; - remove the
/mnt/stashvolume and libraries from your media server.
stash itself is unaffected.
Everything below is optional. The config file is YAML; a mistyped setting name stops stashfs at startup with a message naming it.
| Key | Required | Default | Meaning |
|---|---|---|---|
stash.url |
yes | Base URL of stash, e.g. http://localhost:9999 |
|
stash.api_key |
no | The API key, from Settings → Security in stash. Needed if stash has a login. | |
stash.api_key_env |
no | Alternative to api_key: the name of an environment variable holding the key. If you name a variable, it must be set. api_key wins if both are given. |
|
mount.path |
yes | Mount point. It must exist and be empty. | |
mount.allow_other |
no | false |
Let other users (for example a media server) read the mount. This needs user_allow_other in /etc/fuse.conf. |
path_map |
no | none | List of {from, to} pairs that rewrite stash's paths into host paths. from and to must be absolute. The longest matching from wins, and it only matches whole path segments (/data matches /data/x but not /database). Paths with no match are used unchanged. |
refresh_interval |
no | 5m |
How often to fetch changes from stash. Minimum 10s. |
full_refresh_interval |
no | 1h |
How often to re-fetch every scene, which catches changes that don't touch a scene's update time (tag merges, files moved or renamed in stash). Must be at least refresh_interval. |
unknown_dir |
no | _unknown |
Directory for scenes that have no value at a grouping level. Set it to "" to leave such scenes out. |
views |
no | see below | The top-level directories |
Durations use Go syntax: 30s, 5m, 1h30m. Unknown keys are rejected, so
typos fail at startup.
Each view is one top-level directory:
| Key | Meaning |
|---|---|
name |
Directory name (required, unique, no /) |
filter |
Only include scenes matching this stash scene filter (see Filters) |
saved_filter |
Only include scenes matching the saved scene filter with this name |
all_saved_filters |
true makes one subdirectory per saved scene filter. group_by then applies inside each one. |
performer_filter |
Only include performers matching this stash performer filter (see Filters) |
group_by |
List of grouping levels, one directory depth each (see Grouping levels). Leave it out to list files directly. |
file_name |
Template for file names (see File names). Default {basename}. |
A view may set at most one of filter, saved_filter and
all_saved_filters. With none, it includes every scene. performer_filter
can be combined with any of them.
If views is left out entirely, stashfs uses:
views:
- name: tags
group_by: [tag]
- name: performers
group_by: [{ key: performer, with: true }]
- name: studios
group_by: [studio]
- name: saved
all_saved_filters: trueA level is either a bare key (tag) or a mapping with options
({ key: tag, nested: true }). Each key may appear only once per view.
Scene keys
| Key | Directory names | Options |
|---|---|---|
tag |
Tag name. A scene appears under every tag it has. | nested |
studio |
Studio name | |
group |
Group name. A scene appears under every group it is in. | |
year |
2024 |
|
month |
2024-03 |
|
rating |
0 to 5: the rating out of 100, divided by 20 and rounded down. Unrated scenes go to unknown_dir. |
|
organized |
organized or unorganized |
year, month and age use the scene's production date, falling back to
its release date. This matches stash's own age calculation. On stash
releases without a production date field, the release date is used.
Performer keys
| Key | Directory names | Options |
|---|---|---|
performer |
Performer name, plus (disambiguation) if set |
with |
gender |
Lower-cased stash value: male, female, transgender_male, transgender_female, intersex, non_binary |
|
ethnicity, country, hair_color, eye_color, fake_tits |
The free-text value (see note below) | |
circumcised |
cut or uncut |
|
height |
Height in cm | buckets or bin |
weight |
Weight | buckets or bin |
cup |
Cup size taken from measurements, e.g. DD from 34DD-24-36 or 34-DD |
aliases |
age |
Age in whole years on the day the scene was filmed | buckets or bin |
performer_tag |
Names of the performer's own tags |
A scene with several performers appears under each of them. When a view has
several performer levels, they all describe the same performer. For
example, with group_by: [country, cup, performer],
Japan/C/ only lists Japanese performers with a C cup, even if a scene also
features someone else.
Free-text values are grouped ignoring case and extra spaces, so
Caucasian and caucasian share one directory. The directory shows the
alphabetically first spelling.
Level options
| Option | Allowed on | Meaning |
|---|---|---|
nested: true |
tag, last level only |
Follow stash's tag hierarchy. The top shows tags with no parent. Each tag's directory holds its child tags that have scenes beneath them, plus the scenes tagged with exactly that tag. A tag with several parents appears under each parent. |
with: true |
performer, last level only |
Add a _with directory inside each performer's directory. _with/<co-performer>/ lists the scenes the two share. Only one level of co-performers is supported. |
with: { gender: [female, non_binary] } |
performer, last level only |
Like with: true, but _with only lists co-performers of these genders (the gender key's names, case ignored). Co-performers with no gender set are left out. A performer with no matching co-performers gets no _with directory. |
buckets: [18, 20, 25] |
age, height, weight |
Ranges: under-18, 18-19, 20-24, 25+. Values must be strictly ascending. |
bin: 5 |
age, height, weight |
Fixed-width ranges, e.g. 165-169. Can't be combined with buckets. |
aliases: { DDD: E } |
cup |
Rename cup sizes. Matching ignores case, and the result is upper-cased. |
Without buckets or bin, the exact number is used.
file_name is a template with these placeholders:
| Placeholder | Value |
|---|---|
{basename} |
Original file name, e.g. clip.mp4 |
{stem} |
File name without extension, e.g. clip |
{ext} |
Extension without the dot, e.g. mp4 |
{title} |
Scene title |
{date} |
Production date, or else release date, as YYYY-MM-DD |
{studio} |
Studio name |
{id} |
Stash scene id |
For example, file_name: "{date} {title}.{ext}" gives
2021-06-01 Beach day.mp4. If any placeholder in the template is empty
for a scene (for example no title), that scene uses {basename} instead.
Unknown placeholders are left as written.
Names are cleaned up for use as file and directory names:
/becomes_.- Surrounding spaces are removed.
- Names are cut to 255 bytes, keeping the extension.
When two different scenes (or tags, performers and so on) end up with the
same name in one directory, all of them get their stash id added, e.g.
clip [12].mp4 and clip [40].mp4.
Only each scene's primary file is shown. Images and galleries aren't included.
The easiest way to choose scenes is a saved filter: build the filter on
stash's Scenes page, save it, then use its name with saved_filter: <name>,
or show every saved filter with all_saved_filters: true.
stashfs converts saved filters from the format stash's web page stores them in. A saved filter using a criterion it can't convert, or that stash rejects, is left out, and a line naming it is logged on each refresh. Criteria known not to convert yet include resolution, orientation, duplicates and stash-box ids.
For more control, filter takes stash's own scene filter, which stashfs
passes to stash unchanged:
views:
- name: organized
filter: { organized: true }
- name: top-rated-beach
filter:
rating100: { value: 80, modifier: GREATER_THAN }
tags: { value: ["12"], modifier: INCLUDES, depth: -1 }- The field names are stash's. To browse them, open
http://<your stash>/playgroundand look upSceneFilterTypein its documentation panel. - Ids are written in quotes. A tag's id is the number at the end of its page
address in stash, for example
/tags/12.
performer_filter works the same way with stash's performer filter
(PerformerFilterType in the playground), the one behind stash's
Performers page. In that view:
- only matching performers get directories and count for performer levels
(
performer,country,cup,ageand so on); - scenes with none of the matching performers are left out;
_withdirectories still list every co-performer, matching or not.
views:
- name: favourites
performer_filter: { filter_favorites: true }
group_by: [{ key: performer, with: true }]
- name: top-rated
performer_filter:
rating100: { value: 80, modifier: GREATER_THAN }
group_by: [country, performer]Saved performer filters aren't supported yet.
stash:
url: http://localhost:9999
api_key: <your API key>
mount:
path: /mnt/stash
path_map:
- from: /data
to: /opt/media
refresh_interval: 2m
unknown_dir: _unknown
views:
- name: tags
group_by: [{ key: tag, nested: true }]
- name: performers
group_by: [{ key: performer, with: true }]
- name: by-country
group_by: [country, { key: cup, aliases: { DDD: E } }, performer]
- name: by-age
group_by: [{ key: age, buckets: [18, 20, 25, 30, 40] }]
- name: studios
group_by: [studio, year]
file_name: "{date} {title}.{ext}"
- name: favourites
saved_filter: Favourites
group_by: [performer]
- name: saved
all_saved_filters: trueYou need Go 1.25 or newer (older Go versions download it automatically) and, to run every test, FUSE.
git clone https://github.com/slurpeestraw/stashfs && cd stashfs
go build -o stashfs ./cmd/stashfs
go vet ./... && go test -race ./...The FUSE tests mount real filesystems in temporary directories. They are skipped where FUSE isn't available, such as in most containers.
| Package | Role |
|---|---|
cmd/stashfs |
Command line, startup (waiting for stash), mount lifecycle |
internal/config |
YAML configuration: defaults and validation |
internal/stash |
GraphQL client, and translation of saved filters |
internal/catalog |
In-memory copy of the library and the polling sync |
internal/tree |
Builds the view directories as an io/fs filesystem |
internal/facet |
Value rules: text folding, cup size, age at filming, ranges |
internal/content |
Path mapping and reading files from the host |
internal/fusefs |
Serves any io/fs filesystem over FUSE, read-only |
Releases: pushing a version tag such as v1.2.3 makes
.github/workflows/release.yml run the tests,
build the Linux binaries and publish them as a GitHub release. Tags with a
suffix, such as v1.2.3-rc.1, become prereleases.
This project was written by an LLM (Anthropic's Claude), directed by a human.
- Human: chose the project and its scope, and made the design decisions: which views and grouping keys exist, co-performer directories, performer filters and licensing. The human also ran stashfs against a real stash library and reported the problems found.
- LLM: researched the reference implementation and stash's API, wrote the design spec and implementation plan, and wrote all of the code, tests and documentation.
- Review: a separate LLM instance reviewed the code, and the automated tests include real FUSE mounts. The maintainers have not reviewed the code line by line.
Copyright (C) 2026 The stashfs authors.
stashfs is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See LICENSE for the full text.
stashfs is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Binaries built from this repository include these libraries, whose licenses must be shipped alongside any binary you distribute:
| Library | License |
|---|---|
| github.com/hanwen/go-fuse/v2 | BSD 3-clause |
| gopkg.in/yaml.v3 | MIT and Apache-2.0 |
| golang.org/x/sys | BSD 3-clause |