diff --git a/Setlist/BeatLeaderPlaylistOwnership.cs b/Setlist/BeatLeaderPlaylistOwnership.cs
index ca64b43..f0c014a 100644
--- a/Setlist/BeatLeaderPlaylistOwnership.cs
+++ b/Setlist/BeatLeaderPlaylistOwnership.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
+using BeatSaberPlaylistsLib.Types;
using UnityEngine;
#if false
using BeatLeader.Utils;
@@ -71,7 +72,7 @@ namespace Setlist
/// ownership from playlist JSON. Must be called from the Unity main thread (BSIPA OnApplicationStart is fine).
///
internal static void ScheduleVerifyAndLog(
- List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
+ List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
var go = new GameObject("Setlist.OwnershipRunner");
@@ -86,11 +87,11 @@ namespace Setlist
///
private sealed class OwnershipRunner : MonoBehaviour
{
- private List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
+ private List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
private IPALogger _log;
public void Initialize(
- List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
+ List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
_entries = entries;
@@ -116,6 +117,23 @@ 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);
}
diff --git a/Setlist/BeatLeaderPlaylistUpdateTest.cs b/Setlist/BeatLeaderPlaylistUpdateTest.cs
new file mode 100644
index 0000000..707395f
--- /dev/null
+++ b/Setlist/BeatLeaderPlaylistUpdateTest.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using BeatLeader.Utils;
+using BeatSaberPlaylistsLib;
+using BeatSaberPlaylistsLib.Types;
+using UnityEngine;
+using UnityEngine.Networking;
+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.
+ ///
+ internal static class BeatLeaderPlaylistUpdateTest
+ {
+ /// 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)
+ {
+ if (!string.Equals(playlist.Title, TargetPlaylistTitle, StringComparison.Ordinal))
+ {
+ yield break;
+ }
+
+ 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).");
+ yield break;
+ }
+
+ var signedInField = TryResolveBeatLeaderSignedInField(log);
+ if (signedInField == null)
+ {
+ yield break;
+ }
+
+ var waited = 0f;
+ while (!IsSignedIn(signedInField))
+ {
+ if (waited >= SignInWaitTimeoutSeconds)
+ {
+ log.Info(
+ $"Setlist BeatLeader POST test: BeatLeader login did not complete within {SignInWaitTimeoutSeconds:F0}s.");
+ yield break;
+ }
+
+ yield return new WaitForSeconds(1f);
+ waited += 1f;
+ }
+
+ if (!TrySerializePlaylist(playlist, log, out var bodyJson))
+ {
+ yield break;
+ }
+
+ var url =
+ $"{BLConstants.BEATLEADER_API_URL}/user/playlist?id={Uri.EscapeDataString(serverPlaylistId)}&shared="
+ + (shared ? "true" : "false");
+
+ byte[] bodyRaw = Encoding.UTF8.GetBytes(bodyJson);
+ using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
+ {
+ request.timeout = PostRequestTimeoutSeconds;
+ request.uploadHandler = new UploadHandlerRaw(bodyRaw);
+ request.downloadHandler = new DownloadHandlerBuffer();
+ request.SetRequestHeader("Content-Type", "text/plain;charset=UTF-8");
+ request.SetRequestHeader("User-Agent", "Setlist/" + GetAssemblyVersion());
+
+ yield return request.SendWebRequest();
+
+ if (request.result == UnityWebRequest.Result.ConnectionError)
+ {
+ log.Info("Setlist BeatLeader POST test: network error: " + request.error);
+ yield break;
+ }
+
+ if (request.result == UnityWebRequest.Result.ProtocolError)
+ {
+ log.Info(
+ $"Setlist BeatLeader POST test: HTTP {request.responseCode} body="
+ + TruncateForLog(request.downloadHandler?.text));
+ yield break;
+ }
+
+ log.Info(
+ "Setlist BeatLeader POST test: success HTTP "
+ + request.responseCode
+ + " body="
+ + TruncateForLog(request.downloadHandler?.text));
+ }
+ }
+
+ private static bool IsLocallyOwnedBeatLeaderPlaylist(
+ bool hasSyncUrl,
+ string beatLeaderGuid,
+ string ownerId,
+ string platformUserId)
+ {
+ return hasSyncUrl
+ && !string.IsNullOrEmpty(beatLeaderGuid)
+ && !string.IsNullOrEmpty(ownerId)
+ && !string.IsNullOrEmpty(platformUserId)
+ && string.Equals(ownerId, platformUserId, StringComparison.Ordinal);
+ }
+
+ private static bool TryGetBeatLeaderServerFields(IPlaylist playlist, out string serverPlaylistId, out bool shared)
+ {
+ serverPlaylistId = null;
+ shared = false;
+
+ if (!playlist.TryGetCustomData("id", out var idObj) || idObj == null)
+ {
+ return false;
+ }
+
+ serverPlaylistId = Convert.ToString(idObj);
+ if (string.IsNullOrWhiteSpace(serverPlaylistId))
+ {
+ return false;
+ }
+
+ if (playlist.TryGetCustomData("shared", out var sharedObj) && sharedObj != null)
+ {
+ if (sharedObj is bool b)
+ {
+ shared = b;
+ }
+ else if (bool.TryParse(Convert.ToString(sharedObj), out var parsed))
+ {
+ shared = parsed;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool TrySerializePlaylist(IPlaylist playlist, IPALogger log, out string json)
+ {
+ json = null;
+ var mgr = PlaylistManager.DefaultManager;
+ IPlaylistHandler handler = null;
+ if (!string.IsNullOrEmpty(playlist.SuggestedExtension))
+ {
+ handler = mgr.GetHandlerForExtension(playlist.SuggestedExtension);
+ }
+
+ if (handler == null)
+ {
+ handler = mgr.GetHandlerForPlaylistType(playlist.GetType());
+ }
+
+ if (handler == null)
+ {
+ log.Info("Setlist BeatLeader POST test: no IPlaylistHandler for playlist type.");
+ return false;
+ }
+
+ try
+ {
+ using (var stream = new MemoryStream())
+ {
+ handler.Serialize(playlist, stream);
+ json = Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ return !string.IsNullOrEmpty(json);
+ }
+ catch (Exception ex)
+ {
+ log.Info("Setlist BeatLeader POST test: serialize failed: " + ex.Message);
+ return false;
+ }
+ }
+
+ private static FieldInfo TryResolveBeatLeaderSignedInField(IPALogger log)
+ {
+ var beatLeaderAssembly = typeof(BLConstants).Assembly;
+ var authType = beatLeaderAssembly.GetType(AuthenticationTypeName);
+ if (authType == null)
+ {
+ log.Info($"Setlist BeatLeader POST test: assembly has no {AuthenticationTypeName}.");
+ return null;
+ }
+
+ var field = authType.GetField(SignedInFieldName, BindingFlags.NonPublic | BindingFlags.Static);
+ if (field == null)
+ {
+ log.Info(
+ $"{AuthenticationTypeName} has no static field '{SignedInFieldName}' (BeatLeader API changed?).");
+ return null;
+ }
+
+ return field;
+ }
+
+ private static bool IsSignedIn(FieldInfo signedInField)
+ {
+ var value = signedInField.GetValue(null);
+ return value is bool b && b;
+ }
+
+ private static string GetAssemblyVersion()
+ {
+ var asm = typeof(BeatLeaderPlaylistUpdateTest).Assembly;
+ return asm.GetName().Version?.ToString() ?? "0.0.0";
+ }
+
+ private static string TruncateForLog(string s, int maxLen = 512)
+ {
+ if (string.IsNullOrEmpty(s))
+ {
+ return "";
+ }
+
+ return s.Length <= maxLen ? s : s.Substring(0, maxLen) + "…";
+ }
+ }
+}
diff --git a/Setlist/Plugin.cs b/Setlist/Plugin.cs
index e8be6f1..d845bc0 100644
--- a/Setlist/Plugin.cs
+++ b/Setlist/Plugin.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using BeatSaberPlaylistsLib.Types;
using IPA;
using IPALogger = IPA.Logging.Logger;
@@ -46,7 +47,7 @@ namespace Setlist
return;
}
- var entries = new List<(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)
{
var hasSyncUrl = false;
@@ -69,7 +70,7 @@ namespace Setlist
blGuid = g;
}
- entries.Add((playlist.Title, hasSyncUrl, blGuid, ownerId));
+ entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
}
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
diff --git a/Setlist/Properties/AssemblyInfo.cs b/Setlist/Properties/AssemblyInfo.cs
index de422bc..f18d0d0 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.6.0")]
-[assembly: AssemblyFileVersion("0.0.6.0")]
+[assembly: AssemblyVersion("0.0.7.0")]
+[assembly: AssemblyFileVersion("0.0.7.0")]
diff --git a/Setlist/Setlist.csproj b/Setlist/Setlist.csproj
index 5628bb7..9dadadf 100644
--- a/Setlist/Setlist.csproj
+++ b/Setlist/Setlist.csproj
@@ -118,6 +118,7 @@
+
diff --git a/Setlist/manifest.json b/Setlist/manifest.json
index 28faac5..9afa859 100644
--- a/Setlist/manifest.json
+++ b/Setlist/manifest.json
@@ -3,7 +3,7 @@
"id": "Setlist",
"name": "Setlist",
"author": "",
- "version": "0.0.6",
+ "version": "0.0.7",
"description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8",
"dependsOn": {