From 7879e03cc57a61f6e6a5d5e6880fda0ab8df444e Mon Sep 17 00:00:00 2001 From: pleb Date: Sun, 19 Apr 2026 08:20:15 -0700 Subject: [PATCH] MVP completed: Hook into Playlistmanager to post updates to beatleader maps owned by the user --- Setlist/BeatLeaderPlaylistOwnership.cs | 259 +++++++++--------- ...pdateTest.cs => BeatLeaderPlaylistSync.cs} | 54 ++-- Setlist/Plugin.cs | 38 +-- Setlist/Properties/AssemblyInfo.cs | 4 +- Setlist/Setlist.csproj | 7 +- Setlist/SetlistSyncHost.cs | 32 +++ Setlist/manifest.json | 5 +- 7 files changed, 222 insertions(+), 177 deletions(-) rename Setlist/{BeatLeaderPlaylistUpdateTest.cs => BeatLeaderPlaylistSync.cs} (77%) create mode 100644 Setlist/SetlistSyncHost.cs 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" } }