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; 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> /// <summary>
/// Spawns a hidden coroutine runner that resolves the platform user id, then logs per-playlist /// 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). /// ownership from playlist JSON. Must be called from the Unity main thread (BSIPA <c>OnApplicationStart</c> is fine).
@ -77,6 +108,108 @@ namespace Setlist
runner.Initialize(entries, log); runner.Initialize(entries, log);
} }
internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone)
{
var waited = 0f;
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
while (waited < PlatformUserWaitTimeoutSeconds)
{
foreach (var plm in EnumerateLeaderboardModels())
{
var pid = plm.playerId;
if (!string.IsNullOrEmpty(pid))
{
onDone(pid.Trim());
yield break;
}
}
if (waited - lastGetUserInfoAttempt >= PlatformUserGetUserInfoRetrySeconds)
{
lastGetUserInfoAttempt = waited;
var model = ResolvePlatformUserModel();
if (model != null)
{
Task<UserInfo> task;
try
{
task = model.GetUserInfo(CancellationToken.None);
}
catch (Exception)
{
task = null;
}
if (task != null)
{
while (!task.IsCompleted)
{
yield return null;
}
if (!task.IsFaulted)
{
var uid = task.Result?.platformUserId;
if (!string.IsNullOrEmpty(uid))
{
onDone(uid.Trim());
yield break;
}
}
}
}
}
waited += PlatformUserPollStepSeconds;
yield return new WaitForSeconds(PlatformUserPollStepSeconds);
}
onDone(null);
}
/// <summary>
/// <see cref="Resources.FindObjectsOfTypeAll{T}"/> can miss very early; <see cref="Object.FindObjectOfType{T}()"/> sometimes finds the scene instance sooner.
/// </summary>
private static IEnumerable<PlatformLeaderboardsModel> EnumerateLeaderboardModels()
{
var seen = new HashSet<PlatformLeaderboardsModel>();
foreach (var plm in Resources.FindObjectsOfTypeAll<PlatformLeaderboardsModel>())
{
if (plm != null && seen.Add(plm))
{
yield return plm;
}
}
var active = UnityEngine.Object.FindObjectOfType<PlatformLeaderboardsModel>();
if (active != null && seen.Add(active))
{
yield return active;
}
}
private static IPlatformUserModel ResolvePlatformUserModel()
{
var field = typeof(PlatformLeaderboardsModel).GetField(
"_platformUserModel",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null)
{
return null;
}
IPlatformUserModel last = null;
foreach (var plm in EnumerateLeaderboardModels())
{
if (field.GetValue(plm) is IPlatformUserModel m)
{
last = m;
}
}
return last;
}
/// <summary> /// <summary>
/// Component that drives the logging coroutine; self-destroys when finished. /// Component that drives the logging coroutine; self-destroys when finished.
/// </summary> /// </summary>
@ -97,7 +230,8 @@ namespace Setlist
private IEnumerator Run() private IEnumerator Run()
{ {
string platformUserId = null; string platformUserId = null;
yield return StartCoroutine(FetchPlatformUserId(id => { platformUserId = id; })); yield return StartCoroutine(FetchPlatformUserIdCoroutine(id => { platformUserId = id; }));
Plugin.CachedPlatformUserId = platformUserId;
_log.Info(string.IsNullOrEmpty(platformUserId) _log.Info(string.IsNullOrEmpty(platformUserId)
? "platformUserId=(unknown)" ? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}"); : $"platformUserId={platformUserId}");
@ -112,131 +246,8 @@ namespace Setlist
platformUserId)); 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); 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)
{
var waited = 0f;
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
while (waited < PlatformUserWaitTimeoutSeconds)
{
foreach (var plm in EnumerateLeaderboardModels())
{
var pid = plm.playerId;
if (!string.IsNullOrEmpty(pid))
{
onDone(pid.Trim());
yield break;
}
}
if (waited - lastGetUserInfoAttempt >= PlatformUserGetUserInfoRetrySeconds)
{
lastGetUserInfoAttempt = waited;
var model = ResolvePlatformUserModel();
if (model != null)
{
Task<UserInfo> task;
try
{
task = model.GetUserInfo(CancellationToken.None);
}
catch (Exception)
{
task = null;
}
if (task != null)
{
while (!task.IsCompleted)
{
yield return null;
}
if (!task.IsFaulted)
{
var uid = task.Result?.platformUserId;
if (!string.IsNullOrEmpty(uid))
{
onDone(uid.Trim());
yield break;
}
}
}
}
}
waited += PlatformUserPollStepSeconds;
yield return new WaitForSeconds(PlatformUserPollStepSeconds);
}
onDone(null);
}
/// <summary>
/// <see cref="Resources.FindObjectsOfTypeAll{T}"/> can miss very early; <see cref="Object.FindObjectOfType{T}()"/> sometimes finds the scene instance sooner.
/// </summary>
private static IEnumerable<PlatformLeaderboardsModel> EnumerateLeaderboardModels()
{
var seen = new HashSet<PlatformLeaderboardsModel>();
foreach (var plm in Resources.FindObjectsOfTypeAll<PlatformLeaderboardsModel>())
{
if (plm != null && seen.Add(plm))
{
yield return plm;
}
}
var active = UnityEngine.Object.FindObjectOfType<PlatformLeaderboardsModel>();
if (active != null && seen.Add(active))
{
yield return active;
}
}
private static IPlatformUserModel ResolvePlatformUserModel()
{
var field = typeof(PlatformLeaderboardsModel).GetField(
"_platformUserModel",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null)
{
return null;
}
IPlatformUserModel last = null;
foreach (var plm in EnumerateLeaderboardModels())
{
if (field.GetValue(plm) is IPlatformUserModel m)
{
last = m;
}
}
return last;
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -115,11 +115,16 @@
<HintPath>$(BeatSaberDir)\Plugins\BeatLeader.dll</HintPath> <HintPath>$(BeatSaberDir)\Plugins\BeatLeader.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="PlaylistManager">
<HintPath>$(BeatSaberDir)\Plugins\PlaylistManager.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="BeatLeaderPlaylistOwnership.cs" /> <Compile Include="BeatLeaderPlaylistOwnership.cs" />
<Compile Include="BeatLeaderPlaylistUpdateTest.cs" /> <Compile Include="BeatLeaderPlaylistSync.cs" />
<Compile Include="Plugin.cs" /> <Compile Include="Plugin.cs" />
<Compile Include="SetlistSyncHost.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup> </ItemGroup>
<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", "id": "Setlist",
"name": "Setlist", "name": "Setlist",
"author": "", "author": "",
"version": "0.0.8", "version": "0.1.0",
"description": "Syncs playlists with external sources.", "description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8", "gameVersion": "1.40.8",
"dependsOn": { "dependsOn": {
"BSIPA": "^4.3.0", "BSIPA": "^4.3.0",
"BeatSaberPlaylistsLib": "^1.7.0", "BeatSaberPlaylistsLib": "^1.7.0",
"BeatLeader": "^0.9.0" "BeatLeader": "^0.9.0",
"PlaylistManager": "^1.7.0"
} }
} }