Migrate to .NET 10: cross-platform build, SQLite/Skia/serializer ports, and several latent bug fixes - #70
Migrate to .NET 10: cross-platform build, SQLite/Skia/serializer ports, and several latent bug fixes#70mondul wants to merge 37 commits into
Conversation
Renamed files to match class names and case sensisitive file system on Linux: * SimpleDLNA/StartupUtilities.cs * server/Http/HttpServer.cs * server/Types/Subtitle.cs * util/FFmpeg.cs Changed files to match case sensitive paths / names: * SimpleDLNA/FormMain.cs * server/Http/HttpClient.cs * server/Properties/Resources.resx * util/util.csproj
Drops the two WinForms projects and the Visual Studio setup project ahead
of the .NET 10 conversion:
- SimpleDLNA/ WinForms GUI front-end (~3.4k LOC)
- NMaier.Windows.Forms/ custom WinForms controls (~680 LOC)
- setup/setup.vdproj VS deployment project
- NgenInstaller.cs System.Configuration.Install installer
Rationale:
* WinForms requires a net10.0-windows target and only runs on Windows,
so it cannot be exercised on the macOS toolchain this port targets.
* setup.vdproj is a Visual Studio Installer project. That project type
was dropped from Visual Studio long ago and has no msbuild/dotnet
equivalent, so it has been unbuildable for years regardless.
* NgenInstaller and SimpleDLNA/PathEnvironmentInstaller both derive from
System.Configuration.Install.Installer, which has no counterpart in
modern .NET. Their jobs (ngen warmup, PATH registration) are artifacts
of the old MSI install flow that is going away with setup.vdproj.
Nothing in the remaining projects referenced these: sdlna depends only on
fsserver, server and util, and NMaier.Windows.Forms was consumed solely by
the GUI. The console server (sdlna) remains the sole entry point.
Rewrites the build from the VS2013-era, non-SDK format to SDK-style
projects targeting net10.0. Code changes needed to make this actually
compile follow in subsequent commits; this commit is the build plumbing.
Removed
.nuget/{NuGet.exe,NuGet.targets,NuGet.Config}
The pre-automatic-restore bootstrap. `dotnet restore` handles this.
*/packages.config (5)
Superseded by PackageReference.
*/Properties/AssemblyInfo.cs (5), GlobalAssemblyInfo.cs
The SDK generates assembly info from MSBuild properties. Note the old
GlobalAssemblyInfo.cs declared AssemblyVersion("1.2.*"); wildcard
versions are incompatible with deterministic builds, so the version is
now pinned to 1.2.0.0.
util/App.config, sdlna/app.config
Contained only <supportedRuntime> for the .NET Framework loader.
sdlna.ruleset
A legacy FxCop ruleset ("Microsoft.Analyzers.ManagedCodeAnalysis"),
referenced with RunCodeAnalysis=false. Inert under Roslyn analyzers.
*/sdlna.key.snk (5 duplicates)
All five were byte-identical to the root copy; projects now sign
against the single key at the repository root.
sdlna.sln
Replaced (see below).
Added
Directory.Build.props
TargetFramework, assembly identity (formerly GlobalAssemblyInfo.cs)
and strong-name settings, shared by all projects.
Directory.Packages.props
Central package management; projects reference packages by name and
versions live in one place.
AssemblyAttributes.cs
ComVisible(false) and CLSCompliant(true), the two attributes that
have no MSBuild property equivalent. Linked into every project.
sdlna.slnx
The .NET 10 SDK emits the new XML solution format by default. Five
projects, down from eight.
Package updates, all verified to restore against net10.0:
log4net 2.0.5 -> 3.4.0
TagLibSharp (was "taglib") 2.1.0 -> 2.3.0
GetOptNet 1.2.0 -> 4.0.8
Microsoft.IO.RecyclableMemoryStream 1.1.0 -> 3.0.1
System.Data.SQLite.Core 1.0.103 -> Microsoft.Data.Sqlite 10.0.12
(new) SkiaSharp 4.152.0 + Linux/macOS native assets
log4net 3.4.0 was chosen over the API-identical 2.0.17 because 2.0.17
carries a known moderate-severity advisory (GHSA-4f7c-pmjv-c25w). The
3.x API surface this project uses -- BasicConfigurator.Configure(appender...),
LogManager.GetRepository(), ConsoleAppender, RollingFileAppender,
PatternLayout, custom Level.Notice via ILog.Logger.Log -- was verified to
compile and run unchanged.
Dropped entirely: EntityFramework and EntityFramework.SqlServer. Both were
referenced by util but no source file in the repository used them.
`dotnet restore` succeeds for all five projects.
System.Data.SQLite.Core does compile against net10.0, but it ships no
native interop binary for osx-arm64. Verified on this machine: opening a
connection throws
DllNotFoundException: Unable to load shared library
'SQLite.Interop.dll' or one of its dependencies
Microsoft.Data.Sqlite 10.0.12 bundles SQLitePCLRaw with natives for every
platform this project cares about, and was verified working here
(SQLite 3.53.3).
The port is small because everything outside util/Sqlite.cs already talks
to the database through the ADO.NET interfaces (IDbConnection, IDbCommand,
IDbDataParameter) rather than provider types.
util/Sqlite.cs
Rewritten against SqliteConnection. Three behavioural notes:
* Connection string. The old one was
Uri=file:<path>;Pooling=true;Synchronous=Off;
journal mode=TRUNCATE;DefaultTimeout=5
Microsoft.Data.Sqlite does not accept "Synchronous" or "journal mode"
as connection-string keywords, so those are now issued as
PRAGMA synchronous=OFF; PRAGMA journal_mode=TRUNCATE;
immediately after Open(). Same effect, different mechanism.
* SetChunkSize(16MB) is gone. It mapped to SQLITE_FCNTL_CHUNK_SIZE and
was a System.Data.SQLite extension with no counterpart here. It was a
file-growth hint only; dropping it costs some fragmentation on large
caches, nothing functional.
* The Mono branch is gone. GetDatabaseConnectionMono reflection-loaded
Mono.Data.Sqlite when running under Mono. On .NET 10 the Mono probe
(Type.GetType("Mono.Runtime")) is always null, so the branch was dead.
util/SystemInformation.cs
Deleted. IsRunningOnMono() was its only member and Sqlite.cs was its only
caller.
Named parameters (fsserver/FileStore.cs, fsserver/Files/FileStoreVacuumer.cs)
The old code bound parameters positionally with '?' placeholders. Verified
that Microsoft.Data.Sqlite rejects this:
InvalidOperationException: ParameterName must be set.
The three affected statements now use named parameters (@key, @SiZe,
@time). The INSERT already used named parameters and is unchanged.
While editing these, the parameters for the selectCover and insert
commands are now created from their own command object. They were
previously all created via select.CreateParameter(). That was harmless --
CreateParameter() returns a detached object either way -- but it read as
a copy/paste slip.
DBNull for absent covers (fsserver/FileStore.cs)
`insertCover.Value = null` is rejected by Microsoft.Data.Sqlite, which
requires an explicitly set value. Now binds DBNull.Value.
util and server both compile clean with no warnings. thumbs is still
blocked on System.Drawing, handled next.
System.Drawing.Common is Windows-only from .NET 7 onward; the Unix
compatibility switch that existed in .NET 6 was removed. Verified on this
machine that constructing a Bitmap under .NET 10 on macOS throws
TypeInitializationException: The type initializer for
'Windows.Win32.PInvokeGdiPlus' threw an exception
so the thumbnailer could not run here at all.
SkiaSharp 4.152.0 was chosen over ImageSharp: it is MIT licensed, which
sits comfortably beside this project's BSD license, whereas ImageSharp 3.x
moved to the Six Labors Split License. It also has verified osx-arm64
natives.
thumbs/ThumbnailMaker.cs
ResizeImage now takes and returns SKBitmap. The aspect-fit arithmetic is
unchanged. Rendering maps across as:
Bitmap + Graphics.FromImage -> SKBitmap + SKCanvas
FillRectangle(Brushes.Black, ..) -> canvas.Clear(SKColors.Black)
DrawImage(image, rect) -> canvas.DrawImage(img, rect, sampling)
The old code picked InterpolationMode.High when enlarging and Bicubic
otherwise. SkiaSharp 4.x removed SKFilterQuality entirely, so this is now
expressed with SKSamplingOptions: Mitchell cubic when enlarging, linear
with mipmaps when shrinking.
SetResolution is gone. SKBitmap carries no DPI metadata, and DPI on a
thumbnail is cosmetic.
Added ResizeToJpeg, which folds the resize-then-encode sequence that both
loaders were duplicating into one place.
Added a floor of 1px on the computed size. A source with an extreme
aspect ratio scales to zero on one axis, and a zero-sized bitmap is
invalid; previously this threw and the thumbnailer was skipped.
thumbs/ImageThumbnailLoader.cs
Image.FromStream/FromFile -> SKBitmap.Decode. Decode signals failure by
returning null rather than throwing, so an explicit null check now raises
NotSupportedException to keep the "try the next loader" behaviour in
ThumbnailMaker.GetThumbnailInternal intact.
thumbs/VideoThumbnailLoader.cs
Same decode swap, plus an explicit Seek(0) before decoding. StreamPump
leaves the output stream positioned at the end of what it wrote, and
SKBitmap.Decode reads from the current position. (FFmpeg.cs already did
this rewind explicitly for the same reason.)
JPEG quality is now specified explicitly as 85. GDI+ used its unspecified
default; the encoders differ enough that no setting reproduces the old
output byte for byte, so it is at least pinned and visible now.
Verified end to end on macOS: an 800x600 source through
ThumbnailMaker.GetThumbnail(file, 384, 216) produces a 288x216 JPEG --
correct aspect-preserving fit -- that decodes back cleanly and renders
correctly.
BinaryFormatter's implementation was removed from the runtime in .NET 9
and using it is now a build error (SYSLIB0011), not merely a warning. It
was the encoder for the file store: the media metadata and cover art that
fsserver caches in SQLite.
The compat package (System.Runtime.Serialization.Formatters plus
EnableUnsafeBinaryFormatterSerialization) does restore a working
implementation and was verified to work here, but it reintroduces exactly
the deserialization-gadget exposure the removal was meant to end. Since
the cache is derived data that can always be rebuilt by rescanning, an
explicit format is the better trade.
New: fsserver/Files/MediaSerializer.cs
A small BinaryReader/BinaryWriter format. Each blob starts with a format
version byte; file blobs then carry a one-byte kind discriminator
(audio/image/video) and the type's own fields in a fixed order. No CLR
type or assembly name appears in the payload, which is what makes it
inert on read. Helpers cover nullable strings/ints/longs, string arrays
and byte arrays, each with an explicit presence flag so null and empty
stay distinguishable.
Per-type encoding
AudioFile, ImageFile, VideoFile and Cover lose [Serializable],
ISerializable, their SerializationInfo constructors and GetObjectData,
and gain an internal Serialize(BinaryWriter) plus a
(BinaryReader, DeserializeInfo) constructor. The field lists and their
order are carried over unchanged, including AudioFile.description
deliberately not being persisted.
Two behavioural differences, both deliberate:
* ImageFile round trips properly now. Its (SerializationInfo,
StreamingContext) constructor -- the one BinaryFormatter actually
called -- chained to the base constructor and read no fields at all,
leaving initialized false. Cached image metadata was therefore
discarded on load and re-read from taglib every time. The sibling
constructor that did read the fields was unreachable. There is now one
constructor and it reads them.
* VideoFile records whether a Subtitle instance existed separately from
its text. Previously a probed-but-empty Subtitle serialized to
something that came back null, and VideoFile.Subtitle treats null as
"not looked yet" and re-runs ffmpeg. Videos without subtitles were
re-probed on every listing.
server/Types/Subtitle.cs
Added a public Text property so the video encoder can round-trip the
subtitle without reflection, and dropped [Serializable]/[NonSerialized],
which no longer mean anything.
fsserver/FileStore.cs
Uses MediaSerializer. The catch clauses now key on InvalidDataException
and EndOfStreamException where they previously keyed on
SerializationException, preserving "a bad cache entry is a debug-level
miss, not a crash". CanSerialize replaces the [Serializable] type
attribute check.
Fixed alongside: the NotSupportedException handler around cover
serialization said "Ignore and store null" but left the local cover
reference set, so a throw part way through would persist a truncated
cover blob. It now clears the local.
SCHEMA bumped 0x20160618 -> 0x20260912. Existing caches are in the old
format, and the existing schema-mismatch path deletes and recreates the
database, so stale caches are discarded automatically on first run.
Verified by round-tripping every type through the real serializer with
populated and null-heavy field sets: values, nulls, Unicode, string arrays
with null elements, and the probed-empty-subtitle case all survive
exactly. Corrupt input (bad version byte, unknown kind, truncated payload)
throws InvalidDataException or EndOfStreamException, which are precisely
the types FileStore catches. Encoded sizes are compact: 52 bytes for a
fully populated audio entry.
The solution now builds.
Everything below compiled fine against .NET Framework but is either
non-functional or removed on .NET 10. The solution now builds with zero
warnings and zero errors.
util/StreamPump.cs -- the important one
Finish() dispatched the completion callback with
callback?.BeginInvoke(this, result, callback.EndInvoke, null);
Delegate.BeginInvoke/EndInvoke are not implemented on .NET. Verified:
it throws PlatformNotSupportedException. Two call sites pass a non-null
callback, and one of them is HttpClient's response writer, so every file
served over HTTP would have failed at the point of completion. This is a
runtime failure, which is why the compiler never flagged it.
Replaced with an explicit ThreadPool.QueueUserWorkItem, preserving the
original intent of not running the callback on the completing I/O thread.
A throwing callback is now logged; previously EndInvoke would have
rethrown it on a pool thread with nothing to catch it.
Socket.UseOnlyOverlappedIO (HttpServer, HttpClient, SsdpHandler)
Deprecated and inert on .NET. Verified the setter is a silent no-op and
the property reads back false regardless. Removed rather than suppressed,
since keeping it implies an effect it does not have.
ProcessStartInfo.LoadUserProfile (FFmpeg, VideoThumbnailLoader; 4 sites)
Windows-only (CA1416) and every site assigned false, which is already the
default. Removed.
Legacy exception serialization constructors (SYSLIB0051)
HttpException, HttpStatusException and RepositoryLookupException each
carried a (SerializationInfo, StreamingContext) constructor. These exist
only to support formatter-based serialization, which went away with
BinaryFormatter. Removed. [Serializable] is kept, being conventional and
harmless on exception types.
util/ProductInformation.cs -- Assembly.CodeBase (SYSLIB0012)
.NET Framework only. Now uses Assembly.Location, falling back to
Environment.ProcessPath (Location is empty under single-file publish) and
then to the simple assembly name.
util/HttpStream.cs -- short read (CA2022)
The small-seek path did
bufferedStream.Read(buf, 0, (int)off);
discarding the return value. Stream.Read may legitimately return fewer
bytes than requested, which would leave the stream short of the seek
target and silently misalign every subsequent read. Now ReadExactly.
The WebRequest.Create obsoletion (SYSLIB0014) in the same file is
suppressed locally with a TODO rather than fixed: HttpWebRequest still
works on .NET 10, and HttpStream turns out to be unreferenced by the rest
of the solution now that the GUI is gone, so a rewrite here could not be
verified by anything.
Windows P/Invoke guards
NaturalStringComparer probed shlwapi!StrCmpLogicalW and
AddressToMacResolver called iphlpapi!SendARP, both relying on catching
DllNotFoundException on non-Windows. The fallbacks were correct, so this
was never a crash, just an exception thrown per probe and per address
resolution. Both now short-circuit on OperatingSystem.IsWindows().
Verified: clean build with no diagnostics, the serializer round-trip suite
still passes, and `sdlna --help` runs and prints its usage.
ProgramIcon sets the console window's icon through GetConsoleWindow, LoadImage and SendMessage (kernel32/user32). On macOS every startup logged a DllNotFoundException for kernel32.dll together with roughly twenty lines of dlopen probe output, since the loader reports every path it tried. Functionally harmless -- the constructor already caught and logged at debug level, and Dispose is guarded by the null window handle -- but it dominated the startup log. Now returns early unless running on Windows. Found by running the server end to end rather than by the compiler; the P/Invoke is perfectly legal to compile on any platform.
The old instructions were implicit: open sdlna.sln in Visual Studio 2013. With the conversion to SDK-style projects the build is now a plain `dotnet build`, and the resulting server runs on Linux and macOS as well as Windows, so it is worth stating. Adds a build section, run examples for both `dotnet run` and a release build, a note that video thumbnailing needs ffmpeg on PATH (image and audio handling do not), and a table of the five remaining projects.
|
For those who just need a working binary (available for Windows x64, macOS ARM64 and Linux x64/ARM64) you can get it from here: |
|
Great work! If theres no GUI anymore, might i suggest (at least) surfacing default configuration options (sorting, base directory (or directories), file types to serve) into a yml file that the executable looks for at startup? |
AddressToMacResolver formatted each octet with {:X}, which drops the
leading zero: a client at 01:AF:BC:00:0A:FF was reported as 1:AF:BC:0:A:FF.
MacAuthorizer does an exact lookup against the configured entries, which
IP.IsAcceptedMAC requires to be six two-digit groups, so any client whose
MAC contains an octet below 0x10 could never be admitted -- roughly a third
of all addresses.
This is the same one-character fix proposed upstream in nmaier#68
by @cyclamenkde; credit for spotting it is theirs. It is included here
because the configuration file added in the following commits makes MAC
restrictions a first-class, per-server setting, and shipping that on top of
a known matching bug would be misleading.
Note that the MAC lookup itself (iphlpapi!SendARP) only exists on Windows.
Elsewhere no MAC is resolved and MAC restrictions never match; that
limitation predates the port and is documented rather than changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested in review of this PR: with the GUI gone there was no way to keep
a server setup between runs short of retyping every option. sdlna now reads
~/.sdlna/config.json (%USERPROFILE%\.sdlna\config.json on Windows) and,
when started without folders, starts every server described there.
Two modes, strictly separated
sdlna <folder>... Unchanged: serves exactly those folders using only the
command-line options. The configuration file is not
read at all.
sdlna Starts the configured servers. Previously this served
the current directory, because Options.Directories
defaulted to "."; that implicit default is gone, and
"sdlna ." does what plain "sdlna" used to.
With no file, or a file with no servers, sdlna explains how to add one or
how to serve folders directly, and exits 1.
Per-server settings, like the old GUI
Each server has its own folders, media types, sort order and direction,
views, and restrictions, and all of them share one HTTP server. This is
what the console could not previously express: --seperate mounts one
server per folder but they share a single set of options and one global
authorizer, so "video for the TV, audio for everyone" needed two
processes.
Restrictions are applied per server through FileServer.Authorizer, the
mechanism the GUI used, rather than the global HttpAuthorizer that -i/-m/-u
attach to the HTTP server. As before, MediaMount always admits loopback
clients, so restrictions only constrain other machines.
A folder that is missing at startup (an unmounted drive, say) is skipped
with a warning; a server with no remaining folders is reported and the
others still start.
Process-wide settings
"port" (default 0, any free port) and "cache" (default
~/.sdlna/cache.db; the value "none" turns caching off) live in the file.
-p and -c on the command line override them. -p tracks whether it was
given, so "-p 0" can override a configured port. Logging options and
--no-rescanning apply in both modes.
-t, -v, -s, -d, -n, -i, -m, -u and --seperate describe a single ad-hoc
server. Given without folders they are now rejected with an explanation
instead of being silently ignored.
Reading and writing the file
Read through Microsoft.Extensions.Configuration.Json and the
configuration binder, written back with System.Text.Json (camelCase,
indented, via a temporary file and a move so an interrupted write cannot
truncate it). The .sdlna directory is marked hidden on Windows when
created; the leading dot already hides it elsewhere.
Two binder behaviours shaped the model, both verified empirically:
collections are initialised empty, never with defaults, because the
binder appends to an existing collection rather than replacing it; and
"[]" and a missing key both bind to null, so an empty mediaTypes or
folders list is caught by validation rather than defaulted.
Hand-edited files are checked thoroughly. Comments and trailing commas
are accepted. Unknown keys are rejected (ErrorOnUnknownConfiguration), so
a typo is reported instead of ignored. Every problem is collected and
reported at once -- missing folders or media types, unknown sort orders,
views, media types, malformed MAC or IP addresses, duplicate names -- with
a pointer to the command that opens the file. Binder and parser errors
are reworded to name the offending key and position instead of internal
type names: e.g. "unknown setting 'mediatype' in a server", "',' is an
invalid start of a property name ... BytePositionInLine: 14".
Verified against a hand-written file with three servers on one port:
- a video-only server listed only the video, not the JPEG beside it
- a server with the music view presented Albums/Genre/Performers
- a server restricted to 10.9.9.9 answered 403 to this machine's LAN
address while the unrestricted server answered 200; adding the LAN
address to its list turned the 403 into a 200
- the configured port was used, -p overrode it, the default cache was
created under ~/.sdlna, and "cache": "none" created none
- "sdlna <folder>" used a random port, no cache and none of the
configured servers
- each malformed file above produced the error described
The command to manage the file follows in the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The command-line counterpart of the old GUI's server list, operating on
~/.sdlna/config.json:
sdlna --server add <name> <folder>...
sdlna --server remove <name>
sdlna --server config [<name>]
sdlna --server config <name> --media-types set|unset video|audio|images...
--sort-order date|size|title [asc|desc]
--folders add|remove <folder>...
--views add|remove <view>...
--restrictions add|remove --mac|--ip|--user-agent <entry>... [-- ...]
sdlna --server config --edit
sdlna --server config --editor [<command>|default]
Behaviour
- add creates the file on first use, with "port": 0 and the default cache
path written out so both settings are discoverable. A new server serves
video, audio and images, sorted by title ascending, with no views and
no restrictions -- the same defaults as both the old GUI and the CLI.
(No views, rather than bytitle: neither ever applied one by default.)
- Every option given without values prints its current setting; config
<name> alone prints the whole server and config alone prints everything.
- --edit is the exception: it opens the file in the configured editor
(notepad on Windows, nano elsewhere), waits, then reports whether the
result is valid. It deliberately works even when the file is broken,
since that is exactly when it is needed; --editor given alongside is
used for that one edit.
- --edit and --editor act on the whole file, so they need no server name.
- Several options may be combined in one call. All are applied in memory
first and the file is written only if every one succeeds, so a mistake
in the third option never half-applies the first two.
Parsing
The grammar does not fit GetOptNet, so --server is recognised as the first
argument and parsed by hand. Lists run until the next option. In
--restrictions, a kind flag (--mac, --ip, --user-agent) starts a new group
under the current verb, and "--" ends a group, after which the verb may
change, e.g.
--restrictions add --mac 01:AF:BC:00:0A:FF -- --ip 192.168.1.2 -- remove --ip 10.0.0.1
Usage mistakes exit 2 with a pointer to "sdlna --server help"; invalid
values and conflicts exit 1.
Validation, mirroring what the runtime actually accepts
- Media types: at least one must remain set. "image" is accepted as an
alias for "images", since -t has always spelled it that way.
- Sort orders and views come from their repositories, so the accepted
values track the code. Removing a view by bare name ("large") removes
it whatever its options ("large:size=1000").
- Folders are made absolute and must exist when added; a server keeps at
least one. Folders are compared with symbolic links resolved in every
component, so "/tmp/x" and "/private/tmp/x" on macOS -- or a folder and
a symlink to it -- are recognised as the same folder instead of being
served twice. They are still stored exactly as given.
- MAC addresses must be six hex pairs; the dash-separated form Windows
displays is accepted and stored colon-separated in upper case.
- IP addresses are parsed and stored in canonical form.
Warnings where the runtime would surprise
- Adding a MAC entry on macOS or Linux warns that it can never match:
MAC lookup exists only on Windows, and since restrictions are an
allow-list, a MAC-only restriction there admits no remote client.
- Adding a User-Agent entry notes that it must equal the client's entire
header, case included, because UserAgentAuthorizer compares exactly.
Verified in an isolated HOME: every option's print, change and error
paths; the restriction grammar including verb switches after "--";
atomicity (a later failing option left the file byte-identical);
duplicate and missing server names; --server given after another
argument; an editor with arguments, quoted and unquoted; a missing editor;
repairing a deliberately broken file through --edit; symlinked folder
spellings; and that the written file is strict JSON. A file built entirely
through these commands was then served: its restricted server answered
403 to a LAN client, while its music-view server answered with Albums,
Genre and Performers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raised alongside the configuration request: what views do, and how to
pass them options, was not discoverable from the program itself.
--list-views
Previously one line per view taken from each class's Description, e.g.
"bytitle - Reorganizes files into folders by title" or "large - Show
only large files", with no mention that several views accept options or
how to write them.
It now separates views that reorganize from views that filter, says what
each one actually does, lists every view's options and defaults, shows
the name:key=value,key=value syntax with examples, and explains that
views apply in order. The text was written from the view implementations
rather than their descriptions, which are misleading in places:
bytitle sorts every file into A-Z folders by first letter, splitting
letters over 100 files by shared leading words (there is no
folder "by title")
flatten dissolves folders of three files or fewer into their parent
series, create a folder only for a show or site with at least two
sites entries, and collect them into A-Z folders past 50 unless
no-cascade is given
dimension min and max are the shorter and longer side; items without a
known pixel size are excluded, so it hides all audio
filter matches title or path, case-insensitively; a word with * or ?
must match the whole value instead of a substring
The filter and dimension claims were checked against a running server:
filter:beach and filter:BEA* kept only beach.jpg, dimension:min=600 kept
the 800x600 image and dropped the 640x480 one, and dimension:max=700 did
the reverse.
Views are still enumerated from ViewRepository, so one without an entry
in the help table is listed under "Other views" with its own description
rather than silently omitted.
--help
The epilog explains the two run modes: given folders, only command-line
options apply and the configuration file is ignored; without folders,
the configured servers start and only -c, -p, -l, --log-file and
--no-rescanning apply. It is written with explicit line breaks because
GetOptNet prints the epilog verbatim; the first version came out as one
unwrapped line. Also fixes "Types to serv" in the -t description.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tions
Reworks the readme around the two ways to run sdlna and adds reference
documentation for everything the previous commits introduced. The views
section in particular answers review feedback that the program's own
help did not make views understandable.
Added sections
Quick start Serving a folder once versus saving a server.
Two ways to run With and without folders, the behaviour change for
plain "sdlna" (use "sdlna ." for the old behaviour),
and a table of which options apply in each mode.
Configuring File location per platform (hidden on Windows), a
servers complete annotated example, reference tables for the
global and per-server keys with their defaults, every
--server command and option with examples, how options
combine atomically, and what is allowed when editing
by hand: comments and trailing commas (removed on the
next rewrite), unknown keys rejected, all errors
reported at once, --edit always available to recover.
Views Reorganizing versus filtering views; what each one
really produces, its options and defaults; the
name:key=value syntax; that order matters, with a
combined example for both run modes.
Restrictions The semantics that are easy to get wrong, stated
plainly: an allow-list where a client matching any
entry of any kind is admitted; local clients always
allowed; per-server for configured servers but
process-wide for -i/-m/-u; MAC matching only on
Windows, so a MAC-only restriction on Linux or macOS
admits no other machine; User-Agent matching exact and
case-sensitive.
Checked rather than assumed
- Every command example in the file was run against the build in an
isolated HOME, all succeeding. The JSON example parses as strict JSON
and passes sdlna's own validation.
- The tip for discovering a client's User-Agent was corrected after
testing. The first draft suggested any non-matching restriction would
log it, but only the authorizers for kinds a server actually has run:
with an IP-only restriction the log showed "Rejecting 192.168.20.38.
Not in IP whitelist" and nothing about the User-Agent, which appeared
only once a placeholder User-Agent entry existed. The tip now says to
add a placeholder of the kind being discovered, and to browse from
another machine, since local clients skip the check.
- View and restriction behaviour follows the implementations and the
runtime checks described in the preceding commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The description of this PR already claims these changes, but they were
committed on my fork's master together with its release workflow, and
that commit was left out of the PR as fork-specific. The code described
was therefore missing here. This ports everything from it except the
workflow and the fork's download instructions, so the branch now matches
its description.
util/FFmpeg.cs
Seed the ffmpeg search path from AppContext.BaseDirectory instead of
Assembly.Location. Under single-file publish Location is an empty,
non-null string, so the old null check passed and new FileInfo("")
threw inside FFmpeg's static initializer; every later use of FFmpeg
then raised TypeInitializationException and video thumbnails and
durations stopped working.
SkiaSharp.NativeAssets.Linux -> SkiaSharp.NativeAssets.Linux.NoDependencies
The default package links against libfontconfig1, which Linux users
would have to install. Nothing here renders text.
Directory.Build.props
Version, AssemblyVersion, FileVersion and InformationalVersion collapse
into one VersionPrefix the SDK derives the rest from, and the +<sha>
suffix is kept out of InformationalVersion. The value is 2.0.0,
reflecting the removal of the GUI; that number is a suggestion, and is
what the fork's releases have used.
util/ProductInformation.cs, sdlna/ProgramIcon.cs
Local IL3000/IL3002 suppressions, with reasons. Both sites already
behave correctly under single-file publish.
.gitattributes
Drop the rule for SimpleDLNA/Resources/LICENSE, removed with the GUI,
and treat *.slnx like *.sln.
Six of the files are taken verbatim from the fork, since this branch had
not changed them; Directory.Packages.props only swaps the SkiaSharp line,
preserving the configuration packages added earlier in this branch.
Verified on this branch: all four RIDs (win-x64, osx-arm64, linux-x64,
linux-arm64) publish self-contained and single-file with zero warnings,
including the configuration packages. The osx-arm64 binary ran
"--server add" and "--server config", started the configured server, and
served an 8198-byte JPEG video thumbnail generated through ffmpeg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the work added to nmaier#70 in response to review: 4e8f5fe Pad resolved MAC octets to two digits so MAC restrictions can match badb0c3 Run servers from a JSON configuration file when no folders are given 66e69d5 Add "sdlna --server" to manage the configuration file 32df10b Explain views and both run modes in the built-in help f75a4cc Document the configuration file, server management, views and restrictions 535b9a7 Bring the single-file and packaging fixes into this branch 535b9a7 changes nothing on master: it copies fixes that master already had into the PR branch, so those files merged without conflict. The only conflict was the readme's introduction, where master's Downloads section met the branch's new table of contents. Both are kept, with Downloads added to the contents. The merged tree differs from the PR branch only by the fork-specific release workflow and that Downloads section. [skip ci] keeps the push of this merge from publishing an automatic patch release (2.0.2). This adds a feature, so it is released as 2.1.0 by dispatching the release workflow with an explicit version instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thanks, sir :-). PR was updated with the information about the config file. I went with a JSON file instead of a YAML as dotnet does not handle YAMLs without an additional library. |
Every client was told that .mkv (and .matroska, .mk3d, .webm) files are video/x-mkv. The type Matroska documents, and that MiniDLNA and Universal Media Server both send, is video/x-matroska; clients that only recognise the latter may not list or play MKV files. nmaier#31 by Matthew1471 proposes exactly that one-line change to DlnaMaps, and this includes it. On its own, though, it risks a regression for Samsung TVs. MiniDLNA sends video/x-matroska generally but switches to video/x-mkv for Samsung clients, commenting that they expect that "[wrong]" type, and simpleDLNA -- which already advertises Samsung capabilities (sec:ProductCap) -- may well have chosen x-mkv for them. So: - video/x-matroska is the default, as in nmaier#31. - Clients whose User-Agent contains "SEC_HHP_" (Samsung TVs and players since 2010) or "SamsungWiselinkPro" (2008 Series A) keep video/x-mkv. These are the markers MiniDLNA matches Samsung clients on. Samsung therefore sees no change, and every other client gets the standard type. The MIME table reaches clients in three places, and all now go through DlnaMaps.MimeFor / ProtocolInfoFor so they cannot disagree: 1. The HTTP Content-Type of the file itself (ItemResponse). 2. protocolInfo on each item in Browse results (MediaMount_SOAP). 3. The ConnectionManager GetProtocolInfo source list, previously one precomputed string; there are now two, standard and Samsung. Browse results are cached (soapCache), keyed only by the request's SOAP parameters. That meant the first client to browse a folder decided the types everyone else was told, and testing showed exactly that: after a generic client browsed first, a Samsung client received a Content-Type of video/x-mkv but browse entries still saying video/x-matroska. The cache key now includes the client's MIME variant. Verified against a running server with an .mkv and an .mp4, as a generic client (VLC's User-Agent), a current Samsung TV ("SEC_HHP_[TV] Samsung Q70 Series") and a 2008 Samsung ("SamsungWiselinkPro"): browse protocolInfo Content-Type GetProtocolInfo generic, .mkv video/x-matroska video/x-matroska x-matroska only Samsung, .mkv video/x-mkv video/x-mkv x-mkv only any, .mp4 video/mp4 video/mp4 unchanged Browsing again as the generic client after the Samsung ones still yielded video/x-matroska, so the cache no longer leaks between them. Not verified on physical TVs; the Samsung behaviour relies on MiniDLNA's long-standing handling rather than a device test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous behaviour -- refusing to start when run without folders and
without saved servers -- contradicted the project's own "zero-config"
promise and blocked the simplest use: running sdlna in a folder. That had
been the long-standing behaviour, since Options.Directories defaulted to
"."; the configuration file commit replaced it with guidance and exit 1.
Now, when sdlna is run without folders:
- servers configured -> they start, as before.
- none configured -> the current directory is served exactly as
(no file, or a file "sdlna ." would, with every command-line
listing none) option applying, after a warning on stderr
that shows the absolute path being served,
says nothing has been saved, and gives the
command to add a server.
- file exists but is -> still an error, exit 1. Falling back would
invalid silently hide a setup the user tried to make.
Because the fallback is literally the ad-hoc mode, -t, -s, -d, -v, -n, -i,
-m, -u and --seperate apply to it; they are only rejected when configured
servers exist. That rejection message now says why ("each have their own
settings") and offers both ways forward.
The absolute path is printed rather than "." so it is obvious when sdlna
was started from somewhere unexpected, such as a home directory.
Updates the --help epilog and the readme's Quick start and "Two ways to run"
sections, which described the refusal, including its note that "sdlna ."
was needed for the old behaviour.
Verified with an isolated HOME, running from a folder holding an image, an
.mp4 and an .mkv:
no file warning; served all three
no file, -t video warning; served only the two videos
file with "servers": [] warning naming the file; served all three
file with a server no warning; the server's own settings applied
file with a server, -t video rejected, exit 2
file with "port": "oops" error naming the key, exit 1; nothing served
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the two commits added to nmaier#70: 9a992ef Announce Matroska as video/x-matroska, keeping video/x-mkv for Samsung a3b021e Serve the current directory when no servers are configured Merged without conflicts; the merged tree differs from the PR branch only by the fork-specific release workflow and the readme's Downloads section. Unlike the previous merge, this one does not suppress the release workflow: a bug fix plus restoring the long-standing default behaviour is a patch release, so the workflow's automatic bump to 2.1.1 is the intended outcome. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Browse results contain absolute URLs for each item's file and cover,
built from the local address the requesting client connected to:
http://{request.LocalEndPoint.Address}:{port}/mm-1/file/<id>/res
Those results are cached in soapCache, but the cache key did not include
that address. Whichever client browsed a folder first therefore decided
the host in every link that anyone else was given for it, until the cache
happened to be cleared.
This needs no unusual network setup. Any browse from the machine sdlna
runs on -- a local VLC, say -- connects over loopback, so TVs on the
network were then handed links to 127.0.0.1, which they cannot reach:
they could list the folder but not play anything in it. Machines with
several interfaces (a VPN, a second network card) hit the same thing
between networks.
Reproduced against the previous build by browsing first over loopback,
then over this machine's LAN address:
browsed via 127.0.0.1 -> links point to 127.0.0.1:9000
browsed via 192.168.20.38 -> links point to 127.0.0.1:9000
The cache key now includes the local endpoint, alongside the client's MIME
variant added in 9a992ef. After the change, alternating the two repeatedly
so that cache hits are exercised:
browsed via 127.0.0.1 -> links point to 127.0.0.1:9000
browsed via 192.168.20.38 -> links point to 192.168.20.38:9000
browsed via 127.0.0.1 -> links point to 127.0.0.1:9000
browsed via 192.168.20.38 -> links point to 192.168.20.38:9000
and the file and cover links given to the LAN client, fetched from the LAN
address, both returned 206 with data.
The other places that build URLs from the local address -- the device
description, subtitle links in ItemResponse, and redirects -- are produced
per request and were never cached, so they needed no change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Environment.GetFolderPath returns an empty string, not the path, when the
folder does not exist. Verified on .NET 10:
HOME=/tmp/does-not-exist
GetFolderPath(UserProfile) = ""
GetFolderPath(UserProfile, DoNotVerify) = "/tmp/does-not-exist"
A missing home directory is ordinary for exactly the places a media server
runs unattended -- service accounts, containers, NAS packages -- and two
things broke under it.
util/FFmpeg.cs: video thumbnails and durations stopped working
The static list of places to look for ffmpeg ended with
new DirectoryInfo(GetFolderPath(UserProfile)), i.e. new DirectoryInfo("").
That throws inside FFmpeg's static initializer, so every later use of
FFmpeg raised TypeInitializationException for the life of the process.
Found while testing the previous commit, when a run under a nonexistent
HOME returned 500 for every video cover:
System.TypeInitializationException: The type initializer for
'NMaier.SimpleDlna.Utilities.FFmpeg' threw an exception.
---> System.ArgumentException: The value cannot be an empty string.
at System.IO.DirectoryInfo..ctor(String path)
at NMaier.SimpleDlna.Utilities.FFmpeg..cctor()
Folders that resolve to an empty path are now skipped.
One side effect: on Linux and macOS the Program Files folders also
resolve to "", and Path.Combine("", "ffmpeg") had been producing a
relative "ffmpeg" folder, so ./ffmpeg under the working directory was
searched by accident. That is no longer searched. ffmpeg beside the
executable, in FFMPEG_HOME, under the home directory and on PATH is
still found.
sdlna/Configuration.cs: the configuration moved to the working directory
The home lookup behind ~/.sdlna had the same flaw, so the configuration
path quietly became ".sdlna/config.json" relative to wherever sdlna was
started, and "--server add" would have created .sdlna there. It now uses
SpecialFolderOption.DoNotVerify.
Verified with HOME pointing at a directory that does not exist:
before after
--server config path .sdlna/config.json /tmp/nohome-probe/.sdlna/config.json
video cover request HTTP 500 HTTP 206 with data
ffmpeg type initializer failed "Found ffmpeg at /opt/homebrew/bin/ffmpeg"
--server add (would write to the cwd) created under that HOME; nothing in the cwd
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
".webm" sat in the Matroska extension list, so WebM files were announced exactly like MKV: video/x-matroska to most clients (video/x-mkv before 9a992ef) and video/x-mkv to Samsung. WebM has its own type, video/webm, which Universal Media Server also sends, and clients that recognise WebM by it -- including those that play VP8/VP9 but not arbitrary Matroska -- may not treat these files as playable otherwise. WebM now has its own DlnaMime.VideoWEBM, extension list and MIME entry. - The new enum member is appended rather than inserted alphabetically, so the numeric values of the existing public members do not change. - DLNA defines no profile for WebM. It keeps DLNA.ORG_PN=MATROSKA, which it was announced with all along: WebM is a restricted form of Matroska, and inventing a profile name would be a new untested value. - Samsung clients keep receiving video/x-mkv for WebM, as they always have, following the same no-regression rule as 9a992ef. - With Matroska and WebM now mapping to the same MIME and profile for Samsung, the GetProtocolInfo source list would have repeated that entry; it is de-duplicated. No cache rebuild is needed: the MIME type is derived from the extension each time, never stored in the file store. Verified with a VP9 .webm and an .mkv, before and after, as a generic and a Samsung client: before after generic, .webm video/x-matroska video/webm generic, .mkv video/x-matroska video/x-matroska Samsung, .webm video/x-mkv video/x-mkv Samsung, .mkv video/x-mkv video/x-mkv in browse protocolInfo, Content-Type and GetProtocolInfo alike. The WebM file still got a 3-second duration and an 8081-byte JPEG thumbnail. Both GetProtocolInfo lists contain no duplicates (87 and 86 entries, all unique), and the file is still classed as video. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in the three commits added to nmaier#70: ab0a766 Keep cached browse results from handing out another address's links ef85321 Tolerate a home directory that does not exist 7914d57 Announce WebM as video/webm instead of as Matroska Merged without conflicts; the merged tree differs from the PR branch only by the fork-specific release workflow and the readme's Downloads section. These are bug fixes, so the release workflow's automatic patch bump to 2.1.2 is the intended outcome. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ThumbnailMaker discovers its loaders by reflection in a static initializer
and constructs each one. VideoThumbnailLoader's constructor throws
NotSupportedException("No ffmpeg available") when ffmpeg cannot be found,
and nothing caught it, so the exception escaped the static initializer and
made ThumbnailMaker itself unusable. On any machine without ffmpeg that
disabled every thumbnail -- images and audio album art too, not just video
-- although the readme says image and audio handling have no external
dependencies. This predates the migration.
Found while designing the test suite, and confirmed against a running
server with ffmpeg removed from PATH. Requesting a JPEG's thumbnail
returned HTTP 500:
System.TypeInitializationException: The type initializer for
'NMaier.SimpleDlna.Thumbnails.ThumbnailMaker' threw an exception.
---> System.NotSupportedException: No ffmpeg available
A loader whose constructor throws is now skipped and logged at info level.
The same request then returned a 665-byte JPEG, with the log saying:
INFO ThumbnailMaker - VideoThumbnailLoader is unavailable: No ffmpeg available
BuildThumbnailers now takes the candidate types as a parameter (the static
field passes the assembly's types, as before), so a test can check this
without having to hide ffmpeg from the process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now every behaviour in this PR was verified by hand: throwaway
programs, curl and SOAP requests against a running server, and reverting
fixes to watch things break. This turns that work into an xUnit test
project, so it can be repeated by anyone and run in CI.
Project setup
tests/tests.csproj xunit.v3 4.0.1, referencing every project. xUnit v3
test projects are executables.
global.json Opts "dotnet test" into Microsoft.Testing.Platform.
Required: on the .NET 10 SDK the VSTest route errors
out with "Testing with VSTest target is no longer
supported by Microsoft.Testing.Platform on .NET 10
SDK and later". With the opt-in, xunit.v3 is the only
package needed; xunit.runner.visualstudio and
Microsoft.NET.Test.Sdk are for VSTest only.
Directory.Build.props
InternalsVisibleTo for SimpleDlna.Tests, with the full
public key: every assembly is strong-named with
sdlna.key.snk, and so is the test assembly. The test
project does not get AssemblyAttributes.cs: it is not
a public API, and CLSCompliant(true) would raise
CS3016 on every [InlineData].
Two small internal seams, needed to test fixes that were otherwise
reachable only through Windows APIs or a process-wide static initializer:
- AddressToMacResolver.FormatMac, extracted from Resolve, which only
produces a MAC through iphlpapi!SendARP.
- FFmpeg.GetSpecialLocations, now internal, so a missing home directory
can be tested without restarting the process.
Tests (49)
CacheSerializationTests Audio, image and video files and covers round
trip through MediaSerializer, including nulls,
Unicode and subtitles; corrupt input raises the
exceptions FileStore treats as a cache miss.
FileStoreTests SQLite storage survives reopening, is invalidated
when a file changes, and replaces a database from
another schema.
StreamPumpTests Data is copied and the completion callback runs.
MimeTypeTests Extension-to-type mapping; Matroska and WebM
types for generic and Samsung clients; per-client
GetProtocolInfo lists without duplicates.
ViewTests filter, dimension, large and new behave as the
readme and --list-views describe; documented
views exist.
ThumbnailTests Aspect-fit JPEG thumbnails, extreme aspect ratios,
a loader that fails to construct, and video via
ffmpeg (skipped when ffmpeg is absent).
MacAddressTests Two-digit octets; normalisation and rejection of
configured addresses.
HomeDirectoryTests ffmpeg search locations with a missing home
(Linux and macOS only).
Tests that change process-wide state (console, HOME) run in a
non-parallel collection.
Checked that the regression tests detect their bugs, by reintroducing each
bug alone and running the suite:
MAC octets formatted with X MacAddressTests.ResolvedAddressesUseTwoDigitsPerOctet
thumbnail loader exception uncaught ThumbnailTests.LoaderThatCannotBeCreatedIsSkippedWithoutAffectingOthers
empty home path not skipped HomeDirectoryTests.FFmpegSearchLocationsTolerateAMissingHome
WebM announced as Matroska MimeTypeTests.GenericClientsGetStandardTypes, ...ProtocolInfoMatchesTheTypesEachClientIsTold
Samsung exception removed MimeTypeTests.SamsungClientsKeepVideoXMkvForMatroskaAndWebM
StreamPump back on BeginInvoke the test host itself crashed with an unhandled
PlatformNotSupportedException, as the server would
With the fixes in place: 49 passed, 0 failed, 0 skipped (ffmpeg present).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the configuration feature added in review, which had so far been
checked only through manual runs against a scratch HOME.
Two internal seams:
- ConfigurationStore.HomeDirectoryOverride. Tests point the store at a
scratch directory, so they never read or write the developer's real
~/.sdlna, and it works on Windows, where HOME does not redirect the
profile folder.
- Program.ChooseConfiguredServers, extracted from Main. It holds the
no-folders decision -- run the configured servers, or warn and serve
the current directory -- and takes the warning writer as a parameter,
so it can be tested without starting a server. Main behaves as before.
New tests (36, for 85 in total)
ConfigurationTests No file; saved files are strict JSON and reload with
the documented defaults; every validation problem is
reported in one message; typos are named ("unknown
setting 'mediatype' in a server"); wrong value types
name the key; broken JSON gives a position; comments,
trailing commas and spelling variants are accepted;
cache default, "none" and "~"; the folder is hidden
on Windows (skipped elsewhere); symlinked folders
compare equal.
ServerCommandTests add and remove, with their exit codes; each config
option's changes and limits; the restriction grammar
from the feature request, including switching verbs
after "--"; malformed entries; the warning about MAC
entries off Windows; nothing saved when any option
fails; printing a setting; the editor setting; --edit
running the editor and validating, including on a
broken file (using "sh -c true" as the editor, so
skipped on Windows).
StartupTests No configuration, or one with no servers, serves the
current directory with the right warning; configured
servers are chosen silently; an invalid file is an
error, not a fallback; per-server options are
recognised and process-wide ones are not; "-p 0"
still counts as given.
HomeDirectoryTests The configuration path stays under a missing HOME.
Console output and the home override are process-wide, so these join the
non-parallel collection.
Checked by breaking each behaviour alone and running the suite:
saving before every option succeeds ServerCommandTests.NothingIsSavedWhenAnyOptionFails
fallback not serving "." StartupTests.WithoutAConfigurationTheCurrentDirectoryIsServed,
...AConfigurationWithoutServersAlsoServesTheCurrentDirectory
invalid file silently bypassed StartupTests.AnInvalidConfigurationIsAnErrorRatherThanAFallback
verb unchangeable after "--" ServerCommandTests.RestrictionGroupsAreSeparatedByDoubleDash
home lookup without DoNotVerify HomeDirectoryTests.ConfigurationStaysUnderAMissingHome
unknown keys ignored ConfigurationTests.TypoInAKeyIsReportedByName (3 cases)
symlinks not resolved ConfigurationTests.FoldersReachedThroughASymlinkAreTheSameFolder,
ServerCommandTests.SameFolderThroughASymlinkIsNotAddedTwice
-t not detected as per-server StartupTests.PerServerOptionsAreRecognisedAndProcessWideOnesAreNot
With the code as committed: 84 passed, 1 skipped (the Windows-only
hidden-folder test), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The earlier tests exercise the pieces; these start the real HttpServer on a free port, mount FileServers over scratch folders and send the UPnP SOAP requests a renderer sends (ContentDirectory Browse, ConnectionManager GetProtocolInfo, plain and ranged GETs), choosing both the User-Agent and the local address each request connects through. What is covered (tests/HttpServerTests.cs): - Browse lists files with their UPnP classes (movie, musicTrack, photo), and a server's media types limit what it lists. - Files are streamed byte for byte. Every response passes through StreamPump, whose completion callback used Delegate.BeginInvoke, which throws on .NET. - Matroska and WebM types are client dependent everywhere they appear: the DIDL protocolInfo, the Content-Type of the file response and the GetProtocolInfo source list agree, for a generic client and for a Samsung one, and browsing as one client and then the other does not replay the first client's types from the browse cache. - Browse links use the address each client connected to. The browse cache used to ignore it, so a local browse sent network clients to 127.0.0.1. This uses a non-loopback address of the machine, or 127.0.0.2 on Linux where all of 127/8 is local, and is skipped when neither is available. - Restrictions refuse other machines but always admit this one (skipped without a non-loopback address). - A server set up from the configuration file (Program.SetupConfiguredServer, now internal for this) applies its own media types and restrictions, and skips a folder that does not exist instead of failing. - Image thumbnails are served; video duration and thumbnail come from ffmpeg (skipped when ffmpeg is not installed). Each of those regressions was reintroduced by hand to confirm the tests catch it: the browse cache key without the local endpoint or without the MIME variant, no Samsung MIME exception, a GetProtocolInfo that ignores the client, loopback no longer always admitted, the BeginInvoke callback, and a configured missing folder no longer skipped. All seven fail the suite. Support code: - tests/Support/DlnaClient.cs: a minimal UPnP client built on System.Net.Http. Browse returns the status, the parsed items and the raw DIDL; LinkHosts reads element text only, since namespace declarations in attributes are URIs too. The timeout is 10 seconds: the server is local, so a hung response should fail promptly rather than stall the run. - tests/Support/DlnaTestServer.cs: one HttpServer shared by the collection through a collection fixture. Mount prefixes come from a process-wide counter, so a mount's prefix is found through the index page and the friendly name in each description.xml. Tests unmount and dispose their FileServers afterwards. Production change, test only in effect: HttpServer gains an internal HttpServer(int port, bool announce) constructor; the public constructors pass true and behave exactly as before. With announce false no SsdpHandler is created. Without it the tests: - announced throwaway "test-<guid>" media servers to every TV and player on the developer's network, - needed UDP port 1900, and - spent about 3 seconds per server on dispose while SSDP drained its queue of byebye datagrams (each sent several times, 25-75 ms apart, for every mount, address and device type). The suite went from 28 seconds to about 3.5 seconds: 94 tests, 93 passed and one skipped on macOS (the Windows hidden-folder test). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FileServer.Load has always given a server the music view when it serves
only audio and has no views of its own:
if (types == DlnaMediaTypes.Audio) {
lock (ids) {
if (!ids.HasViews) {
ids.AddView("music");
}
}
}
Nothing said so. The readme described a new server as having no views, and
views as something you add, so a configured server with
"mediaTypes": ["audio"] and "views": [] browsing as Albums/Artists/Folders
rather than as its folders looked like a bug. The same happens with
sdlna -t audio <folder>.
Readme.md: the per-server table's views row and the Views section now say
that an audio-only server without views gets music, for configured servers
and the command line alike, and that any view of its own replaces it.
tests/ViewTests.cs: AudioOnlyServersWithoutViewsGetTheMusicView loads a
FileServer over a folder holding an untagged MP3, once serving audio only
and once serving audio and video, and compares their root folders: Albums
and Folders (the music view; the empty Artists, Performers and Genre folders
are pruned) against the plain "album" folder. Removing the AddView call makes
it fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the GUI gone, the configuration file added and a test suite in place,
there is a lot of knowledge about this code base that lives only in commit
messages. AGENTS.md collects it for anyone about to change the code, whether
a person or an AI coding agent (which read AGENTS.md by convention).
It covers:
- Commands: build, test (whole suite, one class, one method), run from
source, try --server against a scratch HOME without touching your own
configuration, and publish a release-style single-file binary.
- Layout: the six projects, their assemblies, dependencies and roles; the
shared Directory.Build.props and Directory.Packages.props; how a request
flows from Main through HttpServer, MediaMount, the SOAP handlers and
StreamPump; and where file types, views, the metadata cache and the
configuration file are decided.
- Code style as practised in the repository (two-space indentation, brace
placement, block namespaces, explicit usings, no nullable annotations,
field and constant naming, Logging base class, why-comments), and that the
readme and help text change together with behaviour.
- Tests: xUnit v3 on Microsoft.Testing.Platform, InternalsVisibleTo through
the strong-name key, the two serial collections and what belongs in each,
dynamic skips instead of silent passes, the Support helpers, and checking
that a regression test fails with its fix reverted.
- Traps, each of which broke something during the .NET 10 migration or
after it:
- reflection-discovered views, comparers and thumbnail loaders, which rule
out trimming and hide types whose constructors throw;
- Delegate.BeginInvoke throwing on .NET;
- Assembly.Location being empty in single-file executables;
- Environment.GetFolderPath returning "" for missing folders;
- bundled native libraries needing a writable HOME to start at all;
- Windows-only MAC lookup, hidden folder and console icon;
- SkiaSharp's per-platform native asset packages;
- the browse cache key needing every request property a response depends
on (local endpoint, MIME variant);
- the Samsung MIME exception and the three places a type must agree;
- loopback clients always being admitted, and restrictions being
alternatives;
- the implicit music view for audio-only servers;
- bumping FileStore.SCHEMA when a cached payload changes, appending to
DlnaMime, and Microsoft.Data.Sqlite's named parameters and DBNull;
- the configuration binder appending to collections and binding [] to
null, strict unknown keys, atomic writes, and HomeDirectoryOverride.
- A short checklist for before committing.
Every file, type and member the guide names was checked against the code,
and every command in it was run.
Readme.md gains a Testing section (dotnet test, what the suite does and
what it skips), the tests project in the layout table, and a link to
AGENTS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every HTTP response carried "Connection: keep-alive", set by default in
ResponseHeaders. But HttpClient only keeps a connection open when the
request itself said "Connection: keep-alive"; otherwise it closes it once
the response is written. This predates the migration.
HTTP/1.1 connections persist unless one side says "close", so a client that
doesn't ask for keep-alive, yet is told the connection stays open, reuses it.
The server then closes it without reading the next request. Whether that
request is lost depends on timing: the close happens in the response pump's
completion callback on the thread pool, and if the client sends before it
lands, the request fails. .NET's HttpClient reports
HttpRequestException: An error occurred while sending the request.
---> HttpIOException: The response ended prematurely. (ResponseEnded)
and does not retry it.
Found by the first run of the test suite in CI. On the 2-vCPU Linux runner,
with ffmpeg generating thumbnails in the background,
HttpServerTests.VideoDurationAndThumbnailComeFromFFmpeg failed that way on
the browse request that followed the description.xml fetch. It passed on
macOS and Windows, and could not be reproduced locally even with the thread
pool starved or every core busy: the close usually wins the race.
Reproduced deterministically instead, two ways:
- A minimal server that answers with "Connection: keep-alive" and closes
300 ms later: .NET's HttpClient fails the next POST on that connection
with exactly the error above.
- sdlna itself with a 300 ms sleep before HttpClient.Close(): all 10 HTTP
server tests failed with ResponseEnded. With this fix and the same sleep,
all 10 passed.
The fix
- HttpClient.SendResponse decides once whether the connection will be kept
(the request asked for keep-alive) and writes "Connection: keep-alive" or
"Connection: close" to match. The pump callback uses the same decision,
so the header and the behaviour cannot disagree.
- The header is written while building the header block instead of being set
on response.Headers, because StaticHandler hands the same response object,
such as the favicon or an error page, to every client; changing it per
request would race between clients. Any Connection header a handler sets
is replaced.
- ResponseHeaders no longer defaults Connection to keep-alive.
Clients that ask for keep-alive are unaffected. Clients that don't were
already getting a new connection for every request; now they are told so,
instead of occasionally losing a request.
Test: HttpServerTests.ConnectionHeaderSaysWhetherTheConnectionStaysOpen
opens a raw socket, sends a request asking for keep-alive (expects
"keep-alive"), then one without on the same connection (expects "close",
then end of stream). It failed before the fix with "keep-alive" where
"close" was expected. DlnaClient.RawGet sends a request over an open
stream and reads the headers and exactly Content-Length body bytes.
dotnet build: zero warnings. dotnet test, Debug and Release: 96 tests, 95
passed, 1 skipped (the Windows-only hidden-folder test), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SkiaSharp wraps Google's native Skia library, which has to ship as a
separate file beside the executable: libSkiaSharp.so, .dylib or .dll,
about 9 MB. Its packages contain no static build for desktop platforms, so
it cannot be linked into the single-file executable. ImageSharp is written
entirely in C#, so it compiles into the executable itself.
Version choice
- SixLabors.ImageSharp 3.1.12, the current 3.x release.
- ImageSharp 4.x (4.1.2) was tried and rejected: its package adds a build
step that fails Release builds with "No Six Labors license found" unless a
license key is configured. That would break CI and every contributor's
release build. Directory.Packages.props says so next to the version.
- 3.1 is under the Six Labors Split License, which grants Apache 2.0 to
software under an open-source license, as this project is. 2.1.13 is
plain Apache 2.0 but an older, slower build for .NET.
- The stb_image ports (StbImageSharp, Unlicense or MIT) were rejected: they
are compiled as unsafe pointer code, so decoding damaged or hostile files
is no safer than native code.
- NuGet's audit reports no vulnerabilities for 3.1.12. It has no package
dependencies.
Behaviour kept
- The same fit rule: scale down to fit within the requested size keeping
the aspect ratio, never enlarge, and never produce a zero-sized side.
Images are borderless; video frames are letterboxed on black. This is now
ThumbnailMaker.FitWithin.
- Transparent areas become black. JPEG has no alpha channel, and Skia drew
onto a black canvas; ImageSharp would otherwise encode whatever colour the
transparent pixels hold, so the image is drawn onto an opaque black
canvas too.
- JPEG quality 85.
- Damaged or unknown data is reported as NotSupportedException, and the
video loader still turns an undecodable ffmpeg frame into
ArgumentException.
Changes
- Only the first frame of animated images is decoded (MaxFrames = 1), since
only that frame is shown.
- Resizing uses ImageSharp's bicubic resampler throughout, where Skia used
Mitchell when enlarging and mipmapped linear when shrinking.
- Thumbnails are decoded at the size they are shown. LoadImage reads the
image header first, computes the fitted size, and passes it as
DecoderOptions.TargetSize, which makes the JPEG decoder work at a reduced
scale. Measured on a 12 MP JPEG to a 288x216 thumbnail (Release, Apple
silicon): ImageSharp 205 ms with a full decode, 77 ms with TargetSize;
SkiaSharp 74 ms. TargetSize also enlarges images smaller than the target
(a 100x50 JPEG came back 400x200), so it is only set when shrinking. The
final resize uses the size computed from the original dimensions, so
thumbnail sizes don't depend on the decoder's rounding. Streams that
can't seek are decoded in full.
Tests
- The helpers write test images with ImageSharp.
- New in ThumbnailTests:
- SmallImagesAreNotEnlarged
- TransparencyBecomesBlack: a half-transparent PNG whose transparent
pixels are white underneath
- AnimatedGifShowsItsFirstFrame
- DamagedImageIsReportedAsUnsupported
- The aspect-ratio test now also checks the thumbnail's colour.
- Checked against their bugs: a white canvas fails TransparencyBecomesBlack,
and scaling small images up fails SmallImagesAreNotEnlarged.
- The HTTP tests serve image thumbnails and ffmpeg video thumbnails,
which now decode through ImageSharp.
AGENTS.md: ImageSharp in the layout and test helpers; the SkiaSharp
native-asset trap is replaced by why ImageSharp stays on 3.1 and how
TargetSize is used.
dotnet build: zero warnings. dotnet test: 100 tests, 99 passed, 1 skipped
(the Windows-only hidden-folder test).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Microsoft.Data.Sqlite is C#, but the SQLite engine behind it is native:
SQLitePCLRaw adds libe_sqlite3.so, .dylib or e_sqlite3.dll beside the
executable. Its package has no static build for desktop platforms (only
for WebAssembly), and no maintained SQLite engine is written in C#. LiteDB
is an embedded database written entirely in C# (MIT, 5.0.21, no known
advisories), so with ImageSharp the release is now one executable.
The cache is a key/value table, so the port is direct: FileStore keeps the
same methods (MaybeGetFile, MaybeGetCover, HasCover, MaybeStoreFile) and
entries are still valid only for the file's path, size and modification
time. Caches from earlier releases are not migrated: they are discarded
and rebuilt, as a schema change always did.
LiteDB needs more care than SQLite; each point below was found by probing
LiteDB 5.0.21 before writing the store:
- Two processes, one file. LiteDB's direct mode must not have two engines
on a file, yet a second process could open the file and write to it
while the first had it open. Two sdlna processes with the same cache
would corrupt it. A lock file beside the database (cache.db.lock, taken
with FileShare.None, deleted on close) keeps a second process out; it
logs "The cache ... is not available; running without it" and serves
normally, as FileServer already did when a cache could not open.
- One process, one file. Configured servers share a cache file, so
FileStores on the same file share one reference-counted LiteDatabase.
- Collation. LiteDB stores the culture it compares with in the file,
defaulting to the current one (es-CO/IgnoreCase here). Such a file could
not be opened under invariant globalization, common in containers
("Only the invariant culture is supported"), and ids differing only in
case collided. The database is created with "/Ordinal" instead.
- Key length. LiteDB index keys are limited to 1023 bytes, which a deep
path can exceed, so documents are keyed by the SHA-256 of the path, and
the stored path is compared on read.
- Other versions. The schema number is now LiteDB's UserVersion
(0x20260916). A file that is not this version's database throws
LiteException ("File is not a valid LiteDB database format"), including
the SQLite caches of 2.1.x and earlier; it is deleted, along with
LiteDB's -log file and SQLite's -journal, -wal and -shm files, and
recreated. A file that is locked or read-only is never deleted: those
errors propagate and the server runs without a cache.
Fixed on the way
- A changed file kept its old cover. The SQL insert took the previous
cover whenever a file was stored without one (COALESCE), regardless of
the file's size and time. Metadata is often stored before the cover
loads, so after a file changed, its stale cover was stored with the new
size and time, and HasCover then stopped it ever being regenerated. The
stored cover is now kept only for the same version of the file.
- Caches were never closed at exit. HttpServer.Dispose only unregisters
its media servers, so no FileServer or FileStore was disposed. Program
now disposes the file servers it created, in both run modes. Without
that, LiteDB's log was not checkpointed and the lock file remained.
- A cache inside a served folder only ignored changes to the database
file. FileServer now also ignores LiteDB's log and the lock file, via
FileStore.IsStoreFile, so they don't trigger rescans.
The periodic vacuumer keeps its job of removing entries for files that no
longer exist (FileStore.PurgeMissingFiles), once per database file.
LiteDB reuses freed pages, so there is no VACUUM step. util/Sqlite.cs and
its code-analysis suppressions are removed.
Tests (FileStoreTests, 13; two skip on macOS)
- kept: read back after reopening; a changed file is not served
- new:
- covers are stored and read back
- storing again without the cover keeps it
- a changed file does not keep its old cover
- a 2.1.x SQLite cache is replaced
- a database from another schema is replaced
- stores on one file share one database, and the lock file goes when
the last one closes
- a cache held by another process is left untouched and raises
IOException
- long paths are stored (skipped where the file system can't hold
them, like macOS)
- paths differing only in case are separate (skipped on
case-insensitive file systems; run here on a case-sensitive APFS disk
image)
- purging removes deleted files
- the cache's own files don't count as media changes
- Each was checked against its bug, by reintroducing it alone: no lock,
the stale cover kept, the cover never kept, the raw path as id with the
default collation, the schema ignored, the database not shared, and the
purge doing nothing. All seven failed the suite. The lock test takes
the lock through the same FileStore.LockCache the store uses; with a
stricter mode of its own, the no-lock mutation had passed.
End to end, with a single-file osx-arm64 publish (the output directory
holds only the sdlna executable) and HOME pointing at a directory that
doesn't exist:
1. The 2.1.3 release wrote a SQLite cache for a JPEG, a PNG, an MP4 and
an MP3.
2. The new build logged "Recreating the cache database", served the same
titles, durations and JPEG covers, and cached 4 entries with 3 covers
(the MP3 has no art). After Ctrl+C only cache.db remained.
3. A restart reused it, without recreating it.
4. With a second process on the same cache, the second warned and still
served everything; the lock file existed only while they ran.
5. DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 opened the same cache.
6. Two configured servers sharing ~/.sdlna/cache.db both stored into it
(5 entries), and shut down cleanly.
7. No error or fatal log lines in any run, and HOME was never created.
Docs: the readme notes that only one process can use a cache file;
AGENTS.md describes the LiteDB store and its traps, and the rule that the
release has no native libraries.
dotnet build: zero warnings. dotnet test: 110 tests, 107 passed, 3 skipped
on macOS (the Windows hidden-folder test and the two file-system-dependent
store tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sdlna only handled Ctrl+C (SIGINT). Services and containers are stopped with SIGTERM (systemctl stop, docker stop, most init systems and NAS packages), and .NET's default for SIGTERM ends the process without returning to Main. Main is where the HTTP server and, since the previous commit, the file servers are disposed, so a stopped service never closed its cache: LiteDB's log was not checkpointed and the cache's lock file was left behind on Linux and macOS. Found while writing a smoke test for the release binaries: a server started in the background from a script ignores SIGINT (non-interactive shells start background jobs that way), and SIGTERM is how such a server gets stopped. Program now registers a PosixSignalRegistration for SIGTERM that cancels the default termination and releases Main the same way Ctrl+C does. Both go through RequestShutdown, which logs "Termination requested" or "Shutdown requested". The registration is kept in a static field, since collecting it would unregister the handler. Where the platform doesn't support the signal, Ctrl+C still works. The emergency exit after the fourth Ctrl+C is unchanged. Verified with a single-file osx-arm64 publish, started as a background job with a cache: kill -TERM stopped it within a second, it logged "Termination requested" and "Closed!", and only cache.db remained (no cache-log.db, no cache.db.lock). There is no unit test: that would mean starting the real server, which announces itself on the network. The release workflow's smoke test on Linux and macOS will stop the binaries this way. AGENTS.md: a trap about keeping shutdown graceful. dotnet build: zero warnings. dotnet test: 110 tests, 107 passed, 3 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An audio file without embedded album art was listed in Browse results with an albumArtURI and an upnp:icon, and fetching that link returned a 500. Reproduced with a tag-less MP3 from "ffmpeg -f lavfi -i sine=duration=2 -c:a libmp3lame song.mp3", in the 2.1.3 release and on this branch. Why the cover was listed: AudioFile.Cover reads the tags before it answers and returns null when they hold no picture, both before and after the tags are loaded, so null reliably means "no cover" for audio. MediaMount.AddCover did not check for it. It appended the albumArtURI and icon elements, then threw a NullReferenceException on c.PN while building the cover res, and its catch-all swallowed the exception. The item kept the two links and had no cover res. Why fetching it was a 500: the "cover/" handler passed the null cover to ItemResponse, whose constructor threw a NullReferenceException on item.Type. HttpClient answers any exception other than HttpStatusException with a 500. Covers of images and videos fail differently. BaseFile.Cover always returns a lazy Cover, before and after loading, and the thumbnail is only made when the cover is first fetched. When that fails (a damaged image, a video ffmpeg can't read), ThumbnailMaker throws ArgumentException, Cover.ForceLoad logs it and leaves the bytes null, and the size is null. ItemResponse then sent an empty Content-Length and the body threw NotSupportedException, which was also a 500. The next fetch tries again. The fix covers both cases: - AddCover lists nothing when the item's Cover is null. Clients take an albumArtURI as a promise of an image and fetch it, so not listing a cover the item doesn't have is the real fix. The three elements are now appended only once all of them are built, so a failure part way can't leave a dangling albumArtURI again. - The cover handler answers 404 when the item has no cover, or when its cover has no InfoSize, meaning no thumbnail could be made. Lazy covers can't be ruled out at browse time without making every thumbnail in a folder (running ffmpeg once per video), and clients keep old links, as does the server's own browse cache. A 404 says the image doesn't exist; a 500 reports a server fault. Reading InfoSize here costs nothing extra: ItemResponse read it anyway, and a loaded cover keeps its bytes. Checked against a running server with an ffmpeg MP3 without art, an ffmpeg MP4 and an unreadable .mp4. The MP3 has no cover links and its cover URL returns 404. The MP4's cover is still a JPEG. The unreadable file's cover is still listed and now returns 404 instead of 500. Tests (HttpServerTests, on the shared DlnaTestServer): - AudioWithoutArtHasNoCover: an MP3 of plain bytes has no cover link in the DIDL, and its cover URL returns 404. Before the fix it had one, and the URL returned 500. - CoverThatCannotBeMadeIsNotFound: a .jpg that isn't an image returns 404 for its cover, twice. Before the fix it returned 500. - AudioArtIsServed: an MP3 with an embedded front cover still gets a cover link that returns a JPEG. This guards against the fix hiding real art; no test covered audio art before. Test support: BrowsedItem carries the DIDL item id, so tests can build a cover URL for an item that lists none. TestMedia.WriteMp3WithArt writes silent MPEG frames by hand and tags them with TagLib, so the art test needs no encoder. AGENTS.md: a protocol trap saying a listed cover must exist, or be a 404. dotnet build: zero warnings. dotnet test: 113 tests, 110 passed, 3 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoFile and AudioFile put each file's duration into its Duration
property with TimeSpan.ToString("g"). MediaMount_SOAP copies that property
into the DIDL res@duration attribute, and "g" is culture-sensitive: on a
machine set to es-CO (or any culture that writes decimals with a comma) a
2.366 s MP3 was announced as duration="0:00:02,366" instead of
"0:00:02.366". UPnP ContentDirectory requires H+:MM:SS[.F+], with a dot.
"g" also puts the days in front once a duration reaches 24 hours, so an
audiobook of 25 h 1 min 2.5 s came out as "1:1:01:02,5", which is not that
form in any culture.
Formatting.FormatDuration (util) now formats durations with the invariant
culture as total hours, then two-digit minutes and seconds, then
milliseconds after a dot: "0:00:02.366", "25:01:02.500". Milliseconds are
always written with three digits, as MiniDLNA does, and anything below a
millisecond is truncated, so a duration never rounds up into the next
second. Both files use it. A negative duration throws; none can occur,
since durations come from TagLib or ffmpeg (under 0.1 s is dropped) or
from the cache (only positive values are read back).
The HTML index (MediaMount_HTML) shows the same Duration property, and
keeps doing so. The page is formatted on the server, so the old text
followed the server's culture, not the viewer's, and a day part such as
"1:1:01:02,5" was hard to read anyway. "25:01:02.500" reads the same
everywhere, and one property keeps the page and the DIDL in agreement.
The cached payload is unchanged (durations are stored as ticks), so
FileStore.SCHEMA stays. The response doesn't depend on anything new about
the request, so the browse cache key stays too.
Tests:
- DurationTests: FormatDuration for durations under a second, whole
minutes, 23:59:59.999, exactly a day, over four days and one tick under
an hour; negative durations are rejected; AudioFile and VideoFile give
"0:00:02.366" and "25:01:02.500" (duration set through Fields.Set); a
file without a duration has no Duration property.
- HttpServerTests.DurationIsSentWithADecimalPointInAnyCulture browses a
mounted MP3 and checks res@duration and the HTML index. Responses are
built on the server's threads, so this test also changes
CultureInfo.DefaultThreadCurrentCulture; the HTTP server collection runs
alone. Checked that the change reaches those threads: with en-US
instead of es-CO, the test passed on this es-CO machine before the fix.
- VideoDurationAndThumbnailComeFromFFmpeg now checks the duration's form
and parses it with the invariant culture; before, TimeSpan.Parse
accepted the comma form on an es-CO machine, which is why nothing
caught this.
- tests/Support/CultureScope switches to es-CO for a test and back, so the
tests fail on every machine and not only on those set to a comma
culture. When es-CO's data is missing (invariant globalization, common
in containers) the tests are skipped with a reason rather than passing
without proving anything.
Before the fix, the four AudioFile/VideoFile cases, the new HTTP test and
the tightened ffmpeg test failed ("0:00:02,366", "1:1:01:02,5",
"0:00:03"); after it they pass. With
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, DurationTests skips its 11
culture cases and passes the other 2.
AGENTS.md: a trap about keeping protocol values independent of the
machine's culture, and the new test helper.
dotnet build: zero warnings. dotnet test: 127 tests, 124 passed, 3 skipped
(the existing long-path, case-sensitivity and Windows-only skips).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The smoke test of the release binaries failed on Windows: sdlna.exe, started in the background by Git Bash, printed its first blank line and exited with status 0, with nothing else in its output. Main sets Console.TreatControlCAsInput = false before anything else. On Windows that changes the console's input mode, and throws IOException when standard input is not a console: a background job, a scheduled task, a service wrapper, or input redirected from a file. Release builds catch every exception at the end of Main and only log it through log4net, which at that point is not yet configured, so the error went nowhere and the exit status stayed 0. The same silent exit would happen for any other exception raised before logging is set up. - TreatControlCAsInput is only set when standard input is a console (Console.IsInputRedirected is false). Ctrl+C can't arrive through redirected input anyway, and the value is already false by default. - The catch-all also writes "Error: <message>" to standard error and sets exit status 1, so a failure is visible without a log. - Console titles go through SetTitle, which ignores the IOException, PlatformNotSupportedException or Win32Exception thrown when there is no console window to title. They were set at startup, when running and on shutdown, where such an exception would stop the server or its shutdown. Checked on macOS: the smoke test passes with a single-file publish of this code, and running it with standard input from /dev/null still works. The Windows smoke test in the release workflow checks the Windows case. The new catch-all output also exposed an older bug, left for a separate change: in single-file builds, "sdlna --help" and the usage printed after an unknown option fail with "The value cannot be an empty string", because GetOptNet names the program from Assembly.Location, which is empty there. 2.1.3 aborts the same way. dotnet build: zero warnings. dotnet test: 127 tests, 124 passed, 3 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In every 2.x release, "sdlna --help" failed, and so did the usage printed after an unknown option. With the startup error reporting added in the previous commit, both print "Error: The value cannot be an empty string. (Parameter 'path')". Before it, --help printed only a blank line and exited with status 0 (the error went to log4net, which was not yet configured), and an unknown option ended in an unhandled ArgumentException (exit status 134). "dotnet sdlna.dll --help" worked, because there the assembly is a file. Options derives from GetOptNet's GetOpt (4.0.8, still the same on its master branch). GetOpt.PrintUsage calls the virtual AssembleUsage, whose first step is new FileInfo((GetEntryAssembly() ?? GetCallingAssembly()).Location).Name, which names the program in the "Usage:" line. Assembly.Location is "" in a single-file executable, so FileInfo throws before anything is written. This happens even with introAndEpilogue: false, so calling the base method can't avoid it, and no attribute setting helps: UsagePrefix is a prefix style, not a name, and UsageIntro is read after the FileInfo is created. Options now overrides AssembleUsage (sdlna/OptionsUsage.cs, a new part of the now partial class) and builds the same text without the file: - The intro still comes from GetOptNet, through its protected GetUsageIntro, called with the name "sdlna", as the epilog and "sdlna --server help" already call the program. The published binary and "dotnet sdlna.dll" now both show "Usage: sdlna ...", not "sdlna.dll". - GetOptNet keeps its option list (CollectOptInfos, OptInfo) private, so the list and its layout are rebuilt from the same attributes: category filter, short and long names, help variables and their defaults (the enum list for -t), the positional parameters line, the ordinal sort and the wrapping, including GetOptNet's lines that run past the width when an option's name is long. That code is adapted from GetOptNet's GetOpt_Usage.cs and OptInfo.cs, so the file carries GetOptNet's MIT notice. It covers the attribute settings Options uses: GetOptNet's default CaseType, UsagePrefix and UsageShowAliases, arrays but no lists or counted arguments, and no commands. - Help variables are upper-cased in the invariant culture. GetOptNet uses the current UI culture, which turns "file" into "FİLE" in Turkish. - The option help texts and the epilog are unchanged. At the same width, the published binary's --help and unknown-option output are byte-for-byte what the framework-dependent build printed before, apart from the program's name. Replacing GetOptNet was the alternative. Parsing works, and a new parser would risk changing things no test covers today: case-insensitive long options, --opt=value, the -t enum values and the "what" alias, repeated array options, and the error messages. A new parser would also bring its own help layout. Tests (tests/UsageTests.cs, in the process-state collection): - UsageIsPrintedWhenTheProgramHasNoFile loads sdlna.dll from bytes, which gives an assembly whose Location is "" as in a single-file build, makes it the entry assembly with Assembly.SetEntryAssembly, and calls PrintUsage with the console captured. Without the override, it fails with the release builds' ArgumentException. - UsageLooksAsGetOptNetShowsIt compares the override with GetOptNet's own rendering (Options.AssembleGetOptNetUsage calls the base method; the test host's entry assembly has a file). It covers widths 20 to 200, variable-width fonts, no intro or epilog, and the Basic and Advanced categories. Only the program's name may differ, so an option or attribute change that the rebuilt list doesn't handle fails here. Without the override, 6 of its 7 cases fail on the name. AGENTS.md's trap about Assembly.Location now mentions GetOptNet. Checked with a single-file osx-arm64 publish (self-contained, untrimmed): --help, -h and --bogus print the usage; --version, --license, --list-views, --list-sort-orders and "--server help" work as well. Two older problems remain, both also in framework-dependent builds, left for separate changes. "-?", listed as the short form of --help, is never recognised, because GetOptNet's short-option pattern only accepts word characters after the dash; sdlna then tries to serve a folder named "-?". And an unknown option still exits with status 0. dotnet build: zero warnings. dotnet test: 135 tests, 132 passed, 3 skipped (long paths, case-sensitive file system and the Windows hidden attribute, all platform-dependent on macOS). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems noted in the previous commit, both in framework-dependent
builds too: "sdlna -?" never printed help, although usage lists
"-?, --help", and an unknown or invalid option exited with status 0.
GetOptNet 4.0.8 reads an argument as short options only when it matches
^\s*-([\w\d].*?)\s*$, so a letter or digit must follow the dash. "-?"
was therefore a positional parameter, a folder to serve: "sdlna -?"
started the HTTP server and SSDP, logged "Mounting FileServer for -?
(1)", then failed with "The directory name '.../-?' does not exist" (an
unhandled exception in Debug builds, status 134). "-h" worked. GetOptNet
registers '?' all the same, so only a group such as "-d?" reached it.
Main now parses with Options.ParseCommandLine (sdlna/OptionsParsing.cs),
which replaces "-?" with "--help" wherever GetOptNet would have taken it
for a folder, before calling GetOptNet's Parse:
- Not after "--", after which GetOptNet reads every argument as a
folder, so "sdlna -- -?" still serves a folder named "-?".
- Not as the value of a short option, so "-n -?" still names the server
"-?". GetOptNet gives the next argument to an option that ends its
group and takes a value ("-n", "-dn"). The translation does the same,
reading the short names of the non-flag options from the attributes
GetOptNet reads. The walk over Options' fields and properties is now
shared with OptionsUsage.
- "/?" is replaced too, on Windows only: Windows programs read it as
help, and no Windows folder can be named "?". Elsewhere "/?" is a path
like any other, and Options accepts only dash prefixes.
The alternative was to advertise 'h' as the short option instead. That
changes the usage text and the Readme, and "-?", the spelling many
Windows and other users try first, would still be served as a folder and
fail confusingly. The help output is unchanged; "-?" now prints exactly
what "--help" prints.
Main's GetOptException handler printed the error and the usage but never
set Environment.ExitCode, so scripts and service managers took a
mistyped option for success. It now sets 2, the status "--server"
misuse already exits with. Status 1 stays for failures: an invalid
configuration file or, in Release builds, any unexpected exception.
Nothing depended on status 0: the release smoke test
(.github/smoke/run.sh on master) runs only a valid command line, and the
workflow runs sdlna only through it.
Trying invalid values found one that still wasn't a usage error. For
int values GetOptNet calls int.Parse and wraps its FormatException ("-p
abc") or OverflowException ("-p 99999999999") in a
ProgrammingErrorException. That exception is meant for mistakes in the
options class and isn't a GetOptException, so Debug builds ended with an
unhandled exception (status 134), and Release builds printed "Error: The
input string 'abc' was not in a correct format." without usage and
exited with 1. ParseCommandLine turns those two into a GetOptException,
"Invalid value: <message>", so they print usage and exit with 2 as well.
Other ProgrammingErrorExceptions still mean a bug and are not caught.
Main is internal now, so tests can run it in-process for command lines
that end before serving. The Ctrl+C and SIGTERM handlers, and the
Windows console input mode, are now set up after the options that only
print something (--help, --version, --license, --list-views,
--list-sort-orders), just before serving, so those runs leave no
handlers behind in the test host. With the published binary, SIGTERM
still stops a server gracefully ("Termination requested", status 0).
Tests (tests/CommandLineTests.cs, in the process-state collection):
- HelpIsPrintedForEachOfItsNames runs Main with --help, -h and -?.
SlashQuestionMarkPrintsHelpOnWindows does the same with /? on Windows
and is skipped elsewhere.
- SlashQuestionMarkIsHelpOnlyOnWindows and
QuestionMarkIsHelpOnlyWhereAnOptionCanBe cover the translation: -?
after a folder, after a flag, after an option's value, with spaces
around it, after "--", as the value of "-n" and "-dn", after "-nd"
(whose value is "d") and after "-n --" (whose value is "--").
- QuestionMarkAfterAShortOptionIsReadAsGetOptNetReadsDashH checks which
short options the translation thinks take a value against GetOptNet
itself: for every letter and digit that names an option, "-?" after it
must become "--help" exactly when GetOptNet reads "-h" after it as an
option. Options added later are checked too.
- UsageErrorsExitWithStatus2 runs Main with an unknown long and short
option, missing values, a value given to a flag, and rejected values
(-t bogus, -p 70000, -i x). AnUnreadableNumberIsAUsageError uses
-p abc and --port=99999999999. Each must print "Error: ..." and the
usage, and exit with 2.
- tests/Support/ProgramRunner.cs runs Main with the console captured and
returns the exit code it set, then restores the test host's. The
console capture moved from ServerCommandRunner to
CommandResult.Capture, which both use.
Each part of the fix was reverted in turn, and each revert fails tests.
Without the status: the 10 usage error cases. Without the translation:
the -? cases (Main then starts a server and throws, as the bug did).
Treating every short option as a flag, or every one as taking a value,
or reading options after "--": the translation tests. Without the
conversion: the unreadable numbers.
Readme.md names -h, -? and, on Windows, /? beside --help, and documents
status 2. AGENTS.md mentions ParseCommandLine and status 2 in the
request flow, lists ProgramRunner among the helpers, and adds a trap
about the command-line arguments GetOptNet misreads.
Checked with a single-file osx-arm64 publish (self-contained,
untrimmed): -?, -h and --help print the same usage and exit with 0,
"-d -?" prints usage, and "-n -? --list-sort-orders" lists the sort
orders. --bogus, -t bogus, -p abc and -p 99999999999 print the error and
usage and exit with 2. --version still works.
dotnet build: zero warnings. dotnet test, Debug and Release: 152 tests,
148 passed, 4 skipped (long paths, case-sensitive file system, the
Windows hidden attribute and the new Windows-only /? test, all
platform-dependent on macOS).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release run for 74d522b (run 35186240720) failed on the macOS runner only: five HttpServerTests found each file listed twice, with the same id ("Assert.Single() Failure: The collection contained 2 items"). The release was withheld. Linux and Windows passed, as did macOS in earlier runs and locally. FileServer.Load enables its FileSystemWatchers whether or not rescanning is on, and a Created event goes to HandleFileAdded. That method looked the file up, logged "Did find an existing ..." when it was already listed, and then added it anyway. On macOS a new watcher also reports files created just before it started, and the tests write their files right before mounting, so sometimes the watcher added files the scan had already listed. Outside the tests the same happens whenever the watcher reports a listed file again: a file replaced in place, or an event racing the scan. This predates the migration. HandleFileAdded now removes the listed entry before adding the new one, as the Changed case already does through HandleFileDeleted, so the file is listed once and with its current metadata. The folder check now comes first, so nothing is removed when there is no folder to add to. OnChanged, the watcher's handler, is now internal so tests can deliver events. Tests (tests/FileServerTests.cs): - WatcherReportingAListedFileDoesNotListItTwice delivers two Created events for a file the server already listed: it is listed once. - FileCreatedAfterLoadingIsListed creates a file after loading and delivers its Created event: both files are listed, once each. Without the fix both fail. The second fails even though it delivers only one event, because macOS's watcher reported the new file as well ("clip" listed twice), which is the race the release run hit. dotnet build: zero warnings. dotnet test: 154 tests, 150 passed, 4 skipped on macOS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migrates the solution from .NET Framework 4.5.1 to .NET 10, so that it builds
with the
dotnetCLI and runs on Windows, Linux and macOS from a singlecodebase.
The server core needed remarkably little work. The HTTP and SSDP stacks are
hand-rolled on raw
TcpListener/UdpClient— noHttpListener, no WCF, noASP.NET — so they ported without changes. Almost all the effort went into the
four dependencies that genuinely do not exist on modern .NET.
Updated after review: the branch now also adds a JSON configuration file and
an
sdlna --servercommand, so servers can be saved and started with a plainsdlnanow that the GUI is gone. See Configuration file.Updated again: the branch now has a test suite (154 tests, a few seconds)
covering the migration's fixes, the configuration feature and the HTTP server
end to end, plus an
AGENTS.mdguide for people and coding agents changing thecode. See Tests and AGENTS.md.
Updated a third time: no native libraries. SkiaSharp and SQLite shipped
native files that had to sit beside a single-file executable. They are now
replaced by ImageSharp and
LiteDB, both written in C#, so publishing gives
one executable per platform and nothing else. A few more bugs came up along
the way and are fixed; see Bugs found and fixed.
Contents
Scope and breaking changes
The WinForms GUI is removed
This is the one change that is a product decision rather than a technical
necessity, so please weigh it first.
Removed:
SimpleDLNA/NMaier.Windows.Forms/setup/setup.vdprojNgenInstaller.csSystem.Configuration.InstallinstallerReasoning:
net10.0-windowstarget and runs only onWindows. Keeping it forces a multi-targeted solution where most of the tree
is cross-platform and one corner is not.
setup.vdprojis a Visual Studio Installer project. That project type wasremoved from Visual Studio years ago and has no
msbuild/dotnetequivalent, so it has been unbuildable for a long time regardless of this
work.
NgenInstallerandSimpleDLNA/PathEnvironmentInstallerboth derive fromSystem.Configuration.Install.Installer, which has no counterpart in modern.NET. Their jobs — ngen warmup and PATH registration — were artifacts of the
MSI install flow that goes away with
setup.vdproj.Nothing else referenced them:
sdlnadepends only onfsserver,serverandutil, andNMaier.Windows.Formswas consumed solely by the GUI.If you would rather keep the GUI, say so and I will restore both projects on
a
net10.0-windowstarget withEnableWindowsTargetingso they still compilefrom a non-Windows host. That is a contained change; I left it out to keep
this PR to one coherent story rather than to foreclose the option.
Since this PR was opened, the GUI's central capability is covered on every
platform by the configuration file: several named
servers, each with its own folders, media types, sort order, views and
restrictions, saved between runs. Not covered: the tray icon, single-instance
handling, and starting with Windows.
Other user-visible changes
SQLite, and its entries use a new encoding (see
BinaryFormatter below). An
old cache is detected, deleted and recreated automatically, so the first run
after upgrading rescans.
sdlnausing the same cache filelogs a warning and runs without a cache, instead of sharing it.
stop programs that way.
sdlnawithout folders starts the configured servers when thereare any. Otherwise it serves the current directory as it always has, after a
warning that explains how to save servers.
AssemblyVersionis no longer a wildcard.1.2.*is incompatible withdeterministic builds; the version is now pinned and lives in a single
VersionPrefixproperty. It is set to 2.0.0 to reflect the removal of theGUI; change it as you see fit.
encoder. Quality is now stated explicitly (85) rather than left to an
unspecified default.
Configuration file
Requested in review of this PR. With no folders on the command line,
sdlnanowstarts every server in
~/.sdlna/config.json(%USERPROFILE%\.sdlna\config.jsonon Windows, where the folder is created hidden). With folders, it behaves
exactly as before and does not read the file. With no servers configured, it
keeps its zero-config behaviour and serves the current directory, printing a
warning that shows the path being served and how to save servers.
Every server gets its own folders, media types, sort order, views and
restrictions, and all of them share one HTTP server. That is the GUI's model,
and the command line could not express it before:
--seperatemounts one serverper folder, but they all share one set of options.
{ "port": 0, "cache": "/home/me/.sdlna/cache.db", "servers": [ { "name": "Movies", "folders": ["/home/me/Movies"], "mediaTypes": ["video"], "sortOrder": "title", "sortDirection": "asc", "views": ["series"], "restrictions": { "macs": [], "ips": ["192.168.1.20"], "userAgents": [] } } ] }The file is managed with
sdlna --server:add <name> <folder>...remove <name>config [<name>]config <name> --media-types set|unset <type>...video,audio,images; at least one stays setconfig <name> --sort-order date|size|title [asc|desc]config <name> --folders add|remove <folder>...config <name> --views add|remove <view>...-v, applied in orderconfig <name> --restrictions add|remove --mac|--ip|--user-agent <entry>... [-- ...]--separates groups and lets you switch between add and removeconfig --edit,config --editor [<command>]notepadornano, or a chosen editor, then validates itEach option given without values prints its current setting.
Design points:
Microsoft.Extensions.Configuration.Jsonand writtenback with
System.Text.Json, via a temporary file and a move.saved unless all of them succeed.
port(default 0) andcache(default~/.sdlna/cache.db,"none"todisable) live in the file, and
-p/-coverride them. Per-server flags suchas
-tor-v, given while configured servers exist, are rejected with anexplanation instead of being silently ignored. When the current directory is
served instead, every flag applies.
rejected so typos surface, and every problem is reported at once, naming the
server and key.
--editstill works on a broken file./tmp/xand/private/tmp/xon macOS, ora symlink, are recognised as the same folder rather than served twice.
FileServer.Authorizeras in the GUI.--serverwarns when a MAC entry is added on Linux or macOS, where MAC lookupdoes not exist, and notes that User-Agent entries must match the whole header
exactly.
--list-viewswas also rewritten. It used to print one vague line per view anddid not mention options; it now explains what each view produces, its options
and defaults, and that views apply in order. The readme documents all of this.
Build system
The projects move from the VS2013-era non-SDK format to SDK-style, targeting
net10.0.Removed
.nuget/{NuGet.exe,NuGet.targets,NuGet.Config}— the pre-automatic-restorebootstrap;
dotnet restorehandles this now*/packages.config(5) — superseded byPackageReference*/Properties/AssemblyInfo.cs(5) andGlobalAssemblyInfo.cs— the SDKgenerates assembly info from MSBuild properties
util/App.config,sdlna/app.config— contained only<supportedRuntime>for the .NET Framework loader
sdlna.ruleset— a legacy FxCop ruleset(
Microsoft.Analyzers.ManagedCodeAnalysis), referenced withRunCodeAnalysis=false; inert under Roslyn analyzers*/sdlna.key.snk(5) — all byte-identical to the copy at the repositoryroot, which is now the single signing key
Added
Directory.Build.props— target framework, assembly identity (formerlyGlobalAssemblyInfo.cs) and strong-naming, shared by all projectsDirectory.Packages.props— central package management; versions live in oneplace and projects reference packages by name
AssemblyAttributes.cs—ComVisible(false)andCLSCompliant(true), thetwo attributes with no MSBuild property equivalent
sdlna.slnx— the .NET 10 SDK emits the new XML solution format by defaultStrong naming is preserved. Five projects remain, down from eight.
builds clean with zero warnings and zero errors.
Dependencies
No native libraries. Every dependency is now managed code, so a
single-file publish is one executable with nothing beside it. The branch
first moved to Microsoft.Data.Sqlite and SkiaSharp (
f5f7086,4331644).Both still need native files (
e_sqlite3,libSkiaSharp), and neitherpackage has a static build for desktop platforms, so they were replaced in
28ea512ande65fb9d. The first two sections describe the final state.System.Data.SQLite → LiteDB
System.Data.SQLite.Corecompiles againstnet10.0but ships no nativeinterop binary for several modern RIDs. On
osx-arm64it fails at runtime:Microsoft.Data.Sqlite runs everywhere, but its engine is the native
e_sqlite3library, which has to ship beside the executable, and nomaintained SQLite engine is written in C#. The cache is a key/value table
(path, size and modification time mapped to a metadata blob and a cover), so
it now lives in LiteDB 5.0.21, an embedded
database written in C# (MIT).
util/Sqlite.csis gone, and so is theMono-only
util/SystemInformation.cs.LiteDB needs more care than SQLite. Each point was found by probing it
before writing the store:
process open and write the same file, which corrupts it. A lock file beside
the database keeps a second
sdlnaout; it runs without a cache.Servers in one process share one database per file.
and such a file can't be opened under .NET's invariant globalization
(common in containers). Case-only differences also collided. The database
is created with an ordinal collation.
exceed, so entries are keyed by a SHA-256 of the path.
UserVersion. Anythingelse, including SQLite caches from before, is deleted and recreated, but
never a file that is merely locked or read-only.
The periodic cleanup still removes entries for deleted files. LiteDB reuses
freed pages, so there is no VACUUM.
System.Drawing → ImageSharp
System.Drawing.Commonis Windows-only from .NET 7 onward; the Unixcompatibility switch that existed in .NET 6 was removed. On macOS, constructing
a
Bitmapunder .NET 10 throws:Thumbnails now use ImageSharp
3.1.12, which is written in C#. The choice among managed libraries:
Release builds unless a Six Labors license key is configured, which would
break every contributor's and CI's release build.
Directory.Packages.propsnotes why the version is held at 3.1.
Apache 2.0 to software under an open-source license, as this project is.
(SkiaSharp was the first choice because of that license; it is MIT, but
needs its native library beside the executable.)
as unsafe pointer code, so decoding damaged or hostile files is no safer
than native code.
The behaviour is kept: fit within the requested size keeping the aspect
ratio, never enlarge, never produce a zero-sized side; video frames
letterboxed on black; JPEG quality 85. Images are drawn onto an opaque black
canvas, so transparent areas are black, as they were with GDI+ and Skia.
Only the first frame of an animated image is decoded.
Thumbnails are decoded at the size they are shown: the header is read first,
and the fitted size is passed as
DecoderOptions.TargetSize, so JPEGs decodeat a reduced scale. On a 12 MP photo (Release, Apple silicon) that takes
77 ms, against 205 ms for a full decode and 74 ms with SkiaSharp.
TargetSizealso enlarges smaller images, so it is only used whenshrinking.
SetResolutionis gone; DPI on a thumbnail is cosmetic.Note
util/FFmpeg.csneeded nothing here: it only usedSystem.Drawing.Size,which lives in
System.Drawing.Primitivesand is cross-platform.BinaryFormatter → an explicit binary format
BinaryFormatter's implementation was removed from the runtime in .NET 9 andusing it is now a build error (SYSLIB0011), not a warning. It was the
encoder for the file store — the media metadata and cover art that
fsserverkeeps in its cache.
The compatibility package (
System.Runtime.Serialization.FormattersplusEnableUnsafeBinaryFormatterSerialization) does restore a workingimplementation, and I verified it works. I did not take that route: it
reintroduces exactly the deserialization-gadget exposure the removal was meant
to end, and the cache is derived data that can always be rebuilt by rescanning.
New
fsserver/Files/MediaSerializer.csimplements a smallBinaryReader/BinaryWriterformat. Each blob opens with a format versionbyte; file blobs then carry a one-byte kind discriminator (audio/image/video)
and the type's fields in fixed order. No CLR type or assembly name appears in
the payload, which is what makes it inert on read. Helpers cover nullable
strings/ints/longs, string arrays and byte arrays, each with an explicit
presence flag so
nulland empty stay distinguishable.AudioFile,ImageFile,VideoFileandCoverlose[Serializable],ISerializable, theirSerializationInfoconstructors andGetObjectData,and gain an internal
Serialize(BinaryWriter)plus a(BinaryReader, DeserializeInfo)constructor. Field lists and ordering carryover unchanged — including
AudioFile.descriptiondeliberately not beingpersisted, matching the original.
Encoded output is also markedly smaller: 52 bytes for a fully populated
audio entry, against the several hundred BinaryFormatter spent on type names.
log4net 2.0.5 → 3.4.0, and others
taglib)log4net 3.4.0 was chosen over the API-identical 2.0.17 because 2.0.17 carries a
known moderate-severity advisory
(GHSA-4f7c-pmjv-c25w). The
3.x API surface this project uses —
BasicConfigurator.Configure(appender…),LogManager.GetRepository(),ConsoleAppender,RollingFileAppender,PatternLayout, and the customLevel.NoticeviaILog.Logger.Log— compilesand runs unchanged.
EntityFramework and EntityFramework.SqlServer were referenced by
utilbut nosource file in the repository used them.
Bugs found and fixed
Several of these predate the migration and were surfaced by it. They are called
out individually because they change runtime behaviour beyond a straight port.
StreamPumpused delegateBeginInvoke— every HTTP response would failThe most serious one, and invisible to the compiler.
StreamPump.Finishdispatched its completion callback with:Delegate.BeginInvoke/EndInvokeare not implemented on .NET; they throwPlatformNotSupportedException. Two call sites pass a non-null callback, andone of them is
HttpClient's response writer — so every file served overHTTP would have failed at the point of completion. It compiles perfectly.
Replaced with an explicit
ThreadPool.QueueUserWorkItem, preserving the intentof not running the callback on the completing I/O thread. A throwing callback
is now logged; previously
EndInvokewould have rethrown it on a pool threadwith nothing to catch it.
ImageFilenever restored its cached metadataImageFile's(SerializationInfo, StreamingContext)constructor — the oneBinaryFormatteractually called — chained to the base constructor and read nofields at all, leaving
initializedfalse. Cached image metadata was thereforediscarded on load and re-read from taglib every time. The sibling constructor
that did read the fields was unreachable. There is now one constructor and it
reads them.
Videos without subtitles were re-probed on every listing
VideoFileserialized itsSubtitleobject directly. A probed-but-emptySubtitlecame back asnull, andVideoFile.Subtitletreatsnullas "notlooked yet" and re-runs ffmpeg. The new format records the presence of the
Subtitleinstance separately from its text, so "already looked, found none"survives a round trip.
A truncated cover blob could be persisted
FileStore.MaybeStoreFile'sNotSupportedExceptionhandler is commented"Ignore and store null", but it left the local
coverreference set — so athrow part way through serialization persisted a partial blob. It now clears
the local.
Assembly.Locationis"", notnull, under single-file publishFFmpeg.FindExecutableseeded its search path with:Under single-file publish
Locationreturns an empty but non-null string,so the guard passes and
FileInfothrowsArgumentException. That happensinside the static initializer for
FFmpegExecutable, so every later touch ofFFmpegraisesTypeInitializationException— andThumbnailMaker.BuildThumbnailershas no try/catch around its constructorinvocation. Video thumbnails and duration detection would have been dead in any
single-file build. Now uses
AppContext.BaseDirectory.Short read in
HttpStreamThe small-seek path called
bufferedStream.Read(buf, 0, (int)off)anddiscarded the return value.
Stream.Readmay return fewer bytes thanrequested, leaving the stream short of the seek target and silently
misaligning every subsequent read. Now
ReadExactly.MAC restrictions failed for about a third of addresses
AddressToMacResolverformatted each octet with{:X}, dropping leading zeros,so a client at
01:AF:BC:00:0A:FFwas reported as1:AF:BC:0:A:FFand nevermatched a configured entry. This is the same one-character fix as #68 (by
cyclamenkde), whose credit it is. It is included here because the
configuration file makes MAC restrictions a first-class setting.
MKV files were announced as
video/x-mkvThe standard type is
video/x-matroska, used by Matroska itself, MiniDLNA andUniversal Media Server, and it is now the default. This is #31 (by
Matthew1471). On its own, though, that change risks breaking Samsung TVs:
MiniDLNA switches to
video/x-mkvfor Samsung clients because they expect it.So Samsung clients, identified by the same User-Agent markers MiniDLNA uses
(
SEC_HHP_,SamsungWiselinkPro), keep receivingvideo/x-mkv, and see nochange.
The type reaches clients in three places: the file's
Content-Type,protocolInfoin browse results, and theGetProtocolInfosource list. Allthree now go through one function so they cannot disagree. Browse results are
cached by their SOAP parameters alone, so whichever client browsed first
decided the types everyone else saw. Testing caught a Samsung client receiving
the right
Content-Typebut browse entries built for a previous client, so thecache key now includes the client's variant.
WebM files were announced as Matroska
.webmwas in the Matroska extension list, so WebM files got Matroska's MIMEtype. They now get
video/webm, which Universal Media Server also sends, via anew
DlnaMime.VideoWEBM. It is appended to the enum, so existing member valuesdo not change. WebM keeps the
MATROSKADLNA profile it always had, since DLNAdefines none for WebM. Samsung clients keep receiving
video/x-mkvfor it, asbefore.
Cached browse results could hand out unreachable links
Browse results contain absolute links for each file and cover, built from the
local address the client connected to, and those results are cached. The cache
key did not include that address, so whichever client browsed a folder first
decided the host in everyone's links. No unusual network is needed to hit this:
after anything on the same machine browses the server over loopback, TVs on the
network are handed
127.0.0.1links. They can list the folder but not playanything in it.
Reproduced by browsing over loopback and then over the LAN address: both got
127.0.0.1:9000links. The cache key now includes the local endpoint. After thefix, each client gets its own address, including on cache hits, and the LAN
client's links download successfully.
A missing home directory disabled video thumbnails
Environment.GetFolderPathreturns""for a folder that does not exist, andthe ffmpeg search list passed that straight to
new DirectoryInfo(""). Thatthrows inside
FFmpeg's static initializer, disabling video thumbnails anddurations for the life of the process. This is common for service accounts,
containers and NAS packages. Empty folders are now skipped.
The configuration file's home lookup had the same flaw, which would have
silently put
.sdlnain the working directory; it now usesDoNotVerify. WithHOMEpointing at a nonexistent directory, the video cover that returned 500now returns the image, and the configuration path is under that
HOME.Image thumbnails needed ffmpeg
Not caused by the migration, but found while writing the tests.
ThumbnailMakerdiscovers its loaders by reflection in a static initializer and constructs each
one.
VideoThumbnailLoader's constructor throwsNotSupportedException("No ffmpeg available")when ffmpeg is missing, and nothing caught it, soThumbnailMakeritself failed to initialise. On any machine without ffmpeg,that disabled every thumbnail, images and album art included, although the
readme says image and audio handling have no external dependencies. With ffmpeg
removed from
PATH, a JPEG's thumbnail returned HTTP 500(
TypeInitializationException).A loader whose constructor throws is now skipped and logged at info level
(
VideoThumbnailLoader is unavailable: No ffmpeg available). The same requestthen returned the JPEG thumbnail.
Responses said "keep-alive" on connections about to be closed
Found when the test suite first ran in CI, and predating the migration. Every
response carried
Connection: keep-alive, but the server closes theconnection after responding unless the request itself asked for keep-alive.
HTTP/1.1 connections persist unless one side says
close, so a client thatbelieves the header reuses the connection. If it sends its next request before
the close lands (the close happens on a thread-pool callback), that request
is lost. .NET's
HttpClientreports "The response ended prematurely" anddoesn't retry.
It needed a busy machine to show up: it failed once on a 2-vCPU Linux runner
with ffmpeg running in the background, and never on macOS or Windows. It is
deterministic with a 300 ms delay before the close: all 10 HTTP server tests
then failed, and with the fix all 10 pass.
The server now sends
Connection: closewhen it is going to close andkeep-aliveonly when it will keep the connection, from the same decisionthat drives the close. The header is written as the response goes out,
because static and error responses are shared objects. Clients that ask for
keep-alive see no change.
A changed file kept its old cover
The SQL insert kept the previous cover whenever a file was stored without
one, regardless of the file's size and time. Metadata is often stored before
the cover loads, so after a file changed, its old cover was stored with the
new size and time and never regenerated. The stored cover is now kept only
for the same version of the file.
Caches were never closed at exit, and SIGTERM skipped shutdown
HttpServer.Disposeonly unregisters its media servers, so no file server orcache was ever disposed.
Programnow disposes the file servers it created.SIGTERM, which services and containers use to stop programs, ended the
process without that shutdown; it is now handled like Ctrl+C.
sdlna exited silently on Windows without a console for input
MainsetConsole.TreatControlCAsInput, which throws on Windows whenstandard input is not a console (a background job, a scheduled task, a
service wrapper). Release builds caught the exception and only logged it,
before logging was set up, so
sdlna.exeexited with status 0 and nooutput. The input mode is now only set with a console. Errors from that
early stage are printed and end with status 1, and console titles tolerate
having no console. Found by running the published binary on a Windows CI
runner.
Audio without art was listed with a cover that returned 500
A browse listed an album-art link for audio files with no embedded picture,
and fetching it was an internal server error. Such items now list no cover,
and a missing cover is answered with 404.
Durations used the machine's decimal separator
TimeSpan.ToString("g")is culture-sensitive, so on a machine that writesdecimals with a comma, durations were sent as
0:00:02,366, and durationsof a day or more got a day prefix. They are now always in the UPnP form,
H+:MM:SS.mmm.sdlna --helpfailed in single-file buildsGetOptNet names the program in its usage line from the entry assembly's
file,
Assembly.Location, which is empty in a single-file executable. So--help, and the usage printed after an unknown option, failed in everysingle-file build (framework-dependent
dotnet sdlna.dllworked).Optionsnow builds the same usage text itself, calling the programsdlna. GetOptNet keeps its option list private, so that part is adaptedfrom GetOptNet's code (MIT notice in
sdlna/OptionsUsage.cs). A testcompares the result with GetOptNet's own rendering across widths and
categories, so an option change the copy doesn't handle is caught.
-?served a folder named "-?", and usage errors exited with 0The usage lists
-?, --help, but GetOptNet only reads a letter or digitafter a dash as an option, so
sdlna -?started the server for a foldernamed
-?. The command line now reads-?(and/?on Windows) as--helpwherever GetOptNet would have taken it for a folder. It stays afolder after
--, and stays a value after an option that takes one; atest checks that rule against GetOptNet for every short option.
Unknown or invalid options printed the usage but exited with status 0, so
scripts and service managers took them for success. They now exit with 2,
as
--servermisuse already did. An unreadable number (-p abc) is nowa usage error too, instead of an internal one.
Files reported again by the watcher were listed twice
A file-system "created" event for a file already listed added it a second
time. On macOS a new watcher also reports files created just before it
started, so files could appear twice; CI hit this on the macOS runner. A
file replaced in place did the same. The listed entry is now replaced.
Inert and Windows-only API cleanup
Socket.UseOnlyOverlappedIO(3 sites) — deprecated and a verified silentno-op on .NET; removed rather than suppressed, since keeping it implies an
effect it does not have.
ProcessStartInfo.LoadUserProfile(4 sites) — Windows-only, and every siteassigned
false, which is already the default.Assembly.CodeBase→Assembly.Locationwith anEnvironment.ProcessPathfallback.
(SerializationInfo, StreamingContext)exception constructors onHttpException,HttpStatusExceptionandRepositoryLookupException.shlwapi!StrCmpLogicalWandiphlpapi!SendARPrelied on catchingDllNotFoundExceptionon non-Windows. The fallbacks were correct, so thiswas never a crash — just an exception per probe and per address resolution.
Both now short-circuit on
OperatingSystem.IsWindows().ProgramIconlogged akernel32.dllDllNotFoundExceptionplus ~20 linesof dlopen probe output on every non-Windows startup. Harmless, but it
dominated the log. Now returns early off Windows.
Tests
The repository had no tests, so the migration was first verified by hand (see
Verification). Those checks are now a test project anyone can
repeat:
Setup.
tests/uses xUnit v3 4.0.1.global.jsonoptsdotnet testintoMicrosoft.Testing.Platform, which the .NET 10 SDK requires for xUnit v3 (the
VSTest route fails with "Testing with VSTest target is no longer supported by
Microsoft.Testing.Platform on .NET 10 SDK and later"). The shipped assemblies
grant
InternalsVisibleToto the test assembly, which is signed with the samekey.
What is covered
CacheSerializationTestsMediaSerializer, including nulls, Unicode and subtitles; corrupt input raises the exceptionsFileStoretreats as a cache missFileStoreTestsDurationTestsStreamPumpTestsMimeTypeTestsGetProtocolInfolistsViewTestsmusicviewThumbnailTestsMacAddressTestsHomeDirectoryTestsConfigurationTestscachevalues, symlinked folders, the hidden folder on WindowsServerCommandTests--servercommand and option, their limits and exit codes, the restriction grammar, nothing saved when an option fails,--editStartupTestsUsageTestsCommandLineTests--help,-h,-?and (on Windows)/?; where-?counts as help; usage errors exiting with 2FileServerTestsHttpServerTestsConnectionheader matching whether the connection stays open, restrictions, a configured server, image and video thumbnails, no cover for audio without art, durations with a decimal point in any cultureEach regression test was checked against its bug. I reintroduced each bug
on its own and confirmed the suite fails:
StreamPumpback onBeginInvokeHttpServerTeststest; inStreamPumpTeststhe test host crashes, as the server would{:X}MacAddressTests.ResolvedAddressesUseTwoDigitsPerOctetThumbnailTests.LoaderThatCannotBeCreatedIsSkippedWithoutAffectingOthersHomeDirectoryTests.FFmpegSearchLocationsTolerateAMissingHomeDoNotVerifyHomeDirectoryTests.ConfigurationStaysUnderAMissingHomeMimeTypeTests(two tests)MimeTypeTests,HttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhereGetProtocolInfoignoring the clientHttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhereHttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhereHttpServerTests.LinksUseTheAddressEachClientConnectedToHttpServerTests.RestrictionsRefuseOtherMachinesButAlwaysAdmitThisOneHttpServerTests.ConfiguredServerAppliesItsOwnSettingsServerCommandTests.NothingIsSavedWhenAnyOptionFailsStartupTests(two tests)StartupTests.AnInvalidConfigurationIsAnErrorRatherThanAFallback--ServerCommandTests.RestrictionGroupsAreSeparatedByDoubleDashConfigurationTests.TypoInAKeyIsReportedByNameConfigurationTestsandServerCommandTests(one test each)-tnot detected as a per-server flagStartupTests.PerServerOptionsAreRecognisedAndProcessWideOnesAreNotmusicViewTests.AudioOnlyServersWithoutViewsGetTheMusicViewFileStoreTests.CacheHeldByAnotherProcessIsLeftAloneFileStoreTests.ChangedFileDoesNotKeepItsOldCoverFileStoreTests.PathsDifferingOnlyInCaseAreSeparateEntriesFileStoreTests.DatabaseFromAnotherSchemaIsReplacedFileStoreTests.StoresOnTheSameFileShareOneDatabaseThumbnailTests.TransparencyBecomesBlackThumbnailTests.SmallImagesAreNotEnlargedAssembly.LocationUsageTests.UsageIsPrintedWhenTheProgramHasNoFileFileServerTests.WatcherReportingAListedFileDoesNotListItTwiceConnection: keep-aliveon connections being closedHttpServerTests.ConnectionHeaderSaysWhetherTheConnectionStaysOpenSeams. Some code could only be reached through Windows APIs, process-wide
static initializers or a running
Main. These small, internal changes make ittestable, and none alters behaviour:
AddressToMacResolver.FormatMac, extracted from theSendARPpathFFmpeg.GetSpecialLocations, made internalThumbnailMaker.BuildThumbnailers, now taking the candidate typesConfigurationStore.HomeDirectoryOverride, so tests never touch a real~/.sdlnaand work on Windows, whereHOMEdoesn't redirect the profileProgram.ChooseConfiguredServers, extracted fromMain, andProgram.SetupConfiguredServer, made internalHttpServer(port, announce)constructor. The publicconstructors are unchanged. Tests pass
falseto skip SSDP, so they neverannounce throwaway servers to real TVs on the network, don't need UDP port
1900, and shut down at once instead of waiting for SSDP's byebye messages to
drain. That took the suite from 28 seconds to about 3.5.
Tests that cannot run somewhere are skipped with a reason rather than passing
silently: ffmpeg missing, no non-loopback address, Windows-only behaviour, or
/bin/shneeded as a stand-in editor.Results. 154 tests, 0 failed, on all three platforms. My fork's CI runs
the suite on each one, in Release, with ffmpeg installed (that workflow is not
part of this PR):
/?tests; long paths and case-only differences, which its file system can't hold/?tests/bin/shas a stand-in editor, two redirectHOME, one needs a case-sensitive file systemSo the Windows-only paths now run on Windows too: the hidden
.sdlnafolder,symlinked folders and video thumbnails through ffmpeg.
AGENTS.md
AGENTS.mdis a guide to changing the code, for people and for AI codingagents, which read that file by convention.
Readme.mdstays the userdocumentation. It covers:
--serveragainst ascratch
HOMEMaintoStreamPump, and where file types, views, the cache and the configuration aredecided
regression test against its bug
and trimming,
BeginInvoke,Assembly.Location,GetFolderPathreturning"", keeping the release free of native libraries, graceful shutdown,Windows-only features, what the browse cache key must contain, the Samsung
MIME exception, loopback always admitted, the implicit
musicview,bumping
FileStore.SCHEMA, appending toDlnaMime, LiteDB's pitfalls(locking, collation, key length), and the configuration binder's quirks
Every file, type and member it names was checked against the code, and every
command in it was run.
The readme also gains a Testing section, and documents the
musicview thataudio-only servers get.
FileServer.Loadhas always done this, but nothingsaid so, which made it look like a bug in a configured audio server.
Verification
This repository had no automated test suite, so the migration was first
verified empirically, as described below. I did not want to hand over an "it
compiles" migration. Most of these checks are now automated; see
Tests.
Build
dotnet build -c Releasefrom scratch: zero warnings, zero errors.Cache serializer — the highest-risk change, exercised through the real
serializer via reflection:
AudioFile,ImageFile,VideoFile(with and without subtitle) andCoverround-trip exactly: values,
nulls, Unicode, string arrays containingnullelements, and the probed-empty-subtitle case.
payload — throws
InvalidDataExceptionorEndOfStreamException, which areprecisely the types
FileStorecatches to treat a bad cache entry as adebug-level miss rather than a crash.
End-to-end, running the actual server on macOS against real media
description.xmlcorrectly — confirming the.resxembeddedresources survived the SDK-style conversion.
BrowseSOAP request: all items returned withcorrect UPnP classes (
object.item.videoItem.movie,object.item.imageItem.photo,object.item.audioItem.musicTrack), with MP3titles read from taglib metadata.
is what proves the
StreamPumpfix.visually confirmed the video frame is correct and correctly letterboxed.
(That was with SkiaSharp; the ImageSharp versions are covered by the tests,
the smoke test and the checks under Cross-platform publish.)
deserialization failures, and cached covers served byte-identically to the
first run.
Cross-platform publish
All four RIDs publish clean, self-contained and single-file, with zero
warnings:
win-x64,osx-arm64,linux-x64,linux-arm64. Each outputdirectory holds only the executable (
PE32+ console x86-64,Mach-O arm64,ELF x86-64,ELF ARM aarch64).My fork's release workflow now also runs each published binary on its own
platform, including an Arm Linux runner, before releasing it. It checks that
--helpand-?print the usage and that an unknown option exits with 2.Then, with
HOMEpointing at a directory that doesn't exist, it serves twoimages, browses them, checks the downloads, the JPEG thumbnails and the
cache, and stops the server with SIGTERM.
Checked by hand on macOS with the single-file build: a real SQLite cache
written by the previous release was replaced; thumbnails, durations and
covers were cached and reused after a restart; a second process on the same
cache warned and still served everything; invariant globalization opened the
cache; two configured servers shared one cache; and after Ctrl+C only the
database file was left.
Trimming is deliberately not enabled, and I would advise against turning it
on: views, comparers and thumbnail loaders are all discovered by reflection
over assembly types (
Repository<T>,ThumbnailMaker.BuildThumbnailers).Trimming yields a binary that builds cleanly and then serves an empty library.
MKV types, checked against a running server with a generic User-Agent (VLC) and
two Samsung ones:
protocolInfoContent-TypeGetProtocolInfo.mkvvideo/x-matroskavideo/x-matroskax-matroskaonly.mkvvideo/x-mkvvideo/x-mkvx-mkvonly.mp4video/mp4video/mp4Browsing again as the generic client after the Samsung ones still returned
video/x-matroska.Configuration file
--serveroption's show,change and error paths, and the restriction grammar, including switching verbs
after
--.--edit.music-view server showed Albums, Genre and Performersaddress while an unrestricted one answered 200. After the LAN address was
added to its list, it answered 200.
"cache": "none", and folder mode ignoring thefile were each checked.
-t video: only the videos were served-t: rejectedsdlna's validation.
single-file with zero warnings. The macOS binary ran the commands, served the
configured server, and generated video thumbnails.
Known limitations
Stated plainly rather than left to be discovered:
util/HttpStream.csstill uses the obsoleteHttpWebRequest(SYSLIB0014). It is suppressed locally with a TODO rather than rewritten: it
still works on .NET 10, and the class is unreferenced by the rest of the
solution now that the GUI is gone, so a rewrite could not be verified by
anything. The short-read bug in it was fixed.
Formatting.GetSystemName()leaks 8 KB ifunamefails, sinceMarshal.FreeHGlobalis skipped on the throw path. Once, and guarded by acaller's try/catch. Untouched.
notepadfor--edit,and MAC restrictions actually matching (which needs Windows'
SendARPagainst another machine), were written against the documented APIs. Marking
.sdlnahidden is now covered by the tests on Windows.follows MiniDLNA's long-standing handling, and was checked by sending
Samsung User-Agents to a running server.
the lookup only exists on Windows. It is documented, and
--serverwarns whensuch an entry is added.
Commits in this PR
Thirty-five commits, each self-contained with a detailed message explaining
the reasoning:
d26a9e9ce1b261f5f70864331644a145c271341c4d1d252c9ffdf77c4e8f5febadb0c366e69d532df10bf75a4cc535b9a79a992efa3b021eab0a766ef853217914d57eade6426e840f58e715c49ef260bf860976f5dbe7c7ec83d2e65fb9d28ea512f572fa2cfca0a74e79456f0683493b4fabd20d410a70c1b89A correction about
535b9a7: this description already described the ffmpegsingle-file fix, the dependency-free SkiaSharp Linux package and
VersionPrefixwhen the PR was opened, but those changes had been committedalongside my fork's release workflow, which I left out. The branch did not
contain them until that commit.
Two commits in this diff are not mine
Because
masterhere is still atb077977(Oct 2016), this branch alsocarries two earlier commits by @ViToni that the migration was built on
top of:
441b5feAdjustment for compilation on Linuxdf721c2Fix warning raised by MonoDevelop on Linux aboutAutoGenerateBindingRedirectsCredit for those is theirs, not mine. If you would prefer this branch rebased
onto
masterwithout them, that is straightforward — say the word.Deliberately not included
My fork also carries a GitHub Actions release workflow that publishes
cross-platform binaries on every push. I left it out of this PR on purpose: a
workflow that auto-publishes tagged releases is a maintainer policy decision,
not part of a runtime migration, and bundling the two would muddy review. Happy
to open it as a separate PR if it is of interest.
Happy to split this up, adjust scope, restore the GUI, or rebase — whatever
makes it easiest to review. Thanks for simpleDLNA; it is a genuinely tidy
codebase, and the fact that the networking layer needed zero changes across a
nine-year framework jump speaks well of the original design.
🤖 Generated with Claude Code