MVP completed: Hook into Playlistmanager to post updates to beatleader maps owned by the user

This commit is contained in:
pleb 2026-04-19 08:20:15 -07:00
parent f9a0e1669f
commit 7879e03cc5
7 changed files with 222 additions and 177 deletions

View File

@ -62,6 +62,37 @@ namespace Setlist
return true;
}
/// <summary>Reads sync URL, BeatLeader guid, and owner from playlist customData (same rules as startup scan).</summary>
internal static void TryReadBeatLeaderMetadata(
IPlaylist playlist,
out bool hasSyncUrl,
out string beatLeaderGuid,
out string ownerId)
{
hasSyncUrl = false;
beatLeaderGuid = null;
ownerId = null;
if (!playlist.TryGetCustomData("syncURL", out var syncObj) || !(syncObj is string syncUrl)
|| string.IsNullOrWhiteSpace(syncUrl))
{
return;
}
hasSyncUrl = true;
if (playlist.TryGetCustomData("owner", out var ownerObj) && ownerObj is string o
&& !string.IsNullOrWhiteSpace(o))
{
ownerId = o.Trim();
}
if (TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g))
{
beatLeaderGuid = g;
}
}
/// <summary>
/// Spawns a hidden coroutine runner that resolves the platform user id, then logs per-playlist
/// ownership from playlist JSON. Must be called from the Unity main thread (BSIPA <c>OnApplicationStart</c> is fine).
@ -77,66 +108,7 @@ namespace Setlist
runner.Initialize(entries, log);
}
/// <summary>
/// Component that drives the logging coroutine; self-destroys when finished.
/// </summary>
private sealed class OwnershipRunner : MonoBehaviour
{
private List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
private IPALogger _log;
public void Initialize(
List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
_entries = entries;
_log = log;
StartCoroutine(Run());
}
private IEnumerator Run()
{
string platformUserId = null;
yield return StartCoroutine(FetchPlatformUserId(id => { platformUserId = id; }));
_log.Info(string.IsNullOrEmpty(platformUserId)
? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}");
foreach (var e in _entries)
{
_log.Info(Plugin.FormatPlaylistLogLine(
e.Title,
e.HasSyncUrl,
e.BeatLeaderGuid,
e.OwnerId,
platformUserId));
}
foreach (var e in _entries)
{
if (!string.Equals(e.Title, BeatLeaderPlaylistUpdateTest.TargetPlaylistTitle, StringComparison.Ordinal))
{
continue;
}
yield return BeatLeaderPlaylistUpdateTest.RunIfTargetOwned(
e.Playlist,
e.HasSyncUrl,
e.BeatLeaderGuid,
e.OwnerId,
platformUserId,
_log);
break;
}
Destroy(gameObject);
}
/// <summary>
/// BSIPA runs us very early; <see cref="PlatformLeaderboardsModel"/> may not exist yet and its
/// async init may not have set <see cref="PlatformLeaderboardsModel.playerId"/>. Poll until ready.
/// </summary>
private static IEnumerator FetchPlatformUserId(Action<string> onDone)
internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone)
{
var waited = 0f;
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
@ -237,6 +209,45 @@ namespace Setlist
return last;
}
/// <summary>
/// Component that drives the logging coroutine; self-destroys when finished.
/// </summary>
private sealed class OwnershipRunner : MonoBehaviour
{
private List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
private IPALogger _log;
public void Initialize(
List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
_entries = entries;
_log = log;
StartCoroutine(Run());
}
private IEnumerator Run()
{
string platformUserId = null;
yield return StartCoroutine(FetchPlatformUserIdCoroutine(id => { platformUserId = id; }));
Plugin.CachedPlatformUserId = platformUserId;
_log.Info(string.IsNullOrEmpty(platformUserId)
? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}");
foreach (var e in _entries)
{
_log.Info(Plugin.FormatPlaylistLogLine(
e.Title,
e.HasSyncUrl,
e.BeatLeaderGuid,
e.OwnerId,
platformUserId));
}
Destroy(gameObject);
}
}
}
}

View File

