Skip to content

Migrate to .NET 10: cross-platform build, SQLite/Skia/serializer ports, and several latent bug fixes - #70

Open
mondul wants to merge 37 commits into
nmaier:masterfrom
mondul:dotnet10-migration
Open

mondul wants to merge 37 commits into
nmaier:masterfrom
mondul:dotnet10-migration

Conversation

@mondul

@mondul mondul commented Sep 12, 2026 •

Copy link
Copy Markdown

Migrates the solution from .NET Framework 4.5.1 to .NET 10, so that it builds
with the dotnet CLI and runs on Windows, Linux and macOS from a single
codebase.

The server core needed remarkably little work. The HTTP and SSDP stacks are
hand-rolled on raw TcpListener/UdpClient — no HttpListener, no WCF, no
ASP.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 --server command, so servers can be saved and started with a plain
sdlna now 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.md guide for people and coding agents changing the
code. 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:

Path What
SimpleDLNA/ WinForms GUI front-end (~3.4k LOC)
NMaier.Windows.Forms/ Custom WinForms controls (~680 LOC)
setup/setup.vdproj Visual Studio deployment project
NgenInstaller.cs System.Configuration.Install installer

Reasoning:

  • WinForms on modern .NET requires a net10.0-windows target and runs only on
    Windows. Keeping it forces a multi-targeted solution where most of the tree
    is cross-platform and one corner is not.
  • setup.vdproj is a Visual Studio Installer project. That project type was
    removed from Visual Studio years ago and has no msbuild/dotnet
    equivalent, so it has been unbuildable for a long time regardless of this
    work.
  • NgenInstaller and SimpleDLNA/PathEnvironmentInstaller both derive from
    System.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: sdlna depends only on fsserver, server and
util, and NMaier.Windows.Forms was consumed solely by the GUI.

If you would rather keep the GUI, say so and I will restore both projects on
a net10.0-windows target with EnableWindowsTargeting so they still compile
from 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

  • The metadata cache is rebuilt once. It is now a LiteDB file instead of
    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.
  • One process per cache file. A second sdlna using the same cache file
    logs a warning and runs without a cache, instead of sharing it.
  • SIGTERM shuts down cleanly, as Ctrl+C always did. Services and containers
    stop programs that way.
  • Running sdlna without folders starts the configured servers when there
    are any. Otherwise it serves the current directory as it always has, after a
    warning that explains how to save servers.
  • AssemblyVersion is no longer a wildcard. 1.2.* is incompatible with
    deterministic builds; the version is now pinned and lives in a single
    VersionPrefix property. It is set to 2.0.0 to reflect the removal of the
    GUI; change it as you see fit.
  • Thumbnail JPEG output is not byte-identical to the GDI+ output. Different
    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, sdlna now
