TV Aufnahme von Init7 auf Synology NAS

Init7 ist ein Internet-Provider aus Winterthur mit einem eigenen TV Streaming Angebot. Während man das TV-Angebot mittels Apple TV Box oder ähnlichem direkt konsumieren kann, sind TV-Aufnahmen nicht out of the Box vorgesehen. Der Anschluss ermöglicht es allerdings auch, TV-Sendungen direkt via UDP-Stream anzuschauen, z.B. mit VLC. Diese Streams lassen sich mit geeigneten Tools auch aufzeichnen.

Zunächst benötigt man die UDP Adresse des gewünschten Senders. Der Anbieter stellt auf seiner Homepage Playlist-Files zur Verfügung. Details findet unter folgender URL:

https://www.init7.net/de/support/faq/TV-andere-Geraete/

Es findet sich ein Link für eine

Öffnet man eines dieser Files in einem beliebigen Text-Editor, lässt sich die URL des gewünschten Senders herauskopieren. Beispielsweise für SRF1: udp://@239.77.0.77:5000

Um eine Sendung aufzunehmen, kann man das Commandline-Tool ffmpeg verwenden.

Beispiel: Mit diesen Parameter wird 10.5 Minuten von SRF1 aufgenommen und in die Datei Aufnahme.ts gespeichert.

ffmpeg -y -nostdin -hide_banner \
  -i 'udp://@239.77.0.77:5000' \
  -to 00:10:30.0 \
  -vcodec copy -acodec copy -scodec copy \
  -map 0:v -map 0:a -map 0:s \
  Aufnahme.ts

Der Werzeugkasten

Eine Aufnahme auf der Kommandozeile starten zu können, ist eine gute Ausgangslage, genügt aber gehobenen Ansprüchen noch nicht wirklich. Typischerweise sitzt man nicht unbedingt am Rechner, wenn die Sendung läuft, die man gerne aufnehmen möchte.

  1. Um nicht den Laptop laufen lassen zu müssen, soll die Aufnahme direkt auf dem Synology NAS gemacht und auch dort gespeichert werden.
  2. Die Aufnahme soll zu einem vordefinierten Zeitpunkt starten und stoppen.
  3. Da man die Sendung selten präzise erwisch, sollen Start und Ende getrimmt werden.
  4. Die fertige Sendung möchte man sich danach aber wieder auf dem TV im Wohnzimmer angucken.

Synology NAS ausstatten

Auf dem Betriebssystem vom NAS ist zwar ffmpeg mitgeliefert, aus welchen Gründen auch immer, aber nur mit wenigen Optionen ausgestattet. In der Version auf DSM-7 in /bin/ffmpeg, fehlt das nötige Protokoll um einen UDP-Stream als Input verwenden zu können.

ffmpeg -hide_banner -protocols
Supported file protocols:
Input:
  file
  pipe
Output:
  file
  pipe

Im Synology Community Paket-Repository findet man allerdings eine voll funktionsfähige Alternative:

ffmpeg

Auf DSM-7 werden die Pakete auf das Hauptvolume in den Ordner „@appstore“ installiert.

/volume1/@appstore/ffmpeg/bin/ffmpeg -version
ffmpeg version 4.4.3-47 Copyright (c) 2000-2022 the FFmpeg developers
...

/volume1/@appstore/ffmpeg/bin/ffmpeg -hide_banner -protocols
Supported file protocols:
Input:
  async
...
  udp
...

Um in einem Script diese Version zu verwenden, sollte man den Pfad voll qualifiziert angeben um nicht aus versehen die mitgelieferte Version von DSM-7 zu verwenden.

Zeitgesteuerte Aufnahme

Um eine Aufnahme zu einem bestimmten Zeitpunkt zu machen, verwendet man am einfachsten den Task-Scheduler von DSM-7, das ist eine Standardfunktionalität von Synology.

Im Task Scheduler lässt sich für eine Aufnahme ein neuer Task erstellen.

Unter Schedul gibt man die gewünschte Zeit und Datum der Aufnahme ein.

Unter User-defined script fügt man das Script für die Aufnahme ein. Die Aufnahmedauer lässt sich entweder mit dem Parameter -t in Sekunden oder mit dem Parameter -to als Zeitdauer angeben.

Sinnvollerweise nimmt man die Sendung nicht zu knapp auf, so dass weder der Start noch das Ende abgeschnitten werden.

ts=$(/bin/date '+%Y-%m-%d_%H%M')
adr='udp://@239.77.0.100:5000'
sender="ZDF_Neo"
ffmpeg='/volume1/@appstore/ffmpeg/bin/ffmpeg'

$ffmpeg -y -nostdin -hide_banner -i ${adr} \
  -vcodec copy -acodec copy \
  -map 0:v -map 0:a \
  -to 01:50:00.0 /volume1/video/aufnahmen/${ts}_${sender}.ts

Trimmen von Start und Ende

Nachdem die Aufnahme getätigt wurde, kann für die Aufbewahrung die Aufnahme auf die effektive Sendung trimmen. Dazu muss man allerdings den genauen Start und das genaue Ende der Sendung innerhalb der Aufnahme ausfindig machen.

Um Start und Dauer der Sendung herauszufinden, betrachtet man diese in einem geeigneten Videoplayer, z.B. VLC.

Herausschneiden eines zusammengehörigen Teils kann mit folgendem Befehl gemacht werden:

ffmpeg -i Aufnahme.ts -ss 00:12:56.0 -to 01:50:40.0 -map 0:0 -map 0:3 -c:v copy -c:a:0 copy -y Resultat.ts

Abspielen auf dem TV-Gerät

Die geschnittenen Aufnahmen belässt man sinnvollerweise gleich auf dem NAS, da dort normalerweise genügend Platz vorhanden ist. Um diese z.B. via Apple TV Box abzuspielen, kann man darauf ebenfalls VLC installieren und damit auf die NAS-Freigaben zuzugreifen.

Connectiontest von loadbalancedten Services

Wer Webapps oder Restservices hinter einem Loadbalancer betreibt und diese überwachen will, steht zwecks Monitoring vor der Herausforderung, die einzelnen Nodes einzeln zu überwachen zu müssen. Wird auf allen Verbindungen SSL verlangt, muss dein Request auf einen Zielnode einerseits SSL machen und auf der anderen Seite aber einen anderen Hostnamen ansprechen wie der Hostname in der URL.

Mit curl lässt sich diese Aufgabe elegant lösen.

curl --connect-to node1.example.com:443 https://api.example.com/myservice/myaction

Dabei kann mit dem Parameter „–connect-to“ der Hostname/Port angegeben werden, mit dem curl sprechen soll und mit dem URL-Parameter die aufzurufende URL.

Achtung Windows

Während bei den meisten Linux-Distributionen curl standardmässig installiert ist oder einfach als Package installieren kann, muss man unter Windows curl.exe nachinstallieren.

Powershell liefert standardmässig ein Alias namens „curl“, das auf „Invoke-Webrequest“ umleitet. Dieses Commandlet verfügt aber über ganz andere Parameter und ist mit dem regulären curl überhaupt nicht kompatibel.

C#11 String Literals für Unit-Tests

Wollte man grössere Datenstrukturen als Input für Unit-Tests verwenden, war bislang der einfachste Weg, im Projekt ein Json-File mit den entsprechenden Daten abzulegen. Neu können Json-Daten auch inline in den Code kopiert werden ohne diese mühsam escapen zu müssen.

Anbei ein Beispiel-Datensatz:

const string testjson = """
{
    "CreationDate": "2022-11-14T00:00:00+01:00",
    "DocumentLanguage": "de",
    "TemplateName": "LetterTemplate",
    "DocumentId": "b12056b8-4452-4df1-b83c-b288c1c9fd35",
    "IsVerified": true
}
""";

Wichtig ist dabei, die 3 Quotes jeweils auf einer separaten Zeile zu belassen. Falls die letzte Zeile eingerückt wird, sind die entsprechenden Spaces/Tabs nicht im String-Literal enthalten.

DataTest.cs

namespace UnitTests;

public static class DataTests
{
    const string testjson = """
{
    "CreationDate": "2022-11-14T00:00:00+01:00",
    "DocumentLanguage": "de",
    "TemplateName": "LetterTemplate",
    "DocumentId": "b12056b8-4452-4df1-b83c-b288c1c9fd35",
    "IsVerified": true
}
    """;

    public enum Language { de, fr, it, en }

    public record DocumentMetadata(
        Guid DocumentId,
        Language DocumentLanguage,
        string TemplateName,
        bool IsVerified,
        DateTimeOffset CreationDate);

    private static JsonSerializerOptions serializerOptions
        = new JsonSerializerOptions
        {
            Converters = {
                new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, true)
            }
        };

    [Fact]
    public static void TestWithData()
    {
        DocumentMetadata? dm = JsonSerializer.Deserialize<DocumentMetadata>(testjson, serializerOptions);

        dm.Should().NotBeNull();
        dm.CreationDate.Should().Be(new DateTimeOffset(2022, 11, 14, 0, 0, 0, TimeSpan.FromHours(1)));
        dm.DocumentLanguage.Should().Be(Language.de);
        dm.TemplateName.Should().Be("LetterTemplate");
        dm.IsVerified.Should().BeTrue();
    }
}

Usings.cs

global using FluentAssertions;
global using Xunit;
global using System.Text.Json.Serialization;
global using System.Text.Json;

UnitTests.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="FluentAssertions" Version="6.8.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
    <PackageReference Include="xunit" Version="2.4.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="3.1.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

</Project>

Zugriff auf MSSQL mit dotnet unter Linux

Seit Microsoft Dotnet Core auch unter Linux zur Verfügung stellt, ist es ein leichtest Applikationen mit C# auch auf dieser Plattform laufen zu lassen. Möchte man eine bestehende Applikation allerdings portieren stösst man auf verschiedene Hindernisse, welche nicht so offensichtlich sind.

Viele C#-Applikationen verwenden eine CRUD-Architektur, sie verwenden Applikationslogik und greifen auf eine SQL-Server Datenbank zu. Innerhalb einer Windows-Domäne kommt dabei häufig Integrated Security zum Einsatz. Dies hat den Vorteil, dass in der Applikation keine Passwörter für den Zugriff auf Ressourcen aller Art hinterlegt werden müssen.

Dasselbe Konzept klappt auch unter Linux und damit auch innerhalb von Docker-Container, wenn man es richtig anstellt. Das folgende Beispiel zeigt, wie man in einer C#-Consolen Applikation auf eine SQL-Server Datenbank zugreift.

Rahmenbedingungen

Auf der Linux-Kiste wird ein Dotnet Core SDK benötigt um das Projekt erstellen, builden und laufen lassen zu können. Letzteres benötigt kein SDK, man kann eine Dotnet-Core Applikation auch standalone unter Linux laufen lassen.

Dotnet-Projekt erstellen

dotnet new console -lang "C#" -n SqlClientTest
cd SqlClientTest

Sicherstellen, dass im csproj-File die LangVersion-Einstellung vorhanden ist:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Data.SqlClient" Version="4.6.0" />
  </ItemGroup>
</Project>

Beispielprogramm

using System;
using System.Threading.Tasks;
using System.Data.SqlClient;

class Program
{
	private const string TestConnectionString =
		"data source=db.beispiel.local;Initial Catalog=Test;MultipleActiveResultSets=True;App=SqlClientTest;Integrated Security=true";
	private const string ProdConnectionString =
		"data source=db.beispiel.local;Initial Catalog=Test;MultipleActiveResultSets=True;App=SqlClientTest;Integrated Security=true";

	static async Task Main(string[] args)
	{
		var useProd = args != null && args.Length > 0 && args[0].Equals("prd", StringComparison.InvariantCultureIgnoreCase);
		var connectionString = useProd ? ProdConnectionString : TestConnectionString;
		
		try {
			Console.WriteLine("Query SQL-Server Edition");
			await RunQuery(connectionString);
		} catch(Exception ex) {
			Console.Error.WriteLine(ex.Message);
		}
	}

	static async Task RunQuery(string connectionString)
	{
		Console.WriteLine(connectionString);
		using (var db = new SqlConnection(connectionString)) {
			await db.OpenAsync();

			var sqlQuery = "select serverproperty('MachineName') as  MachineName, serverproperty('Edition') as Edition, serverproperty('ProductVersion') as ProductVersion";
			var cmd = new SqlCommand(sqlQuery, db);
			var rdr = await cmd.ExecuteReaderAsync();
			while (await rdr.ReadAsync()) {
				Console.WriteLine($"MachineName={rdr[0]}, Edition={rdr[1]}, Version={rdr[2]}");
			}
		}
	}
}

Nuget-Package System.Data.SqlClient hinzufügen

dotnet add package System.Data.SqlClient -s http://packages.argus.local/nuget/Argus/

Projekt kompilieren

dotnet build

Projekt laufen lassen

user@testhost:~/thomy/SqlClientTest$ dotnet run
Query SQL-Server Edition
data source=db.beispiel.local;Initial Catalog=Test;MultipleActiveResultSets=True;App=SqlClientTest;Integrated Security=true
MachineName=dbserver, Edition=Standard Edition (64-bit), Version=13.0.4466.4
a

Preisfrage: Was fehlt, damit genau das läuft? Ohne weiteres Zutun wird da eher eine Fehlermeldung kommen wie:

Cannot authenticate using Kerberos. Ensure Kerberos has been initialized on the client with 'kinit' and a Service Principal Name has been registered for the SQL Server to allow Kerberos authentication.

Die Fehlermeldung liefert auch schon die Antwort: Kerberos installieren und mit kinit ein Ticket vom KDC abholen.

Wenn nicht bereits vorhanden, stellt man sicher, dass das Package krb5-user auf dem System installiert ist.

apt install krb5-user

Bei der Installation wird eine Beispiel-Konfiguration in /etc/krb5.conf angelegt, welche man aber besser noch etwas anpasst. Ich verwende folgende Konfiguration:

# /etc/krb5.conf -- Kerberos V5 general configuration.
#

[appdefaults]
    default_lifetime      = 25hrs
    krb4_convert          = false
    krb4_convert_524      = false

[libdefaults]
    default_realm         = BEISPIEL.LOCAL
    ticket_lifetime       = 25h
    renew_lifetime        = 7d
    forwardable           = true
    noaddresses           = true
    allow_weak_crypto     = true
    rdns                  = false

[realms]
     BEISPIEL.LOCAL = {
        kdc            = ads1.beispiel.local
        kdc            = ads2.beispiel.local
        default_domain = beispiel.local
    }

[domain_realm]
    argus.local    = ARGUS.LOCAL

[logging]
    kdc          = SYSLOG:NOTICE
    admin_server = SYSLOG:NOTICE
    default      = SYSLOG:NOTICE

Mit kinit kann man sich nun ein Ticket vom KDC abholen, danach funktioniert auch die SQL-Server-Connection

user@testhost:~/thomy/SqlClientTest$ kinit vorname.nachname
Password for vorname.nachname@BEISPIEL.LOCAL: