feat: Playnite library sync plugin + exporter
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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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/<this-id>/</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
```
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user