diff --git a/.gitattributes b/.gitattributes
index 1a31c558..3416981d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,5 @@
-*.cs text
+*.cs text
*.vcproj eol=crlf
*.csproj eol=crlf
*.sln eol=crlf
-SimpleDLNA/Resources/LICENSE eol=crlf
+*.slnx eol=crlf
diff --git a/.gitignore b/.gitignore
index f2680446..4f5e134a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,8 @@
-util/x86
-util/x64
-packages/
-*/bin
-*/obj
+bin/
+obj/
*.suo
*.user
*.DotSettings
*.dat
-setup/Debug
-setup/Release
-setup/*.cmd
pages/
cov-int*
diff --git a/.nuget/NuGet.Config b/.nuget/NuGet.Config
deleted file mode 100644
index 67f8ea04..00000000
--- a/.nuget/NuGet.Config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.nuget/NuGet.exe b/.nuget/NuGet.exe
deleted file mode 100644
index 8dd7e45a..00000000
Binary files a/.nuget/NuGet.exe and /dev/null differ
diff --git a/.nuget/NuGet.targets b/.nuget/NuGet.targets
deleted file mode 100644
index 3f8c37b2..00000000
--- a/.nuget/NuGet.targets
+++ /dev/null
@@ -1,144 +0,0 @@
-
-
-
- $(MSBuildProjectDirectory)\..\
-
-
- false
-
-
- false
-
-
- true
-
-
- false
-
-
-
-
-
-
-
-
-
-
- $([System.IO.Path]::Combine($(SolutionDir), ".nuget"))
-
-
-
-
- $(SolutionDir).nuget
-
-
-
- $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName.Replace(' ', '_')).config
- $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName).config
-
-
-
- $(MSBuildProjectDirectory)\packages.config
- $(PackagesProjectConfig)
-
-
-
-
- $(NuGetToolsPath)\NuGet.exe
- @(PackageSource)
-
- "$(NuGetExePath)"
- mono --runtime=v4.0.30319 "$(NuGetExePath)"
-
- $(TargetDir.Trim('\\'))
-
- -RequireConsent
- -NonInteractive
-
- "$(SolutionDir) "
- "$(SolutionDir)"
-
-
- $(NuGetCommand) install "$(PackagesConfig)" -source "$(PackageSources)" $(NonInteractiveSwitch) $(RequireConsentSwitch) -solutionDir $(PaddedSolutionDir)
- $(NuGetCommand) pack "$(ProjectPath)" -Properties "Configuration=$(Configuration);Platform=$(Platform)" $(NonInteractiveSwitch) -OutputDirectory "$(PackageOutputDir)" -symbols
-
-
-
- RestorePackages;
- $(BuildDependsOn);
-
-
-
-
- $(BuildDependsOn);
- BuildPackage;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..804cf8ea
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,342 @@
+AGENTS.md
+===
+
+Guidance for anyone changing SimpleDLNA, human or AI agent.
+[Readme.md](Readme.md) documents the program for its users. This file
+documents the code: how to build and test it, how it fits together, and the
+traps that have already cost someone a debugging session.
+
+Commands
+---
+
+Requires the .NET SDK 10.0. `global.json` only selects the test runner, not
+an SDK version.
+
+```bash
+dotnet build
+```
+
+Builds `sdlna.slnx`: the five projects and the tests. It should finish with
+zero warnings; keep it that way.
+
+```bash
+dotnet test
+```
+
+Runs the whole suite in a few seconds. To run part of it:
+
+```bash
+dotnet test --filter-class NMaier.SimpleDlna.Tests.HttpServerTests
+```
+
+```bash
+dotnet test --filter-method NMaier.SimpleDlna.Tests.ViewTests.UnknownViewIsReported
+```
+
+To run the program from source:
+
+```bash
+dotnet run --project sdlna -- -l DEBUG ~/Videos
+```
+
+`sdlna --server ...` reads and writes `~/.sdlna/config.json`. To try it
+without touching your own configuration on Linux or macOS, build first and
+point `HOME` at a scratch folder. Run the built `sdlna.dll` rather than
+`dotnet run`, which would restore every package into that folder:
+
+```bash
+HOME=$(mktemp -d) dotnet sdlna/bin/Debug/net10.0/sdlna.dll --server add Test ~/Videos
+```
+
+To produce a release-style binary:
+
+```bash
+dotnet publish sdlna/sdlna.csproj -c Release -r osx-arm64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=false
+```
+
+The result is one executable with nothing beside it, because every
+dependency is managed code; see [Traps](#traps). `PublishTrimmed=false`
+matters too.
+
+Layout
+---
+
+| Project | Assembly | Depends on | Role |
+|------------|------------------------------|------------------------------|------|
+| `util` | `SimpleDlna.Utilities` | - | Logging base class, ffmpeg discovery and invocation, `StreamPump`, MAC lookup, natural sorting, `Repository` |
+| `server` | `SimpleDlna.Server` | util | HTTP server, SSDP, UPnP/DLNA handlers, MIME and profile maps, views, comparers |
+| `thumbs` | `SimpleDlna.Thumbnails` | util, server | Thumbnails: images through ImageSharp, video through ffmpeg |
+| `fsserver` | `SimpleDlna.FileMediaServer` | util, server, thumbs | `FileServer`: scans folders into media items, reads tags with TagLibSharp, caches metadata in LiteDB |
+| `sdlna` | `sdlna` | util, server, fsserver | Console entry point, command-line options, configuration file, `--server` commands |
+| `tests` | `SimpleDlna.Tests` | all of the above | xUnit v3 test suite |
+
+Shared build settings live at the root: `Directory.Build.props` (target
+framework, version, strong naming, `InternalsVisibleTo` for the tests) and
+`Directory.Packages.props` (every package version, through Central Package
+Management; `PackageReference` items in projects carry no `Version`).
+
+### How a request flows
+
+1. `sdlna/Program.cs` `Main` hands `--server ...` to `ServerCommand.Run`,
+ which edits the configuration file and exits. Otherwise it parses
+ `Options` (`ParseCommandLine`); an invalid command line prints the error
+ and usage and exits with 2. With folders it builds one ad-hoc
+ `FileServer`; without, `ChooseConfiguredServers` either returns the
+ configuration file's servers (one `FileServer` each) or falls back to
+ serving the current directory.
+2. Every `FileServer` is registered with a single `HttpServer`
+ (`server/Http`), which mounts it at `/mm-N/` as a `MediaMount`
+ (`server/Handlers`). `SsdpHandler` announces each mount over UDP multicast
+ on port 1900, which is how TVs discover it.
+3. A client fetches `/mm-N/description.xml`, then posts SOAP to
+ `/mm-N/control` (`MediaMount_SOAP.cs`: ContentDirectory `Browse`,
+ ConnectionManager `GetProtocolInfo`, and so on). Browse results are DIDL
+ documents with absolute links to `/mm-N/file//res` and
+ `/mm-N/cover//...`.
+4. File responses are copied to the socket by `util/StreamPump.cs`. A browser
+ pointed at `/` gets an HTML index (`IndexHandler`, `MediaMount_HTML.cs`).
+
+### Where things are decided
+
+- **File types.** `server/Types/DlnaMaps.cs` maps extensions to `DlnaMime`
+ values, and those to MIME types, DLNA profile names (PN) and the
+ GetProtocolInfo list. `fsserver/Files/BaseFile.GetFile` picks `AudioFile`,
+ `ImageFile` or `VideoFile` from the media type.
+- **Views** (`server/Views`) rebuild or filter the folder tree. `Identifiers`
+ (`server/Types`) applies them in order when the tree is loaded.
+- **Metadata cache.** `fsserver/FileStore.cs` keeps each file's metadata and
+ cover in a LiteDB database, encoded by `fsserver/Files/MediaSerializer.cs`.
+- **Configuration file.** `sdlna/Configuration.cs` holds the model,
+ validation and loading/saving; `sdlna/ServerCommand.cs` the `--server`
+ commands.
+
+Code style
+---
+
+Match the surrounding code rather than modernising it in passing.
+
+- Two-space indentation, LF line endings for `.cs` (`.editorconfig`). Project
+ and solution files are CRLF (`.gitattributes`). Many `.cs` files start with
+ a UTF-8 BOM; leave existing BOMs alone.
+- Braces: types and members open on their own line; `if`, `foreach`,
+ `using` and friends open on the same line, and `else` starts a new line
+ after the closing brace.
+- Block-scoped namespaces, explicit `using` directives (implicit usings are
+ off), no nullable reference types, `var` for locals, `typeof (T)` with a
+ space.
+- Private fields are camelCase without an underscore; constants are
+ UPPER_SNAKE_CASE.
+- Classes that log derive from `NMaier.SimpleDlna.Utilities.Logging` and call
+ `Debug`, `InfoFormat`, `Warn` and so on. log4net is configured once, in
+ `sdlna/Options.cs`.
+- Comments explain why, especially for workarounds. Existing ones often name
+ the failure they prevent; keep that habit.
+- User-facing behaviour is documented in `Readme.md`, and in the help text
+ (`sdlna --help`, `sdlna --server help`, `sdlna --list-views`). Change both
+ together.
+
+Tests
+---
+
+The suite uses xUnit v3 on Microsoft.Testing.Platform (selected in
+`global.json`). The test project is an executable; `dotnet test` builds and
+runs it.
+
+- **Internal access.** The shipped assemblies grant `InternalsVisibleTo` to
+ `SimpleDlna.Tests` with the public key of `sdlna.key.snk`, which signs the
+ tests too. Prefer testing through `internal` members over reflection; making
+ a private method `internal` for a test is fine.
+- **Collections.** Most tests run in parallel. Tests that change process-wide
+ state (the console writers, environment variables such as `HOME`,
+ `ConfigurationStore.HomeDirectoryOverride`) belong in
+ `[Collection(ProcessStateCollection.NAME)]`. Tests that talk to the HTTP
+ server belong in `[Collection(HttpServerCollection.NAME)]` and receive the
+ shared `DlnaTestServer` through their constructor.
+- **Skips, not silent passes.** When a test cannot run on a machine (no
+ ffmpeg, no non-loopback address, a Windows-only behaviour), skip it with
+ `Assert.Skip`, `Assert.SkipWhen` or `Assert.SkipUnless` and a reason, so it
+ shows as skipped.
+- **Cancellation.** Pass `TestContext.Current.CancellationToken` to async
+ APIs (xUnit analyzer rule xUnit1051).
+- **Helpers** in `tests/Support`:
+ - `TempDirectory` creates and deletes a scratch folder.
+ - `TestMedia` writes real JPEGs, transparent PNGs and animated GIFs
+ (ImageSharp) and, when ffmpeg is installed, short videos.
+ - `TestFileServer.Create` builds a `FileServer` over a folder.
+ - `ConfigurationHome` redirects the configuration file to a scratch folder
+ and reads back what was written.
+ - `ServerCommandRunner` runs `--server` commands and captures their output
+ and exit code.
+ - `ProgramRunner` does the same for `Program.Main`, for command lines that
+ end before serving (`--help`, an invalid option); others start a server.
+ - `DlnaTestServer` and `DlnaClient` host file servers on a real
+ `HttpServer` and talk to it with SOAP, choosing the User-Agent and the
+ local address each request connects through.
+ - `CultureScope.CommaDecimals` switches to es-CO, which writes decimals
+ with a comma, so culture-dependent output fails on every machine. With
+ `processWide: true` it reaches the HTTP server's threads too; use that
+ only in a collection that runs alone.
+- **Regression tests name their bug.** Each one's summary says what used to
+ go wrong. When adding one, check that it fails with the fix reverted before
+ relying on it.
+
+`DlnaTestServer` uses the internal `HttpServer(port, announce: false)`
+constructor. With SSDP off, tests never announce `test-` servers to
+real TVs on the network, need no UDP port 1900, and shut down instantly
+rather than after SSDP's byebye datagrams drain.
+
+Traps
+---
+
+Each of these has broken something before.
+
+### Runtime and platform
+
+- **Reflection discovers views, comparers and thumbnail loaders.**
+ `Repository` (`util/Repository.cs`) instantiates every type in the
+ interface's assembly that implements `IView` or `IItemComparer` and has a
+ public parameterless constructor; `ThumbnailMaker` does the same for
+ `IThumbnailLoader`. Adding one needs no registration. It also means
+ **trimming must stay off**: the trimmer cannot see these types are used.
+ A constructor that throws hides that type: `Repository` silently,
+ `ThumbnailMaker` with an Info log line (the video loader throws when ffmpeg
+ is missing, which used to disable image thumbnails too).
+- **`Delegate.BeginInvoke` throws `PlatformNotSupportedException` on
+ .NET.** It compiles without a warning. Use `ThreadPool.QueueUserWorkItem`
+ or tasks.
+- **`Assembly.Location` is `""` in a single-file executable.** Use
+ `AppContext.BaseDirectory` for the program's folder. Libraries read it
+ too: GetOptNet's `AssembleUsage` names the program after the entry
+ assembly's file and throws there, so `sdlna/OptionsUsage.cs` replaces it,
+ and `UsageTests` runs the usage with a file-less entry assembly.
+- **GetOptNet misreads parts of the command line.** It reads short options
+ only when a letter or digit follows the dash, so `-?` was taken for a
+ folder, and it reports a number it can't read (`-p abc`) as a
+ `ProgrammingErrorException` rather than a `GetOptException`.
+ `Options.ParseCommandLine` (`sdlna/OptionsParsing.cs`) handles both; parse
+ through it rather than `Parse`. To leave `-?` alone where it is a value
+ (`-n -?`), it decides itself which short options take the next argument,
+ and `CommandLineTests` holds that to GetOptNet's own reading.
+- **`Environment.GetFolderPath` returns `""` for a folder that doesn't
+ exist**, such as the home folder of a service account. Pass
+ `Environment.SpecialFolderOption.DoNotVerify` when you need the path, and
+ skip empty results when searching (`FFmpeg.GetSpecialLocations`).
+- **No native libraries.** The release is a single executable because every
+ dependency is managed code; SkiaSharp and SQLite were replaced for that.
+ A package with native parts either adds files beside the executable or,
+ bundled with `IncludeNativeLibrariesForSelfExtract`, is extracted to
+ `$HOME/.net` before `Main` runs, and then the program refuses to start at
+ all when `HOME` is missing or read-only. Check the `dotnet publish` output
+ before adding a package.
+- **Shutdown has to be graceful.** Ctrl+C and SIGTERM (how services and
+ containers are stopped) both release `Main`, which then disposes the HTTP
+ server and every file server; that closes the caches and removes their
+ lock files. `HttpServer.Dispose` alone only unregisters the file servers.
+ Anything else that must be released at exit belongs on that path.
+- **Windows-only features.** Looking up a client's MAC address
+ (`util/AddressToMacResolver.cs`) only works on Windows, so MAC restrictions
+ never match elsewhere. The `.sdlna` folder's hidden attribute and the
+ console icon are Windows-only too. Guard such code with
+ `OperatingSystem.IsWindows()`.
+- **ImageSharp stays on 3.1.** ImageSharp 4 fails Release builds unless a
+ Six Labors license key is configured. 3.1 is under the Six Labors Split
+ License, which grants Apache 2.0 to open-source projects like this one.
+- **Thumbnails decode at the size they are shown.** `ThumbnailMaker.LoadImage`
+ reads the image's size first and passes the fitted size as
+ `DecoderOptions.TargetSize`, so JPEGs decode at a reduced scale (about
+ three times faster for a 12 MP photo). `TargetSize` also enlarges smaller
+ images, so it is only set when shrinking.
+
+### Protocol
+
+- **The browse cache key must include everything a response depends on.**
+ `MediaMount_SOAP.cs` caches SOAP responses under a key built from the
+ prefix, `request.LocalEndPoint`, `DlnaMaps.MimeVariant(request.Headers)`
+ and the request parameters. The endpoint is there because DIDL links embed
+ the address the client connected to; without it, a browse from
+ `127.0.0.1` sent network clients to localhost. The MIME variant is there
+ because Samsung clients get different types. If you make a response depend
+ on anything else about the request, add it to the key.
+- **Samsung gets `video/x-mkv`.** Clients whose User-Agent contains
+ `SEC_HHP_` or `SamsungWiselinkPro` are sent `video/x-mkv` for Matroska and
+ WebM; everyone else gets `video/x-matroska` and `video/webm`. The type
+ appears in three places, which must agree: DIDL `protocolInfo`, the file
+ response's `Content-Type`, and the GetProtocolInfo list. Go through
+ `DlnaMaps.MimeFor` and `DlnaMaps.ProtocolInfoFor` instead of reading
+ `DlnaMaps.Mime` directly.
+- **Protocol values must not depend on the machine's culture.** Durations
+ were once formatted with `TimeSpan`'s culture-sensitive `"g"`, so an es-CO
+ machine sent `duration="0:00:02,366"`. Format what goes into SOAP and DIDL
+ with `CultureInfo.InvariantCulture`, and durations with
+ `Formatting.FormatDuration` (`H+:MM:SS.FFF` in total hours, as UPnP
+ requires; the HTML index shows the same text).
+- **Loopback clients are always admitted**, by `HttpServer.AuthorizeClient`
+ and by `MediaMount.HandleRequest`, whatever the restrictions. Restrictions
+ can only be observed from another address.
+- **Restrictions are alternatives.** A client matching any entry of any kind
+ (IP, MAC, User-Agent) is admitted. User-Agent entries must match the whole
+ header exactly, including case.
+- **Audio-only servers get the `music` view.** `FileServer.Load` adds it when
+ the server serves only audio and has no views.
+- **A listed cover must exist, or be a 404.** Clients fetch every
+ `albumArtURI` they are given. An item whose `Cover` is null (audio without
+ embedded art) gets no cover links. Other covers are thumbnails made when
+ first fetched, so a Browse can list one that can't be made; the cover
+ handler answers 404 when a cover has no `InfoSize`. Both cases used to end
+ in a 500.
+
+### Persistence
+
+- **Changing a cached payload means bumping `FileStore.SCHEMA`.**
+ `MediaSerializer` replaced `BinaryFormatter` (removed from .NET 9) with an
+ explicit field-by-field format. Change what `AudioFile`, `ImageFile` or
+ `VideoFile` write or read, and old caches will misparse unless the schema
+ number changes, which makes existing caches be discarded and rebuilt.
+- **`DlnaMime` values are persisted.** Append new members at the end of the
+ enum; never reorder or insert.
+- **The cache is a LiteDB database, and LiteDB needs care** (see
+ `fsserver/FileStore.cs`):
+ - Its direct mode must not have two engines on one file, yet it lets a
+ second process open and write the same file. Servers in one process
+ share one database per file, and `FileStore.LockCache` keeps other
+ processes out; they run without a cache.
+ - The collation is stored in the file when it is created. The default is
+ the current culture, which can't be loaded under invariant
+ globalization (common in containers), so it is set to `/Ordinal`.
+ - Index keys are limited to 1023 bytes, so documents are keyed by a
+ SHA-256 of the path, and the path is stored and compared on read.
+ - The schema number is the database's `UserVersion`. Anything that isn't
+ this version's database (including SQLite caches from 2.1.x and
+ earlier) is deleted and recreated, but never a file that is merely
+ locked or read-only.
+ - While it is open, LiteDB keeps a `-log` file beside the database, and
+ the lock file sits there too. `FileStore.IsStoreFile` keeps changes to
+ all three from triggering rescans.
+- **A stored cover belongs to one version of a file.** Metadata is often
+ stored again without the cover, which loads lazily, so the stored cover is
+ kept, but only while the file's size and modification time are unchanged.
+
+### Configuration file
+
+- **The configuration binder appends to collections** that already hold
+ items instead of replacing them, so the model's collections start empty
+ and `ConfigurationStore.Normalize` fills in defaults after binding. An
+ empty JSON array (`[]`) binds to `null`, not to an empty list, which
+ `Normalize` also handles.
+- Unknown keys are errors (`ErrorOnUnknownConfiguration`), so renaming a key
+ breaks existing files. Add new keys; don't rename old ones.
+- Writes are atomic (a temporary file, then a move) so a crash never leaves a
+ half-written configuration, and `--edit` keeps working on an invalid file.
+ Keep both properties.
+- `ConfigurationStore.HomeDirectoryOverride` redirects the file's location
+ for tests. Use it (through `ConfigurationHome`) rather than changing `HOME`.
+
+Before you commit
+---
+
+- `dotnet build` with no warnings, and `dotnet test` passing.
+- New behaviour has a test; a bug fix has a regression test that fails
+ without the fix.
+- `Readme.md` and the command help describe any user-visible change.
+- Cached payload changed? `FileStore.SCHEMA` bumped.
+- New response dependency? Added to the browse cache key.
diff --git a/AssemblyAttributes.cs b/AssemblyAttributes.cs
new file mode 100644
index 00000000..172fb628
--- /dev/null
+++ b/AssemblyAttributes.cs
@@ -0,0 +1,10 @@
+using System;
+using System.Runtime.InteropServices;
+
+// Assembly-wide attributes shared by every project, linked in via
+// Directory.Build.props. Everything else that used to live in
+// GlobalAssemblyInfo.cs is now expressed as MSBuild properties and emitted by
+// the SDK's generated assembly info.
+
+[assembly: ComVisible(false)]
+[assembly: CLSCompliant(true)]
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 00000000..542219aa
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,55 @@
+
+
+
+
+
+ net10.0
+ latest
+ disable
+ disable
+ false
+
+ true
+
+
+
+
+ Nils Maier
+ SimpleDLNA
+ Copyright © 2012-2016 Nils Maier
+ en-US
+
+ 2.0.0
+
+ false
+
+
+
+
+ true
+ $(MSBuildThisFileDirectory)sdlna.key.snk
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 00000000..b21f43d6
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,29 @@
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GlobalAssemblyInfo.cs b/GlobalAssemblyInfo.cs
deleted file mode 100644
index f71fbd15..00000000
--- a/GlobalAssemblyInfo.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System;
-using System.Reflection;
-using System.Resources;
-using System.Runtime.InteropServices;
-
-#if DEBUG
-[assembly: AssemblyConfiguration("Debug")]
-#else
-[assembly: AssemblyConfiguration("Release")]
-#endif
-
-[assembly: AssemblyCompany("Nils Maier")]
-[assembly: AssemblyProduct("SimpleDLNA")]
-[assembly: AssemblyCopyright("Copyright © 2012-2016 Nils Maier")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-[assembly: ComVisible(false)]
-[assembly: AssemblyVersion("1.2.*")]
-[assembly: AssemblyInformationalVersion("1.2")]
-[assembly: NeutralResourcesLanguage("en-US")]
-[assembly: CLSCompliant(true)]
diff --git a/NMaier.Windows.Forms/Form.cs b/NMaier.Windows.Forms/Form.cs
deleted file mode 100644
index 45bbd797..00000000
--- a/NMaier.Windows.Forms/Form.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Drawing;
-using System.Windows.Forms.VisualStyles;
-using win = System.Windows.Forms;
-
-namespace NMaier.Windows.Forms
-{
- public class Form : win::Form
- {
- static Form()
- {
- if (LicenseManager.UsageMode != LicenseUsageMode.Runtime) {
- return;
- }
- win::Application.EnableVisualStyles();
- win::Application.VisualStyleState =
- VisualStyleState.ClientAndNonClientAreasEnabled;
- win::Application.SetCompatibleTextRenderingDefault(false);
-
- win::ToolStripManager.VisualStylesEnabled = true;
- win::ToolStripManager.Renderer = new ToolStripRealSystemRenderer();
- }
-
- public Form()
- {
- Font = SystemFonts.MessageBoxFont;
- BoldFont = new Font(Font, FontStyle.Bold);
- ItalicFont = new Font(Font, FontStyle.Italic);
- SetFlatStyle(this);
- SetStyle(
- win::ControlStyles.OptimizedDoubleBuffer |
- win::ControlStyles.AllPaintingInWmPaint,
- true);
- }
-
- public virtual Font BoldFont { get; set; }
-
- public virtual Font ItalicFont { get; set; }
-
- public static void SetFlatStyle(object control)
- {
- if (!(control is win::Button)) {
- var t = control.GetType();
- var p = t.GetProperty("FlatStyle", typeof(win::FlatStyle));
- if (p != null && p.CanWrite) {
- p.SetValue(control, win::FlatStyle.System, null);
- }
- }
- var ctrl = control as win::Control;
- if (ctrl != null) {
- foreach (var sc in ctrl.Controls) {
- SetFlatStyle(sc);
- }
- }
- }
- }
-}
diff --git a/NMaier.Windows.Forms/NMaier.Windows.Forms.csproj b/NMaier.Windows.Forms/NMaier.Windows.Forms.csproj
deleted file mode 100644
index 847c0b7a..00000000
--- a/NMaier.Windows.Forms/NMaier.Windows.Forms.csproj
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
- Debug
- AnyCPU
- {56667283-EBCD-4D63-BF61-FF319CB28A0B}
- Library
- Properties
- NMaier.Windows.Forms
- NMaier.Windows.Forms
- v4.5.1
- 512
-
-
-
-
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
- false
-
-
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
- false
-
-
- true
-
-
- NMaier.Windows.Forms.snk
-
-
-
-
-
-
-
-
- Form
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/NMaier.Windows.Forms/NMaier.Windows.Forms.snk b/NMaier.Windows.Forms/NMaier.Windows.Forms.snk
deleted file mode 100644
index 7dba3973..00000000
Binary files a/NMaier.Windows.Forms/NMaier.Windows.Forms.snk and /dev/null differ
diff --git a/NMaier.Windows.Forms/Properties/AssemblyInfo.cs b/NMaier.Windows.Forms/Properties/AssemblyInfo.cs
deleted file mode 100644
index 77c3f001..00000000
--- a/NMaier.Windows.Forms/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System;
-using System.Reflection;
-using System.Runtime.InteropServices;
-
-#if DEBUG
-[assembly: AssemblyConfiguration("Debug")]
-#else
-[assembly: AssemblyConfiguration("Release")]
-#endif
-[assembly: AssemblyTitle("NMaier.Windows.Forms")]
-[assembly: AssemblyDescription("Various WinForrms helpers")]
-[assembly: AssemblyCompany("Nils Maier")]
-[assembly: AssemblyProduct("NMaier.Windows.Forms")]
-[assembly: AssemblyCopyright("Copyright © 2014 Nils Maier")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-[assembly: ComVisible(false)]
-[assembly: CLSCompliant(true)]
-[assembly: Guid("381b29a0-7e27-49fa-ac74-f740c2da1f00")]
-[assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/NMaier.Windows.Forms/ToolStripRealSystemRenderer.cs b/NMaier.Windows.Forms/ToolStripRealSystemRenderer.cs
deleted file mode 100644
index 8741c198..00000000
--- a/NMaier.Windows.Forms/ToolStripRealSystemRenderer.cs
+++ /dev/null
@@ -1,601 +0,0 @@
-/***
-The MIT License (MIT)
-Copyright © 2008 Lee Houghton
-Copyright © 2014 Nils Maier
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the “Software”), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-***/
-
-using System;
-using System.Collections.Generic;
-using System.Drawing;
-using System.Runtime.InteropServices;
-using System.Windows.Forms;
-using System.Windows.Forms.VisualStyles;
-
-namespace NMaier.Windows.Forms
-{
- public class ToolStripRealSystemRenderer : ToolStripSystemRenderer
- {
- private const int RebarBackground = 6;
-
- private static readonly Dictionary defined =
- new Dictionary();
-
- public static readonly bool IsSupported = new Func(() =>
- {
- if (!VisualStyleRenderer.IsSupported) {
- return false;
- }
- return IsElementDefined(
- "Menu",
- (int)MenuParts.BarBackground,
- (int)MenuBarStates.Active);
- })();
-
- private VisualStyleRenderer renderer;
-
- public ToolStripRealSystemRenderer()
- : this(ToolbarTheme.Toolbar)
- {
- }
-
- public ToolStripRealSystemRenderer(ToolbarTheme theme)
- {
- Theme = theme;
- }
-
- private enum MenuBarItemStates : int
- {
- Normal = 1,
- Hover = 2,
- Pushed = 3,
- Disabled = 4,
- DisabledHover = 5,
- DisabledPushed = 6
- }
-
- private enum MenuBarStates : int
- {
- Active = 1,
- Inactive = 2
- }
-
- private enum MenuParts : int
- {
- ItemTMSchema = 1,
- DropDownTMSchema = 2,
- BarItemTMSchema = 3,
- BarDropDownTMSchema = 4,
- ChevronTMSchema = 5,
- SeparatorTMSchema = 6,
- BarBackground = 7,
- BarItem = 8,
- PopupBackground = 9,
- PopupBorders = 10,
- PopupCheck = 11,
- PopupCheckBackground = 12,
- PopupGutter = 13,
- PopupItem = 14,
- PopupSeparator = 15,
- PopupSubmenu = 16,
- SystemClose = 17,
- SystemMaximize = 18,
- SystemMinimize = 19,
- SystemRestore = 20
- }
-
- private enum MenuPopupCheckBackgroundStates : int
- {
- Disabled = 1,
- Normal = 2,
- Bitmap = 3
- }
-
- private enum MenuPopupCheckStates : int
- {
- CheckmarkNormal = 1,
- CheckmarkDisabled = 2,
- BulletNormal = 3,
- BulletDisabled = 4
- }
-
- private enum MenuPopupItemStates : int
- {
- Normal = 1,
- Hover = 2,
- Disabled = 3,
- DisabledHover = 4
- }
-
- private enum MenuPopupSubMenuStates : int
- {
- Normal = 1,
- Disabled = 2
- }
-
- public enum ToolbarTheme
- {
- BrowserTabBar,
- CommunicationsToolbar,
- HelpBar,
- MediaToolbar,
- Toolbar
- }
-
- private string MenuClass
- {
- get
- {
- return SubclassPrefix + "Menu";
- }
- }
-
- private string RebarClass
- {
- get
- {
- return SubclassPrefix + "Rebar";
- }
- }
-
- private string SubclassPrefix
- {
- get
- {
- switch (Theme) {
- case ToolbarTheme.MediaToolbar:
- return "Media::";
- case ToolbarTheme.CommunicationsToolbar:
- return "Communications::";
- case ToolbarTheme.BrowserTabBar:
- return "BrowserTabBar::";
- case ToolbarTheme.HelpBar:
- return "Help::";
- default:
- return string.Empty;
- }
- }
- }
-
- public ToolbarTheme Theme
- {
- get;
- set;
- }
-
- private bool EnsureRenderer()
- {
- if (!IsSupported) {
- return false;
- }
- if (renderer == null) {
- renderer =
- new VisualStyleRenderer(VisualStyleElement.Button.PushButton.Normal);
- }
- return true;
- }
-
- private static Rectangle GetBackgroundRectangle(ToolStripItem item)
- {
- if (!item.IsOnDropDown) {
- return new Rectangle(new Point(), item.Bounds.Size);
- }
-
- var rect = item.Bounds;
- rect.X = item.ContentRectangle.X + 1;
- rect.Width = item.ContentRectangle.Width - 1;
- rect.Y = 0;
- return rect;
- }
-
- private static int GetItemState(ToolStripItem item)
- {
- var selected = item.Selected;
-
- if (item.IsOnDropDown) {
- if (item.Enabled) {
- return selected ?
- (int)MenuPopupItemStates.Hover :
- (int)MenuPopupItemStates.Normal;
- }
- return selected ?
- (int)MenuPopupItemStates.DisabledHover :
- (int)MenuPopupItemStates.Disabled;
- }
-
- if (item.Pressed) {
- return item.Enabled ?
- (int)MenuBarItemStates.Pushed :
- (int)MenuBarItemStates.DisabledPushed;
- }
-
- if (item.Enabled) {
- return selected ?
- (int)MenuBarItemStates.Hover :
- (int)MenuBarItemStates.Normal;
- }
-
- return selected ?
- (int)MenuBarItemStates.DisabledHover :
- (int)MenuBarItemStates.Disabled;
- }
-
- private Color GetItemTextColor(ToolStripItem item)
- {
- var partId = item.IsOnDropDown ?
- (int)MenuParts.PopupItem :
- (int)MenuParts.BarItem;
- renderer.SetParameters(MenuClass, partId, (int)GetItemState(item));
- return renderer.GetColor(ColorProperty.TextColor);
- }
-
- private Padding GetThemeMargins(IDeviceContext dc,
- MarginProperty marginType)
- {
- NativeMethods.MARGINS margins;
- try {
- var hDC = dc.GetHdc();
- var rv = NativeMethods.GetThemeMargins(
- renderer.Handle,
- hDC,
- renderer.Part,
- renderer.State,
- (int)marginType,
- IntPtr.Zero,
- out margins);
- if (rv == 0) {
- return new Padding(
- margins.cxLeftWidth,
- margins.cyTopHeight,
- margins.cxRightWidth,
- margins.cyBottomHeight);
- }
- return new Padding(0);
- }
- catch (Exception) {
- return renderer.GetMargins(dc, marginType);
- }
- finally {
- dc.ReleaseHdc();
- }
- }
-
- private static bool IsElementDefined(VSEInternal e)
- {
- var el = VisualStyleElement.CreateElement(e.cls, e.part, e.state);
- return VisualStyleRenderer.IsElementDefined(el);
- }
-
- protected override void Initialize(ToolStrip toolStrip)
- {
- if (toolStrip.Parent is ToolStripPanel) {
- toolStrip.BackColor = Color.Transparent;
- }
- base.Initialize(toolStrip);
- }
-
- protected override void InitializePanel(ToolStripPanel toolStripPanel)
- {
- foreach (Control control in toolStripPanel.Controls) {
- if (control is ToolStrip) {
- Initialize((ToolStrip)control);
- }
- }
- base.InitializePanel(toolStripPanel);
- }
-
- protected static bool IsElementDefined(string className, int part,
- int state)
- {
- var el = new VSEInternal(className, part, state);
- bool rv;
- if (!defined.TryGetValue(el, out rv)) {
- defined.Add(el, rv = IsElementDefined(el));
- }
- return rv;
- }
-
- protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e)
- {
- if (EnsureRenderer()) {
- e.ArrowColor = GetItemTextColor(e.Item);
- }
- base.OnRenderArrow(e);
- }
-
- protected override void OnRenderImageMargin(ToolStripRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderImageMargin(e);
- return;
- }
- if (!e.ToolStrip.IsDropDown) {
- return;
- }
- renderer.SetParameters(MenuClass, (int)MenuParts.PopupGutter, 0);
- var margins = GetThemeMargins(e.Graphics, MarginProperty.SizingMargins);
- var ts = e.ToolStrip;
- var extraWidth =
- (ts.Width - ts.DisplayRectangle.Width - margins.Left - margins.Right - 1) -
- e.AffectedBounds.Width;
- var rect = e.AffectedBounds;
- rect.Y += 2;
- rect.Height -= 4;
- var sepWidth =
- renderer.GetPartSize(e.Graphics, ThemeSizeType.True).Width;
- if (e.ToolStrip.RightToLeft == RightToLeft.Yes) {
- rect = new Rectangle(
- rect.X - extraWidth, rect.Y, sepWidth, rect.Height);
- rect.X += sepWidth;
- }
- else {
- rect = new Rectangle(
- rect.Width + extraWidth - sepWidth,
- rect.Y,
- sepWidth,
- rect.Height);
- }
- renderer.DrawBackground(e.Graphics, rect);
- }
-
- protected override void OnRenderItemCheck(ToolStripItemImageRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderItemCheck(e);
- return;
- }
- var bgRect = GetBackgroundRectangle(e.Item);
- bgRect.Width = bgRect.Height;
-
- if (e.Item.RightToLeft == RightToLeft.Yes) {
- bgRect = new Rectangle(
- e.ToolStrip.ClientSize.Width - bgRect.X - bgRect.Width,
- bgRect.Y,
- bgRect.Width,
- bgRect.Height);
- }
- var state = e.Item.Enabled ?
- (int)MenuPopupCheckBackgroundStates.Normal :
- (int)MenuPopupCheckBackgroundStates.Disabled;
- renderer.SetParameters(
- MenuClass,
- (int)MenuParts.PopupCheckBackground,
- state);
- renderer.DrawBackground(e.Graphics, bgRect);
-
- var checkRect = e.ImageRectangle;
- checkRect.X = bgRect.X + bgRect.Width / 2 - checkRect.Width / 2;
- checkRect.Y = bgRect.Y + bgRect.Height / 2 - checkRect.Height / 2;
-
- state = e.Item.Enabled ?
- (int)MenuPopupCheckStates.CheckmarkNormal :
- (int)MenuPopupCheckStates.CheckmarkDisabled;
- renderer.SetParameters(MenuClass, (int)MenuParts.PopupCheck, state);
-
- renderer.DrawBackground(e.Graphics, checkRect);
- }
-
- protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
- {
- if (EnsureRenderer()) {
- e.TextColor = GetItemTextColor(e.Item);
- }
- base.OnRenderItemText(e);
- }
-
- protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e)
- {
- if (EnsureRenderer()) {
- var partID = e.Item.IsOnDropDown ?
- (int)MenuParts.PopupItem :
- (int)MenuParts.BarItem;
- renderer.SetParameters(MenuClass, partID, GetItemState(e.Item));
-
- var bgRect = GetBackgroundRectangle(e.Item);
- renderer.DrawBackground(e.Graphics, bgRect, bgRect);
- }
- else {
- base.OnRenderMenuItemBackground(e);
- }
- }
-
- protected override void OnRenderOverflowButtonBackground(
- ToolStripItemRenderEventArgs e)
- {
- if (EnsureRenderer()) {
- var rebarClass = RebarClass;
- if (Theme == ToolbarTheme.BrowserTabBar) {
- rebarClass = "Rebar";
- }
- var state = VisualStyleElement.Rebar.Chevron.Normal.State;
- if (e.Item.Pressed) {
- state = VisualStyleElement.Rebar.Chevron.Pressed.State;
- }
- else {
- if (e.Item.Selected) {
- state = VisualStyleElement.Rebar.Chevron.Hot.State;
- }
- }
- renderer.SetParameters(
- rebarClass,
- VisualStyleElement.Rebar.Chevron.Normal.Part,
- state);
- renderer.DrawBackground(
- e.Graphics, new Rectangle(Point.Empty, e.Item.Size));
- }
- else {
- base.OnRenderOverflowButtonBackground(e);
- }
- }
-
- protected override void OnRenderSeparator(
- ToolStripSeparatorRenderEventArgs e)
- {
- if (e.ToolStrip.IsDropDown && EnsureRenderer()) {
- renderer.SetParameters(MenuClass, (int)MenuParts.PopupSeparator, 0);
- var rect = new Rectangle(
- e.ToolStrip.DisplayRectangle.Left,
- 0,
- e.ToolStrip.DisplayRectangle.Width,
- e.Item.Height);
- renderer.DrawBackground(e.Graphics, rect, rect);
- }
- else {
- base.OnRenderSeparator(e);
- }
- }
-
- protected override void OnRenderSplitButtonBackground(
- ToolStripItemRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderSplitButtonBackground(e);
- return;
- }
- var sb = (ToolStripSplitButton)e.Item;
- base.OnRenderSplitButtonBackground(e);
- OnRenderArrow(new ToolStripArrowRenderEventArgs(
- e.Graphics,
- sb,
- sb.DropDownButtonBounds,
- Color.Red,
- ArrowDirection.Down));
- }
-
- protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderToolStripBackground(e);
- return;
- }
- if (e.ToolStrip.IsDropDown) {
- renderer.SetParameters(MenuClass, (int)MenuParts.PopupBackground, 0);
- }
- else {
- if (e.ToolStrip.Parent is ToolStripPanel) {
- return;
- }
- if (IsElementDefined(RebarClass, RebarBackground, 0)) {
- renderer.SetParameters(RebarClass, RebarBackground, 0);
- }
- else {
- renderer.SetParameters(RebarClass, 0, 0);
- }
- }
-
- if (renderer.IsBackgroundPartiallyTransparent()) {
- renderer.DrawParentBackground(
- e.Graphics,
- e.ToolStrip.ClientRectangle,
- e.ToolStrip);
- }
- renderer.DrawBackground(
- e.Graphics,
- e.ToolStrip.ClientRectangle,
- e.AffectedBounds);
- }
-
- protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderToolStripBorder(e);
- return;
- }
- renderer.SetParameters(MenuClass, (int)MenuParts.PopupBorders, 0);
- if (e.ToolStrip.IsDropDown) {
- var oldClip = e.Graphics.Clip;
- var insideRect = e.ToolStrip.ClientRectangle;
- insideRect.Inflate(-1, -1);
- e.Graphics.ExcludeClip(insideRect);
- renderer.DrawBackground(
- e.Graphics,
- e.ToolStrip.ClientRectangle,
- e.AffectedBounds);
- e.Graphics.Clip = oldClip;
- }
- }
-
- protected override void OnRenderToolStripPanelBackground(
- ToolStripPanelRenderEventArgs e)
- {
- if (!EnsureRenderer()) {
- base.OnRenderToolStripPanelBackground(e);
- return;
- }
- if (IsElementDefined(RebarClass, RebarBackground, 0)) {
- renderer.SetParameters(RebarClass, RebarBackground, 0);
- }
- else {
- renderer.SetParameters(RebarClass, 0, 0);
- }
-
- if (renderer.IsBackgroundPartiallyTransparent()) {
- renderer.DrawParentBackground(
- e.Graphics,
- e.ToolStripPanel.ClientRectangle,
- e.ToolStripPanel);
- }
- renderer.DrawBackground(e.Graphics, e.ToolStripPanel.ClientRectangle);
-
- e.Handled = true;
- }
-
- private struct VSEInternal
- {
- public readonly string cls;
-
- public readonly int part;
-
- public readonly int state;
-
- public VSEInternal(string cls, int part, int state)
- {
- this.cls = cls;
- this.part = part;
- this.state = state;
- }
- }
-
- private static class NativeMethods
- {
- [DllImport("uxtheme.dll")]
- public extern static int GetThemeMargins(
- IntPtr hTheme,
- IntPtr hdc,
- int iPartId,
- int iStateId,
- int iPropId,
- IntPtr rect,
- out MARGINS pMargins);
-
- [StructLayout(LayoutKind.Sequential)]
- public struct MARGINS
- {
- public int cxLeftWidth;
-
- public int cxRightWidth;
-
- public int cyTopHeight;
-
- public int cyBottomHeight;
- }
- }
- }
-}
diff --git a/NgenInstaller.cs b/NgenInstaller.cs
deleted file mode 100644
index fb516f89..00000000
--- a/NgenInstaller.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-using System.Collections;
-using System.ComponentModel;
-using System.Configuration.Install;
-using System.Diagnostics;
-using System.IO;
-using System.Reflection;
-using System.Runtime.InteropServices;
-
-namespace NMaier.SimpleDlna
-{
- [RunInstaller(true)]
- public class NgenInstaller : Installer
- {
- public override void Install(IDictionary stateSaver)
- {
- base.Install(stateSaver);
- using (var proc = new Process()) {
- proc.StartInfo.FileName = Path.Combine(
- RuntimeEnvironment.GetRuntimeDirectory(),
- "ngen.exe"
- );
- proc.StartInfo.Arguments = $"install /nologo \"{Assembly.GetExecutingAssembly().Location}\"";
- proc.StartInfo.UseShellExecute = false;
- proc.StartInfo.CreateNoWindow = true;
- proc.Start();
- proc.WaitForExit();
- if (proc.ExitCode != 0) {
- throw new Exception("Failed to run ngen");
- }
- }
- }
- }
-}
diff --git a/Readme.md b/Readme.md
index 0806d10c..4a83ebf6 100644
--- a/Readme.md
+++ b/Readme.md
@@ -4,3 +4,415 @@ A simple, zero-config DLNA media server, that you can just fire up and be done w
See [the github page](http://nmaier.github.io/simpleDLNA/) for more details and downloads.
+
+- [Building](#building)
+- [Quick start](#quick-start)
+- [Two ways to run](#two-ways-to-run)
+- [Configuring servers](#configuring-servers)
+ - [The configuration file](#the-configuration-file)
+ - [Adding and removing servers](#adding-and-removing-servers)
+ - [Changing a server](#changing-a-server)
+ - [Editing the file directly](#editing-the-file-directly)
+- [Views](#views)
+- [Restrictions](#restrictions)
+- [Thumbnails and ffmpeg](#thumbnails-and-ffmpeg)
+- [Testing](#testing)
+- [Project layout](#project-layout)
+
+Building
+---
+
+Requires the [.NET SDK 10.0](https://dotnet.microsoft.com/download) or newer.
+
+```
+dotnet build -c Release
+```
+
+Runs on Windows, Linux and macOS.
+
+The examples below write `sdlna` for the program. From a source build, that
+means either of:
+
+```
+dotnet run --project sdlna -- [OPTION]...
+dotnet sdlna/bin/Release/net10.0/sdlna.dll [OPTION]...
+```
+
+Quick start
+---
+
+With nothing set up, just run `sdlna` in a folder to serve it:
+
+```
+cd ~/Videos
+sdlna
+```
+
+It warns that no servers are configured and serves the current directory. Or
+name the folders to serve:
+
+```
+sdlna ~/Videos ~/Music
+```
+
+Either way nothing is saved. To keep a setup, add a server. From then on,
+running `sdlna` on its own starts it, along with any others you add:
+
+```
+sdlna --server add Videos ~/Videos
+sdlna
+```
+
+Any DLNA client on the network, such as a smart TV, games console or VLC,
+finds the server by its name.
+
+Two ways to run
+---
+
+**With folders**, `sdlna ...` serves exactly those folders, using only
+the options on the command line. The configuration file is not read at all.
+
+**Without folders**, `sdlna` starts every server in the
+[configuration file](#the-configuration-file). If no servers are configured,
+because the file doesn't exist or lists none, it serves the current directory
+instead, exactly as `sdlna .` would, and prints a warning explaining how to
+save servers. A configuration file that exists but is invalid is reported as an
+error rather than skipped.
+
+The options split accordingly. `sdlna --help` lists them all, as do `-h`,
+`-?` and, on Windows, `/?`. An unknown option or an invalid value prints the
+error and that list, and exits with status 2.
+
+| Option | With folders, or no servers configured | Configured servers |
+|---|---|---|
+| `-p`, `--port` | HTTP port (default 0: any free port) | Overrides `port` from the file |
+| `-c`, `--cache` | Metadata cache file (default: none) | Overrides `cache` from the file |
+| `-l`, `--log-level`, `--log-file` | Logging | Logging |
+| `--no-rescanning` | Don't watch folders for changes | Same, for every server |
+| `-t`, `-s`, `-d`, `-v`, `-n`, `-i`, `-m`, `-u`, `--seperate` | Media types, sort order, views, name and restrictions | Rejected: each configured server has its own |
+
+Configuring servers
+---
+
+The old Windows GUI let you keep several named servers, each with its own
+folders, media types, views and restrictions. `sdlna --server` does the same
+from the command line, on any platform. Every server runs inside one `sdlna`
+process, on one port.
+
+`sdlna --server help` summarises everything below.
+
+### The configuration file
+
+| Platform | Location |
+|---|---|
+| Linux, macOS | `~/.sdlna/config.json` |
+| Windows | `%USERPROFILE%\.sdlna\config.json` (the `.sdlna` folder is created hidden) |
+
+It is created by the first `sdlna --server add`. A complete example:
+
+```json
+{
+ "port": 8200,
+ "cache": "/home/me/.sdlna/cache.db",
+ "editor": "code --wait",
+ "servers": [
+ {
+ "name": "Movies",
+ "folders": [
+ "/home/me/Movies",
+ "/mnt/nas/Movies"
+ ],
+ "mediaTypes": ["video"],
+ "sortOrder": "title",
+ "sortDirection": "asc",
+ "views": ["series"],
+ "restrictions": {
+ "macs": [],
+ "ips": ["192.168.1.20"],
+ "userAgents": []
+ }
+ },
+ {
+ "name": "Music",
+ "folders": ["/home/me/Music"],
+ "mediaTypes": ["audio"],
+ "sortOrder": "title",
+ "sortDirection": "asc",
+ "views": ["music"],
+ "restrictions": { "macs": [], "ips": [], "userAgents": [] }
+ }
+ ]
+}
+```
+
+**Global settings**
+
+| Key | Meaning | Default |
+|---|---|---|
+| `port` | HTTP port; `0` picks any free port each run. Clients find the server either way, but a fixed port keeps its web page at a stable address. | `0` |
+| `cache` | File that stores media metadata and thumbnails, so restarts don't rescan every file. `"none"` turns it off. A leading `~` means your home folder. If missing or empty, the default is used. Only one `sdlna` process can use a cache file at a time; another one runs without it. | `~/.sdlna/cache.db` |
+| `editor` | Command that `--edit` opens the file with. | `notepad` on Windows, `nano` elsewhere |
+
+**Per server**
+
+| Key | Meaning | Default for a new server |
+|---|---|---|
+| `name` | Name shown to clients. Unique, ignoring case. | (required) |
+| `folders` | Folders to serve; at least one. A folder missing at startup, such as an unmounted drive, is skipped with a warning. | (required) |
+| `mediaTypes` | Any of `video`, `audio`, `images`; at least one. | all three |
+| `sortOrder` | `title`, `date` (file date) or `size`. | `title` |
+| `sortDirection` | `asc` or `desc`. | `asc` |
+| `views` | [Views](#views), applied in order. A server with no views that serves only audio gets `music`. | none |
+| `restrictions` | [Restrictions](#restrictions): `macs`, `ips`, `userAgents`. | none |
+
+### Adding and removing servers
+
+```
+sdlna --server add [...]
+sdlna --server remove
+```
+
+A new server serves video, audio and images, sorted by title ascending, with no
+views and no restrictions. Folders may be relative and must exist. Use quotes
+for names with spaces:
+
+```
+sdlna --server add "Home Videos" ~/Videos /mnt/camera
+```
+
+### Changing a server
+
+```
+sdlna --server config # every setting and every server
+sdlna --server config # one server
+sdlna --server config