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