@ -13,44 +13,42 @@ using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
/// <summary>
/// Dev/test path: POST the on-disk playlist JSON to BeatLeader to prove authenticated updates work.
/// Requires BeatLeader to have finished sign-in (Unity cookie cache). See docs/beatleader-playlist-api.md.
/// POSTs an updated playlist to BeatLeader when the local copy is owned by the player (playlist JSON
/// <c>owner</c> vs platform user id). Uses Unity cookie session after BeatLeader sign-in.
/// </summary>
internal static class BeatLeaderPlaylistUpdateTest
internal static class BeatLeaderPlaylistSync
{
/// <summary>When this playlist title is locally owned (owner matches platform id), run POST test on startup.</summary>
internal const string TargetPlaylistTitle = "2025-mapfilter-01";
private const string AuthenticationTypeName = "BeatLeader.API.Authentication";
private const string SignedInFieldName = "_signedIn";
private const float SignInWaitTimeoutSeconds = 90f;
private const int PostRequestTimeoutSeconds = 120;
/// <summary>POST <c>/user/playlist</c> with the serialized playlist body (same shape as the website sample).</summary>
internal static IEnumerator RunIfTargetOwned(
IPlaylist playlist,
bool hasSyncUrl,
string beatLeaderGuid,
string ownerId,
string platformUserId,
IPALogger log)
internal static IEnumerator CoPostOwnedPlaylistToBeatLeader(IPlaylist playlist, IPALogger log)
{
if (!string.Equals(playlist.Title, TargetPlaylistTitle, StringComparison.Ordinal))
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(playlist, out var hasSyncUrl, out var beatLeaderGuid, out var ownerId);
var platformUserId = Plugin.CachedPlatformUserId;
if (string.IsNullOrEmpty(platformUserId))
{
yield break;
string resolved = null;
yield return BeatLeaderPlaylistOwnership.FetchPlatformUserIdCoroutine(id => { resolved = id; });
platformUserId = resolved;
if (!string.IsNullOrEmpty(platformUserId))
{
Plugin.CachedPlatformUserId = platformUserId;
}
}
if (!IsLocallyOwnedBeatLeaderPlaylist(hasSyncUrl, beatLeaderGuid, ownerId, platformUserId))
{
log.Info(
$"Setlist BeatLeader POST test: skip \"{TargetPlaylistTitle}\" (not locally owned or missing BeatLeader fields).");
yield break;
}
if (!TryGetBeatLeaderServerFields(playlist, out var serverPlaylistId, out var shared))
{
log.Info(
$"Setlist BeatLeader POST test: skip \"{TargetPlaylistTitle}\" (customData missing id or not parseable).");
$"Setlist BeatLeader sync: skip \"{playlist.Title}\" (customData id missing; cannot POST).");
yield break;
}
@ -66,7 +64,7 @@ namespace Setlist
if (waited >= SignInWaitTimeoutSeconds)
{
log.Info(
$"Setlist BeatLeader POST test: BeatLeader login did not complete within {SignInWaitTimeoutSeconds:F0}s.");
$"Setlist BeatLeader sync: login did not complete within {SignInWaitTimeoutSeconds:F0}s (playlist \"{playlist.Title}\").");
yield break;
}
@ -96,22 +94,20 @@ namespace Setlist
if (request.result == UnityWebRequest.Result.ConnectionError)
{
log.Info("Setlist BeatLeader POST test: network error: " + request.error);
log.Info("Setlist BeatLeader sync: network error: " + request.error);
yield break;
}
if (request.result == UnityWebRequest.Result.ProtocolError)
{
log.Info(
$"Setlist BeatLeader POST test: HTTP {request.responseCode} body="
$"Setlist BeatLeader sync: HTTP {request.responseCode} playlist=\"{playlist.Title}\" body="
+ TruncateForLog(request.downloadHandler?.text));
yield break;
}
log.Info(
"Setlist BeatLeader POST test: success HTTP "
+ request.responseCode
+ " body="
$"Setlist BeatLeader sync: OK HTTP {request.responseCode} playlist=\"{playlist.Title}\" body="
+ TruncateForLog(request.downloadHandler?.text));
}
}
@ -163,7 +159,7 @@ namespace Setlist
private static bool TrySerializePlaylist(IPlaylist playlist, IPALogger log, out string json)
{
json = null;
var mgr = PlaylistManager.DefaultManager;
var mgr = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager;
IPlaylistHandler handler = null;
if (!string.IsNullOrEmpty(playlist.SuggestedExtension))
{
@ -177,7 +173,7 @@ namespace Setlist
if (handler == null)
{
log.Info("Setlist BeatLeader POST test: no IPlaylistHandler for playlist type.");
log.Info($"Setlist BeatLeader sync: no IPlaylistHandler for \"{playlist.Title}\".");
return false;
}
@ -193,7 +189,7 @@ namespace Setlist
}
catch (Exception ex)
{
log.Info("Setlist BeatLeader POST test: serialize failed: " + ex.Message);
log.Info($"Setlist BeatLeader sync: serialize failed: {ex.Message}");
return false;
}
}
@ -204,7 +200,7 @@ namespace Setlist
var authType = beatLeaderAssembly.GetType(AuthenticationTypeName);
if (authType == null)
{
log.Info($"Setlist BeatLeader POST test: assembly has no {AuthenticationTypeName}.");
log.Info($"Setlist BeatLeader sync: assembly has no {AuthenticationTypeName}.");
return null;
}
@ -227,7 +223,7 @@ namespace Setlist
private static string GetAssemblyVersion()
{
var asm = typeof(BeatLeaderPlaylistUpdateTest).Assembly;
var asm = typeof(BeatLeaderPlaylistSync).Assembly;
return asm.GetName().Version?.ToString() ?? "0.0.0";
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using BeatSaberPlaylistsLib.Types;
using IPA;
using PlaylistManager.Utilities;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
@ -16,6 +17,9 @@ namespace Setlist
/// </summary>
internal static IPALogger Log { get; private set; }
/// <summary>Set after the ownership scan resolves the platform user id; used when syncing after PlaylistManager adds a song.</summary>
internal static string CachedPlatformUserId { get; set; }
[Init]
public Plugin(IPALogger logger)
{
@ -28,6 +32,10 @@ namespace Setlist
{
try
{
SetlistSyncHost.Ensure();
Events.playlistSongAdded += OnPlaylistSongAdded;
var playlists = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager.GetAllPlaylists(
includeChildren: true,
out AggregateException loadErrors);
@ -50,25 +58,11 @@ namespace Setlist
var entries = new List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>();
foreach (var playlist in playlists)
{
var hasSyncUrl = false;
string syncUrl = null;
if (playlist.TryGetCustomData("syncURL", out var syncObj) && syncObj is string url)
{
syncUrl = url;
hasSyncUrl = !string.IsNullOrWhiteSpace(url);
}
string ownerId = null;
if (playlist.TryGetCustomData("owner", out var ownerObj) && ownerObj is string o)
{
ownerId = string.IsNullOrWhiteSpace(o) ? null : o.Trim();
}
string blGuid = null;
if (hasSyncUrl && BeatLeaderPlaylistOwnership.TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g))
{
blGuid = g;
}
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(
playlist,
out var hasSyncUrl,
out var blGuid,
out var ownerId);
entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
}
@ -84,6 +78,12 @@ namespace Setlist
[OnExit]
public void OnApplicationQuit()
{
Events.playlistSongAdded -= OnPlaylistSongAdded;
}
private void OnPlaylistSongAdded(IPlaylistSong song, IPlaylist playlist)
{
SetlistSyncHost.Instance?.StartPostOwnedBeatLeaderPlaylist(playlist, Log);
}
/// <summary>

View File

@ -11,5 +11,5 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: Guid("50F53E6E-21D5-4780-8E67-273877DAA28C")]
[assembly: AssemblyVersion("0.0.8.0")]
[assembly: AssemblyFileVersion("0.0.8.0")]
[assembly: AssemblyVersion("0.1.0.0")]
[assembly: AssemblyFileVersion("0.1.0.0")]

View File

@ -115,11 +115,16 @@
<HintPath>$(BeatSaberDir)\Plugins\BeatLeader.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlaylistManager">
<HintPath>$(BeatSaberDir)\Plugins\PlaylistManager.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="BeatLeaderPlaylistOwnership.cs" />
<Compile Include="BeatLeaderPlaylistUpdateTest.cs" />
<Compile Include="BeatLeaderPlaylistSync.cs" />
<Compile Include="Plugin.cs" />
<Compile Include="SetlistSyncHost.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,32 @@
using BeatSaberPlaylistsLib.Types;
using UnityEngine;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
/// <summary>
/// Persists for <see cref="UnityEngine.MonoBehaviour.StartCoroutine"/> (e.g. BeatLeader POST after UI adds a song).
/// </summary>
internal sealed class SetlistSyncHost : MonoBehaviour
{
internal static SetlistSyncHost Instance { get; private set; }
internal static void Ensure()
{
if (Instance != null)
{
return;
}
var go = new GameObject("Setlist.SyncHost");
UnityEngine.Object.DontDestroyOnLoad(go);
go.hideFlags = HideFlags.HideAndDontSave;
Instance = go.AddComponent<SetlistSyncHost>();
}
internal void StartPostOwnedBeatLeaderPlaylist(IPlaylist playlist, IPALogger log)
{
StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log));
}
}
}

View File

@ -3,12 +3,13 @@
"id": "Setlist",
"name": "Setlist",
"author": "",
"version": "0.0.8",
"version": "0.1.0",
"description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8",
"dependsOn": {
"BSIPA": "^4.3.0",
"BeatSaberPlaylistsLib": "^1.7.0",
"BeatLeader": "^0.9.0"
"BeatLeader": "^0.9.0",
"PlaylistManager": "^1.7.0"
}
}