MusicBee UPnP Plugin ความช่วยเหลือ

มีอะไรใหม่

2.0.0 - 2026-07-22

This is the first public release of the open-source yaiol fork of the MusicBee UPnP plugin. It is presented in two parts: everything that is new to this fork, then the fixes and improvements made to the original plugin. Each item keeps the What / Why form of the project's internal feature catalog so the reasoning behind every change is on the page, not just the change.

New in this fork

MediaRenderer - play to MusicBee

N01 - MusicBee as a play-to renderer

What: normally this plugin works one way round: a phone or another device browses MusicBee's library and plays the music on itself. This feature adds the opposite direction - it lets MusicBee be the player. From a controller app on your phone (such as BubbleUPnP) you can pick your desktop MusicBee as the thing that plays, then drive it from your hand: play, pause, stop, skip forward or back, jump to a point in the track, and change the volume or mute.

Why: it turns your phone into a remote for the music already on your PC. Sit on the couch, browse your library on the phone, tap a track, and it comes out of the speakers attached to your desktop - with full control from where you are sitting. The original plugin never shipped this as a working feature.

Turning it on: it is off by default, because switching it on lets anything on your home network start playback on your PC. You enable it with a tick-box on the settings dialog's General tab. The plugin's three roles each have their own tick-box there - share my library (Server), let others play to me (Renderer), and play to other devices (Control Point) - and the dialog only shows the settings tabs that the roles you turned on actually need, so you are never faced with options that do not apply to you.

Telling your machines apart: you can give the renderer any name you like (it starts as "MusicBee (yaiol)"). That name is what appears in your phone's list of play-to targets, so when more than one PC is running MusicBee you can tell which is which. A name change takes effect immediately, with no restart.

Best possible sound when it plays to itself: when you browse MusicBee's own library from your phone and send a track back to that same MusicBee, the plugin recognises that it is being asked to play one of its own files and simply plays it straight from your disk. The result is exact and instant - bit-perfect, with MusicBee's own equaliser and volume-levelling applied - instead of pointlessly pushing the audio out onto the network and straight back to itself.

Running it privately: the three roles work independently, so you can switch the renderer on while leaving library-sharing off. In that "renderer only" setup your library stays completely hidden from the network - only the play-to target is announced - and MusicBee will never offer to play to itself.


Playback behaviour

N02 - 5.1 FLAC not auto-downmixed

What: the channel-count clamp in MediaServerDevice.GetEncodedFile was If StereoOnly OrElse Not isPcmData Then channelCount = 2. The Not isPcmData clause silently downmixed every non-PCM transcode (FLAC, MP3, AAC, Ogg) to stereo regardless of source channel count, defeating 5.1-capable renderers when the source was 5.1 FLAC. Now the second clause excludes FLAC: Not isPcmData AndAlso encoder.Codec <> FileCodec.Flac. FLAC 5.1 passes through; MP3/AAC/Ogg still force-stereo because MusicBee's command-line encoders for those formats expect 2-channel input.

Why: the whole point of transcoding a 5.1 FLAC source to FLAC output is to preserve the multi-channel mix. Silent downmix made the FLAC transcode option useless for surround listening. With N02 it does the right thing.


Architecture

The structural change that makes the fork viable on a large library - absent from the original plugin.

N03 - Lazy (on-demand) browse tree

What: the original plugin built the entire browse tree at MusicBee startup - enumerate every track, full Library_GetFileTags per file, assemble the whole container hierarchy - before opening the HTTP port. On a real library (50k+ tracks, 5400 podcast episodes, hundreds of stations) that's minutes of cold start, and the tree stays in RAM forever including branches no client ever opens. This fork builds nothing upfront: the root exposes one L:-prefixed placeholder per endpoint (L:music, L:podcast, L:filter:…); each level is computed only when a client browses into it (LazyBrowseEnsureLazyEndpointInMemory → per-level caches), and library-change notifications clear the caches (SetLibraryDirty).

Why: cold start is essentially instant - the HTTP port is open by the time MusicBee finishes plugin-init - and memory stays proportional to what's been browsed, not to library size. Trade-off: the first browse into an endpoint pays its load cost; re-entry is cached until the next library change. This is the foundation everything else depends on. Full notes: FIXES.md.


Networking & robustness

Hardening the HTTP server's bind path. The original plugin dies silently when its port is unavailable.

N04 - Self-healing HTTP port binding

What: the plugin's HTTP server no longer dies when its configured port is unavailable. Three linked changes:

  1. Auto-fallback on bind failure. HttpServer.Start tries the configured port, and on SocketException scans upward up to 20 ports for the first free one. The actual bound port is recorded in a new Plugin.boundServerPort, and everything that advertises the server - SSDP LOCATION URLs (NOTIFY + M-SEARCH response), the device URL (PrimaryHostUrl), the router port-forward, and the SSDP/control-point self-filters - now reads boundServerPort instead of Settings.ServerPort. UPnP clients discover the real port via SSDP, so a moved port is transparent to renderers.
  2. User notification. When a fallback happens (the saved port isn't the one in use), a localized MessageBox (WarnPortInUse) tells the user which port is actually serving and that devices will still find it - because the plugin runs headless and an in-dialog message would only be seen by someone who already suspected a problem.
  3. Restart recovery. RestartServer (the settings-save restart path) used to dereference Plugin.controller / Plugin.server blindly. If the initial Initialise threw before creating them (exactly what a failed bind caused), the next settings-save hit a NullReferenceException - leaving a half-dead plugin. It now recreates-and-starts them when Nothing, so saving a working port revives the plugin without a full MusicBee restart.

Why: the trigger was a real user incident. The old default port 49382 lives in the Windows dynamic range (49152-65535), where Hyper-V/WSL2/Docker/WinNAT reserve large blocks that shift on each boot - so binding failed with WSAEACCES ("access forbidden") on a machine where it had worked for months. Changing the default to a free port then collided with Serviio (a separate DLNA server already on the new port), failing with WSAEADDRINUSE. Each failure was swallowed in Initialise, leaving the plugin silently dead and then NRE-ing on the next settings save. After N04 a port collision self-heals - the server keeps running on the next free port, the user is told, and clients re-discover it - instead of taking the whole plugin down.

Implementation:

  • Default port moved 493829779 (below the dynamic range, so Windows never auto-reserves it; not a known media-server default) in all three ServerPort declarations + the settings parse-failure fallback.
  • Plugin.boundServerPort (new shared field) holds the live listening port; activeServerPort stays the configured snapshot so the "Restart Required" badge logic doesn't false-fire on a fallback.
  • HttpServer.PortScanRange = 20; the scan stops at the first successful TcpListener.Start() and throws the last exception only if all attempts fail.
  • New EN resource key WarnPortInUse (translations follow the publish-time locale pass).

N05 - SSDP announcements over the multicast group (VPN / point-to-point)

What: SSDP announcements are sent to the UPnP multicast group (239.255.255.250) instead of an IP broadcast address. The harmless "cannot access a disposed object" error logged when an SSDP search response races a server restart is also suppressed.

Why: on point-to-point / VPN network adapters, IP broadcast doesn't apply - the old broadcast send failed with "invalid argument" and announcements were missed, so the plugin was invisible to clients on those links. Announcing to the proper multicast group fixes discovery on exactly those adapters.

Library navigation

These shipped in this fork and are not in the original plugin. They came from actually browsing the plugin's own output from real UPnP clients.

N06 - Filter-based library exposure

What: MusicBee's filter tabs (.xautopf files in the user's MusicBee folder) become UPnP root containers in the plugin's library. Each filter's tracks are then browsable in a hierarchy of AlbumArtistSort → Album → Tracks.

Why: users with curated MusicBee filters (e.g. "5-star tracks", "Recently added", "Classical → Baroque") expect to find them when browsing the plugin from a UPnP client. Original plugin only exposed the raw library tree.


N07 - SortAlbumArtist field wiring

What: the plugin now reads MusicBee's MetaDataType 165 (Sort Album Artist) and uses it to group/sort artists in browse views.

Why: hi-fi browsers and audiophiles use sort-artist names ("Beethoven, Ludwig van" instead of "Ludwig van Beethoven") to organise libraries. Standard expectation for serious listeners. Missing from both upstreams.


N08 - Multi-value AlbumArtist handling

What: when an album's AlbumArtist field contains multiple artists separated by "; " (e.g. "yaiol; Ars Ricercata"), the track now appears under each artist in browse views, not under a single Frankenstein artist combining the names.

Why: collaborative albums and compilations need to surface under every collaborator. Without this, half the search paths to find the album are broken.


N09 - Album-container artwork (upnp:albumArtURI)

What: album container nodes in DIDL Browse responses now include an upnp:albumArtURI element pointing at the album cover.

Why: without this, every album in a UPnP client's browse view shows a generic icon instead of the album cover. Visual cue for navigation; expected by every modern hi-fi browser.


N10 - Track ordering inside filter albums

What: tracks inside a filter-exposed album are now sorted by Disc Number, then Track Number.

Why: standard album order. Without explicit sort, tracks came back in whatever order the filter happened to return them - usually random-looking.


N11 - Playlist folder tree fix

What: the LoadLibraryPlaylists function (originally by Steven Mayall, ~2014) failed to descend into newly-created playlist folders. The first playlist in each folder, plus any sub-folders, ended up orphaned at the root level.

Why: present in the original plugin for eleven years. Visible within 30 seconds of opening BubbleUPnP and clicking Playlists. Fixed in yaiol by correctly recursing into newly-created folders during tree construction.


N12 - XML-illegal control character sanitization

What: any track with a tag containing a C0 control character (e.g. 0x19 from a bad encoding pass - UTF-8 → Latin-1 → back truncating 0x99 into 0x19) caused the entire Browse response to fail with Action Failed once the bad track entered a paginated batch.

Why: XML 1.0 forbids most C0 control characters, and XmlWriter throws when asked to write any. Present in the original plugin. Fixed by stripping invalid chars at every Library_GetFileTags exit point via XmlConvert.IsXmlChar.


N13 - Radio listing deterministic across paginated Browse

What: Browse for the Radio container fell into the generic file-list branch, which called files.Sort(AlbumFileComparer) on every call. Radio entries have empty Album/Disc/Track tags, so every comparison returned 0 - List(Of T).Sort is unstable, producing a different order each invocation. UPnP control points paginate (BubbleUPnP fetches 0..15 then 16..end); between the two calls the list reshuffled, so some stations appeared in both pages (duplicates) and some in neither (missing) - looking random each refresh.

Why: present in the original plugin (its author never browses radio via UPnP). Fixed here with a dedicated ContainerCategory.Radio branch in Browse, no per-call sort; radioFiles is sorted once at load time by Title (stable). Paginated browse now sees a deterministic order; page 1 and page 2 are disjoint.


N14 - UPnP Search album-class returns album containers

What: UPnP Search for album-class queries (upnp:class = "object.container.album.musicAlbum", e.g. BubbleUPnP's "Random Albums") returned the full track list instead of album containers, so the client showed zero albums. The original handler only parsed parenthesised criteria, then dumped all tracks regardless of the requested class.

Why: fixed here - album-class queries now enumerate distinct albums (grouped by AlbumArtist+Album) and emit each as a proper musicAlbum container with cover art, addressable via the Salb<idx> virtual-ID space so the client can drill into a result and play it.


N15 - Working, scope-aware UPnP search with click-through

What: the original advertised no search capabilities (GetSearchCapabilities returned empty), so clients refused to even send a Search; and the old backend read from musicFiles, permanently empty in the lazy-tree era. This fork advertises the real searchable properties, implements tracks-by-title and albums-by-title against the lazy library (HandleLazySearch), scopes the query to the client's current branch when a real container ID is sent (otherwise substitutes L:music so top-bar searches don't drag in podcast/radio/audiobook noise), and makes album results clickable via synthetic Ssrch_alb_* IDs that a Browse early-branch maps back to the album's tracks. (The album-class-results-as-containers piece is N14.)

Why: search in BubbleUPnP went from "Library doesn't support search" to returning useful, scoped, playable results. Full design + rejected approaches: SEARCH.md.


N16 - UPnP cache invalidation (SystemUpdateID)

What: the original returned a constant SystemUpdateID=0 - the UPnP ContentDirectory cache-invalidation contract - so spec-compliant clients (BubbleUPnP) treated the library as never-changing: stale browse results, 404 thumbnails after a URL-scheme change, and the "restart MusicBee twice to see changes" dance. This fork seeds SystemUpdateID from epoch-seconds at load (so every restart is strictly ahead of the last) and bumps it on every library mutation and settings change (SetLibraryDirty / ResetCacheBumpSystemUpdateId).

Why: clients reliably pick up edits, new files, and setting changes on their next browse. Known limit: subscribed clients aren't actively re-pushed the new value via GENA (parked as future work); they still see it on their next browse.


N17 - Podcast subscription artwork

What: podcast tiles showed no images - every /PodcastThumbnail/ request 404'd. Two stacked bugs: the resolution chain never checked MusicBee's actual artwork cache (%LocalAppData%\MusicBee\InternalCache\Subscriptions\<name>.jpg, where the desktop UI loads from), and the HTTP layer's unescape+lowercase mangled the feed-URL route key down to its last path segment. This fork resolves artwork from MB's InternalCache and routes lookups via a URL-safe slug that survives the HTTP layer intact (PodcastSlug / podcastSubIdBySlug).

