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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user