Skip to content
slurpeestrawPublic

About

A highly configurable, FUSE-based semantic filesystem for stashapp/stash

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

stashfs

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.

Install

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 the fuse3 package (sudo apt install fuse3, sudo dnf install fuse3 or sudo 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
cd

The 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.

If you can't read your video files

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 -l shows): 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.

Set up

1. Get your stash API key

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.

2. Find your video folders (path_map)

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/clips

Note 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.

3. Write the config file

mkdir -p ~/.config/stashfs
nano ~/.config/stashfs/config.yaml

Paste 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/videos
  • url: the address you open stash at. If stash runs in Docker on this machine, use the port you published (the left side of 9999:9999). If stash is on another machine, use its address, for example http://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.yaml

That'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.

Run

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.yaml

stashfs 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/stash

You 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.

Start automatically

To run stashfs in the background and start it at login, use a systemd user service.

  1. If stashfs is running in a terminal, stop it with Ctrl-C.

  2. 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/stash if your mount.path is different. If you installed with go install, change %h/.local/bin to %h/go/bin.

  3. Start it now and at every login, then watch its log:

    systemctl --user daemon-reload
    systemctl --user enable --now stashfs
    journalctl --user -u stashfs -f

    Press 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).

Use with Jellyfin, Plex and other media servers

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.

  1. Allow it, once per machine:

    echo user_allow_other | sudo tee -a /etc/fuse.conf

    (If /etc/fuse.conf already has a #user_allow_other line, you can remove the # instead.)

  2. In your stashfs config, add the line allow_other: true inside the existing mount: section, so that it looks like this:

    mount:
      path: /mnt/stash
      allow_other: true

    Then restart stashfs: systemctl --user restart stashfs if you use the service, or Ctrl-C and start it again.

  3. If the media server runs in Docker, add the folder to its volumes read-only, with rslave so 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).

Troubleshooting

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.

Upgrade and uninstall

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/stash

If you also did these, undo them:

  • remove the user_allow_other line from /etc/fuse.conf;
  • run loginctl disable-linger "$USER", if you enabled it only for stashfs;
  • remove the /mnt/stash volume and libraries from your media server.

stash itself is unaffected.

Configuration reference

Everything below is optional. The config file is YAML; a mistyped setting name stops stashfs at startup with a message naming it.

Top-level keys

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.

Views

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: true

Grouping levels

A 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 names

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.

Filters

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>/playground and look up SceneFilterType in 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, age and so on);
  • scenes with none of the matching performers are left out;
  • _with directories 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.

A fuller example

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: true

Develop

You 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.

AI disclosure

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.

License

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

About

A highly configurable, FUSE-based semantic filesystem for stashapp/stash

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages