Files
punktfunk-plugin-playnite/exporter/PunktfunkSyncPlugin.cs
T
enricobuehler e832201669
CI / publish (push) Successful in 17s
CI / exporter (push) Successful in 11s
CI / build (push) Successful in 20s
fix: read/write the library via the host ingest inbox (de-privileged runner)
The host plugin runner is de-privileged now (LocalService) and can't read the
interactive user's %APPDATA%\Playnite\ExtensionsData — so it never saw the
export. Bridge it through the host's user-writable ingest inbox:

- exporter (C#): also drop punktfunk-library.json into
  %ProgramData%\punktfunk\ingest\playnite (best-effort, only when a punktfunk
  host is present), alongside its ExtensionsData home.
- plugin (TS): locateExport() reads <config_dir>/ingest/playnite first, then
  falls back to the ExtensionsData scan (same-user/SYSTEM runner, Linux). The
  watcher also watches the inbox so a drop reconciles within seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:40:28 +02:00

170 lines
7.1 KiB
C#

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
{
/// <summary>
/// Writes the Playnite library to <c>punktfunk-library.json</c> whenever it changes, to two
/// places: this plugin's data directory (<c>…/Playnite/ExtensionsData/&lt;this-id&gt;/</c>) and —
/// best-effort — the host's ingest inbox (<c>%ProgramData%\punktfunk\ingest\playnite\</c>). The
/// second matters because the host's plugin runner is de-privileged (<c>NT AUTHORITY\LocalService</c>)
/// and cannot read this user's <c>%APPDATA%</c>; <c>punktfunk-host plugins enable</c> 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.
/// </summary>
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()` (`<config_dir>/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<MainMenuItem> 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();
}
/// <summary>Resolve a Playnite media reference to an absolute path (remote URLs pass through).</summary>
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<string>(),
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");
}
}
/// <summary>
/// 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).
/// </summary>
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})");
}
}
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
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);
}
}
}