Skip to content

Release 3.3.5#337

Merged
plz12345 merged 23 commits into
erosfrom
release-3.3.5
Jul 20, 2026
Merged

Release 3.3.5#337
plz12345 merged 23 commits into
erosfrom
release-3.3.5

Conversation

@plz12345

Copy link
Copy Markdown
Contributor

Database Migration

NO

Description

Rolling the 3.3.5 development cycle into a stable release.

Replaces #330, which was headed by eros-develop and therefore kept absorbing
post-3.3.5 commits as they landed. This one is headed by the frozen
release-3.3.5 branch, pinned to 39b1db6 — the exact commit carrying the
v3.3.5 marker tag — so the release contents can't drift.

Commits after the tag (#331, #334, #335) are deliberately left on
eros-develop for the next cycle.

Merge with "Create a merge commit" — not squash, not rebase. The build
derives its base version from the v3.3.5 tag's commit SHA being reachable
from eros; squashing collapses that SHA and rebasing rewrites it, either of
which makes the release build as 3.3.4.

Screenshot(s) (if UI related)

Details

Todos

  • Tests
  • Translation Keys (./src/NzbDrone.Core/Localization/Core/en.json)

Issues Fixed or Closed by this PR

Mothball7205 and others added 23 commits June 19, 2026 18:00
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…315)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
MountCheck had no tests. Pins current behaviour before optimising the
mount lookups it performs: the null-mount and null-MountOptions guards
(MountOptions is always null on Windows, since DriveInfoMount is built
without them), the DistinctBy on root directory, and the reported path
ordering.
GetMount() re-enumerated the entire OS mount table on every call. On
Linux that reads /proc/mounts and stats every mount point, so on a host
with many network mounts it is hundreds of syscalls per call.

MountCheck calls GetMount() once per movie path, which made Check Health
take over 24 hours on a 264k scene library. DiskTransferService also pays
this twice per file transfer.

Cache the mount list for 15 seconds. That collapses a health check run
from hundreds of thousands of enumerations to roughly one every 15s,
while staying short enough that a newly mounted share still shows up in
the folder browser without any invalidation hooks.

GetMount() stays per-path: MountCheck resolves mounts from movie paths
specifically so symlinks and junctions are handled by the provider, so
the number of lookups is unchanged, only their cost.

Only topology is cached. Free space is unaffected, since IMount reads it
live from the underlying DriveInfo/UnixDriveInfo on each property access.

Fixes Whisparr/Whisparr#1102
RootFolderCheck and DiskSpaceService called GetBestRootFolderPath without
passing the root folders. The result is cached per path, but on a miss it
falls back to querying every root folder from the database, so a cold
cache meant one query per movie.

RootFolderCheck runs on startup, when the cache is always cold, which on
a 264k scene library is 264k queries.

Pass the root folders in via the existing overload, which is there for
exactly this.
GetMount tested every mount with IsParentPath, which walks the path back
up to the root each time. That walk only depends on the path, so it was
being repeated identically for every mount on the system.

Hoist it: build the ancestors once per lookup and compare each mount root
against them. PathEquals is untouched, and the comparison is the same
OsPath equality IsParentPath performed, so paths resolve exactly as
before, including relative segments that PathEquals resolves via
CleanFilePath.

Measured over 130 mounts, roughly 160us -> 62us per lookup, or ~42s ->
~16s across a 264k scene library.
None of these sit on lines the mount check perf work touches; they are
pre-existing findings inherited from upstream. Keeping them in their own
commit so that PR stays reviewable and the divergence is easy to drop.

- Pass the caught exception to the logger in FolderWritable (S6667). The
  NLog layout renders ${exception:format=ToString} on any call carrying
  one, so the inline copy of e.Message goes away rather than being
  printed twice. Net gain is the exception type, which the bare message
  did not carry.
- Rename IsFileLocked's parameter to path, matching IDiskProvider (S927).
  No caller passes it by name.
- Drop RemoveReadOnlyFolder, which has no callers (S1144).
- Make GetDriveInfoMounts static (S2325). It was never virtual, so no
  subclass could have overridden it.
- Make SetWritePermissionsInternal static in the Mono test fixture (S2325).
The index status bar showed a generic "Downloaded" instead of the file's
quality. Every index view (table, poster, overview, for both scenes and
movies) already renders movieFile.quality.quality.name and falls back to
"Downloaded" when it is absent -- the paged API simply never sent it.

PagedBuilder already LEFT JOINs MovieFiles, but QueryJoined builds its
SELECT from its type parameters, so PagedQuery's <Movie, MovieMetadata>
emitted only Movies.* and MovieMetadata.*. Movie.MovieFile is a plain
property rather than LazyLoaded, so nothing back-filled it and
ToResource's null-conditional dropped the file.

GetPaged now maps MovieFile via its own query. Two constraints shaped it:

- PagedQuery is shared with MoviesWithoutFiles and MoviesWhereCutoffUnmet,
  which GROUP BY Movies.Id. Selecting a non-aggregated file column under
  that GROUP BY errors on Postgres, so those keep using PagedQuery and only
  GetPaged changes. Neither needs the file: their rows fetch it separately.
- MovieFileResource.ToResource dereferences Movie.QualityProfile to compute
  QualityCutoffNotMet, so hydrating MovieFile without a profile would throw.
  The profile is hydrated from the repository, falling back the way All()
  does when a movie points at a profile that no longer exists.

MediaInfo is left out of the query: it stores the full ffprobe dump
(~7KB a row, measured) that no index view renders, and it would otherwise
be deserialized for every row on a page of up to 1000.

Fixes Whisparr/Whisparr#1104
POST /movie/paged took request.PageSize verbatim, so a client could ask
for any number of records while the index page size options cap at 1000.
The tag-filter branch of this same endpoint, StudioController and
PerformerController all already guard their page size.

Uses <= 1000 rather than the < 1000 those three use: the page size options
allow 1000 inclusive, so the exclusive form would quietly serve 10 records
to anyone who picked the documented maximum.

Out-of-range values fall back to 10, matching the existing guards. This is
a behavior change for any client that passes a page size above 1000.
Fixes Whisparr/Whisparr#1102

`CheckHealthCommand` runs for 24+ hours on a 264k scene library (~130
NFS/ZFS mounts), pinning CPU throughout, then re-triggers 6 hours later.
Health checks run serially on the command executor thread, so this
blocks every other scheduled task.

Note: Radarr `develop` is byte-identical in `MountCheck.cs` and
`DiskProviderBase.cs`, so this is not Whisparr specific and there was no
upstream fix to mirror. Worth upstreaming.

## The bug

`MountCheck` calls `GetMount()` once per movie path, and `GetMount()`
re-enumerated the entire OS mount table on **every** call, with no
caching. On Linux that means reading `/proc/mounts` and stat'ing every
mount point, so at 264k paths it did 264k full mount table enumerations,
each hundreds of syscalls (and network round trips, on NFS).

## The changes

Each is a separate commit and reviewable on its own.

**1. Cache the mount list (the actual fix).** `GetAllMounts()` becomes a
private caching wrapper with a 15s absolute TTL over a new `protected
virtual FetchAllMounts()`, which Mono overrides. Private so a subclass
cannot accidentally bypass the cache, and so a stray `override
GetAllMounts` fails loudly at compile time rather than silently doing
nothing.

`GetMount()` deliberately stays per-path. `MountCheck` resolves mounts
from movie paths specifically so the provider handles symlinks and
junctions, so the number of lookups is unchanged, only their cost.

Only topology is cached. Free space is unaffected: `DriveInfoMount` and
`ProcMount` read it live from the underlying `DriveInfo`/`UnixDriveInfo`
on each property access.

15s is short enough that a newly mounted share still turns up in the
folder browser without needing any invalidation hooks, while still
collapsing a health check run from hundreds of thousands of enumerations
to roughly one per 15s. This also speeds up imports, since
`DiskTransferService` pays two lookups per file transfer.

**2. Fix a `RootFolderCheck` N+1 (independent bug found on the way).**
`RootFolderCheck` and `DiskSpaceService` called `GetBestRootFolderPath`
without passing the root folders. The result is cached per path, but on
a miss it falls back to querying every root folder from the database.
`RootFolderCheck` runs on startup, when the cache is always cold, so
that was one query per movie. Fixed via the existing overload, which
exists for exactly this.

**3. Cheapen the per-mount scan.** `GetMount` tested every mount with
`IsParentPath`, which walks the path back up to the root each time. That
walk only depends on the path, so it was repeated identically for all
~130 mounts. Hoisted to once per lookup.

I originally planned to also hoist `PathEquals`'s normalization, but
benchmarking showed that assumption was wrong: normalization was only
~14% of the cost, while the ancestor walk was the bulk. So `PathEquals`
is left completely untouched, which avoids precomputing `CleanFilePath`
per mount (it can throw, which would have changed when exceptions fire
on the import hot path).

Measured over 130 mounts: ~160us -> ~62us per lookup, or ~42s -> ~16s
across 264k paths.

## Testing

`MountCheck` had no coverage at all, so the first commit adds a fixture
pinning current behaviour before anything changed: the null-mount and
null-`MountOptions` guards (`MountOptions` is always null on Windows,
since `DriveInfoMount` is constructed without them), the `DistinctBy` on
root directory, and the reported path ordering.

Both regression tests were confirmed to fail without their fix:

- mount cache: *"Expected invocation on the mock once, but was 3 times"*
- root folder N+1: *"should never have been performed, but was 10
times"* (10 movies)

New `GetMount` semantics tests (longest matching mount wins, exact mount
root, paths with relative segments) were confirmed to **pass against the
old code** first, so they pin existing behaviour rather than the new
implementation.

I also ran a differential check of old vs new resolution across 34 real
paths against this machine's 11 real mounts, including a nested pair
(`/System/Volumes/Data` and `/System/Volumes/Data/home`), `..` segments
and trailing slashes. Identical results.

Green: `Common.Test`, `Mono.Test`, `Host.Test` (`ContainerFixture`
resolves the whole DI graph, which covers the constructor plumbing), and
the 144 `Core.Test` health check tests. Two `HttpClientFixture`
Cloudflare tests fail, but they fail identically on `eros-develop`
without these changes; they hit a live network endpoint and are
unrelated.
)

Fixes Whisparr/Whisparr#1104

The index status bar showed a generic "Downloaded" instead of the file's
quality. Radarr shows the quality, and this used to as well.

## What was actually wrong

The issue suspected an expensive `movie.moviefileid.quality.id`
relationship walk needing an in-memory movie-file cache. It turned out
to be neither expensive nor a frontend gap:

- **The frontend was already complete.** Table, poster and overview —
for both scenes and movies — already render
`movieFile.quality.quality.name` with `?? translate('Downloaded')` as
the fallback. That fallback *is* the reported bug. No frontend changes
here.
- **The JOIN already existed.** `PagedBuilder` already LEFT JOINs
`MovieFiles`. But `QueryJoined` builds its SELECT from its *type
parameters*, so `PagedQuery`'s `<Movie, MovieMetadata>` emitted only
`Movies.*` and `MovieMetadata.*` — the file was joined and then
discarded. `Movie.MovieFile` is a plain property rather than
`LazyLoaded`, so nothing back-filled it and `ToResource`'s
null-conditional dropped it.

## The change

`GetPaged` now maps `MovieFile` through its own query. Two constraints
shaped it, and both are worth knowing before simplifying this:

1. **`PagedQuery` is deliberately left alone.** It is shared with
`MoviesWithoutFiles` and `MoviesWhereCutoffUnmet`, which `GROUP BY
Movies.Id`. Selecting a non-aggregated file column under that GROUP BY
errors on **PostgreSQL** (`column "MovieFiles.Id" must appear in the
GROUP BY clause`) — SQLite tolerates it, so this would not surface in
local testing. Neither Wanted page needs the file: their rows already
fetch it via `useSingleMovieFile`.
2. **`QualityProfile` must be hydrated.** `MovieFileResource.ToResource`
dereferences `movie.QualityProfile` unconditionally to compute
`QualityCutoffNotMet`, and `UpgradableSpecification.QualityCutoffNotMet`
dereferences `profile.UpgradeAllowed` on its first line. Hydrating
`MovieFile` without a profile throws `NullReferenceException` (verified)
— latent today only because `MovieFile` is always null. The profile
comes from the repository with a fallback, the way `All()` already
handles movies pointing at a profile that no longer exists.

`MediaInfo` is excluded from the query. Measured against a real library
it stores the full ffprobe dump at ~7KB a row, ~90% of it
`rawStreamData`, which `MediaInfoResource` discards anyway and no index
view renders. Including it would deserialize ~7MB per request on a page
of 1000. Consequence: `/movie/paged` returns `movieFile.mediaInfo =
null` while `/movie` still populates it.

The column list is derived from the mapper rather than hardcoded, so a
new `MovieFiles` column can't silently go missing, and `Id` is forced to
lead the segment because Dapper splits on the first `Id` column.

## Second commit

`POST /movie/paged` took `request.PageSize` verbatim, so a client could
request any number of records. The tag-filter branch of this same
endpoint, `StudioController` and `PerformerController` all already guard
theirs. Kept as a separate commit — it's a pre-existing gap, not part of
the quality fix.

Uses `<= 1000` rather than the `< 1000` those three use: the index page
size options allow 1000 inclusive, so the exclusive form would quietly
serve 10 records to anyone picking the documented maximum. **Behavior
change** for any client passing a page size above 1000 (now falls back
to 10, matching the existing guards).

## Testing

- 7 repository tests covering quality hydration, profile hydration, the
missing-profile fallback, `MediaInfo` exclusion, and null-file. 4 of
them fail without the fix.
- A `MoviesWithoutFiles` test pinning constraint 1 — that path had no
coverage at all.
- 4 `MovieResource` mapping tests covering constraint 2, which the
repository tests can't reach.
- Verified end-to-end against a real library: `/movie/paged` returns the
file's quality with `qualityCutoffNotMet` computed and `mediaInfo:
null`; Wanted/Missing and Cutoff Unmet still return records; 1000
records served in ~48ms. Quality confirmed rendering on the index in the
UI.

Not verified: the Postgres GROUP BY hazard is reasoned from the
shared-`PagedQuery` structure and avoided by construction rather than
reproduced — SQLite won't surface it.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
….29.7 (#298)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
@plz12345
plz12345 merged commit fb3539d into eros Jul 20, 2026
104 checks passed
@plz12345
plz12345 deleted the release-3.3.5 branch July 20, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants