Initial commit - HomeStream 0.1.0

This commit is contained in:
administrator 2026-05-10 23:25:29 +02:00
commit c0bb485a58
28 changed files with 2836 additions and 0 deletions

38
Services/AppPaths.cs Normal file
View file

@ -0,0 +1,38 @@
using System.IO;
namespace FritzTV.Services;
/// <summary>
/// Liefert den AppData-Pfad der App. Migriert beim ersten Start automatisch
/// alle Daten vom alten "FritzTV"-Pfad herüber, falls vorhanden.
/// </summary>
public static class AppPaths
{
private const string AppFolderName = "HomeStream";
private const string LegacyFolderName = "FritzTV";
private static readonly Lazy<string> _root = new(() =>
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var newDir = Path.Combine(appData, AppFolderName);
var oldDir = Path.Combine(appData, LegacyFolderName);
// One-Time Migration: alter Ordner existiert, neuer noch nicht
if (Directory.Exists(oldDir) && !Directory.Exists(newDir))
{
try { Directory.Move(oldDir, newDir); }
catch { Directory.CreateDirectory(newDir); }
}
else
{
Directory.CreateDirectory(newDir);
}
return newDir;
});
public static string Root => _root.Value;
public static string Settings => Path.Combine(Root, "settings.json");
public static string Logos => Path.Combine(Root, "logos");
public static string Epg => Path.Combine(Root, "epg");
public static string CrashLog => Path.Combine(Root, "crash.log");
}

40
Services/AppSettings.cs Normal file
View file

@ -0,0 +1,40 @@
using System.IO;
using System.Text.Json;
namespace FritzTV.Services;
public class AppSettings
{
public string FritzBoxIp { get; set; } = "192.168.4.254";
public List<string> Favorites { get; set; } = new();
public string LastChannel { get; set; } = "";
public double Volume { get; set; } = 80;
private static readonly string ConfigPath = AppPaths.Settings;
public static AppSettings Load()
{
try
{
if (File.Exists(ConfigPath))
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
}
catch { /* fallback auf Defaults */ }
return new AppSettings();
}
public void Save()
{
try
{
var dir = Path.GetDirectoryName(ConfigPath)!;
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json);
}
catch { /* nicht kritisch */ }
}
}

33
Services/DarkTitleBar.cs Normal file
View file

@ -0,0 +1,33 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace FritzTV.Services;
/// <summary>
/// Schaltet die Windows-Titelleiste eines WPF-Fensters auf Dark Mode.
/// Funktioniert ab Windows 10 Build 1809; ältere Versionen behalten Standard-Look.
/// </summary>
public static class DarkTitleBar
{
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; // Win10 20H1+
private const int DWMWA_USE_IMMERSIVE_DARK_MODE_OLD = 19; // Win10 18091909
/// <summary>Im SourceInitialized-Handler des Windows aufrufen.</summary>
public static void Apply(Window w)
{
var hwnd = new WindowInteropHelper(w).Handle;
if (hwnd == IntPtr.Zero) return;
int useDark = 1;
if (DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int)) != 0)
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_OLD, ref useDark, sizeof(int));
}
/// <summary>Hängt sich an SourceInitialized — bequemer Helper.</summary>
public static void EnableFor(Window w)
{
w.SourceInitialized += (_, _) => Apply(w);
}
}

214
Services/EpgService.cs Normal file
View file

@ -0,0 +1,214 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Xml.Linq;
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Lädt EPG-Daten als XMLTV von einer öffentlichen Quelle.
/// Cache-Strategie: pro Tag eine Datei in %APPDATA%\FritzTV\epg\
/// XMLTV-Format: https://wiki.xmltv.org/index.php/XMLTVFormat
///
/// Quelle: epg.pw bietet kostenlose XMLTV-Feeds für DE (DVB-T/Cable Lineup),
/// alternative: xmltv.se (für deutsche Sender funktioniert Mappingname).
/// </summary>
public class EpgService
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(2) };
/// <summary>Cache-Verzeichnis für heruntergeladene XMLTV-Dateien</summary>
private static readonly string CacheDir = AppPaths.Epg;
/// <summary>EPG-Quelle: kostenloser XMLTV-Feed für deutschsprachige Sender (DE/AT/CH)</summary>
private const string EpgUrl = "https://epg.pw/xmltv/epg_DE.xml.gz";
/// <summary>In-Memory-Index: Sendername (normalisiert) → Liste der Events, sortiert nach Startzeit</summary>
private Dictionary<string, List<EpgEvent>> _eventsByChannel = new();
private DateTime _loadedAt = DateTime.MinValue;
/// <summary>True wenn Daten geladen sind und nicht älter als 12h</summary>
public bool IsCurrent => _eventsByChannel.Count > 0
&& (DateTime.Now - _loadedAt).TotalHours < 12;
/// <summary>Lädt EPG-Daten (Cache-Hit oder Web), parst und indiziert sie</summary>
public async Task LoadAsync(IProgress<string>? progress = null)
{
Directory.CreateDirectory(CacheDir);
var todayFile = Path.Combine(CacheDir, $"epg_{DateTime.Today:yyyyMMdd}.xml");
if (!File.Exists(todayFile) || new FileInfo(todayFile).Length < 1000)
{
progress?.Report("Lade EPG-Daten…");
await DownloadAndExtractAsync(todayFile);
CleanupOldCache();
}
else
{
progress?.Report("EPG-Cache verwendet");
}
progress?.Report("Parse EPG…");
ParseXmlTv(todayFile);
_loadedAt = DateTime.Now;
progress?.Report($"EPG: {_eventsByChannel.Count} Sender, {_eventsByChannel.Values.Sum(l => l.Count)} Events");
}
/// <summary>Hole alle Events für einen Sender ab jetzt für N Stunden</summary>
public List<EpgEvent> GetEvents(string channelName, int hoursAhead = 24)
{
var key = NormalizeName(channelName);
if (!_eventsByChannel.TryGetValue(key, out var list)) return new();
var now = DateTime.Now;
var until = now.AddHours(hoursAhead);
return list.Where(e => e.EndTime >= now && e.StartTime <= until)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>Aktuelles Event für einen Sender (jetzt laufend)</summary>
public EpgEvent? GetCurrent(string channelName)
=> GetEvents(channelName, 1).FirstOrDefault(e => e.IsCurrent);
// ────────── Internals ──────────
private static async Task DownloadAndExtractAsync(string targetFile)
{
var tmpGz = targetFile + ".gz";
try
{
using (var resp = await _http.GetAsync(EpgUrl, HttpCompletionOption.ResponseHeadersRead))
{
resp.EnsureSuccessStatusCode();
using var fs = File.Create(tmpGz);
await resp.Content.CopyToAsync(fs);
}
// Gzip entpacken
using var inStream = File.OpenRead(tmpGz);
using var gz = new GZipStream(inStream, CompressionMode.Decompress);
using var outStream = File.Create(targetFile);
await gz.CopyToAsync(outStream);
}
finally
{
try { if (File.Exists(tmpGz)) File.Delete(tmpGz); } catch { }
}
}
private void ParseXmlTv(string path)
{
// Sender-ID → Sendername (xmltv hat <channel id="..."><display-name>...</display-name></channel>)
var idToName = new Dictionary<string, string>();
var events = new Dictionary<string, List<EpgEvent>>(StringComparer.OrdinalIgnoreCase);
using var fs = File.OpenRead(path);
var doc = XDocument.Load(fs);
var root = doc.Root;
if (root == null) return;
foreach (var ch in root.Elements("channel"))
{
var id = ch.Attribute("id")?.Value ?? "";
var name = ch.Element("display-name")?.Value ?? "";
if (id.Length > 0 && name.Length > 0)
idToName[id] = name;
}
foreach (var prog in root.Elements("programme"))
{
var channelId = prog.Attribute("channel")?.Value;
if (channelId == null || !idToName.TryGetValue(channelId, out var channelName))
continue;
var startStr = prog.Attribute("start")?.Value ?? "";
var stopStr = prog.Attribute("stop")?.Value ?? "";
var start = ParseXmltvTime(startStr);
var stop = ParseXmltvTime(stopStr);
if (start == DateTime.MinValue || stop == DateTime.MinValue) continue;
var title = prog.Element("title")?.Value ?? "";
var desc = prog.Element("desc")?.Value;
var subtitle = prog.Element("sub-title")?.Value;
var ev = new EpgEvent
{
ChannelName = channelName,
Title = title,
Description = !string.IsNullOrWhiteSpace(subtitle) ? subtitle : desc,
StartTime = start,
Duration = stop - start
};
var key = NormalizeName(channelName);
if (!events.TryGetValue(key, out var list))
events[key] = list = new List<EpgEvent>();
list.Add(ev);
}
// Sortieren je Sender
foreach (var k in events.Keys.ToList())
events[k] = events[k].OrderBy(e => e.StartTime).ToList();
_eventsByChannel = events;
}
/// <summary>XMLTV-Zeit "20260510120000 +0200" → DateTime (lokale Zeit)</summary>
private static DateTime ParseXmltvTime(string s)
{
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
try
{
// Format: "yyyyMMddHHmmss +0200" oder ohne Offset
var parts = s.Trim().Split(' ', 2);
var dt = DateTime.ParseExact(parts[0], "yyyyMMddHHmmss",
System.Globalization.CultureInfo.InvariantCulture);
if (parts.Length == 2 && parts[1].Length == 5)
{
var sign = parts[1][0] == '-' ? -1 : 1;
var hh = int.Parse(parts[1].Substring(1, 2));
var mm = int.Parse(parts[1].Substring(3, 2));
var offsetMin = sign * (hh * 60 + mm);
// dt war als UTC+offset interpretiert, nach lokaler Zeit konvertieren
var asUtc = new DateTimeOffset(dt, TimeSpan.FromMinutes(offsetMin));
return asUtc.LocalDateTime;
}
return dt;
}
catch { return DateTime.MinValue; }
}
/// <summary>Sendername normalisieren für Matching (FritzBox vs XMLTV)</summary>
public static string NormalizeName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "";
var s = name.Trim().ToLowerInvariant();
// HD/SD-Suffixe entfernen
string[] suffixes = { " hd", " uhd", " 4k", " sd", " austria", " österreich", " schweiz" };
foreach (var suf in suffixes)
if (s.EndsWith(suf)) s = s[..^suf.Length].TrimEnd();
// Sonderzeichen rauswerfen
s = new string(s.Where(c => char.IsLetterOrDigit(c) || c == ' ').ToArray());
s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return s;
}
private static void CleanupOldCache()
{
try
{
var files = Directory.GetFiles(CacheDir, "epg_*.xml*");
foreach (var f in files)
{
var info = new FileInfo(f);
if ((DateTime.Now - info.LastWriteTime).TotalDays > 2)
info.Delete();
}
}
catch { }
}
}

View file

@ -0,0 +1,41 @@
using System.Net.Http;
using System.Text;
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Lädt M3U-Listen von der FritzBox und parst sie in Channel-Objekte.
/// FritzBox-Endpunkte:
/// /dvb/m3u/tvsd.m3u
/// /dvb/m3u/tvhd.m3u
/// /dvb/m3u/radio.m3u
/// </summary>
public class FritzBoxClient
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly string _baseUrl;
public FritzBoxClient(string fritzBoxIp)
{
_baseUrl = $"http://{fritzBoxIp}";
}
public Task<List<Channel>> LoadTvSdAsync() => LoadAsync("/dvb/m3u/tvsd.m3u", ChannelKind.TvSd);
public Task<List<Channel>> LoadTvHdAsync() => LoadAsync("/dvb/m3u/tvhd.m3u", ChannelKind.TvHd);
public Task<List<Channel>> LoadRadioAsync() => LoadAsync("/dvb/m3u/radio.m3u", ChannelKind.Radio);
public async Task<List<Channel>> LoadAllAsync()
{
var tasks = new[] { LoadTvSdAsync(), LoadTvHdAsync(), LoadRadioAsync() };
var results = await Task.WhenAll(tasks);
return results.SelectMany(r => r).ToList();
}
private async Task<List<Channel>> LoadAsync(string path, ChannelKind kind)
{
var url = _baseUrl + path;
var content = await _http.GetStringAsync(url);
return M3UParser.Parse(content, kind);
}
}

250
Services/LogoService.cs Normal file
View file

@ -0,0 +1,250 @@
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace FritzTV.Services;
/// <summary>
/// Lädt Sender-Logos von AVM und cacht sie lokal in %APPDATA%\FritzTV\logos\
///
/// Zwei Quellen:
/// 1. http://tv.avm.de/tvapp/logos/ (von der FRITZ!App TV genutzt, aktuell, hat auch Radio)
/// 2. https://download.avm.de/tv/logos/ (alter Server, Stand 2017, nur TV)
///
/// Logo-Filename-Konvention: <sendername lowercased, special chars zu '_'>.png
/// Beispiele:
/// "Das Erste HD" -> "das_erste_hd.png" (mit fallback "das_erste.png")
/// "Bayern 1" -> "bayern_1.png"
/// "MDR SPUTNIK" -> "mdr_sputnik.png"
///
/// Wir probieren mehrere Filename-Varianten pro Sender und cachen sowohl Treffer
/// als auch "kein Logo verfügbar" (negativer Cache, vermeidet wiederholte 404er).
/// </summary>
public class LogoService
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
private static readonly string CacheDir = AppPaths.Logos;
private static readonly string[] BaseUrls =
{
"http://tv.avm.de/tvapp/logos/", // primär: aktuell, hat Radio
"https://download.avm.de/tv/logos/" // fallback: älter, nur TV
};
/// <summary>Negative-Cache-Datei: Sender für die kein Logo gefunden wurde</summary>
private static string MissingCachePath => Path.Combine(CacheDir, "_missing.txt");
private readonly HashSet<string> _missingCache;
public LogoService()
{
Directory.CreateDirectory(CacheDir);
_missingCache = LoadMissingCache();
}
/// <summary>
/// Lokaler Pfad zum Logo. Lädt im Hintergrund nach falls nicht im Cache.
/// Gibt null zurück wenn kein Logo verfügbar ist.
/// </summary>
public async Task<string?> GetLogoPathAsync(string channelName)
{
if (string.IsNullOrWhiteSpace(channelName)) return null;
// 1. Erst im File-Cache schauen — schon mal heruntergeladen?
var cached = GetCachedLogoPath(channelName);
if (cached != null) return cached;
// 2. Negativer Cache: vor kurzem 404 gehabt? Dann nicht erneut probieren.
var nameKey = NormalizeForCacheKey(channelName);
if (_missingCache.Contains(nameKey)) return null;
// 3. Online probieren — verschiedene Filename-Varianten, alle BaseUrls
var candidates = GenerateFilenameCandidates(channelName);
foreach (var fn in candidates)
{
foreach (var baseUrl in BaseUrls)
{
var url = baseUrl + fn;
try
{
var bytes = await _http.GetByteArrayAsync(url);
if (bytes.Length > 100) // Sanity-Check (404-Page wäre kleiner oder anders)
{
var localPath = Path.Combine(CacheDir, fn);
await File.WriteAllBytesAsync(localPath, bytes);
// Auch unter dem Sender-Namen verlinken (für GetCachedLogoPath)
SaveNameMapping(channelName, fn);
return localPath;
}
}
catch { /* 404 oder Netzwerkfehler -> nächster Kandidat */ }
}
}
// Kein Logo gefunden — im Negativ-Cache merken
_missingCache.Add(nameKey);
SaveMissingCache();
return null;
}
/// <summary>Synchroner Cache-Lookup ohne Netzwerk (für UI-Initial-Bindung)</summary>
public string? GetCachedLogoPath(string channelName)
{
if (string.IsNullOrWhiteSpace(channelName)) return null;
// Über Mapping-Datei: SendernameNormalisiert -> Filename
var mapping = LoadNameMapping();
var key = NormalizeForCacheKey(channelName);
if (mapping.TryGetValue(key, out var fn))
{
var p = Path.Combine(CacheDir, fn);
if (File.Exists(p)) return p;
}
// Fallback: erste Filename-Variante direkt probieren
foreach (var fn2 in GenerateFilenameCandidates(channelName))
{
var p = Path.Combine(CacheDir, fn2);
if (File.Exists(p)) return p;
}
return null;
}
// ────────── Filename-Kandidaten ──────────
/// <summary>
/// Generiert plausible Filename-Varianten für einen Sender.
/// Reihenfolge: voller Name → ohne Region → ohne Suffix → compact.
/// Bei mehreren Wörtern werden vom Ende Wörter abgeschnitten,
/// damit "WDR HD Köln" → "wdr_hd_koeln", dann "wdr_hd", dann "wdr".
/// </summary>
private static IEnumerable<string> GenerateFilenameCandidates(string name)
{
var seen = new HashSet<string>();
var lower = ReplaceUmlauts(name.ToLowerInvariant().Trim());
var noSuffix = StripResolutionSuffix(lower);
// 1. Volle Form mit HD/SD-Suffix
foreach (var c in CandidatesFor(lower, seen)) yield return c;
// 2. Ohne Auflösungs-Suffix
foreach (var c in CandidatesFor(noSuffix, seen)) yield return c;
// 3. Mit explizitem _hd ergänzt
foreach (var c in CandidatesFor(noSuffix + " hd", seen)) yield return c;
// 4. Bei mehreren Wörtern: vom Ende abkürzen
// "wdr hd köln" → "wdr hd" → "wdr"
var words = noSuffix.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries);
for (int n = words.Length - 1; n >= 1; n--)
{
var shortened = string.Join(" ", words.Take(n));
foreach (var c in CandidatesFor(shortened, seen)) yield return c;
// mit _hd Variante
foreach (var c in CandidatesFor(shortened + " hd", seen)) yield return c;
}
}
private static IEnumerable<string> CandidatesFor(string input, HashSet<string> seen)
{
var slug = ToSlug(input);
if (slug.Length == 0) yield break;
// Underscore-Variante
var fn1 = slug + ".png";
if (seen.Add(fn1)) yield return fn1;
// Compact ohne Underscore (z.B. "n-tv" → "ntv")
var compact = slug.Replace("_", "");
if (compact.Length > 0)
{
var fn2 = compact + ".png";
if (seen.Add(fn2)) yield return fn2;
}
}
/// <summary>Deutsche Umlaute zu ASCII-Equivalenten (ö → oe etc.)</summary>
private static string ReplaceUmlauts(string s)
{
return s.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue")
.Replace("ß", "ss");
}
private static string ToSlug(string s)
{
// Zeichen die kein Buchstabe/Ziffer sind → "_", dann mehrfache "_" auf eins reduzieren
var slug = Regex.Replace(s, @"[^a-z0-9]+", "_").Trim('_');
return slug;
}
private static string StripResolutionSuffix(string lower)
{
// Suffix mit Whitespace davor: " hd", " uhd", " sd", " 4k"
return Regex.Replace(lower, @"\s+(hd|uhd|sd|4k)$", "").Trim();
}
// ────────── Negativ-Cache (vermeidet wiederholte 404er) ──────────
private static string NormalizeForCacheKey(string name)
=> StripResolutionSuffix(name.ToLowerInvariant().Trim());
private HashSet<string> LoadMissingCache()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(MissingCachePath))
{
// Datei zu alt? Nach 7 Tagen verwerfen — vielleicht hat AVM Logos ergänzt
var age = DateTime.Now - new FileInfo(MissingCachePath).LastWriteTime;
if (age.TotalDays < 7)
{
foreach (var line in File.ReadAllLines(MissingCachePath))
if (!string.IsNullOrWhiteSpace(line)) set.Add(line.Trim());
}
}
}
catch { }
return set;
}
private void SaveMissingCache()
{
try { File.WriteAllLines(MissingCachePath, _missingCache); } catch { }
}
// ────────── Name-Mapping (Senderalias -> Filename) ──────────
private static string MappingPath => Path.Combine(CacheDir, "_mapping.txt");
private static Dictionary<string, string> LoadNameMapping()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(MappingPath))
{
foreach (var line in File.ReadAllLines(MappingPath))
{
var parts = line.Split('|', 2);
if (parts.Length == 2) dict[parts[0]] = parts[1];
}
}
}
catch { }
return dict;
}
private static void SaveNameMapping(string channelName, string filename)
{
try
{
var dict = LoadNameMapping();
dict[NormalizeForCacheKey(channelName)] = filename;
var lines = dict.Select(kv => $"{kv.Key}|{kv.Value}");
File.WriteAllLines(MappingPath, lines);
}
catch { }
}
}

61
Services/M3UParser.cs Normal file
View file

@ -0,0 +1,61 @@
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Parser für M3U-Playlists der FritzBox.
/// Format:
/// #EXTM3U
/// #EXTINF:0,Sendername
/// #EXTVLCOPT:network-caching=1000
/// rtsp://192.168.x.x:554/?avm=1&...
/// </summary>
public static class M3UParser
{
public static List<Channel> Parse(string content, ChannelKind kind)
{
var channels = new List<Channel>();
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
string? pendingName = null;
int number = 1;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("#EXTINF:"))
{
var commaIdx = line.IndexOf(',');
pendingName = commaIdx >= 0 ? line[(commaIdx + 1)..].Trim() : null;
}
else if (line.StartsWith("#")) // andere Direktiven (#EXTM3U, #EXTVLCOPT)
{
continue;
}
else if (pendingName != null)
{
// Leere Programmslots ausblenden (Sender deren Name nur aus Punkten
// oder Sonderzeichen besteht, z.B. ".", "...", "-")
var trimmed = pendingName.Trim();
bool isPlaceholder = trimmed.Length == 0
|| trimmed.All(c => c == '.' || c == '-' || c == '_' || char.IsWhiteSpace(c));
if (!isPlaceholder)
{
channels.Add(new Channel
{
Name = pendingName,
Url = line,
Kind = kind,
Number = number++
});
}
pendingName = null;
}
}
return channels;
}
}