diff --git a/Setlist/BeatLeaderPlaylistOwnership.cs b/Setlist/BeatLeaderPlaylistOwnership.cs
index dce563b..331ad5b 100644
--- a/Setlist/BeatLeaderPlaylistOwnership.cs
+++ b/Setlist/BeatLeaderPlaylistOwnership.cs
@@ -62,6 +62,37 @@ namespace Setlist
return true;
}
+ /// Reads sync URL, BeatLeader guid, and owner from playlist customData (same rules as startup scan).
+ 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;
+ }
+ }
+
///
/// 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 OnApplicationStart is fine).
@@ -77,6 +108,108 @@ namespace Setlist
runner.Initialize(entries, log);
}
+ internal static IEnumerator FetchPlatformUserIdCoroutine(Action 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 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);
+ }
+
+ ///
+ /// can miss very early; sometimes finds the scene instance sooner.
+ ///
+ private static IEnumerable EnumerateLeaderboardModels()
+ {
+ var seen = new HashSet();
+ foreach (var plm in Resources.FindObjectsOfTypeAll())
+ {
+ if (plm != null && seen.Add(plm))
+ {
+ yield return plm;
+ }
+ }
+
+ var active = UnityEngine.Object.FindObjectOfType();
+ 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;
+ }
+
///
/// Component that drives the logging coroutine; self-destroys when finished.
///
@@ -97,7 +230,8 @@ namespace Setlist
private IEnumerator Run()
{
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)
? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}");
@@ -112,131 +246,8 @@ namespace Setlist
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);
}
-
- ///
- /// BSIPA runs us very early; may not exist yet and its
- /// async init may not have set . Poll until ready.
- ///
- private static IEnumerator FetchPlatformUserId(Action 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 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);
- }
-
- ///
- /// can miss very early; sometimes finds the scene instance sooner.
- ///
- private static IEnumerable EnumerateLeaderboardModels()
- {
- var seen = new HashSet();
- foreach (var plm in Resources.FindObjectsOfTypeAll())
- {
- if (plm != null && seen.Add(plm))
- {
- yield return plm;
- }
- }
-
- var active = UnityEngine.Object.FindObjectOfType();
- 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;
- }
}
}
}
diff --git a/Setlist/BeatLeaderPlaylistUpdateTest.cs b/Setlist/BeatLeaderPlaylistSync.cs
similarity index 77%
rename from Setlist/BeatLeaderPlaylistUpdateTest.cs
rename to Setlist/BeatLeaderPlaylistSync.cs
index 707395f..b3a99ce 100644
--- a/Setlist/BeatLeaderPlaylistUpdateTest.cs
+++ b/Setlist/BeatLeaderPlaylistSync.cs
@@ -13,44 +13,42 @@ using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
///
- /// 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
+ /// owner vs platform user id). Uses Unity cookie session after BeatLeader sign-in.
///
- internal static class BeatLeaderPlaylistUpdateTest
+ internal static class BeatLeaderPlaylistSync
{
- /// When this playlist title is locally owned (owner matches platform id), run POST test on startup.
- 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;
/// POST /user/playlist with the serialized playlist body (same shape as the website sample).
- 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";
}
diff --git a/Setlist/Plugin.cs b/Setlist/Plugin.cs
index d845bc0..baf5cf0 100644
--- a/Setlist/Plugin.cs
+++ b/Setlist/Plugin.cs
@@ -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
///
internal static IPALogger Log { get; private set; }
+ /// Set after the ownership scan resolves the platform user id; used when syncing after PlaylistManager adds a song.
+ 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);
}
///
diff --git a/Setlist/Properties/AssemblyInfo.cs b/Setlist/Properties/AssemblyInfo.cs
index 02773a7..e38d79a 100644
--- a/Setlist/Properties/AssemblyInfo.cs
+++ b/Setlist/Properties/AssemblyInfo.cs
@@ -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")]
diff --git a/Setlist/Setlist.csproj b/Setlist/Setlist.csproj
index 9dadadf..db92f52 100644
--- a/Setlist/Setlist.csproj
+++ b/Setlist/Setlist.csproj
@@ -115,11 +115,16 @@
$(BeatSaberDir)\Plugins\BeatLeader.dll
False
+
+ $(BeatSaberDir)\Plugins\PlaylistManager.dll
+ False
+
-
+
+
diff --git a/Setlist/SetlistSyncHost.cs b/Setlist/SetlistSyncHost.cs
new file mode 100644
index 0000000..2a0d866
--- /dev/null
+++ b/Setlist/SetlistSyncHost.cs
@@ -0,0 +1,32 @@
+using BeatSaberPlaylistsLib.Types;
+using UnityEngine;
+using IPALogger = IPA.Logging.Logger;
+
+namespace Setlist
+{
+ ///
+ /// Persists for (e.g. BeatLeader POST after UI adds a song).
+ ///
+ 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();
+ }
+
+ internal void StartPostOwnedBeatLeaderPlaylist(IPlaylist playlist, IPALogger log)
+ {
+ StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log));
+ }
+ }
+}
diff --git a/Setlist/manifest.json b/Setlist/manifest.json
index 57b03ab..0e5ed9a 100644
--- a/Setlist/manifest.json
+++ b/Setlist/manifest.json
@@ -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"
}
}