Skip to content

Idea: a hybrid Library — browse via debrid, store select titles locally, play local copies without streaming #405

Description

@buzzromain

The goal: an unlimited library through the debrid, plus a local library grown on top of it —
either passively as you watch, or by hand when you pick something specific. What this is, in
short
— a proof of concept on a branch of my fork, four pieces:

# Piece Status
1 Library — see every title, debrid-only or already local, and choose what to keep built in the POC
2 Local Downloads page — watch those copies happen, pause/resume/cancel, history built in the POC
3 Local-first playback — read from disk instead of the debrid once a copy exists idea, small, backend-only
4 Automatic storage by rules — watch activity, etc., instead of a manual click idea, not built

The rest of this issue is the detail behind each of those four, in the shape the repo's Feature
Request template expects.


Is there an existing issue for this?

  • I have searched the existing open and closed issues

Is your feature request related to a problem? Please describe

Decypharr streams debrid content well, but there's no supported way to say "keep this one,
permanently, on local disk" and then have the app actually behave like it did:

  • No screen shows, at a glance, which movies/shows are on the debrid only vs. already stored
    locally — today's UI is organized per-torrent, not per-title.
  • No page shows a local copy being made — no progress, no queue, no pause/cancel, no history of
    what finished or failed.
  • Once a local copy exists, nothing uses it: the mount still streams playback from the debrid
    instead of reading the file that's already on disk.
  • Deciding what to keep is entirely manual — there's no way to say "store what I actually watch"
    and have it happen without a click every time.

The goal underneath all of this: an effectively unlimited library through the debrid —
nothing bound by local disk space — with a local library grown as a chosen subset on top of it,
decided per title rather than by a global switch. Two ways to grow it: passively, as a
byproduct of watching — a streamed title gets cached, and keeping it permanently is just deciding
not to let that cache disappear (point 4, once it exists) — and manually, picking a specific
title in the Library and downloading it on purpose, watched or not (point 1). This isn't about
changing what Decypharr already does by default: default_download_action: download
(internal/config/config.go, alongside symlink/strm/none) already fully downloads
everything at import time, and that stays untouched — everything keeps streaming from the debrid
unless something says otherwise. What's missing is the middle ground this issue is about: after
import, per title, choosing the few worth a permanent copy while the rest stay debrid-only — a
hybrid library, not an all-or-nothing setting.

Describe the solution you'd like

Detail on each piece from the table above — real, running code, not a mockup, but a POC rather
than a finished feature. (All data in the screenshots below — titles, sizes, quality names — is
synthetic demo data seeded into a local dev instance, not a real catalog.)

1. Library — debrid vs. local, in one place

One screen to see every movie and show Decypharr knows about, and decide, title by title, which
ones are worth a permanent local copy. One card per title (movie/show), even when it exists in
more than one quality or across more than one *Arr instance — never one card per copy. The
header carries the running local-storage budget, because storing is a decision made under a disk
constraint.

Library grid
Grid view — one card per title, storage budget in the header.

Library, table layout
The same data as a sortable table.

Filtered — Movies, not stored
Grid and table share the same filters: storage state, type, *Arr instance, search.

No match for a search
A filtered search with no results — names the filters at fault instead of just saying "nothing here."

Storing. Select several titles at once, see the total and what's left after, confirm once. A
single title with only one copy skips the dialog entirely and starts right away, with an undo
window instead of a confirmation step.

Bulk selection
Selecting three titles — running total and free space after, computed live.

Direct store + undo
One title, one copy: no dialog, just an undo window.

A title's own page shows where a stored copy lives and what it's made of — no seeding
integration yet, so that section says so rather than pretending the feature exists.

Movie detail, stored
A fully-stored title's page.

Storing is cheap because it reuses what's already been streamed: this 1080p release is already
38% present in the VFS sparse cache from prior playback — storing it permanently only needs to
fetch the remaining 62%, not the whole file again (mechanism detailed under point 2).

Cache reuse detail
38% of this release is already on disk from playback — only the rest needs downloading.

Picking a release, or several seasons at once for a show — a season can come from a different
release than its neighbours, so this can start more than one copy at a time.

Store dialog — movie
Two releases of the same movie — picking which one to keep.

TV seasons
A show's seasons, each with its own release(s) and status.

Store dialog — TV, multiple seasons
Picking up several seasons in one go — each can start its own copy.

Reclaiming space, one title or several at once — both replace the browser's native confirm()
with something that shows exactly what's about to happen.

Delete confirmation
Deleting one title's local copy.

Bulk delete
Deleting several at once, with a per-title result.

2. A Local Downloads page

Storing a title starts a tracked task, not a fire-and-forget copy. One page shows what's running,
queued or paused, with pause/resume/cancel/reorder, and a history of what finished, failed, or
was cancelled — including why a failure happened and whether retrying makes sense.

Local downloads
Running, paused and queued copies, each showing the cache-reuse split — plus history with a real
failure case.

The cache-reuse math visible per task above is the actual mechanism: for each chunk of the target
file, if the byte range is already in the sparse cache, it's copied directly; if not, it's fetched
from the debrid provider. Peak extra disk usage is the missing bytes only, never the full file
size again.

Debrid (network)        streamed on demand, nothing on disk
     │
     ▼
/data/cache/             sparse file — chunks written as read, LRU-evictable (exists today)
     │
     ▼
/data/local/              dense file, permanent, never evicted — new

Today there's no supported path to a permanent copy at all, cache-aware or otherwise: CopyEntry
(pkg/manager/entry.go:446) is the only existing hook for this — the WebDAV drag-and-drop copy
flow calls into it — but its body has been entirely commented out since it was added, and it
unconditionally returns "copying entries is not supported yet". The POC uses a new
LocalDownloadManager instead, tracked as its own task type, separate from the existing
torrent/NZB import tasks.

Two things that make sense once this exists but that the POC doesn't cover: tying a finished copy
to seeding (offer to start seeding through qBittorrent once a torrent's copy lands), and warning
before the temporary cache evicts a title — the existing LRU eviction in /data/cache/ runs on
its own regardless of what a person might have wanted to keep, so a title that's 80% cached from
playback can lose that progress with no warning. Offering to store it permanently before that
happens would turn a silent loss into a choice.

3. Reading from local/cache instead of debrid

For points 1 and 2 to actually deliver "no permanent debrid dependency for locally stored items,"
the mount needs to prefer a local copy when one exists. Right now
pkg/mount/dfs/backend/cgofuse/fs.go:219 (OpenEx) branches only on info.IsRemote(), which is
false only for in-memory static content (e.g. version.txt) — there's no notion of "this file
also has a dense copy on disk, read that instead of streaming." A title marked fully stored is
still streamed from the debrid on playback. No screenshot for this one — it's the absence of a
behavior, not a screen. Self-contained and backend-only, and worth doing regardless of how the
rest of this issue lands.

4. Automatic local storage by rules — an idea, not built

Points 1–3 exist in the POC; this one is the next idea in the same direction. Instead of only
picking what to store by hand, let a rule decide it. Watch activity is the obvious first rule — a
title gets stored locally once it's actually been watched, or watched past some percentage, or
watched more than once. This isn't disconnected from what already exists: Decypharr already
receives Tautulli webhooks and acts on them (pkg/server/webhook.go, currently used to trigger a
targeted repair recheck) — the same kind of event could just as well trigger a "store locally,"
through the same task machinery point 2 already tracks, instead of a person clicking the button.
Other rules follow naturally once that one exists: age since added, *Arr tags or quality, or the
reverse — deleting a permanent copy from /data/local/ (not the temporary cache point 2 talks
about) if nobody has watched it in a while, gated by the same quota point 1 already shows.

Describe alternatives you've considered

  • Extending default_download_action instead of building a separate flow. That setting
    already fully downloads to local disk on import (download, vs. symlink/strm/none), so
    it's the obvious first thing to reach for. It doesn't fit because it's global and decided at
    import time — one action for everything a given *Arr/category sends in, before anyone has
    watched or judged the title. What I want is the opposite shape: nothing downloads by default,
    and a title becomes a local copy only after someone (or a rule, in point 4) opts it in later,
    one title at a time, reusing whatever's already cached from streaming it. Different lever,
    same underlying download_action machinery could plausibly end up reused underneath — but the
    decision point has to move from "at import, for everything" to "per title, after the fact."
  • Building this against the current server-rendered/DaisyUI templates instead of a new SPA.
    I tried, and a title-centric Library (one card per movie/show, not per torrent) didn't fit the
    existing per-torrent templates without feeling bolted on — so the POC is a from-scratch React +
    TypeScript SPA (Vite, TanStack Query, react-router) instead. That's my read, not a settled
    conclusion: nothing here is a diff against the current UI, it's a replacement of it, and I know
    that's a much bigger ask than the feature itself. I'd genuinely like to know whether a framework
    migration is something this project would consider at all, because the answer decides whether
    points 1 and 2 are portable in any form. Points 3 and 4 don't depend on this either way — 3 is
    backend-only, and 4's configuration surface is small enough it doesn't obviously need the same
    frontend as the Library.
  • Reusing the existing CopyEntry hook (point 2) instead of a new local-download manager.
    CopyEntry's model is a WebDAV-style rename/duplicate of a torrent entry; it has no notion of
    reusing part of a file that's already in the sparse cache. Chunk-level, cache-aware copying
    needed a different mechanism, so the POC adds LocalDownloadManager alongside it rather than
    trying to extend CopyEntry. CopyEntry itself is still just dead code either way — worth
    deciding on its own regardless of what happens with the rest of this.
  • Manual-only storage, dropping point 4 entirely. Simplest option, and it's the only part of
    this that's actually built end-to-end. But it doesn't address the actual reason I wanted rules
    in the first place — I forget to manually store things I've already watched — so I kept it in
    as an idea rather than drop it for being unbuilt.
  • Designing a full multi-destination storage system up front. Held off. The POC has exactly
    one local path and one quota, deliberately, rather than guessing at a config shape nobody's
    asked for yet.

Anything else?

What I'd like feedback on, specifically:

  • Whether the frontend framework question (above) is something worth having at all, and if it's
    a hard no, whether that means "rebuild these screens against the current stack" or "this
    direction doesn't fit the project."
  • Local storage destinations — worth designing multiple named ones before a v1, or fine to start
    with the single default path the POC has now?
  • The bulk-selection behavior in point 1, for titles that each have a different cheapest release
    — it's one idea among several plausible ones, and I'd like a second opinion on whether it's the
    right one once sizes and instances genuinely diverge.
  • Whether automatic storage by rules (point 4) fits how this project wants to behave at all — an
    automation that adds or deletes local files on its own is a different trust level than a screen
    that waits for a click — and if it does, whether reusing the existing Tautulli webhook is the
    right integration point.
  • Any existing plans or branches in these areas I should know about before going further.
Code pointers used above
  • pkg/manager/entry.go:446CopyEntry, commented out
  • pkg/mount/dfs/backend/cgofuse/fs.go:219OpenEx / IsRemote()
  • pkg/server/webhook.go — existing Tautulli webhook handling
  • internal/config/config.go — today's single Mount.MountPath

Happy to open the fork (or specific diffs) for a closer look at anything above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions