Skip to content

Main -> Dev - #2105

Merged
chrisbenincasa merged 33 commits into
devfrom
main
Sep 15, 2026
Merged

chrisbenincasa merged 33 commits into
devfrom
main

Conversation

@chrisbenincasa

Copy link
Copy Markdown
Owner

No description provided.

chrisbenincasa and others added 30 commits September 4, 2026 08:26
…ing them (#2006)

`WatermarkSchema` and `ChannelIconSchema` used `.catch()` on several fields, so
`PUT /channels/:id` answered 200 while storing something other than what was
submitted:

- `watermark.opacity` of 150, -10, 50.5 or "50" was stored as 100, fully opaque.
- `watermark.fadeConfig[].programType: "movies"` became undefined, which means
  "no restriction" — so a fade rule the user scoped to one program type applied
  to every program on the channel.
- `watermark.fadeConfig[].leadingEdge: "false"`, as a form serialiser sends it,
  became true, the opposite of what was asked for.
- `icon.path: null` cleared the icon to "", `icon.width: -20` became 0, and
  `icon.position: "centre"` became "bottom-right".

The `.catch()` calls could not simply be deleted. These schemas are also on
channel *responses*, and fastify-type-provider-zod validates responses: the
`icon` and `watermark` columns are raw JSON that drizzle casts but never
validates, so tightening them in place would turn a legacy or malformed row
into a hard 500 on GET /channels, GET /channels/:id, GET /channels/all/lineups,
GET /channels/:id/lineup and GET /guide/channels. The server-local copies in
db/schema/base.ts do not shield them — those are used for one default value and
two type aliases, nothing else.

So the schemas are split. StrictChannelIconSchema and StrictWatermarkSchema are
used by SaveableChannelSchema, the request body; the lenient originals stay on
the response path, with a comment saying why they must not be tightened.

The strict variants use `.default()` exactly where the lenient ones use
`.catch()`, so a *missing* field behaves identically and a partial icon or
watermark is still accepted. Only an *invalid* value changes behaviour, from a
silent substitution to a 400.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…off (#2010)

`UpdateFeatureFlagsRequestSchema` was `FeatureFlagsSchema.partial()`, and every
flag carries `.default(false)`. `.partial()` wraps a field's default rather than
removing it, so all six flags arrived populated whatever the client actually
sent:

  parse({ tonemapEnabled: true })
    => { proxyArtwork: false, tonemapEnabled: true, webvttSidecarEnabled: false,
         disableSearchSnapshotInBackup: false, disableVulkan: false,
         disableVaapiPad: false }

The handler then does `Object.assign(file.featureFlags, req.body)` over all six,
so enabling one flag through the API silently turned every other flag off. The
Features settings page submits the whole form, so the UI never showed it, but
the route is published as a partial update and any client following the spec
loses state.

The body is now declared field by field with genuinely optional fields and no
defaults. Zod omits an absent optional key from its output entirely, so the
handler's Object.assign is already the right thing for this shape and does not
change.

Writing the fields out rather than deriving them introduces a drift hazard: a
new flag added to FeatureFlagsSchema would not be updatable. A test asserts the
two schemas cover exactly the same keys, and that none of the update fields
carries a default.

Found by the zod contract audit rather than by hand.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2031) (#2049)

Copy-paste errors name the wrong media source in three places. Control flow
is unaffected — only the message text:

- Plex route guard said "not a Jellyfin server" when the source is not Plex
  (plexApi.ts, withPlexMediaSource).
- The Emby client factory logged "updating Jellyfin media source user info"
  inside getEmbyApiClient (MediaSourceApiFactory.ts).
- The Emby route guard read "is not a Emby server" (grammar: "an Emby").

Each guard/factory now names its own media source type, mirroring
jellyfinApi.ts which already said "Jellyfin".
midRollUtil.ts has no production importer — the only file referencing it is
its own test, midRollUtil.test.ts. The live implementation is
midRollBreakRules.ts, imported at slotSchedulerUtil.ts:56, and the two
implement genuinely different algorithms (midRollBreakRules supports
fixed_interval/percentage/initial_then_interval rule types, a tailBuffer,
and randomized break durations that midRollUtil has no concept of).

midRollUtil.ts is also stale and incorrect: programQualifiesForMidRoll is a
no-op that returns true for every content program regardless of
config.programTypes. Deleting the module and its test removes a trap for
anyone reviving it, leaving midRollBreakRules.ts as the single break-point
authority.

No behavior change — nothing in production imports these symbols.
)

* fix(web): repair eleven cache invalidations that matched no query

Generated hey-api query keys are a single-element array holding an
object: [{ _id, baseURL, tags }]. TanStack matches keys by structural
prefix, so a hand-written string key diverges at element 0 and matches
nothing. Every site below invalidated successfully, logged nothing, and
left the cache stale.

The give-away is `['Channels']`: 'Channels' is the endpoint *tag*, not a
key segment. ChannelDeleteDialog already did this correctly with
getChannelsQueryKey(), so the two delete paths behaved differently.

Sites, and what stayed stale:

  useCreateChannel          new channel missing from the list
  ChannelsPage (delete)     deleted row still on screen
  ChannelsPage (stream ev)  session counts never update
  ChannelNowPlayingCard     finished program shown indefinitely
  XmlTvSettingsPage         settings cache stale after save
  SystemDebugPage x3        env/nvidia/vaapi checks inert on reclick
  TvGuide                   ignores xmltv regeneration events
  useUpdateLineup           lineup queries missed
  useScanNow                pre-scan metadata retained

useScanNow had two stacked bugs: its predicate compared a Query instance
against a key array, and one operand was an options object rather than a
key. Both always false.

Categorical events now invalidate by tag through invalidateTaggedQueries,
which is what the tags exist for and what sixteen other call sites already
do. Two sites stay key-scoped on purpose: the now-playing card runs a
per-card timer where a tag would refetch every channel query on the page,
and the two debug buttons each re-run exactly one query.

Choosing a tag is not automatically safe -- the TV Guide fix nearly went
out invalidating the 'Guide' tag, which belongs to the /api/guide/channels
endpoints that page never calls; it renders from the channel lineup
endpoints, tagged 'Channels'. The new tests drive a real QueryClient and
pin every site to the query it must refresh, including a case asserting
the old string key does not match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(web): refresh lingui catalogs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(web): refresh lingui catalogs after pre-commit formatting

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry (#1982)

* fix(api): contain troubleshoot file reads within session temp directory

* fix(api): contain HLS fragment reads within session directory

The /stream/channels/:id/:sessionType/:file handler builds file paths with
join(session.workingDirectory, req.params.file) and passes them straight to
fs.readFile, so a traversal sequence in the file param escapes the session
directory. Add the same resolve + prefix guard used for the troubleshoot
routes to the .vtt and .m3u8 read branches; sendFile already blocks escapes.
…2063)

Set a fixed peak instead of letting ffmpeg re-derive it from each
frame's mastering-display/CLL side data, which caused visible brightness
pumping at scene cuts on the VAAPI (tonemap_opencl) and
CPU tonemap fallback paths.

Co-authored-by: Corey Vaillancourt <coreyjv@gmail.com>
Jellyfin person mapping used .map and kept credits with empty or missing
Name values. Emby already filters those with seq.collect and
isNonEmptyString. Mirror that guard for actor, writer, and director, and
add a unit test.

Fixes #2028

Co-authored-by: MrStewood <stewood@outlook.com>
…ed (#2017)

tsconfig.build.json excluded ./src/**/*.test.ts(x), and that is the only
config CI typechecks web through -- `turbo build --filter=@tunarr/web`
runs `tsgo -p tsconfig.build.json --noEmit`. So no web test file has ever
been typechecked.

Four of the eleven had drifted far enough to stop compiling: 14 errors,
all fixtures asserting against a ContentProgram shape the app no longer
produces. `persisted`, `uniqueId`, `subtype`, `title` at the top level --
none of which exist on ContentProgram -- and in useAddBreaks a fixture
with no `program` field at all, whose assertions then read a `title` the
fixture itself invented. Those tests pass while testing a shape nothing
in the app can produce.

Dropping the exclude makes CI catch this, with no CI workflow change:
`build` for web is already a typecheck, and vite bundling is a separate
script unaffected by the include set.

The four files now build their programs through the cast-free fixture
factory, which is what keeps them honest when the schema next moves.
useRemoveDuplicates additionally needed channelNumber/channelName on its
redirect fixture and index on its custom-show fixture -- fields the
function under test does not read, but the type requires.

No test assertions were weakened; all 83 still pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SchemaBackedDbAdapter.read caught every error from the underlying adapter
and returned null. lowdb's TextFile only returns null for ENOENT and throws
for anything else, so that catch collapsed "this file could not be read"
into "this file does not exist".

With a defaultValue configured -- as LineupRepository does for every channel
-- the null took the merge-with-defaults path, which succeeds, sets
needsWriteFlush, and immediately writes the defaults back over the file that
had just failed to read. A transient EACCES/EIO/EMFILE therefore erased the
channel's programming during the failed read itself, leaving only a
debug-level log line. The channel still appeared in the UI with an empty
schedule and streams fell back to flex.

Read errors now propagate. An absent file still initializes from defaults,
a valid file is still returned untouched, and a file missing fields is still
repaired by merging defaults in; all three are covered by tests so the fix
cannot overshoot into never writing defaults.

Two related fixes:

- The constructor took an `adapter` parameter and then unconditionally
  overwrote it with `new TextFile(filename)`, so an injected adapter was
  silently discarded. The sync variant does not do this. Honouring it is
  also what makes the class testable.

- saveChannelLineupDirect wrote with a plain fs.writeFile, which truncates
  before writing, so an interrupted write leaves a zero-length lineup file --
  which the adapter path above then treats as empty and overwrites with
  defaults. It now goes through a new writeFileAtomic helper that writes a
  scratch file and renames it into place.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

Deleting a folder from a local media source failed with
"SqliteError: no such column: media_source_library". The library
deletion in MediaSourceDB.updateMediaSource passed the whole
MediaSourceLibrary table object to Drizzle's eq() instead of its
mediaSourceId column, so the generated DELETE WHERE clause referenced
the table name as a column identifier. Use MediaSourceLibrary.mediaSourceId.

Add MediaSourceDB.test.ts covering library deletion and addition when
updating a local media source's paths, so this class of query-builder
error is caught by the test suite.
…2041)

* fix: match ISO 639-2 bibliographic and terminological language codes

ISO 639-2 assigns 20 languages two three-letter codes: a bibliographic
(/B) code derived from the English name and a terminological (/T) code
derived from the native name. German is both "ger" and "deu". Media
servers and containers use them interchangeably.

A German audio preference never matched a Jellyfin track, because:

- LanguageService.getAlpha3TCode called alpha3TToAlpha2 in its /B branch,
  so it returned undefined for every /B code. This broke normalization in
  CelEvaluationService.matchLanguageInList, SubtitleStreamPicker and
  LocalSubtitlesService, and dropped the language of any sidecar or
  container track tagged with a /B code.
- StreamSelectionEvaluator compared codes with raw string equality and
  never normalized either side.
- SubtitleStreamPicker normalized the stream's code but compared it to a
  raw, unnormalized preference.
- The FFmpeg settings language picker emitted /B codes while the channel
  subtitle picker emitted /T codes, so the two screens disagreed and the
  subtitle table could not resolve a display name for either.

Adds LanguageService.codesMatch, which normalizes both sides to /T and
falls back to a case-insensitive exact comparison for codes it cannot
resolve, and routes all stream language matching through it. Stream
language is now resolved by tag precedence (ISO 639-2 > ISO 639-1 >
free-form), matching what buildCelContext already does.

Both web pickers now emit /T. Preferences already stored as /B keep
working through codesMatch and still resolve a display name, so no
migration is needed.

Fixes #1960

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): dedupe language options by ISO 639-1 code

The options list suppressed already-selected languages by comparing
ISO 639-2 codes while isOptionEqualToValue compared ISO 639-1 codes.
Now that the picker emits terminological codes, a preference saved
earlier as "ger" no longer matched the "deu" option, so German was
offered again in the dropdown and could be selected twice.

Both checks now key on the ISO 639-1 code, which has no bibliographic
and terminological split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move conventional commits resolutions to pnpm-workspace

* fix: use shared language matching in the troubleshoot report (#2042)

The troubleshoot simulator resolved audio through the stream selector but
re-implemented subtitle language matching inline with raw string
comparison. After the selector learned to treat the two ISO 639-2 code
sets as equivalent, the two disagreed: the report would claim "No
subtitle found for languages: ger" for a file that playback selects a
German subtitle for.

This matters more than a normal display bug, because the troubleshoot
report is what users paste into issues — #1960 was diagnosed from one.

Extracts the lookup into findSubtitleForLanguages and routes it through
the selector's streamMatchesLanguage, which is now exported.

Also adds the missing iso6392 to the FFmpeg settings language-preference
fallback; LanguagePreferenceSchema requires it, so the fallback produced
a value that would fail validation.

Refs #1960, #2026

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove duplicate overrides key

* chore: regen translations

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…slot (#2069)

* chore: update translation messages

* fix(scheduling): apply per-slot pad override to every program in the slot

The per-slot padMs override was only used for the first program in a
slot. The loop that fills the remainder of the slot and the flex
distributor both fell back to the schedule-level padMs.

With a 30m schedule pad, a 6:00 slot overridden to a 5m pad, and 7m
episodes, the first episode was padded to 10m but the second was padded
to 30m. The slot then ran 40m into a 30m window, the cursor realigned to
the next 30m mark at 7:00, and the following slot was 30m late. That
exceeded max lateness, so it was flexed away entirely and never
scheduled.

Hoist the effective pad into slotPadMs and use it at all three sites.

Introduced in 57b7f63 (#1614), which only patched one call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(scheduling): use the shared pad helper in handleFixedDurationSlot

The first program in a fixed-duration random slot inlined the same
`padStyle === 'slot' ? 1 : padMs` expression that createPaddedProgram
already computes, while every subsequent program went through the
helper. Both resolved to the same value, so this is not a behavior
change -- but it is the same duplication that let the time slot pad
override drift out of sync in the first place.

The local padStyle/padMs destructure is now unused, so it goes too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eduler (#2013)

Every numeric field on a time slot schedule was a bare z.number() and slots had
no minimum length, so several inputs reached the scheduler that it mishandles
rather than rejects. Probing each one:

  startTime: 1234.567   an infinite loop. The scheduling loop is synchronous
                        despite the enclosing async, so it blocks the event
                        loop outright -- the test runner's own timeout never
                        fires and the process has to be killed. The worker
                        pool's timeoutPromise rejects the caller while the
                        thread keeps burning a core, so N such requests
                        permanently exhaust an N worker pool.
  padMs: 0              reports success and returns a lineup whose durations
                        are all NaN.
  maxDays: -1           reports success and returns zero lineup items.
  maxDays: 0            returns half the expected items.
  slots: []             throws "Could not find a suitable slot", the same
                        message an out-of-period offset produces, so the log
                        cannot tell the two apart.

Add StrictTimeSlotScheduleSchema and use it for the request body.

TimeSlotScheduleSchema itself has to stay permissive. It also feeds
LineupScheduleSchema inside CondensedChannelProgrammingSchema, which is both
persisted and used to serialize channel responses, so tightening it would make
a channel already holding one of these values fail response serialization and
stop loading entirely -- worse than the bug. This is the same split already
used for StrictChannelIconSchema.

The bounds are startTime a whole number within the period, padMs and maxDays
positive, latenessMs non-negative, and at least one slot. A test asserts the
permissive schema still accepts every case the strict one rejects, so the
distinction cannot be erased by accident later.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#2073)

The Plex content hashes (movie/show/season/episode/artist/album/track)
only folded title, dates and media/tag fields, never Label. Editing an
item's labels in Plex therefore left the canonical id unchanged, so a
label-only change never triggered a re-canonicalization.

Fold each item's Label tags into the hash via a shared helper.
…ies (#2056) (#2066)

* fix(deps): bump @fastify/static to 10.1.3 to clear 5 security advisories (#2056)

* test(deps): exercise static file serving after @fastify/static bump (#2056)
loadLineup returned Low.data directly, so every caller shared one mutable
object with the lowdb cache. The guide build reads items and startTimeOffsets
interleaved with awaited DB writes; a saveLineup landing in between rebound
those properties underneath it, leaving the captured offsets indexed against a
discarded items array. That either throws "General algorithm error, completely
unexpected" or emits plausible but wrong airtimes.

loadLineup and saveLineup now return a top-level copy. Every writer rebinds
whole properties on Low.data rather than mutating the arrays in place, so a
shallow copy is enough to pin a caller to the lineup it read, and it keeps
loadAllLineups off the guide path's critical cost.

createChannel returned getFileDb(...).data directly; route it through
loadLineup so it gets the same treatment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createFillerIterators deduped slot filler definitions by fillerListId
alone, but registered each iterator under `filler_<listId>_<order>` and
looked it up by (fillerListId, fillerOrder). A filler list referenced at
two different orders therefore only got an iterator for whichever order
appeared first, and every slot using the other order silently resolved
to no filler iterator at all -- no head, pre, post or tail, and no error.

Dedupe on the list id and order together so each pair gets its own
iterator.

Refs #1984

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chrisbenincasa and others added 3 commits September 15, 2026 11:19
Point the slot-save skew plan's validation step at the new plan, which
keeps lineup validation instead of removing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MkDocs publishes every file under docs/, nav or not, so the internal
architecture review and candidate records were live on tunarr.com.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chrisbenincasa
chrisbenincasa merged commit 077943f into dev Sep 15, 2026
12 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

This is included in v2026.9.0.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants