130 PRs since v0.12.0. Read the upgrade notes at the bottom before pulling this: there is a data-loss fix, an authentication change that closes the GraphQL API, and migrations that delete rows.
Server
Intro and credits detection
Mydia detects intro and credits spans in TV episodes. The skip button and its behavior are under Player.
- Detection runs over a whole season at once. Files carrying chapter markers are read straight off them; otherwise every episode in the season is fingerprinted with Chromaprint and correlated for the audio span they share, and
blackdetectsnaps the credits boundary to the fade (#307, #310, #311, #312, #315). - New
media_segmentstable and per-file analysis state (#308, #309), exposed over GraphQL (#314), scheduled per season in batches of 20 every five minutes (#316). - A season is left alone until enough of its files are present, so a season still downloading is not analyzed (#315).
- Per-season status with a Re-analyze button in the admin UI (#317).
- Chapter-derived intros are bounded at 180s or 25% of runtime, whichever is tighter, rejected per chapter rather than per file. Container chapters partition the whole timeline, so a two-chapter release could produce an "intro" covering 96% of the episode. Credits are deliberately left unbounded (#329).
- End-to-end coverage against real fingerprint detection (#319).
Automatic quality upgrades
The "Allow automatic quality upgrades" setting is implemented (#296). It previously had no effect.
- A daily sweep finds library files scoring below their profile cutoff and searches for something better. Budget is counted in indexer searches rather than items. Episodes group into season packs where possible.
- An imported upgrade is re-scored on ffprobe output. If it is worse than what it replaced, it goes to trash and the release is blacklisted.
upgrade_until_qualityis replaced by a score cutoff plus a minimum margin, so a better codec or audio track can trigger an upgrade on its own.
Quality profiles and release selection
- One model, one struct, dead config removed, with parity between what the UI shows and what ranking does (#223).
- Multi-resolution profiles pick the highest resolution checked. They previously picked the lowest, so a profile with 720p and 1080p ticked grabbed 720p (#223).
- Per-profile
excluded_sourceswith an Exclude tab in the editor. Excluded sources are dropped before ranking rather than penalized. There was no source-quality floor in the automatic search path at all, so CAM and TELESYNC releases were grabbed and imported whenever they outscored the alternatives (#335, closes issue #80). - Source detection rewritten as one delimiter-anchored vocabulary shared by the indexer parser and the library scorer, pinned by a false-positive corpus. The old unanchored two-letter alternations classified
Ghosts.of.Marsas Telesync,Watchmenas Telecine,Screamas Screener andCameronas CAM (#335). - Cam-tier files already in the library count as a hard violation and score zero, so the upgrade path replaces them (#335).
- Items with no profile fall back to the default profile (#266).
Downloads
- External torrents are separated from Mydia's queue. Unmanaged torrents are read off the client on each scan and never persisted; anything parseable goes to Issues → Needs Matching, the rest to a new External tab. Matching from either creates the download at that point (#268).
- Magnets that arrive without trackers get a public tracker list appended at the single point every magnet path funnels through. Bitmagnet always emits a bare magnet, and Prowlarr does whenever its
/downloadredirects to one; those downloads sat at zero bytes until the stall detector gave up. The stored URL keeps the indexer's original string and the info hash is unchanged (#331). - A torrent whose file list contains no video is rejected as soon as its metadata is known, before the payload downloads, through the same path as the manual Reject button: blacklist, remove from client, delete the row, queue a replacement search. Transmission's adapter never requested the
filesfield, so its downloads had no file list at all (#357). - Import failures keep the file listing they saw: name, size, why each file was skipped, and the parser's guess. Files rejected on extension get an ffprobe header read. A match-files modal imports any subset, and a reject action blacklists and re-searches. The import path never called
Blacklists.add/5, so the blacklist stayed empty and a fake release could be re-grabbed indefinitely (#336). - Season packs can fill partially-complete seasons. The existing-files guard blocked a pack if any episode in the season had a file, and two different checks returned the same atom, so the individual-episode fallback was skipped too (#334).
- qBittorrent 5.2+ support: 204 login, renamed session cookie, plus API-key auth (#234).
- TorBox upload handling, with the same Req multipart fix applied to Premiumize and AllDebrid (#221).
- rqbit shared-directory series contamination (#230).
- Real per-file error detail in partial import failures (#231), and provider-native failure detail carried into debrid blacklist reasons (#247).
- Two production failure bugs where downloads never imported (#235).
- Removing a download client adopts or honestly orphans its downloads instead of leaving them pointing at a name that does not resolve (#265).
- A titleless release no longer crashes
DownloadMonitor(#330). - Manual-search grabs return immediately and survive closing the modal (#229).
- Bulk auto search from the selection toolbar (#244).
Streaming and transcoding
startStreamingSessionacceptsmaxHeightalongsidemaxBitrate, andStreamingSessionResultechoes what the server actually applied, which can be lower than requested on a relay connection (#349).- Transcodes preserve aspect ratio and never upscale. Forced transcodes previously emitted
-s 1280x720with no aspect handling, so 2.39:1 and 4:3 sources came out geometrically wrong. Output height is rounded to even on both the capped and uncapped paths (#349). - Session matching compares position plus both caps. It previously compared position only, so a rung change reused the session already running at the old cap (#349).
- New
MAX_TRANSCODE_HEIGHToperator ceiling, settable by env,config.yml, the DB overlay and the admin UI (#349). - New
Mydia.Library.FileRankingranks by resolution, then bitrate, then id, with unanalyzed files last, wired intoStreaming.Candidates,StreamControllerand the subtitle controller. The subtitle controller previously took the head of an unordered preload (#346). StreamControllerand the subtitle controller filter trashed files. OnlyCandidatesdid, so the candidates endpoint and the stream route could name different files (#346).- New
contentRating,trailerUrl,castandsimilarfields onMovieandTvShow.contentRatingpreviously always resolved to nil; it now parses TMDBrelease_datesandcontent_ratingswith a US, GB, first-available fallback. All four ride the existingappend_to_responsemechanism, so metadata-relay needed no changes (#361).
Library and metadata
- Recently added means when content arrived, not when the record was created. Files group into slots, one per episode or movie; an item's timestamp is the newest of its slots, so a quality upgrade cannot bump an item back to the top. Computed at query time, no migration (#350).
recentlyAddedgainsnewEpisodeCount,latestSeasonNumberandlatestEpisodeNumber.favorites,unwatched,collectionItemsand the/tvand/movies"Added (Newest)" sorts report the corrected value (#350).- Opt-in scheduled library scanning, per library, off by default (#264).
- Movie franchise section on the detail page (#248), backed by a new TMDB collections endpoint on metadata-relay (#250, #251).
- TV show trailers on discover, dashboard and library detail (#257), with hardened and memoized TVDB trailer resolution.
- Progress filter on library pages (#224).
- Metadata refresh consolidated into one job (#261), and a shared ffmpeg runner extracted from four generators (#305).
- Filename parsing: standalone anime absolute-episode numbering and
(US)-style parentheses in titles. Corpus pass rate goes from 75.8% to 76.4% (#339). - Web UI: infinite scroll loaded only the first page (#217); library grid dates sorted by term order rather than value (#238); browser favicon restored (#225).
Security and data safety
- Every GraphQL root field goes through deny-by-default middleware, with login and the two refresh mutations allowlisted. Authorization was opt-in per resolver and 19 resolvers never opted in; 14 of those were browse, so anyone reaching the endpoint could read the whole catalog, and four were downloads, which also allowed starting transcode jobs. A test walks every root field and fails the build on a new ungated one (#301).
- Paired players renew their access token. Pairing minted a 30-day token that nothing renewed, because the player's refresh call was a stub with no endpoint behind it, so every request after expiry arrived unauthenticated. The player now exchanges its pairing token and retries once, over HTTP and p2p. HLS is gated on the same token (#301).
- SQLite table rebuilds no longer fire foreign-key deletes on child tables. One rebuild shipped in v0.10.0 through v0.12.0 wiped
subtitles,media_hashesandtranscode_jobson installs predating v0.10.0. The fix lives in the migration helper, so every rebuild inherits it (#292). - The database is backed up before migrations on the unattended upgrade path. The code existed and was tested, but its only caller was a dev mix task. SQLite snapshots with
VACUUM INTO; Postgres logs thepg_dumpcommand. A failed backup logs an error and boots anyway.SKIP_BACKUPSis now read (#302). - Metadata provider API keys are no longer dropped on query auth (#255).
- Metadata structs are no longer read with Access syntax, which took down the Issues tab library picker for any item with metadata and crashed guest request search on its first result (#261, #313, closes issue #282).
Release notes in the app
- Notes ship inside the image.
/changelogrenders them, and after an upgrade a banner lists the versions not yet read. v0.2.0 through v0.12.0 are backfilled (#323).
Admin and activity
- All 32 recorded event types are labelled from one registry,
Mydia.Events.Presentation. Presentation previously lived in three maps that had drifted: 13 types were unhandled by the feed and 9 had no presentation anywhere.Event.changeset/2validates:typeagainst the registry, and filter chips are added for theplaybackandplugincategories (#337). - Every typed setting in Admin → Configuration raised a
FunctionClauseErroron blur, so the DB overlay worked for toggles but not for anything typed. A setting whose value has not changed no longer writes a config row (#349). - Adding or removing a Direct URL on the Remote Access page persists. The handler called a stub left behind when the WebSocket relay was replaced by p2p and flashed success regardless (#339).
/admin/configno longer 500s when a streaming user has no username (#276).- The remaining config pages route through
admin_page(#298). - A plugin whose new manifest asks for more than was granted says so, and the admin status page shows upgrades that got stuck.
Docs
- docs.mydia.dev is restructured around Diátaxis. The deployment guide is five how-tos, the two PostgreSQL guides are one, and the OIDC, Cardigann and env var material is no longer duplicated across three places. mkdocs builds with
--strictin CI (#300). - Planning artifacts are kept out of the published site (#274).
- Stale
metadata-relay.fly.devreferences removed;relay.mydia.devis the default (#364).
Player
Detail pages and chrome
- Infuse-style movie and TV show detail pages: backdrop hero with a large Play button, action row, tag row, overview, cast rail and similar-titles rail. TV shows drive the hero from a selected episode defaulting to next-up, changeable by season and episode selection (#365).
- Playback chrome rebuilt into one component, replacing a 3px scrubber at 20% opacity, four Material icon families in one control row and timecodes 1450px apart (#263).
- Neutral palette with a single gold accent (#273).
- Season episodes render as a horizontal rail (#219).
- Watched toggle and a
Watched · Aug 2badge on the movie detail screen, where a watched movie and an unopened one rendered identically (#332). - Every show-level Play affordance opens the correct next unplayed episode and resumes silently when partway through. Two copies of the same query disagreed, so the hero Play button rendered permanently greyed. The backing
determine_next_episode/2ignoredwatched, which affected the web UI too (#333). - Compact per-season rows in series downloads, with season grouping in the queue (#356).
- Poster cards that only navigate no longer show a hover play button (#344). Search posters use the library depth treatment (#347). The 128px dead space above library grids is gone (#345).
- Library search across movies, shows, episodes and collections. It was unreachable on desktop, the magnifier rendering only in the mobile app bar (#232).
- Fresh data reaches the UI, and the UI says when it has not (#236).
- A failed startup step no longer leaves a blank window (#262).
- Web build branding and a reproducible icon set (#293).
Skip intro and credits
- Skip Intro and Skip Credits buttons appear when playback reaches a detected segment. Auto-skip is a separate setting, off by default (#318).
- Skip works while casting. Both were bound to the local player, which the cast path never builds, so the button was gated off and the seek returned early. Local and cast playback share one auto-skip rule (#352).
- Up Next fires on a detected credits segment instead of a flat 90% of runtime, which landed 7 minutes early on a 70-minute episode. Without a detected segment the fallback is a fixed 60-second window before the end. Up Next no longer offers to auto-play into a next episode during offline playback unless that episode is itself downloaded (#358).
- Limits: auto-skip fires only while the app is foregrounded, since every cast skip is the phone issuing a seek, and skip is available on the player screen only, not the mini controller.
Casting
Chromecast and DLNA on desktop and mobile (#233).
- iOS and macOS declare Bonjour. Without it the device picker returned empty on both, which the previous Chromecast path could not have worked around (#233, #239).
- Selecting a device connects to it, and the cast bar reports connecting and not-connected states (#321).
- Casting resumes from the saved position, reports real progress, keeps subtitles across a seek that restarts the stream, and restarts the session when you seek past the streamed window instead of stalling (#304).
- The cast bar mounts in an Overlay with its own Navigator, so controls are reachable from any screen (#239, #270).
- A denied Apple local-network permission is named as such, with a button to the right settings pane. It previously looked like an empty device list (#306).
- The cast button is back on the playback screen.
ChromeTopBarhas acastActionslot that the player screen never filled (#351). - Limits: cast sessions do not survive an app restart, and DLNA sidecar subtitles are best-effort.
Playback quality and file selection
- The quality switcher changes the stream on every platform. It previously rendered only on web, where it set a state field and showed a snackbar (#349).
- The rung ladder derives from source height, so a 720p file offers nothing above 720p. The choice persists and restarts the session at the current position, and vetoes direct play. Original still direct-plays (#349).
- The playback transport cluster went from 256px to 192px and secondary buttons from 40px to 32px, so four discrete buttons fit down to 360px width (#349).
- The player streams the file you picked. It previously fetched streaming candidates keyed on the media id, so the selection could not be expressed, and the direct-play branch overwrote it with the server's pick (#346).
- The streaming candidates query no longer reads a cache-first on-disk store, so replacing a file no longer breaks playback of that item permanently. A cached entry kept naming a deleted file across restarts, rendering a black screen with an advancing timeline and no error. This affected every automatic quality upgrade, which writes a new file row and deletes the old one (#343).
- The player subscribes to playback stream errors, so a failure after open reaches the error UI instead of a black screen, gated on playback never having advanced (#343).
- Audio tracks are re-detected as mpv finishes probing. The list was sampled once, 500ms after open (#341).
- Progress on downloaded media is recorded locally, so resume works offline and syncs back when the server is reachable. A record counts as synced only once the server confirms it (#304).
Desktop windows
- Window geometry is remembered and restored, including when the monitor it lived on is gone, and sizes to the video's aspect on playback until you resize it yourself (#320).
- Hold anywhere to drag the window; chrome hides on mouse exit (#294).
- The macOS traffic-light strip is reserved once at the app root by injecting it into
MediaQuery.padding.top, so the back button no longer sits under the window buttons on detail and player routes. It drops to zero in native fullscreen (#328). - The macOS traffic lights hide with the playback controls (#359).
- Always-on-top toggle with a
Tshortcut, hidden below ~900px width where a fifth button overflows the control panel (#360). - The Updates settings section is hidden on iOS, where its actions were no-ops (#340).
Linux Flatpak
The Linux player ships as a Flatpak on two OSTree channels: mydia for stable and mydia-beta for prereleases, both dev.mydia.player, distinguished by OSTree branch (#342).
flatpak remote-add --if-not-exists --from mydia https://flatpak.mydia.dev/mydia.flatpakrepo
flatpak install mydia dev.mydia.player
- Built on
org.gnome.Platform50 with libass, libplacebo and mpv as manifest modules, so libmpv ships inside the app. The tarball links the system libmpv and dies at load time without it. Desktop entry and AppStream metadata are included, so the player appears in GNOME Software and KDE Discover (#342). - Signing runs in a separate job from building, keeping the GPG key out of the job that runs build code from the dependency tree (#342).
- The signing public key is committed and embedded in both
.flatpakrepofiles, and rclone uploads against a bucket-scoped R2 token, which cannot callListBucketsorCreateBucket(#353). - The staged OSTree skeleton is recreated with
mkdir -pafter the artifact round trip, which does not preserve empty directories (#354). - Flathub is added before the post-publish verification, so
org.gnome.Platform//50resolves there (#355). - The
mydia-player-linux-*.tar.gzasset stays.
Technical
- Release workflow pins a release to a commit SHA and refuses to publish partial builds (#299).
- Dead code removal:
Mydia.Library.FileParser(589 lines, no production callers, reached through an alias to the live parser),Mydia.Settings.QualityMatcher, the WebSocket relay stubs, the orphanedUserFavoriteschema, the old home screen, and themin_quality_scoreconfig key whose only reader wasQualityMatcher. New advisorymix mydia.dead_codetask (#339). - The E2E stack runs the shipped production image.
Dockerfile.e2eis deleted; it was a 351-line near-duplicate of the production Dockerfile.player/Dockerfile.testgoes from 291 lines to 66. Player E2E coverage goes from 1 test file to 3 (#338). flutter testruns at--concurrency=1in CI, where the default silently drops files while exiting 0 (#338).player/.fvmrcis the single source for the Flutter version (#260), andrust-toolchain.tomlthe single place naming a Rust version (#267).- NixOS module tests decoupled from the package build job (#363). Nix Tailwind binary hashes corrected, and stale ones no longer pass (#256).
- macOS player artifacts packaged with
dittoto preserve symlinks (#291). - A
DownloadMonitorTestflake traced to a global adapter registry leak from two search test files (#362), and three tests that depended on real host state (#249, #226). - Credo runs in precommit (#253). Generated GraphQL Dart output is no longer tracked (#254). Dependency sweep (#245).
- metadata-relay and iroh-relay metrics are scraped (#290).
- Search, quality-profile skip and download-failure observability (#258).
Upgrade notes
Back up first.
- SQLite installs predating v0.10.0: a table rebuild shipped in v0.10.0 through v0.12.0 fired foreign-key deletes that wiped
subtitles,media_hashesandtranscode_jobs. Fixed in #292. This is the release to upgrade on. - GraphQL denies unauthenticated requests to every root field except login and token refresh. Anything pointed at the API without credentials stops working. If your instance has ever been port-forwarded, assume the catalog was readable before this.
- The quality-profile migration cannot be rolled back. It backfills
preferred_resolutions, then dropsqualities,metadata_preferencesandcustomizations.down/0raises on purpose. - Multi-resolution profiles now pick the highest resolution checked, where they picked the lowest. This changes what your instance grabs.
- Every quality profile without
excluded_sourcesis backfilled with the cam tier: CAM, Telesync, Telecine, Screener, Workprint. This also changes what your instance grabs. It stays per-profile and editable in the Exclude tab, and a profile where the key was already cleared is left alone. Cam-tier files already in your library score zero, so automatic quality upgrades will replace them. - Old
unmatcheddownload rows are deleted. Nothing referenced them and none had been imported. Anything still in a client reappears in the new tabs on the next scan. - Downloads already stalled at zero bytes will not recover from the magnet tracker fix on their own. Re-grab them.
- Segment detection starts on its own after upgrade. It queues up to 20 seasons every five minutes and works them one at a time. The Docker image ships
chromaprint; withoutfpcalcit queues nothing, logs at debug, and picks up the backlog once installed. Two additive migrations:media_segments, plus analysis state onmedia_files. - Chapter-derived intro segments longer than 180 seconds are deleted and their files return to the detection backlog. Credits rows are untouched.
media.scan_interval_hoursis gone and every library starts manual-only, so nothing begins scanning on its own. A leftover env var or YAML key is ignored rather than rejected.- Trashed media moves to a trash directory with a retention window, instead of being marked in place.
min_quality_scoreis removed from config. Its only reader had no callers, so removing it changes no behavior. A leftover value is ignored.- GraphQL additions are additive:
TvShow.nextUp, playable files onContinueWatchingItem,maxHeightonstartStreamingSession,maxBitrateandmaxHeightonStreamingSessionResult, three nullable fields onrecentlyAdded, andcontentRating,trailerUrl,castandsimilaronMovieandTvShow. An older player build against this server keeps working, and a newer player against an older server retries a rejected document once with a frozen legacy version. - Until the player app updates, it still asks for streaming candidates by media id. That previously returned an arbitrary file and now returns the highest-resolution one. The web player ships with the server and is unaffected.
- TV shows matched via TVDB rather than TMDB do not get
contentRatingorsimilarpopulated. The TVDB to TMDB shape transform does not carry those fields yet.
Full Changelog: v0.12.0...v0.13.0