starts every server in ~/.sdlna/config.json (%USERPROFILE%\.sdlna\config.json
on 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: --seperate mounts one server
per 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:

Command Does
add <name> <folder>... Adds a server: all media types, title ascending, no views, no restrictions
remove <name> Removes one
config [<name>] Shows everything, or one server
config <name> --media-types set|unset <type>... video, audio, images; at least one stays set
config <name> --sort-order date|size|title [asc|desc]
config <name> --folders add|remove <folder>... At least one folder stays
config <name> --views add|remove <view>... The same values as -v, applied in order
config <name> --restrictions add|remove --mac|--ip|--user-agent <entry>... [-- ...] -- separates groups and lets you switch between add and remove
config --edit, config --editor [<command>] Opens the file in notepad or nano, or a chosen editor, then validates it

Each option given without values prints its current setting.

Design points:

  • The file is read through Microsoft.Extensions.Configuration.Json and written
    back with System.Text.Json, via a temporary file and a move.
  • Several options can go in one call. They are validated together, and nothing is
    saved unless all of them succeed.
  • port (default 0) and cache (default ~/.sdlna/cache.db, "none" to
    disable) live in the file, and -p/-c override them. Per-server flags such
    as -t or -v, given while configured servers exist, are rejected with an
    explanation instead of being silently ignored. When the current directory is
    served instead, every flag applies.
  • Hand edits are checked strictly. Comments are allowed, unknown keys are
    rejected so typos surface, and every problem is reported at once, naming the
    server and key. --edit still works on a broken file.
  • Two spellings of one folder, such as /tmp/x and /private/tmp/x on macOS, or
    a symlink, are recognised as the same folder rather than served twice.
  • Restrictions are per server, through FileServer.Authorizer as in the GUI.
    --server warns when a MAC entry is added on Linux or macOS, where MAC lookup
    does not exist, and notes that User-Agent entries must match the whole header
    exactly.

--list-views was also rewritten. It used to print one vague line per view and
did 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-restore
    bootstrap; dotnet restore handles this now
  • */packages.config (5) — superseded by PackageReference
  • */Properties/AssemblyInfo.cs (5) and GlobalAssemblyInfo.cs — the SDK
    generates 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 with
    RunCodeAnalysis=false; inert under Roslyn analyzers
  • */sdlna.key.snk (5) — all byte-identical to the copy at the repository
    root, which is now the single signing key

Added

  • Directory.Build.props — target framework, assembly identity (formerly
    GlobalAssemblyInfo.cs) and strong-naming, shared by all projects
  • Directory.Packages.props — central package management; versions live in one
    place and projects reference packages by name
  • AssemblyAttributes.cs — ComVisible(false) and CLSCompliant(true), the
    two attributes with no MSBuild property equivalent
  • sdlna.slnx — the .NET 10 SDK emits the new XML solution format by default

Strong naming is preserved. Five projects remain, down from eight.

dotnet build -c Release

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 neither
package has a static build for desktop platforms, so they were replaced in
28ea512 and e65fb9d. The first two sections describe the final state.

System.Data.SQLite → LiteDB

System.Data.SQLite.Core compiles against net10.0 but ships no native
interop binary for several modern RIDs. On osx-arm64 it fails at runtime:

DllNotFoundException: Unable to load shared library 'SQLite.Interop.dll'

Microsoft.Data.Sqlite runs everywhere, but its engine is the native
e_sqlite3 library, which has to ship beside the executable, and no
maintained 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.cs is gone, and so is the
Mono-only util/SystemInformation.cs.

LiteDB needs more care than SQLite. Each point was found by probing it
before writing the store:

  • Two processes must not share a file. LiteDB's direct mode lets a second
    process open and write the same file, which corrupts it. A lock file beside
    the database keeps a second sdlna out; it runs without a cache.
    Servers in one process share one database per file.
  • Collation. LiteDB stores the current culture in the file by default,
    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.
  • Key length. Index keys are limited to 1023 bytes, which deep paths can
    exceed, so entries are keyed by a SHA-256 of the path.
  • Old caches. The schema number is LiteDB's UserVersion. Anything
    else, 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.Common is Windows-only from .NET 7 onward; the Unix
compatibility switch that existed in .NET 6 was removed. On macOS, constructing
a Bitmap under .NET 10 throws:

TypeInitializationException: The type initializer for
'Windows.Win32.PInvokeGdiPlus' threw an exception

Thumbnails now use ImageSharp
3.1.12, which is written in C#. The choice among managed libraries:

  • ImageSharp 4.x was rejected: its package adds a build step that fails
    Release builds unless a Six Labors license key is configured, which would
    break every contributor's and CI's release build. Directory.Packages.props
    notes why the version is held at 3.1.
  • ImageSharp 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.
    (SkiaSharp was the first choice because of that license; it is MIT, but
    needs its native library beside the executable.)
  • The stb_image ports (Unlicense or MIT) were rejected: they are compiled
    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 decode
at 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.
TargetSize also enlarges smaller images, so it is only used when
shrinking. SetResolution is gone; DPI on a thumbnail is cosmetic.

Note util/FFmpeg.cs needed nothing here: it only used System.Drawing.Size,
which lives in System.Drawing.Primitives and is cross-platform.

BinaryFormatter → an explicit binary format

BinaryFormatter's implementation was removed from the runtime in .NET 9 and
using 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 fsserver
keeps in its cache.

The compatibility package (System.Runtime.Serialization.Formatters plus
EnableUnsafeBinaryFormatterSerialization) does restore a working
implementation, 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.cs implements a small
BinaryReader/BinaryWriter format. Each blob opens with a format version
byte; 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 null and empty stay distinguishable.

AudioFile, ImageFile, VideoFile and Cover lose [Serializable],
ISerializable, their SerializationInfo constructors and GetObjectData,
and gain an internal Serialize(BinaryWriter) plus a
(BinaryReader, DeserializeInfo) constructor. Field lists and ordering carry
over unchanged — including AudioFile.description deliberately not being
persisted, 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

Package From To
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
EntityFramework + EntityFramework.SqlServer 6.0.0 dropped

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 custom Level.Notice via ILog.Logger.Log — compiles
and runs unchanged.

EntityFramework and EntityFramework.SqlServer were referenced by util but no
source 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.

StreamPump used delegate BeginInvoke — every HTTP response would fail

The most serious one, and invisible to the compiler.
StreamPump.Finish dispatched its completion callback with:

callback?.BeginInvoke(this, result, callback.EndInvoke, null);

Delegate.BeginInvoke/EndInvoke are not implemented on .NET; they throw
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
. It compiles perfectly.

Replaced with an explicit ThreadPool.QueueUserWorkItem, preserving the 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.

ImageFile never restored its cached metadata

ImageFile's (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.

Videos without subtitles were re-probed on every listing

VideoFile serialized its Subtitle object directly. A probed-but-empty
Subtitle came back as null, and VideoFile.Subtitle treats null as "not
looked yet" and re-runs ffmpeg. The new format records the presence of the
Subtitle instance separately from its text, so "already looked, found none"
survives a round trip.

A truncated cover blob could be persisted

FileStore.MaybeStoreFile's NotSupportedException handler is commented
"Ignore and store null", but it left the local cover reference set — so a
throw part way through serialization persisted a partial blob. It now clears
the local.

Assembly.Location is "", not null, under single-file publish

FFmpeg.FindExecutable seeded its search path with:

var assemblyLoc = Assembly.GetExecutingAssembly().Location;
if (assemblyLoc != null) { places.Add(new FileInfo(assemblyLoc).Directory); }

Under single-file publish Location returns an empty but non-null string,
so the guard passes and FileInfo throws ArgumentException. That happens
inside the static initializer for FFmpegExecutable, so every later touch of
FFmpeg raises TypeInitializationException — and
ThumbnailMaker.BuildThumbnailers has no try/catch around its constructor
invocation. Video thumbnails and duration detection would have been dead in any
single-file build. Now uses AppContext.BaseDirectory.

Short read in HttpStream

The small-seek path called bufferedStream.Read(buf, 0, (int)off) and
discarded the return value. Stream.Read may return fewer bytes than
requested, 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

AddressToMacResolver formatted each octet with {:X}, dropping leading zeros,
so a client at 01:AF:BC:00:0A:FF was reported as 1:AF:BC:0:A:FF and never
matched 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-mkv

The standard type is video/x-matroska, used by Matroska itself, MiniDLNA and
Universal 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-mkv for Samsung clients because they expect it.
So Samsung clients, identified by the same User-Agent markers MiniDLNA uses
(SEC_HHP_, SamsungWiselinkPro), keep receiving video/x-mkv, and see no
change.

The type reaches clients in three places: the file's Content-Type,
protocolInfo in browse results, and the GetProtocolInfo source list. All
three 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-Type but browse entries built for a previous client, so the
cache key now includes the client's variant.

WebM files were announced as Matroska

.webm was in the Matroska extension list, so WebM files got Matroska's MIME
type. They now get video/webm, which Universal Media Server also sends, via a
new DlnaMime.VideoWEBM. It is appended to the enum, so existing member values
do not change. WebM keeps the MATROSKA DLNA profile it always had, since DLNA
defines none for WebM. Samsung clients keep receiving video/x-mkv for it, as
before.

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.1 links. They can list the folder but not play
anything in it.

Reproduced by browsing over loopback and then over the LAN address: both got
127.0.0.1:9000 links. The cache key now includes the local endpoint. After the
fix, 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.GetFolderPath returns "" for a folder that does not exist, and
the ffmpeg search list passed that straight to new DirectoryInfo(""). That
throws inside FFmpeg's static initializer, disabling video thumbnails and
durations 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 .sdlna in the working directory; it now uses DoNotVerify. With
HOME pointing at a nonexistent directory, the video cover that returned 500
now 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. ThumbnailMaker
discovers its loaders by reflection in a static initializer and constructs each
one. VideoThumbnailLoader's constructor throws NotSupportedException("No ffmpeg available") when ffmpeg is missing, and nothing caught it, so
ThumbnailMaker itself 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 request
then 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 the
connection after responding unless the request itself asked for keep-alive.
HTTP/1.1 connections persist unless one side says close, so a client that
believes 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 HttpClient reports "The response ended prematurely" and
doesn'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: close when it is going to close and
keep-alive only when it will keep the connection, from the same decision
that 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.Dispose only unregisters its media servers, so no file server or
cache was ever disposed. Program now 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

Main set Console.TreatControlCAsInput, which throws on Windows when
standard 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.exe exited with status 0 and no
output. 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 writes
decimals with a comma, durations were sent as 0:00:02,366, and durations
of a day or more got a day prefix. They are now always in the UPnP form,
H+:MM:SS.mmm.

sdlna --help failed in single-file builds

GetOptNet 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 every
single-file build (framework-dependent dotnet sdlna.dll worked).
Options now builds the same usage text itself, calling the program
sdlna. GetOptNet keeps its option list private, so that part is adapted
from GetOptNet's code (MIT notice in sdlna/OptionsUsage.cs). A test
compares 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 0

The usage lists -?, --help, but GetOptNet only reads a letter or digit
after a dash as an option, so sdlna -? started the server for a folder
named -?. The command line now reads -? (and /? on Windows) as
--help wherever GetOptNet would have taken it for a folder. It stays a
folder after --, and stays a value after an option that takes one; a
test 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 --server misuse already did. An unreadable number (-p abc) is now
a 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 silent
    no-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 site
    assigned false, which is already the default.
  • Assembly.CodeBase → Assembly.Location with an Environment.ProcessPath
    fallback.
  • Legacy (SerializationInfo, StreamingContext) exception constructors on
    HttpException, HttpStatusException and RepositoryLookupException.
  • shlwapi!StrCmpLogicalW and iphlpapi!SendARP relied on catching
    DllNotFoundException on non-Windows. The fallbacks were correct, so this
    was never a crash — just an exception per probe and per address resolution.
    Both now short-circuit on OperatingSystem.IsWindows().
  • ProgramIcon logged a kernel32.dll DllNotFoundException plus ~20 lines
    of 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:

dotnet test

Setup. tests/ uses xUnit v3 4.0.1. global.json opts dotnet test into
Microsoft.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 InternalsVisibleTo to the test assembly, which is signed with the same
key.

What is covered

Class Covers
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 The LiteDB cache: reopening, changed files, covers kept only for the same file version, old SQLite and other-schema databases replaced, one database shared in a process, a cache locked by another process left alone, long paths, case-distinct paths, purging deleted files
DurationTests Durations in the UPnP form, including over a day, in any culture
StreamPumpTests Data is copied and the completion callback runs
MimeTypeTests Extension mapping; Matroska and WebM types for generic and Samsung clients; per-client GetProtocolInfo lists
ViewTests The filtering views behave as documented; documented views exist; audio-only servers get the music view
ThumbnailTests Aspect-fit JPEG thumbnails, small images not enlarged, transparency on black, the first frame of an animated GIF, damaged images, extreme aspect ratios, a loader that fails to construct, video through ffmpeg
MacAddressTests Two-digit octets; normalisation and rejection of configured addresses
HomeDirectoryTests The ffmpeg search path and the configuration path with a missing home directory
ConfigurationTests Strict JSON, documented defaults, every problem reported at once, typos named, comments and trailing commas, cache values, symlinked folders, the hidden folder on Windows
ServerCommandTests Every --server command and option, their limits and exit codes, the restriction grammar, nothing saved when an option fails, --edit
StartupTests Running without folders: configured servers, the current-directory fallback and its warnings, invalid files, per-server flags
UsageTests The usage text with no assembly file, as in a single-file build, and its match with GetOptNet's own rendering
CommandLineTests --help, -h, -? and (on Windows) /?; where -? counts as help; usage errors exiting with 2
FileServerTests Files reported by the file-system watcher: listed once, new ones added
HttpServerTests A real server on a free port, browsed with SOAP as a TV does: UPnP classes, media type filtering, byte-exact streaming, client-dependent MKV and WebM types in all three places, links built from each client's address, the Connection header 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 culture

Each regression test was checked against its bug. I reintroduced each bug
on its own and confirmed the suite fails:

Bug reintroduced Caught by
StreamPump back on BeginInvoke every HttpServerTests test; in StreamPumpTests the test host crashes, as the server would
MAC octets formatted with {:X} MacAddressTests.ResolvedAddressesUseTwoDigitsPerOctet
Thumbnail loader exception uncaught ThumbnailTests.LoaderThatCannotBeCreatedIsSkippedWithoutAffectingOthers
Empty home path not skipped HomeDirectoryTests.FFmpegSearchLocationsTolerateAMissingHome
Home lookup without DoNotVerify HomeDirectoryTests.ConfigurationStaysUnderAMissingHome
WebM announced as Matroska MimeTypeTests (two tests)
Samsung exception removed MimeTypeTests, HttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhere
GetProtocolInfo ignoring the client HttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhere
Browse cache key without the MIME variant HttpServerTests.MatroskaAndWebMTypesDependOnTheClientEverywhere
Browse cache key without the local endpoint HttpServerTests.LinksUseTheAddressEachClientConnectedTo
Loopback no longer always admitted HttpServerTests.RestrictionsRefuseOtherMachinesButAlwaysAdmitThisOne
Missing configured folder not skipped HttpServerTests.ConfiguredServerAppliesItsOwnSettings
Configuration saved before every option succeeds ServerCommandTests.NothingIsSavedWhenAnyOptionFails
Fallback not serving the current directory StartupTests (two tests)
Invalid configuration silently bypassed StartupTests.AnInvalidConfigurationIsAnErrorRatherThanAFallback
Verb unchangeable after -- ServerCommandTests.RestrictionGroupsAreSeparatedByDoubleDash
Unknown configuration keys ignored ConfigurationTests.TypoInAKeyIsReportedByName
Symlinks not resolved ConfigurationTests and ServerCommandTests (one test each)
-t not detected as a per-server flag StartupTests.PerServerOptionsAreRecognisedAndProcessWideOnesAreNot
Audio-only servers not given music ViewTests.AudioOnlyServersWithoutViewsGetTheMusicView
No lock against a second process FileStoreTests.CacheHeldByAnotherProcessIsLeftAlone
A changed file keeping its old cover FileStoreTests.ChangedFileDoesNotKeepItsOldCover
The raw path as the key, with the default collation FileStoreTests.PathsDifferingOnlyInCaseAreSeparateEntries
The schema ignored FileStoreTests.DatabaseFromAnotherSchemaIsReplaced
One database per store instead of per file FileStoreTests.StoresOnTheSameFileShareOneDatabase
Transparent areas drawn on white ThumbnailTests.TransparencyBecomesBlack
Small images enlarged ThumbnailTests.SmallImagesAreNotEnlarged
Usage built from Assembly.Location UsageTests.UsageIsPrintedWhenTheProgramHasNoFile
A listed file added again on a watcher event FileServerTests.WatcherReportingAListedFileDoesNotListItTwice
Connection: keep-alive on connections being closed HttpServerTests.ConnectionHeaderSaysWhetherTheConnectionStaysOpen

Seams. Some code could only be reached through Windows APIs, process-wide
static initializers or a running Main. These small, internal changes make it
testable, and none alters behaviour:

  • AddressToMacResolver.FormatMac, extracted from the SendARP path
  • FFmpeg.GetSpecialLocations, made internal
  • ThumbnailMaker.BuildThumbnailers, now taking the candidate types
  • ConfigurationStore.HomeDirectoryOverride, so tests never touch a real
    ~/.sdlna and work on Windows, where HOME doesn't redirect the profile
  • Program.ChooseConfiguredServers, extracted from Main, and
    Program.SetupConfiguredServer, made internal
  • An internal HttpServer(port, announce) constructor. The public
    constructors are unchanged. Tests pass false to skip SSDP, so they never
    announce 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/sh needed 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):

Platform Passed Skipped
macOS (arm64, and locally) 150 4: the Windows hidden-folder and /? tests; long paths and case-only differences, which its file system can't hold
Linux (x64, Ubuntu, ffmpeg 6.1) 152 2: the Windows hidden-folder and /? tests
Windows (x64, ffmpeg 9.0) 149 5: two need /bin/sh as a stand-in editor, two redirect HOME, one needs a case-sensitive file system

So the Windows-only paths now run on Windows too: the hidden .sdlna folder,
symlinked folders and video thumbnails through ffmpeg.


AGENTS.md

AGENTS.md is a guide to changing the code, for people and for AI coding
agents, which read that file by convention. Readme.md stays the user
documentation. It covers:

  • build, test, run and publish commands, including trying --server against a
    scratch HOME
  • the project layout and dependencies, how a request flows from Main to
    StreamPump, and where file types, views, the cache and the configuration are
    decided
  • the code style as practised in the repository
  • how the tests are organised: collections, skips, helpers, and checking a
    regression test against its bug
  • traps, each of which broke something during this work: reflection discovery
    and trimming, BeginInvoke, Assembly.Location, GetFolderPath returning
    "", 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 music view,
    bumping FileStore.SCHEMA, appending to DlnaMime, 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 music view that
audio-only servers get. FileServer.Load has always done this, but nothing
said 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

  • Clean dotnet build -c Release from 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) and Cover
    round-trip exactly: values, nulls, Unicode, string arrays containing null
    elements, and the probed-empty-subtitle case.
  • Corrupt input — bad version byte, unknown kind discriminator, truncated
    payload — throws InvalidDataException or EndOfStreamException, which are
    precisely the types FileStore catches to treat a bad cache entry as a
    debug-level miss rather than a crash.

End-to-end, running the actual server on macOS against real media

  • Started, bound HTTP, announced over SSDP, scanned the directory.
  • Served description.xml correctly — confirming the .resx embedded
    resources survived the SDK-style conversion.
  • Issued a real ContentDirectory Browse SOAP request: all items returned with
    correct UPnP classes (object.item.videoItem.movie,
    object.item.imageItem.photo, object.item.audioItem.musicTrack), with MP3
    titles read from taglib metadata.
  • Downloaded every media file over HTTP, byte-exact against the sources — which
    is what proves the StreamPump fix.
  • Generated image thumbnails, and video thumbnails from ffmpeg frames, and
    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.)
  • Restarted against the existing cache: no schema recreation, zero
    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 output
directory 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
--help and -? print the usage and that an unknown option exits with 2.
Then, with HOME pointing at a directory that doesn't exist, it serves two
images, 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:

Client 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 returned
video/x-matroska.

Configuration file

  • Exercised in an isolated home directory: every --server option's show,
    change and error paths, and the restriction grammar, including switching verbs
    after --.
  • A later option failing validation left the file byte-identical.
  • A deliberately broken file was repaired through --edit.
  • Symlinked folder spellings were recognised as one folder.
  • Three configured servers ran on one port:
    • a video-only server listed only the video
    • a music-view server showed Albums, Genre and Performers
    • a server restricted to another IP answered 403 to this machine's LAN
      address while an unrestricted one answered 200. After the LAN address was
      added to its list, it answered 200.
  • The port and cache overrides, "cache": "none", and folder mode ignoring the
    file were each checked.
  • Running without folders:
    • no file: a warning, then the current directory was served
    • no file, with -t video: only the videos were served
    • a file listing no servers: the same warning, naming the file
    • a file with a server: that server started, without a warning
    • a file with a server, plus -t: rejected
    • an invalid file: an error, and nothing served
  • Every command example in the readme was run, and its JSON example passes
    sdlna's validation.
  • With the configuration packages included, all four platforms still publish
    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.cs still uses the obsolete HttpWebRequest
    (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 if uname fails, since
    Marshal.FreeHGlobal is skipped on the throw path. Once, and guarded by a
    caller's try/catch. Untouched.
  • Some Windows-only paths were not run. Launching notepad for --edit,
    and MAC restrictions actually matching (which needs Windows' SendARP
    against another machine), were written against the documented APIs. Marking
    .sdlna hidden is now covered by the tests on Windows.
  • The Samsung MKV exception was not tested on a Samsung device. It
    follows MiniDLNA's long-standing handling, and was checked by sending
    Samsung User-Agents to a running server.
  • MAC restrictions never match on Linux or macOS. This predates the port:
    the lookup only exists on Windows. It is documented, and --server warns when
    such an entry is added.

Commits in this PR

Thirty-five commits, each self-contained with a detailed message explaining
the reasoning:

Commit
d26a9e9 Remove Windows-only GUI and the VS deployment project
ce1b261 Convert build system from .NET Framework 4.5.1 to SDK-style net10.0
f5f7086 Migrate from System.Data.SQLite to Microsoft.Data.Sqlite
4331644 Port thumbnail generation from System.Drawing to SkiaSharp
a145c27 Replace BinaryFormatter with an explicit binary cache format
1341c4d Fix .NET Core runtime incompatibilities and clear all build warnings
1d252c9 Skip the Windows console icon setup on other platforms
ffdf77c Document the .NET 10 build and run steps
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
9a992ef Announce Matroska as video/x-matroska, keeping video/x-mkv for Samsung
a3b021e Serve the current directory when no servers are configured
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
eade642 Keep image thumbnails working when ffmpeg is not installed
6e840f5 Add a test project covering the migration's fixes
8e715c4 Test the configuration file, server commands and startup decision
9ef260b Test the HTTP server end to end, as a TV talks to it
f860976 Document and test the music view that audio-only servers get
f5dbe7c Add AGENTS.md, a guide for developers and coding agents
7ec83d2 Say "Connection: close" when the server is going to close the connection
e65fb9d Generate thumbnails with ImageSharp instead of SkiaSharp
28ea512 Keep the metadata cache in LiteDB instead of SQLite
f572fa2 Shut down gracefully on SIGTERM, as on Ctrl+C
cfca0a7 List no cover for audio without art, and answer 404 for a missing cover
4e79456 Send media durations in the UPnP form, whatever the machine's culture
f068349 Start on Windows without a console for input, and report startup errors
3b4fabd Print usage in single-file builds, naming the program sdlna
20d410a Read -? as help, and exit with status 2 for usage errors
70c1b89 List a file once when the watcher reports it again

A correction about 535b9a7: this description already described the ffmpeg
single-file fix, the dependency-free SkiaSharp Linux package and
VersionPrefix when the PR was opened, but those changes had been committed
alongside 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 master here is still at b077977 (Oct 2016), this branch also
carries two earlier commits by @ViToni that the migration was built on
top of:

  • 441b5fe Adjustment for compilation on Linux
  • df721c2 Fix warning raised by MonoDevelop on Linux about AutoGenerateBindingRedirects

Credit for those is theirs, not mine. If you would prefer this branch rebased
onto master without 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

ViToni and others added 10 commits April 1, 2024 21:32
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.
@mondul

mondul commented Sep 12, 2026

Copy link
Copy Markdown
Author

For those who just need a working binary (available for Windows x64, macOS ARM64 and Linux x64/ARM64) you can get it from here:
https://github.com/mondul/simpleDLNA/releases/latest

@jeremymeyers

Copy link
Copy Markdown

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?

mondul and others added 6 commits September 14, 2026 02:30
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>
mondul added a commit to mondul/simpleDLNA that referenced this pull request Sep 14, 2026
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>
@mondul

mondul commented Sep 14, 2026

Copy link
Copy Markdown
Author

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?

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.

mondul and others added 2 commits September 14, 2026 11:10
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>
mondul added a commit to mondul/simpleDLNA that referenced this pull request Sep 14, 2026
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>
mondul and others added 3 commits September 14, 2026 12:32
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>
mondul added a commit to mondul/simpleDLNA that referenced this pull request Sep 14, 2026
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>
mondul and others added 3 commits September 14, 2026 13:05
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>
mondul and others added 13 commits September 14, 2026 13:37
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants