feat: Playnite library sync plugin + exporter
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped

A two-part Punktfunk plugin that syncs the Playnite library into the host
game library as the `playnite` provider:

- exporter/ — a minimal C# Playnite GenericPlugin ("Punktfunk Sync") that
  writes punktfunk-library.json on every library change. Reading Playnite's
  live-locked LiteDB from outside isn't robust, so this adapter runs inside
  Playnite. Builds net462 via reference assemblies; packaged as a .pext.
- src/ — the TS plugin (on @punktfunk/host, rom-manager shape): watches the
  export, embeds covers as data URLs, reconciles via
  PUT /library/provider/playnite, with a console-hosted web UI + standalone
  fallback + CLI. Launch = playnite://playnite/start/<id>, run by the host
  in the interactive session so Playnite performs the real launch.

Verified locally: backend tsc + 23 tests + biome clean, SPA build, C#
exporter build + .pext pack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 23:35:35 +02:00
commit 6694be9848
48 changed files with 3885 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
using System.Collections.Generic;
using Playnite.SDK.Data;
namespace PunktfunkSync
{
// The on-disk contract with the Punktfunk `playnite` plugin. Keep these property names in lockstep
// with `src/export-schema.ts` (ExportedGame / LibraryExport). Serialized via the Playnite SDK's
// `Serialization.ToJson`, so `[SerializationPropertyName]` pins the JSON keys regardless of the
// serializer's default casing.
public class ExportedGame
{
// Playnite's stable game Guid ("d"-format, lowercase with dashes) — the reconcile external_id.
[SerializationPropertyName("id")]
public string Id { get; set; }
[SerializationPropertyName("name")]
public string Name { get; set; }
[SerializationPropertyName("installed")]
public bool Installed { get; set; }
[SerializationPropertyName("hidden")]
public bool Hidden { get; set; }
// Library source name ("Steam", "GOG", "Epic", emulator name…) or null.
[SerializationPropertyName("source")]
public string Source { get; set; }
[SerializationPropertyName("platforms")]
public List<string> Platforms { get; set; }
// ABSOLUTE paths on the host box (already resolved from Playnite's database-relative form), or
// null. A remote (http) art URL is passed through verbatim.
[SerializationPropertyName("cover")]
public string Cover { get; set; }
[SerializationPropertyName("background")]
public string Background { get; set; }
[SerializationPropertyName("icon")]
public string Icon { get; set; }
}
public class PlayniteInfo
{
[SerializationPropertyName("version")]
public string Version { get; set; }
[SerializationPropertyName("mode")]
public string Mode { get; set; }
}
public class LibraryExport
{
[SerializationPropertyName("schema")]
public int Schema { get; set; }
[SerializationPropertyName("generatedAt")]
public string GeneratedAt { get; set; }
[SerializationPropertyName("playnite")]
public PlayniteInfo Playnite { get; set; }
[SerializationPropertyName("games")]
public List<ExportedGame> Games { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The Playnite side of the plugin: a GenericPlugin that writes `punktfunk-library.json` into its own
plugin-data dir on every library change, for the Punktfunk `playnite` plugin to read and reconcile.
Targets .NET Framework 4.6.2 (Playnite's runtime). `Microsoft.NETFramework.ReferenceAssemblies`
lets `dotnet build` compile net462 on Linux/macOS (no Windows/MSBuild needed), so CI builds it on
the same ubuntu runner as the TS side. PlayniteSDK is a COMPILE-only reference — Playnite provides
it at runtime — so `ExcludeAssets=runtime` keeps it out of the packaged `.pext`.
-->
<PropertyGroup>
<TargetFramework>net462</TargetFramework>
<AssemblyName>PunktfunkSync</AssemblyName>
<RootNamespace>PunktfunkSync</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PlayniteSDK" Version="6.11.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="extension.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+136
View File
@@ -0,0 +1,136 @@
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> in this plugin's data directory
/// (<c>…/Playnite/ExtensionsData/&lt;this-id&gt;/</c>) whenever it changes. The Punktfunk
/// <c>playnite</c> plugin globs for that file and reconciles it into the host game library — 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 Timer debounce;
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
{
Properties = new GenericPluginProperties { HasSettings = false };
exportPath = Path.Combine(GetPluginUserDataPath(), "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 via a temp file + move so a reader never sees a half-written export.</summary>
private void WriteAtomic(string json)
{
Directory.CreateDirectory(Path.GetDirectoryName(exportPath));
var tmp = exportPath + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(exportPath)) File.Delete(exportPath);
File.Move(tmp, exportPath);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
# Punktfunk Sync — Playnite exporter
The Playnite side of [`@punktfunk/plugin-playnite`](../README.md): a minimal
[GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html) that writes
your library to `punktfunk-library.json` whenever it changes. The Punktfunk plugin on the host reads
that file and reconciles it into the streaming library.
It exists because Playnite keeps its library database (LiteDB) open and locked while running, so the
only robust way to read the library — with correct install state, art paths and metadata — is from
*inside* Playnite. This plugin does exactly that and nothing else: no network, no settings, no UI.
## What it writes
On startup, on every `OnLibraryUpdated`, and (debounced) on any per-game add/update/remove, it writes:
```
%APPDATA%\Playnite\ExtensionsData\<plugin-guid>\punktfunk-library.json
```
Shape (kept in lockstep with `../src/export-schema.ts`):
```jsonc
{
"schema": 1,
"generatedAt": "2026-07-18T21:00:00Z",
"playnite": { "version": "10.x", "mode": "Desktop" },
"games": [
{
"id": "e3b0c442-98fc-1c14-9afb-4c8996fb9242", // Playnite Guid = reconcile external_id
"name": "Hollow Knight",
"installed": true,
"hidden": false,
"source": "Steam",
"platforms": ["PC (Windows)"],
"cover": "C:\\Users\\me\\AppData\\Roaming\\Playnite\\library\\files\\ab\\cover.jpg",
"background": null,
"icon": null
}
]
}
```
Art paths are **absolute** (resolved via `IPlayniteAPI.Database.GetFullFilePath`). Filtering
(installed-only, hidden, per-source) happens on the Punktfunk side, so the export is the full library.
You can also trigger a write by hand: Playnite **main menu → Punktfunk Sync → Export library to
Punktfunk now**.
## Install (for users)
Double-click `punktfunk-sync.pext` — Playnite installs it like any add-on — and restart Playnite.
Grab the `.pext` from the repo's [CI artifacts](https://git.unom.io/unom/punktfunk-plugin-playnite/actions).
## Build
Targets .NET Framework 4.6.2 (Playnite's runtime). Thanks to
`Microsoft.NETFramework.ReferenceAssemblies` it builds with the plain .NET SDK on any OS — no Windows
or MSBuild needed:
```sh
dotnet build PunktfunkSync.csproj -c Release
# output: bin/Release/PunktfunkSync.dll + extension.yaml (PlayniteSDK.dll is provided by Playnite)
```
Package the `.pext` (a zip of the two files):
```sh
( cd bin/Release && zip -j ../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll )
```
+9
View File
@@ -0,0 +1,9 @@
Id: Punktfunk_LibraryExporter
Name: Punktfunk Sync
Author: unom
Version: 0.1.0
Module: PunktfunkSync.dll
Type: GenericPlugin
Links:
- Name: Repository
Url: https://git.unom.io/unom/punktfunk-plugin-playnite