6694be9848
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>
137 lines
5.2 KiB
C#
137 lines
5.2 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> 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);
|
|
}
|
|
}
|
|
}
|