Why: subscription artwork now renders in browse views (all 22 previously-404'ing requests resolve).


N18 - Hierarchical (delimited) tag browsing

What: any field can be marked hierarchical on the Library Options tab and given a one-character delimiter (a field picker + delimiter box with add/remove, persisted in the plugin settings). Set Grouping to / and a value like Jazz/Cool Jazz then browses as Jazz › Cool Jazz instead of one flat entry. Tracks tagged exactly at a branch (just Jazz) get their own [Jazz] node so nothing is hidden, a branch with a single child collapses on its own, and ; is refused as a delimiter because it is MusicBee's own multi-value separator.

Why: deep tag taxonomies a user has already encoded in a single field (genre trees, mood hierarchies, "Classical/Baroque/Concerto") finally browse as the tree the tag describes, instead of a flat wall of slash-separated strings the user has to read end-to-end.


N19 - Single root path labelled by its grouping field

What: a single browse path at the root is labelled by its grouping field (e.g. "Genre") rather than its full short path, matching how merged first-field groups are named.

Why: the browse tree reads consistently - one naming rule whether a root entry stands alone or was merged with siblings (N20) - instead of a lone root entry showing a verbose internal path while its merged neighbours show a clean field name.


N20 - Merge browse paths that share a first field

What: two browse paths that share the same first field - "Genre / Sort Album Artist" and "Genre / Podcast People" - collapse into a single Genre root folder that lists the genre values first and then splits into the two views, instead of two near-duplicate "Genre / …" entries side by side at the root.

Why: a user with several related views nested under a common field saw the root cluttered with almost-identical top-level entries. Merging them keeps the root readable and groups the related views where they belong - under their shared field.


N21 - Category-typed browse paths (Standard / Radio / Podcast)

What: every browse path is typed by category - Standard, Radio or Podcast. The template list is grouped into those three sections, each template's field picker offers only the fields that category's data can actually deliver, and a template can only be applied to matching nodes in the View tree (incompatible nodes grey out and can't be checked). The reserved Radio and Podcasts templates can't be deleted, so their category section never disappears.

Why: without the typing a user could build a layout that silently comes up empty - a radio station has no "album", a podcast episode has no "album artist" - and only discover it by browsing to a dead folder from a UPnP client. Constraining the field menu and the apply targets to the category's real data makes empty layouts unbuildable.


N22 - Group podcasts by publish year

What: each podcast episode's publish date is read in, so a podcast browse path with a Year level buckets episodes by year instead of collapsing them under a single "Unknown".

Why: large podcast subscriptions become navigable by year like the rest of the library, instead of every episode landing in one undated heap because the plugin never looked at the per-episode publish date.


N23 - Collapse single-result grouping levels

What: a grouping level that resolves to a single value - a Record Type level showing only "LP" for an artist who only made LPs, or a letter level with a single letter - is skipped automatically, dropping the user straight into its contents.

Why: browsing through a folder that contains exactly one folder is pure friction. Collapsing the single-choice level removes the dead click without changing what the user can reach.


N24 - Year grouping/search against MusicBee's date field

What: the year condition no longer queries MusicBee's full-date "Year" field with a bare four-digit value, and the hard-coded year-field aliasing is gone so every group-by field now resolves generically from the path definition.

Why: for libraries whose Year tag holds a complete date, grouping or searching by year previously returned nothing - the four-digit query never matched the full-date field. Querying the right field makes year grouping and search find the tracks again.


N25 - Separate "Year" and "Year (yyyy)" group-by fields

What: album group-by and browse paths now expose both of MusicBee's own year fields - Year (the full date tag) and Year (yyyy) (just the four-digit year) - so the user can pick either when defining an album group-by or a browse path.

Why: the two fields mean different things in MusicBee, and collapsing them lost that distinction. Surfacing both lets a user gather every release of a year together (yyyy) or keep exact-date ordering (full Year tag), as they intend.


N32 - Pinned filters and playlists grouped by kind at the root

What: a pinned filter now appears directly below the Filters folder at the browse root, and a pinned playlist directly below the Playlists folder, instead of all pins collecting in one clump at the end of the root. Each pinned shortcut sits with its own kind.

Why: as a user pins more shortcuts, a single trailing clump of mixed filters and playlists gets harder to scan and separates each shortcut from the folder it belongs to. Grouping pinned items under their own category keeps the root readable and keeps each shortcut next to the things it belongs with.

Settings dialog & packaging

N26 - Sectioned settings dialog

What: the Preferences page got a left-nav layout with sections: General / Playback / Library / Device Profiles / Diagnostics.

Why: the original was a single tall flat list of every setting - fine for the dev who built it, bewildering for everyone else. Sectioning groups related options and makes the dialog feel more like modern app settings.


Assembly + plugin renaming (no F-id - packaging note)

What: the compiled DLL is named mb_UPnP_yaiol.dll and the plugin reports itself as "MusicBee UPnP (yaiol)". Distinct from the original mb_Upnp.dll.

Why: users can install yaiol alongside the original plugin and compare behaviour side-by-side.


Badge system - surfacing runtime state (mechanism behind F40)

What: a generic UI pattern for surfacing important runtime conditions as visible coloured badges in the Settings dialog. Current instances:

  • ⚠ Max Conn (N04) - fires when the max-connections cap was hit at least once since MusicBee started. Sticky session flag Plugin.MaxConnectionsHit. Set inside WaitOnSendBarrier when no slot is free.
  • ⚠ Restart Required - fires when a saved setting needs a MusicBee restart to take effect. Sticky session flag Plugin.RestartRequired. Set in the dialog's Save handler when the new persisted value differs from the runtime snapshot (Plugin.activeMaxConnections, Plugin.activeServerPort, Plugin.activeIpAddress). Restart-required settings are limited to those that genuinely can't hot-reload - HTTP server bind params and the SemaphoreSlim built once at Initialise.

Why: the plugin's log file is fine for tech users debugging, but a non-tech user staring at "device sounds wrong" or "playback is slow" will never open Diagnostics → View log. Badges capture the cases where the user needs to know something happened and surface it the next time they open the plugin - discoverable without reading anything.

Reusable for future:

  • Profile mismatch detected (device user-agent never matched any profile, fell back to Generic).
  • NextURI backoff triggered (F13 - gapless disabled for the session on a flaky device).
  • Library scan failed / partial.
  • Renderer connection lost mid-session.
  • Any other condition where "happened once, user should know" beats "logged silently among 1000 other lines".

Implementation conventions:

  • Badge labels live at the dialog level (not inside any panel) so they're visible regardless of which section the user is on.
  • Positioned along the bottom row near Save/Cancel (current: y=410 horizontally stacked).
  • Each badge has a corresponding sticky session flag in Plugin that flips True when the condition occurs and resets only on MusicBee restart.
  • Resources: <Condition>Badge (label text, prefix with ⚠) + <Condition>BadgeTip (tooltip explaining cause + remedy).
  • For "saved setting needs restart" badges, take a runtime snapshot at Plugin.Initialise() and compare to Settings.* after Settings.SaveSettings() in the dialog Save handler.

N27 - Cancel discards path/template edits

What: edits made to paths and templates inside the settings dialog are now discarded when the user clicks Cancel, instead of quietly staying applied, and any reserved template removed during the session is re-created. (Templates otherwise save live as they're edited - there's no separate Save button on the Paths tab.)

Why: Cancel should mean cancel. Previously a user who experimented with path/template changes and backed out found the changes already committed, with no way to undo them short of redoing each by hand.


N28 - Literal ampersands in the field-picker menu

What: a field whose name contains "&" - e.g. "Mood & Context" - renders the ampersand literally in the field-picker menu instead of swallowing it as an Alt-mnemonic prefix.

Why: field names with an ampersand displayed wrong (the character vanished and the next letter became an accelerator), making the menu entry hard to recognise.


N29 - Stable, untranslated settings window title

What: the settings window title is fixed to the brand string "MusicBee UPnP Plugin" and no longer varies with the interface language; the per-language DialogTitle string was dropped from every locale bundle.

Why: a window title that changed wording per language was a translatable surface with no benefit - the title is a brand mark. Pinning it keeps it stable and consistent everywhere.

Localization

The original plugin is English-only. This fork is fully localizable - every user-facing string flows through a resource bundle, and the plugin auto-detects MusicBee's own UI language.

N30 - Multi-language UI (translations pending)

What: the localization machinery is complete and shipping. Localisation.vb reads MusicBee's selected language from MusicBee3Settings.ini (<SystemLanguage> endonym) and applies the matching .NET culture to the thread, so My.Resources.Resources.* returns the localized string. Every user-facing label/button/message is wired to a resource key (designer controls via ApplyDesignerExtras + sync-en-locale.js; runtime strings like WarnPortInUse hand-added). What's not done yet is the actual translation: only the English source bundle (Resources.resx) exists - the satellite bundles for the other languages are produced in one batch pass when the plugin is feature-complete (translating piecemeal while strings still churn wastes effort).

Target languages (the set MusicBee itself offers, matched 1:1 by endonymToCulture so the plugin follows MusicBee's language automatically):

Arabic (ar) Czech (cs) Deutsch (de) Greek (el)
Español (es) Français (fr) Hungarian (hu) Italiano (it)
Korean (ko) Nederlands (nl) Norsk (nb) Polski (pl)
Português BR (pt-BR) Português PT (pt-PT) Svenska (sv) Turkish (tr)
Ukrainian (uk) Русский (ru) 日本語 (ja) 简体中文 (zh-CN)
繁体中文 (zh-TW) English (en, source)

Variant policy (per the workspace locale rule): PT and ZH are split into distinct bundles because vocabulary/script genuinely diverges (pt-BR/pt-PT, zh-CN/zh-TW). EN is a single bundle - MusicBee's "English(US)" (en-US) falls back to en via .NET's culture chain, so no separate US bundle is produced. ES and FR are likewise single-locale.

Why: a UPnP plugin's settings ("do not use raw PCM", "force little-endian PCM", port-fallback warnings) are cryptic enough in one's native language. Following MusicBee's own UI language - rather than forcing English - is the difference between a tool a non-English user can configure and one they can't. Neither upstream attempted this.


N31 - Help link opens in the full interface language

What: opening the Help link from the plugin respects the user's complete interface language (e.g. pt-BR, zh-CN) instead of collapsing to the base language, and sends a clearer update-check identifier.

Why: a user running MusicBee in a regional variant (Brazilian Portuguese, Simplified Chinese) was sent to the base-language help page. Carrying the full culture lands them on the help page in the exact language they use.

Fixes and improvements to the original plugin

Core protocol & playback

F01 - Updated default DLNA device profiles

What: ships fresh default profiles for PlayStation 4, Xbox 360/One, and modern BubbleUPnP, with capability flags (sample rates, bit depths, codecs) that reflect what those devices actually support today.

Why: the original plugin's defaults were frozen circa 2014. PS4/Xbox/BubbleUPnP have since gained hi-res audio support. Out-of-the-box, a new install plays best-in-class on these devices without the user touching device profile settings.


F02 - Control devices that advertise MediaRenderer:3

What: the plugin probes a renderer's UPnP service description to decide whether MusicBee can drive it. The original only matched urn:schemas-upnp-org:device:MediaRenderer:1. Modern devices advertise :2 or :3. F02 broadens the match.

Why: without this, recent Sonos / WiiM / Eversolo units simply don't show up as targets in MusicBee's "Play To" device list - even though they speak the same protocol. A single string-prefix match fix unlocks the entire modern device generation.


F03 - "Force native stream" per-profile option (default ON)

What: when ticked, the plugin sends the original file bytes to the device with no transcoding, no DSP, no ReplayGain processing applied. Just the raw file the user picked, byte-for-byte (modulo HTTP framing).

Why: by forum testimony this is the single biggest playback-quality win. Hi-fi users buying expensive renderers explicitly want bit-perfect output; any DSP touch defeats the point. Default ON because most modern devices handle whatever codec the user threw at them, and ReplayGain/EQ should be opt-in. It is per-profile so you can keep transcoding for an old Xbox while sending native to a hi-fi DAC.


F04 - "Force transcoding" per-profile

What: per-profile override that forces every stream to this device through the transcoder, regardless of native-codec support. The inverse of F03 (ForceNativeStream). Mutually exclusive with F03 - the UI auto-unticks the other when either is toggled on.

Why: a single global toggle would be contradictory with the per-profile ForceNativeStream (F03). Real-world case: device A is a hi-fi DAC that wants bit-perfect native streams; device B is an old AV receiver that chokes on FLAC. With a global toggle the user has to choose - at the cost of the other device. With per-profile each device gets the right answer.

Implementation:

  • StreamingProfile.ForceTranscoding As Boolean = False.
  • Persistence schema bumped to v9. Pre-v9 files load the legacy global value once and copy it into all profiles, preserving the old behaviour through the upgrade.
  • UI: removed from Diagnostics panel, added to Device Profiles section beside ForceNativeStream. Two-way mutual-exclusion handlers (CheckedChanged on each unsubscribes the other before flipping, to avoid an infinite loop).
  • Decision site: Settings.ForceTranscodingstreamingProfile.ForceTranscoding in WriteAudioFileDIDL.

F05 - "Force little-endian PCM" per-profile

What: PCM streams (L16/L24 mime types) are big-endian by spec. Some devices wrongly expect little-endian and play back white noise when handed proper big-endian data. F05 toggles the byte order per-profile.

Why: without this, certain devices output a wall of static. The symptom is dramatic and the cause invisible without knowledge of PCM encoding - the toggle gives users a guess-and-check fix.


F06 - "Do not use Raw PCM" per-profile

What: when the device claims it supports raw PCM, the plugin uses that. Some devices lie - they accept the SOAP handshake but garble actual raw PCM data, while properly handling PCM wrapped in a WAVE container. F06 forces PCM-over-Wave regardless of what the device advertises.

Why: certain Marantz models specifically - they advertise raw PCM but only WAVE works. Without this, raw PCM streams come out distorted, with no error message to point at.


F07 - "Content length" per-profile

What: what value to send in HTTP Content-Length header. Four options:

  • Default - actual byte count when known, omit when unknown.
  • None - never send the header (chunked encoding only).
  • PCM Only - only send for raw PCM; omit for everything else.
  • Fixed - send UInt32.MaxValue - 8192 (a sentinel for "huge unknown length").

Why: UPnP/DLNA devices vary wildly in how they react to Content-Length. Some need an exact number, some hate it on streams, some need a sentinel "really big" value to keep buffering. this was later broadened from PCM-only to all output formats because the same problems showed up in transcoded MP3/AAC streams.


F08 - "Do not clear NextURI" per-profile

What: normally the plugin clears the device's queued NextURI when the queue empties (sending SetNextAVTransportURI with a blank URL). Some devices (notably Denon) interpret a blank NextURI as "stop everything" and halt playback immediately. F08 prevents the plugin from ever clearing it.

Why: without this, Denon owners experience the device cutting off mid-track when the queue empties. With F08 ticked, the device keeps the stale NextURI in memory (harmless - it just gets overwritten next time something is queued).


F09 - FLAC as transcode output format

What: the transcode-format dropdown in Device Profiles now offers FLAC alongside PCM 16/24, MP3, AAC, Ogg. Picking it routes the BASS encoder through MusicBee's standard FLAC convert command line (same mechanism MP3/AAC/Ogg already use).

Why: for devices that handle FLAC well but can't decode the source codec (e.g. an Eversolo receiving MusicBee's WMA library converted to FLAC), this preserves lossless quality where MP3/AAC would throw audio data away. Unblocks N02 (5.1 downmix control), which couldn't be addressed without a lossless transcode option.

Implementation: one-line addition to Encoder.StartEncode's Select Case Codec block - FLAC joins MP3/AAC/Ogg in the command-line-driven branch. UI dropdown gets "FLAC" as a 6th option. Load/save mapping in SettingsDialog extends to recognise FileCodec.FlacSelectedIndex = 5. Mime, DLNA type, and encode feature were already wired in ItemManager.GetMimes / GetDlnaType / GetEncodeFeature from earlier work (F21, F26).


Gapless (SetNextAVTransportURI)

F10 - SetNextAVTransportURI / NextURI core

What: true gapless playback. When the device advertises support for SetNextAVTransportURI in its UPnP service description, the plugin pre-queues the next track on the device before the current one ends. The device transitions internally with no audible gap between tracks - what you hear on a CD player. This is not the "continuous stream" hack (which concatenates everything into one long stream and loses per-track metadata).

Why: the flagship Tier-2 feature. Albums recorded as a continuous live performance (live records, classical movements, DJ sets) sound wrong when there's a half-second silence between tracks. Solving this properly is a flagship feature, now in this fork.

Notes: the queued audio is served via the plugin's HTTP server using streamHandle=0 (library-fetch mode), which means MusicBee's audio engine isn't in the loop for the queued track. Trade-off: ReplayGain/DSP/EQ effects don't apply to the next track. Acceptable when "force native stream" is on (the default).


F11 - "Disable NextURI support" per-profile

What: even if a device advertises SetNextAVTransportURI, this checkbox forces the plugin to ignore that advertisement and fall back to one-track-at-a-time playback.

Why: some devices advertise NextURI but have a buggy implementation (crashes, half-transitions, hangs). Rather than reverse-engineer each broken device, the user gets a "just turn it off here" toggle.


F12 - NextURI lifecycle on the now-playing-list

What: when MusicBee fires NowPlayingListChanged, the plugin re-evaluates what should be queued for gapless transition. It asks MusicBee for the new "next" track via NowPlayingList_GetNextIndex(1) + NowPlayingList_GetListFileUrl, compares against what's currently queued on the device (tracked via the new nextPlaySourceUrl field), and re-queues if it changed (or clears the queue if MusicBee says there's no next track).

Why: without F12, the device kept playing a stale NextURI when the user removed/reordered the queued track. This historically took several iterations because every list-mutation needs different handling - we simplified by trusting NowPlayingList_GetNextIndex (which already respects shuffle and repeat-all wrap-around), so all variants funnel through the same comparison.

Implementation:

  • New nextPlaySourceUrl field stores the MusicBee library URL of what's queued (the streaming URL with handle suffix isn't comparable to a library path).
  • New Public Sub RefreshQueuedNextUri() on MediaRendererDevice. Three outcomes: no NextURI queued → no-op; queued matches new "next" → no-op; queued differs → call QueueNext with the new URL (or QueueNext("") to clear - which respects F08 DoNotClearNextUri).
  • Wired in Plugin.ReceiveNotification under NotificationType.NowPlayingListChanged.

F13 - NextURI failure backoff

What: after 4 consecutive SetNextAVTransportURI failures on the same device, the plugin disables gapless for that device until MusicBee restarts.

Why: if a device is genuinely broken for NextURI (intermittent SOAP faults, network glitches), the plugin would otherwise keep retrying on every track. F13 stops the noise and falls back to one-track-at-a-time silently.


F14 - Repeat-mode + NextURI integration

What: F14 splits into two cases handled at the F15 transition detector in OnAvTransportStatusCheck:

  • Repeat-All: MusicBee passes the correct "wrap" URL (track 1 at end-of-list) to Plugin.QueueNext itself. No special plugin logic needed - the device transitions to it and the F15 detector calls Player_PlayNextTrack as usual, which wraps MusicBee's NPL index back to 0.
  • Repeat-One: MusicBee passes the SAME track URL to Plugin.QueueNext. The device transitions to it (new stream handle, same source). The F15 detector now queries Player_GetRepeat() - if it's RepeatMode.One, it skips the Player_PlayNextTrack call so MusicBee doesn't advance the NPL index away from the looping track.

Why: without the Repeat-One skip, calling Player_PlayNextTrack at gapless transition would advance MusicBee to the next track in the list (Repeat-One affects only end-of-track auto-advance behaviour in the player's UI - Next Track always moves forward), contradicting what Repeat-One means.

Play-count caveat: in Repeat-One, the play count increment depends on MusicBee 3.7.9563+ noticing the looped playback. Older MusicBee versions play the gapless repeat correctly but miss the play-count bump. Documented; not blocking.


F15 - Track-transition detection state machine

What: when the device internally transitions from current track to NextURI, the plugin needs to notice and tell MusicBee to advance its now-playing index. Otherwise MusicBee thinks it's still on the previous track and play counts / UI / scrobbling drift out of sync.

Implementation: polls GetPositionInfo.TrackURI on each status-timer tick. When the reported URI matches the one we queued via NextURI, we call Player_PlayNextTrack on MusicBee and set suppressNextSoapCall so the resulting PlayToDevice doesn't re-send SetAVTransportURI (which would interrupt the gapless playback).

Why: without F15 the device plays the next track but MusicBee's UI says it's still on the previous one. Confusing, breaks scrobbling, breaks play-count tracking. Track-transition detection needs long, per-renderer iteration because each renderer brand has its own quirks in when it reports the URI change (some report TRANSITIONING first, some skip straight to PLAYING with new URI, some have a brief STOPPED in between).

Notes: our first cut works on BubbleUPnP renderer. Per-device edge cases stay in B6.


F16 - Pop-on-gapless-transition fix

What: the pop happens when the source format (sample rate / channels / codec) of the queued track differs from the currently-playing track, forcing the device's DAC to re-lock at the transition. F16 adds a NextUri:FormatChange diagnostic that fires at queue time whenever the formats differ, naming both sides - so users hearing pops can correlate.

The diagnostic also points at the mitigation: tick ForceTranscoding on the device profile. That homogenises every track to a single transcode codec/sample-rate/bit-depth, eliminating the source-format difference entirely.

Why deferred for the actual transcode-to-match fix: the structural fix (transcode the queued track to match the playing track's format) requires changes to the plugin's HTTP server URL scheme - currently /encode/{id}0.{ext} serves the queued file natively. A future v2 of F16 would add per-format /encode/{id}0_{rate}_{depth}.{ext} routes and wire them through the encoder. That's a bigger architectural change worth doing if a real device exhibits the pop after ForceTranscoding doesn't suffice.

Implementation today:

  • lastSourceUrl field tracks the currently-playing source URL.
  • QueueNext reads FilePropertyType.SampleRate/Channels/Kind for both the current and queued tracks and logs NextUri:FormatChange on mismatch.

F17 - Progress bar resync after seek

What: the Seek() function already called GetPlayPositionInformation() after a successful Seek SOAP, which fixes the "no resync at all" case. F17 closes the remaining up-to-1s drift caused by UPnP's 1-second RelTime quantisation: when the device's reported position rounds to within 1 second of the user's requested target, the plugin now trusts the user's sub-second-accurate value instead of the device's truncation. Only when the device reports something dramatically different (>1s off) do we use its value (the seek landed somewhere else than asked, e.g. snap-to-keyframe on some codecs).

Why: without this, seeking to 2:30.500 anchored against the device's "2:30" report would make the progress bar show ~500ms behind reality. After F17 the bar matches the user's intent for the common in-track scrub case, and still respects the device's report for the snap-to-keyframe outlier.


F18 - Continuous-stream / NextURI interlock

What: two interlocks now in place:

  1. Runtime: QueueNext early-returns False when Settings.ContinuousOutput is on. Continuous-stream is its own gapless mechanism (one long concatenated stream); sending SetNextAVTransportURI on top of it confuses the device about whether each track is a discrete URI or part of the continuous flow.
  2. UI: when the user ticks the global continuous-stream checkbox, the currently-displayed profile's forceNativeStream auto-unticks. Continuous-stream always transcodes, so force-native is meaningless in combination.

Why: prevents the user from enabling two conflicting gapless mechanisms simultaneously. Without F18, the device would receive both a continuous stream URI AND a NextURI for each subsequent track, with undefined behaviour depending on the renderer.


F19 - Blank NextURI errors ignored

What: when SetNextAVTransportURI is called with a blank URL (e.g. last track in list), some devices return a SOAP fault. F19 swallows these silently - logged but not propagated as errors.

Why: the "no next track" condition is normal, not an error. Treating it as fatal pollutes the log and (in some flows) triggers retry storms.


Mime types & DLNA metadata

F20 - MP3 mime → audio/mpeg

What: the standards-conforming MP3 mime type is audio/mpeg, not audio/mp3. The latter is a common misnomer most devices tolerate, but stricter renderers reject it.

Why: silently fixes playback on stricter devices that follow the standard. The yaiol codebase had this correct already; no change needed.


F21 - Mime type order: non-x- variant first

What: when a device advertises both audio/flac and audio/x-flac, the plugin returns the non-x- variant first. Same for any codec with both standard and experimental mimes.

Why: the x- prefix marks experimental/unofficial mimes. Some renderers behave better with the standard form. Tiny re-ordering, real-world impact.


F22 - Opus mime type support

What: recognises Opus as a streamable audio codec; sends audio/opus mime when serving Opus tracks.

Why: Opus is now common (modern speech/music compromise codec). Without F22 the plugin would refuse to stream Opus files even to devices that handle them.


F23 - Monkey Audio (APE) source file support

What: recognises .ape files as a valid source codec for streaming/transcoding.

Why: APE is a lossless format with a niche but loyal user base. Adding it costs little and unlocks the library for those users.


F24 - AAC / ALAC mime fallback

What: if a device supports AAC or ALAC but doesn't explicitly advertise them in its UPnP service description, the plugin offers them anyway as a fallback.

Why: several devices that do handle AAC fine forgot to list it in their capabilities XML. Without F24 the plugin won't even try, forcing transcoding. With F24 the plugin tries and lets the device handle it natively if it can.


F25 - DLNA type flag for native + encoded WAV streams

What: the DLNA type flag (a profile identifier like LPCM, WAVE, MP3) needs to match what the device receives. F25 ensures native streams and encoded WAV streams are flagged correctly.

Why: mismatched DLNA type causes some devices to refuse playback entirely or apply the wrong decoder.


F26 - DLNA header for FLAC files

What: FLAC streams get the proper DLNA profile identifier in their headers.

Why: without it, some devices that support FLAC don't recognise the stream as such.


F27 - Bitrate calculation fix in metadata

What: the continuous-stream res@bitrate was being computed as (sampleRate * channels * bitsPerSample) / 1000 - kbps, off by a factor of ~125 from the UPnP DIDL spec which defines the attribute as bytes per second. Now divides by 8 instead of 1000.

Why: wrong bitrate display on device - cosmetic on most renderers, but some allocate stream buffers from the value and stutter on streams that look ~125× smaller than they are. The non-continuous source-file path already had this right ((bitrate_kbps * 1000) \ 8 = bytes/sec); only the continuous-stream path was wrong.


F28 - Metadata time format fix (Marantz)

What: res@duration in DIDL was formatted as H:MM:SS (e.g. 0:03:42). The UPnP DIDL spec defines the format as H+:MM:SS[.F+] - strictly with fractional seconds optional but recommended; some Marantz devices treat the bare form as invalid and leave their duration display blank. Now formatted as H:MM:SS.fff (e.g. 0:03:42.000).

Why: display issue specific to a brand; conformant ISO-style format with fractional seconds fixes it without affecting any other device. Applied at both DIDL emission sites (source-file path + encoded-stream path in WriteAudioFileDIDL).

Bonus fix in the same pass: pv:addedTime and pv:lastPlayedTime were using hh (12-hour clock) in their DateTime format strings instead of HH (24-hour). Any track added or played between 13:00 and 23:59 would render with a wrong hour (e.g. 17:42 → "05:42") on devices that surface the field. Now uses HH.


F29 - Encoded MP3 seek support (CBR)

What: transcoded MP3 streams now advertise DLNA.ORG_OP=11 (both byte and time seek) instead of DLNA.ORG_OP=10 (byte-only). Devices that previously refused time-seek on transcoded MP3 can now drive their progress bar/seek UI normally.

Why: MusicBee's transcoder produces constant-bitrate MP3 at the HighQuality preset, so byte ↔ time mapping is linear - the device can convert a time-seek request to an HTTP Range byte-seek itself without any encoder-side support. Advertising OP=11 unlocks that UI on the device. Without F29, users seeking inside a transcoded MP3 either had the seek silently ignored or got dropped to track start.

Implementation: restructured GetEncodeFeature in ItemManager.vb to break the inline If into a readable If/ElseIf/Else chain. MP3 gets OP=11 explicitly; other non-PCM codecs keep OP=10. No change for AAC/FLAC/etc. - those would need codec-specific verification of CBR-ness which MusicBee doesn't guarantee.


F30 - .mpeg file extension handled

What: files with .mpeg (and the even rarer .mpe) extension are now recognised as FileCodec.Mp3 in GetCodec. Before F30 they returned FileCodec.Unknown and were silently rejected from the library / unable to be transcode sources.

Why: old MPEG-1 Layer 3 archives sometimes used .mpeg instead of .mp3 (the spec allows both). A handful of files in a 300k library is enough to feel "MusicBee shows them but the plugin doesn't" - confusing for the user.


Playback behaviour

F31 - Radio streams auto-use continuous mode

What: WriteAudioFileDIDL now probes the source URL's Kind property via Library_GetFileProperty and treats any file whose Kind ends in "Stream" (MusicBee reports "MP3 Stream", "Internet Stream", etc. for radio) as continuous regardless of the global Settings.ContinuousOutput toggle. The continuous-stream DIDL branch (Title: "Continuous Stream", id="continuousstream", fixed PCM/Wave output) is used; the device sees a single infinite-style stream.

Why: radio streams have no track boundaries, no fixed length, no seek. Treating them like discrete files in the DIDL caused the plugin to advertise byte ranges and durations that don't exist. Auto-switching when MusicBee already told us "this is a Stream" removes a foot-gun the user shouldn't have to think about.

Scope: only applies when MusicBee is driving playback (musicBeePlayToMode). Library-fetch path (UPnP client browsing) is unchanged - radio URLs there are rare and the user-facing behavior shouldn't change without explicit testing.


F32 - Codec-advertisement fallback

What: if a device doesn't advertise certain codecs (or the plugin can't parse the device's capability XML), the plugin doesn't immediately reject the stream. Instead, it tries serving it and lets the device decide.

Why: many devices have incomplete or unreadable capability XML but actually handle the codec fine. F32 trades a small "best-guess and try" for an outright refusal.


F33 - Progress-bar sync improvement

What: the in-between-polls position is already wallclock-extrapolated from a single anchor (currentPlayStartTicks), so the progress bar updates smoothly at sub-second rate. The remaining jitter source was the initial anchor for a freshly-started track: the previous code assumed position=0 at the moment the status-timer first noticed the state went Playing, but by then the device might have been playing for 100-500ms (one poll interval). MusicBee's progress bar would start at 0, then jump forward when reality caught up.

F33 fix: when transitioning into Playing for the first time on a new track (currentPlayStartTimeEstimated=True), call GetPlayPositionInformation() to get the device's actual current position, then anchor against that. UPnP only reports 1-second resolution so the anchor is still quantised, but it's much closer to truth than assuming 0.

Why: smoother + more accurate progress display, especially right after track-change. No way around the 1-second UPnP reporting resolution itself - that's spec.


F34 - Progress bar jitter after track-change

What: when PlayToDevice is called for a new track, the plugin used to leave currentPlayPositionMs and currentPlayStartTicks at their previous-track values for the ~100ms window between SOAP-Play and the first status-timer poll detecting the new Playing state. MusicBee's progress bar would briefly show the end of the previous track, then snap back to 0, then climb. F34 zeros both at PlayToDevice entry - the moment we know a track-change is happening, before any of the SOAP work.

Why: visual glitch on rapid-skip use cases (manual next or gapless transition). Now MusicBee's first PlayPositionMs query post-Play returns 0 cleanly, then F33's GetPlayPositionInformation refines it to the device's actual position on the first state-change tick.

Implementation: four lines at the top of PlayToDevice, paired with F33's transition-time accurate anchoring.


F35 - "Force transcoding" bug

What: force transcoding could still skip transcoding in certain combinations. After the F04 per-profile rework, two specific gaps were sealed:

  1. Precedence with ForceNativeStream. When both were True (which can happen across a schema migration or a partial settings file), ForceTranscoding now wins outright (If streamingProfile.ForceTranscoding Then forceEncode = True ElseIf streamingProfile.ForceNativeStream Then forceEncode = False). UI mutual-exclusion prevents the user from ticking both, but the runtime guard handles any state that loaded inconsistent from disk.
  2. bypassTranscodeDecision logic. Previously: streamingProfile.ForceNativeStream AndAlso Not Settings.ForceTranscoding. Now: streamingProfile.ForceNativeStream AndAlso Not streamingProfile.ForceTranscoding - same precedence rule but at the same per-profile scope.

Why: "force" should mean force. If the user explicitly enabled ForceTranscoding for a device, the plugin must never silently fall through to native streaming, regardless of how other flags happen to combine.


F36 - Renderer-closed exception

What: wrapped Plugin.ReceiveNotification in a top-level Try/Catch that logs any uncaught exception instead of letting it propagate back to MusicBee's notification pump.

Why: notifications from MusicBee (PlayStateChanged, VolumeMuteChanged, etc.) dispatch into ControlPointManager which talks to the renderer over SOAP. Individual call sites already had Try/Catch around their SOAP calls, but a sufficiently weird timing case (e.g. renderer dying between two SOAP calls in the same notification handler) could still escape. The top-level wrapper is the final safety net so the user never sees a generic "TargetInvocationException" popup from MusicBee.

Implementation: renamed the existing body to ReceiveNotificationInternal and added a thin wrapper ReceiveNotification that does Try { ReceiveNotificationInternal(...) } Catch { LogError(...) }. The pre-existing per-method Try/Catch infrastructure inside ControlPointManager (around every PostSoapRequest call) stays - F36 is belt + suspenders.


F37 - Long-track seek triggering false transition

What: seeking inside a long track can produce a brief Stopped→Playing cycle on some renderers. Without discrimination, ProcessNewPlayState.Stopped treats that as natural-end-of-track and calls Player_PlayNextTrack, advancing MusicBee when the user just wanted to scrub. F37 stamps lastUserInitiatedSeek in Seek() and adds a 5-second guard in the Stopped handler (mirroring the existing lastUserInitiatedStop window).

Why: silent skip-to-next-track during a seek is one of those bugs nobody can guess at the cause of - the user thinks "weird, I tried to scrub forward and now it's playing the next song". The fix is mechanical: same pattern as the user-stop discrimination already in place.


F38 - Improved seek handling for crash-prone codecs

What: BubbleUPnP crashing on MP3 seek was the canonical symptom. After audit, the current yaiol seek code already does the right things - native path handles HTTP Range correctly (206, Content-Range, AcceptRanges), encoded path advertises X-AvailableSeekRange and parses incoming timeSeekRange.dlna.org / npt headers, DLNA.ORG_OP flags reflect the actual stream capabilities (with DisablePcmTimeSeek opt-out for problem Platinum devices). User-tested on current BubbleUPnP 4.6.4: no crashes observed.

Why: the BubbleUPnP MP3-seek crash was reported circa 2024 and the app has had ~16 months of fixes since. F29 (encoded MP3 OP=11) was the new variable that could have re-exposed it; doesn't, on tested versions.

If a crash returns: the fix-shape would be a per-profile "limited seek" toggle that forces DLNA.ORG_OP=10 (byte-only) on flagged codecs - mirroring how DisablePcmTimeSeek already works for PCM. Add then, not pre-emptively.


UI & logging

F39 - "Add" button selects the new profile

What: clicking "Add" in the device profiles list creates a new profile AND auto-selects it so the user can immediately edit fields. Our sectioned-dialog refactor already does this - both the direct-Add path and the from-template path end with Me.activeStreamingProfiles.SelectedIndex = Me.activeStreamingProfiles.Items.Count - 1. A check confirmed our fork already handles this - nothing to change.

Why: small UX papercut that turned out to be already-not-a-papercut here.


F40 - Bigger max-connections + warning log

What: the plugin's concurrent-stream cap (SemaphoreSlim around Sockets_Stream_File / Sockets_Encoder_Start) was a hardcoded 4. F40 makes it user-configurable in the General settings page (default 16, range 1-256), adds a MaxConnections log line when a request has to wait for a slot, AND surfaces a red ⚠ Max Conn badge at the bottom-left of the settings dialog if the cap was hit at least once since MusicBee started.

Why: when a device fires parallel requests (some Marantz/Linn during artwork scans, BubbleUPnP's metadata probes alongside active playback), additional requests blocked silently behind the semaphore - the user saw "device slow" with no visible cause. The log line is good for tech debugging but non-tech users never read logs. The visible badge in the settings dialog makes the cap-hit condition discoverable to anyone who opens the plugin's preferences.

Implementation:

  • Centralised the wait in WaitOnSendBarrier(logTag) in MusicBeeUpnp.vb; both call sites (MediaServerDevice.GetFile, Encoder.StartEncode) use it.
  • Settings.MaxConnections persisted in v8 of the settings schema.
  • Plugin.MaxConnectionsHit is a sticky session flag set inside WaitOnSendBarrier; resets only on MusicBee restart.
  • SettingsDialog.maxConnectionsBadge is a red bold label at (16, 410) that only shows when Plugin.MaxConnectionsHit is True. Has a tooltip explaining the cause and remedy.
  • Semaphore is initialized once at type-load, so changing the setting requires a MusicBee restart (noted in the field label).

F41 - Log "encoding due to ReplayGain/DSP"

What: instead of separate "encoding for RG" / "encoding for DSP" log lines, the single StreamDecision line from F42 includes MB-DSP/EQ, MB-ReplayGain, Profile-DSP/EQ, Profile-ReplayGain as accumulated reasons. Same diagnostic value, less noise.

Why: users see all the reasons transcoding is happening for a given track on one log line, not scattered. See F42 for full details.


F42 - Log "renderer doesn't support source codec"

What: added a StreamDecision log line per play-to-device track that says either "native CODEC" or "transcode CODEC→CODEC reason=…". The reason field accumulates every condition that triggered transcoding: MB-DSP/EQ, MB-ReplayGain, Profile-DSP/EQ, Profile-ReplayGain, WebFile, VirtualFile, ForceTranscoding(global), SampleRate<min/SampleRate>max, DownmixToStereo, DeviceLacksCodec(X), BandwidthConstrained.

Why: users were confused by unexpected CPU spikes on files they expected to stream natively. One log line per track tells them exactly which condition caused transcoding - and if the field shows DeviceLacksCodec(Flac) they immediately know the device's protocol-info was incomplete and may want F32's fallback to kick in.

Implementation: single accumulator string built incrementally through the decision chain; logged once at the end. Gated on Settings.LogDebugInfo to avoid log noise in production.


F43 - SetNextAVTransport log shows source URL

What: QueueNext log entries now include source=<MusicBee library path> alongside stream=<HTTP streaming URL>. Same change applied to the success path and the failure path (QueueNext:Failed).

Why: when debugging a queued-track issue, the streaming URL (/encode/aabbccdd0.flac) is opaque on its own - same for every track. The source URL is the human-grep-able library path that tells you exactly which file MusicBee tried to queue.


F44 - Better mime-type error logging

What: two new log entries during Activate:

  • Activate:MimeUnverified - fires per malformed entry in the device's GetProtocolInfo response, naming which entry couldn't be parsed (so the user can see e.g. "the Marantz returned http-get:*::* for some codec - capability is unverified, F32's fallback will guess").
  • Activate:NoSinkInfo - fires once if the device returned no <Sink> element at all. Means SupportedMimeTypes stays Nothing and IsCodecSupported degrades to "assume everything works" - useful context when later "device refused stream" errors appear.

Why: before F44 these silent capability fall-throughs left users guessing why their tracks were either transcoded against expectations or refused by the device. Now a single grep for Activate: shows whether the device's capability info was usable.


F45 - Better metadata error logging

What: the Browse exception log in ContentDirectoryService.vb was already enriched in earlier yaiol work (the Alia Vox bug session) with ObjectID and stack trace. F45 expands it further with BrowseFlag (metadata vs children), Filter (which attrs the client requested), sortCriteria, and partialResultLength (how many bytes of DIDL were produced before the failure - points at how far through the batch the bad track sits).

Why: when something goes wrong mid-DIDL, the partial-length value tells you whether the failure was on the first track of the batch (partial=0) or partway through (partial=N) - combined with the batch's startingIndex, you can identify the offending track index. Filter and BrowseFlag explain which kind of browse the client wanted; sometimes a metadata-only browse fails where a children browse for the same ID succeeds.


Networking

F46 - Automatic mode advertises only on real-network adapters

What: in Automatic interface mode the plugin used to announce itself (SSDP) on every operational IPv4 adapter. On a machine that also runs a VPN tunnel (NordLynx) or a virtual switch (Hyper-V / WSL / Docker), the same library was announced on each of those adapters too, so the control point you cast from discovered the server two or three times and listed the library as duplicate copies. Automatic mode now keeps only adapters that have a real IPv4 default gateway (HasIPv4Gateway) - which the tunnel and virtual-switch adapters don't have - so those are dropped from the announce list. A user-pinned address still wins outright (announce on that interface only), and if no adapter reports a gateway the selector falls back to every adapter, so the advertised address list is never empty and the plugin can't become invisible.

Why: the duplicate isn't caused by "being on a VPN" - it's caused by announcing on the LAN adapter and the tunnel/virtual adapter at the same time, so one control point sees the same server at two addresses. A consumer VPN (NordVPN/NordLynx) tunnels internet-bound traffic only; the DLNA renderer lives on the LAN and local-subnet traffic bypasses the tunnel, so the tunnel adapter never reaches a renderer anyway - dropping it removes a phantom copy, never a working path. The gateway test is the cheap, reliable signal that separates a real LAN/Wi-Fi adapter from a tunnel or virtual switch. Complements N05 (which fixed how announcements are sent on such links - multicast instead of broadcast); F46 governs which adapters get announced on at all.

Known limit: a mesh / remote-access VPN (Tailscale, ZeroTier, WireGuard-to-home) whose renderers genuinely live across the tunnel usually presents an adapter with no default gateway, so Automatic mode drops it too. Those users pin the VPN address instead, which takes precedence over the gateway filter.