using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Timers; using Playnite.SDK; using Playnite.SDK.Data; using Playnite.SDK.Events; using Playnite.SDK.Models; using Playnite.SDK.Plugins; namespace PunktfunkSync { /// /// Writes the Playnite library to punktfunk-library.json whenever it changes, to two /// places: this plugin's data directory (…/Playnite/ExtensionsData/<this-id>/) and — /// best-effort — the host's ingest inbox (%ProgramData%\punktfunk\ingest\playnite\). The /// second matters because the host's plugin runner is de-privileged (NT AUTHORITY\LocalService) /// and cannot read this user's %APPDATA%; punktfunk-host plugins enable makes the /// ingest inbox user-writable so we can drop the file where the runner can read it. This side /// stays a dumb, dependency-free exporter. /// public class PunktfunkSyncPlugin : GenericPlugin { private const int Schema = 1; private static readonly ILogger logger = LogManager.GetLogger(); // Stable id → stable ExtensionsData folder. Never change it (it's the folder name the reader // finds), and keep it distinct from every other extension. public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b"); private readonly string exportPath; private readonly string ingestPath; private readonly Timer debounce; public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api) { Properties = new GenericPluginProperties { HasSettings = false }; exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json"); // The host runner is de-privileged and can't read our %APPDATA%; drop a copy in the // host's user-writable ingest inbox too (see WriteAtomic). Keep this in lockstep with the // TS reader's `ingestDir()` (`/ingest/playnite`). ingestPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "punktfunk", "ingest", "playnite", "punktfunk-library.json"); // Coalesce bursts of per-item events (a metadata download touches many games) into one write. debounce = new Timer(3000) { AutoReset = false }; debounce.Elapsed += (s, e) => Export("changed"); } public override void OnApplicationStarted(OnApplicationStartedEventArgs args) { // Initial export, then react to any subsequent library mutation. Export("app-started"); PlayniteApi.Database.Games.ItemCollectionChanged += (s, e) => ScheduleExport(); PlayniteApi.Database.Games.ItemUpdated += (s, e) => ScheduleExport(); } public override void OnLibraryUpdated(OnLibraryUpdatedEventArgs args) { Export("library-updated"); } public override IEnumerable GetMainMenuItems(GetMainMenuItemsArgs args) { yield return new MainMenuItem { Description = "Export library to Punktfunk now", MenuSection = "@Punktfunk Sync", Action = a => Export("manual"), }; } private void ScheduleExport() { debounce.Stop(); debounce.Start(); } /// Resolve a Playnite media reference to an absolute path (remote URLs pass through). private string ResolveArt(string reference) { if (string.IsNullOrEmpty(reference)) return null; if (reference.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return reference; try { var full = PlayniteApi.Database.GetFullFilePath(reference); return File.Exists(full) ? full : null; } catch (Exception ex) { logger.Warn(ex, $"Punktfunk Sync: could not resolve art path '{reference}'"); return null; } } private void Export(string reason) { try { var games = PlayniteApi.Database.Games.Select(g => new ExportedGame { Id = g.Id.ToString("D"), Name = g.Name ?? string.Empty, Installed = g.IsInstalled, Hidden = g.Hidden, Source = g.Source?.Name, Platforms = g.Platforms?.Select(p => p.Name).ToList() ?? new List(), Cover = ResolveArt(g.CoverImage), Background = ResolveArt(g.BackgroundImage), Icon = ResolveArt(g.Icon), }).ToList(); var export = new LibraryExport { Schema = Schema, GeneratedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), Playnite = new PlayniteInfo { Version = PlayniteApi.ApplicationInfo.ApplicationVersion?.ToString(), Mode = PlayniteApi.ApplicationInfo.Mode.ToString(), }, Games = games, }; WriteAtomic(Serialization.ToJson(export, true)); logger.Info($"Punktfunk Sync: exported {games.Count} game(s) ({reason}) to {exportPath}"); } catch (Exception ex) { logger.Error(ex, "Punktfunk Sync: export failed"); } } /// /// Write the export to its ExtensionsData home (always) and to the host ingest inbox /// (best-effort — only when a punktfunk host is present on this box, so we never create a /// stray %ProgramData%\punktfunk on a machine that has no host). /// private void WriteAtomic(string json) { WriteAtomicTo(exportPath, json); try { var pfDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "punktfunk"); if (Directory.Exists(pfDir)) WriteAtomicTo(ingestPath, json); } catch (Exception ex) { // The inbox only exists (and is writable) where `plugins enable` has run; a failure // here just means the de-privileged runner falls back to the ExtensionsData scan. logger.Info($"Punktfunk Sync: ingest drop skipped ({ex.Message})"); } } /// Write via a temp file + move so a reader never sees a half-written export. private static void WriteAtomicTo(string path, string json) { Directory.CreateDirectory(Path.GetDirectoryName(path)); var tmp = path + ".tmp"; File.WriteAllText(tmp, json); if (File.Exists(path)) File.Delete(path); File.Move(tmp, path); } } }