Get the user id from the platform, so now we can identify the user in the playlists customData.owner field

This commit is contained in:
pleb 2026-04-18 21:17:08 -07:00
parent abc658a4f9
commit f1a853691c
6 changed files with 207 additions and 63 deletions

View File

@ -3,7 +3,7 @@
## Repo ## Repo
- Modding workflow and references live in `docs/pc-modding.md` (Linux + Cursor + `dotnet` CLI; no VS/Rider BSMT extension). - Modding workflow and references live in `docs/pc-modding.md` (Linux + Cursor + `dotnet` CLI; no VS/Rider BSMT extension).
- `docs/pc-modding.md` §References (wiki paths, BSMT templates, BSIPA, SongCore, etc.) match local git checkouts under `~/src/…` on this machine (same layout as the guides `../../../src/…` links from here). Read those directories first; only fetch upstream (raw GitHub, bsmg.wiki) if a checkout is missing. - The References section in `docs/pc-modding.md` (wiki paths, BSMT templates, BSIPA, SongCore, etc.) matches local git checkouts under `~/src/…` on this machine (same layout as the guides `../../../src/…` links from here). Read those directories first; only fetch upstream (raw GitHub, bsmg.wiki) if a checkout is missing.
- BSMG wiki (Modding section): Available on disk at `~/src/bsmg/wiki` (also opened via `bs-modding-tools.code-workspace`). Prefer that tree over web mirrors for static wiki content. - BSMG wiki (Modding section): Available on disk at `~/src/bsmg/wiki` (also opened via `bs-modding-tools.code-workspace`). Prefer that tree over web mirrors for static wiki content.
## Game install (BSManager) ## Game install (BSManager)
@ -26,19 +26,22 @@ BeatSaverDownloader, BeatSaverUpdater, BSML, BS_Utils, PlaylistManager, SiraUtil
- Plugin projects are .NET Framework 4.7.2 class libraries loaded by BSIPA; builds are CIL — Linux `dotnet build` output is valid for the Proton game instance. - Plugin projects are .NET Framework 4.7.2 class libraries loaded by BSIPA; builds are CIL — Linux `dotnet build` output is valid for the Proton game instance.
- Point `BeatSaberDir` / game references at the BSManager instance path above when editing project user files or HintPaths. - Point `BeatSaberDir` / game references at the BSManager instance path above when editing project user files or HintPaths.
- **Single path, not compatibility layers:** Prefer one straightforward implementation. Avoid backwards-compatibility boilerplate (dual code paths, “try old API then new API” probes, feature flags for multiple stack versions) unless the user explicitly asked to support more than one game or dependency version.
- **Plugin version bump on compile:** Whenever you run `dotnet build` on `Setlist/` as part of agent work, increment the **patch** segment (the `z` in `0.0.z`) in both `Setlist/manifest.json` (`version`) and `Setlist/Properties/AssemblyInfo.cs` (`AssemblyVersion` / `AssemblyFileVersion` as `0.0.z.0`) **before** that build so IPAs `Setlist (Setlist): …` log line matches the new artifact. Skip bumping if you only build to reproduce a compile error without changing shipped bits (then fix and bump once before the successful build). - **Plugin version bump on compile:** Whenever you run `dotnet build` on `Setlist/` as part of agent work, increment the **patch** segment (the `z` in `0.0.z`) in both `Setlist/manifest.json` (`version`) and `Setlist/Properties/AssemblyInfo.cs` (`AssemblyVersion` / `AssemblyFileVersion` as `0.0.z.0`) **before** that build so IPAs `Setlist (Setlist): …` log line matches the new artifact. Skip bumping if you only build to reproduce a compile error without changing shipped bits (then fix and bump once before the successful build).
- **Build then validate:** Whenever agent work produces a **new shipped** `Setlist` build (version bump + successful `dotnet build` that outputs the plugin DLL), run the **Smoketest** below before considering the task finished, unless the user explicitly said not to launch the game.
## Smoketest (run game, check plugin log) ## Smoketest (run game, check plugin log)
Use this on the host when you need to verify the Setlist plugin after a build (full env + `steam-run` line is in `docs/notes.md` under Testing). **Budget: the whole smoketest should finish in about 15 seconds wall time** (launch, log check, teardown). Do not use long `timeout` values such as 60s or 90s for routine validation. If Setlist lines reliably appear later than that, treat it as a plugin bug and fix startup work so the smoketest can pass — do not stretch the smoketest timeout to compensate.
Full env + exact `steam-run` / Proton / env vars: `docs/notes.md` under Testing.
Session / display: On Plasma + Wayland, the shell should have a sensible `DISPLAY` (often `:0`), `WAYLAND_DISPLAY` (e.g. `wayland-0`), and `XDG_RUNTIME_DIR` (e.g. `/run/user/$UID`). If they are empty, set them to match the logged-in desktop session before launching. Session / display: On Plasma + Wayland, the shell should have a sensible `DISPLAY` (often `:0`), `WAYLAND_DISPLAY` (e.g. `wayland-0`), and `XDG_RUNTIME_DIR` (e.g. `/run/user/$UID`). If they are empty, set them to match the logged-in desktop session before launching.
Launch (important): Wrap the `steam-run``proton``Beat Saber.exe``--no-yeet fpfc` invocation in `timeout 20``timeout 30` and run it in the foreground (stdin/stdout attached). In Cursors integrated terminal, the same command started with `&` in the background has been observed to exit immediately (only Proton `fsync` in capture, no new `_latest.log` lines). A short foreground `timeout` keeps the Proton/game tree alive long enough to boot. Launch (important): Wrap the `steam-run``proton``Beat Saber.exe``--no-yeet fpfc` invocation in **`timeout 15`** and run it in the **foreground** (stdin/stdout attached). In Cursors integrated terminal, the same command started with `&` in the background has been observed to exit immediately (only Proton `fsync` in capture, no new `_latest.log` lines). A short foreground `timeout` caps wall time and keeps the Proton/game tree alive long enough to boot.
1. `cd` to the BSManager instance path, export vars from `docs/notes.md` (and display vars above if needed). 1. `cd` to the BSManager instance path, export vars from `docs/notes.md` (and display vars above if needed).
2. Run e.g. `timeout 25 steam-run …/proton run …/Beat Saber.exe --no-yeet fpfc` (optionally `2>&1 | tee /tmp/bs-smoke.log`). Expect Setlist lines in `Logs/_latest.log` within ~15 seconds of a successful boot. 2. Run e.g. `timeout 15 steam-run …/proton run …/Beat Saber.exe --no-yeet fpfc` (optionally `2>&1 | tee /tmp/bs-smoke.log`). Expect Setlist lines in `Logs/_latest.log` within **a few seconds** after a successful boot (well inside the 15s cap).
3. Confirm: `grep Setlist Logs/_latest.log | tail -1` — expect `hasSyncUrl=True, beatLeaderOwnerConfirmed=…` (or your current message shape). 3. Confirm: `grep Setlist Logs/_latest.log` — expect current Setlist `INFO` lines (e.g. `platformUserId=…`, playlist lines with `hasSyncUrl=…`, `beatLeaderOwnerConfirmed=…`).
4. When done, `timeout` may already have stopped the run; if `Beat Saber.exe` / Wine is still running, kill that process tree. 4. **When the smoketest has succeeded in the log** (expected Setlist lines present), **stop Beat Saber immediately** — do not wait for `timeout 15` to expire. Kill the game / Proton tree (e.g. `pkill -f "Beat Saber.exe"` or `pkill -f "Beat Saber"`, or `kill` the `steam-run` child tree if needed). If `timeout 15` already terminated everything, no extra kill is required.
5. If `_latest.log` does not show new Setlist lines within ~15s of a foreground launch, treat as failure: read `/tmp/bs-smoke.log` (if used) and the timestamped file under `Logs/`, or rerun from Konsole on the desktop if the IDE shell misbehaves. Kill any leftover `Beat Saber.exe` / Wine processes after a failed run too.
If `_latest.log` does not grow within ~15s of a foreground launch, treat as failure: read `/tmp/bs-smoke.log` (if used) and the timestamped file under `Logs/`, or rerun the same block from Konsole on the desktop if the IDE shell still misbehaves.

View File

@ -3,6 +3,8 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using BeatLeader.Utils; using BeatLeader.Utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using UnityEngine; using UnityEngine;
@ -24,6 +26,10 @@ namespace Setlist
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 LoginWaitTimeoutSeconds = 90f; private const float LoginWaitTimeoutSeconds = 90f;
private const float PlatformUserPollStepSeconds = 0.5f;
/// <summary>How long to wait for <see cref="PlatformLeaderboardsModel"/> to appear and populate <c>playerId</c> (plugin runs before menu init).</summary>
private const float PlatformUserWaitTimeoutSeconds = 30f;
private const float PlatformUserGetUserInfoRetrySeconds = 3f;
private const int RequestTimeoutSeconds = 30; private const int RequestTimeoutSeconds = 30;
private sealed class UserPlaylistSummary private sealed class UserPlaylistSummary
@ -78,7 +84,7 @@ namespace Setlist
/// Must be called from the Unity main thread (BSIPA's <c>OnApplicationStart</c> is fine). /// Must be called from the Unity main thread (BSIPA's <c>OnApplicationStart</c> is fine).
/// </summary> /// </summary>
internal static void ScheduleVerifyAndLog( internal static void ScheduleVerifyAndLog(
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries, List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log) IPALogger log)
{ {
var go = new GameObject("Setlist.OwnershipRunner"); var go = new GameObject("Setlist.OwnershipRunner");
@ -93,11 +99,11 @@ namespace Setlist
/// </summary> /// </summary>
private sealed class OwnershipRunner : MonoBehaviour private sealed class OwnershipRunner : MonoBehaviour
{ {
private List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> _entries; private List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
private IPALogger _log; private IPALogger _log;
public void Initialize( public void Initialize(
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries, List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log) IPALogger log)
{ {
_entries = entries; _entries = entries;
@ -107,43 +113,52 @@ namespace Setlist
private IEnumerator Run() private IEnumerator Run()
{ {
string platformUserId = null;
yield return StartCoroutine(FetchPlatformUserId(id => { platformUserId = id; }));
_log.Info(string.IsNullOrEmpty(platformUserId)
? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}");
HashSet<string> owned = null; HashSet<string> owned = null;
string failure = null; string failure = null;
FieldInfo signedInField; if (_entries.Any(e => e.BeatLeaderGuid != null))
try
{ {
signedInField = ResolveSignedInField(_log); FieldInfo signedInField;
} try
catch (Exception ex)
{
signedInField = null;
failure = "reflecting BeatLeader Authentication failed: " + ex.Message;
}
if (signedInField != null)
{
var waitedSeconds = 0f;
while (!IsSignedIn(signedInField))
{ {
if (waitedSeconds >= LoginWaitTimeoutSeconds) signedInField = ResolveSignedInField(_log);
{ }
failure = $"BeatLeader login did not complete within {LoginWaitTimeoutSeconds:F0}s; " catch (Exception ex)
+ "is the BeatLeader mod actually signing in (check BeatLeader log lines)?"; {
break; signedInField = null;
} failure = "reflecting BeatLeader Authentication failed: " + ex.Message;
yield return new WaitForSeconds(1f);
waitedSeconds += 1f;
} }
if (failure == null) if (signedInField != null)
{ {
var fetchEnumerator = FetchOwnedGuids(result => var waitedSeconds = 0f;
while (!IsSignedIn(signedInField))
{ {
owned = result.OwnedGuids; if (waitedSeconds >= LoginWaitTimeoutSeconds)
failure = result.Failure; {
}); failure = $"BeatLeader login did not complete within {LoginWaitTimeoutSeconds:F0}s; "
yield return StartCoroutine(fetchEnumerator); + "is the BeatLeader mod actually signing in (check BeatLeader log lines)?";
break;
}
yield return new WaitForSeconds(1f);
waitedSeconds += 1f;
}
if (failure == null)
{
var fetchEnumerator = FetchOwnedGuids(result =>
{
owned = result.OwnedGuids;
failure = result.Failure;
});
yield return StartCoroutine(fetchEnumerator);
}
} }
} }
@ -154,12 +169,124 @@ namespace Setlist
foreach (var e in _entries) foreach (var e in _entries)
{ {
_log.Info(Plugin.FormatPlaylistLogLine(e.Title, e.HasSyncUrl, e.BeatLeaderGuid, owned)); _log.Info(Plugin.FormatPlaylistLogLine(
e.Title,
e.HasSyncUrl,
e.BeatLeaderGuid,
e.OwnerId,
platformUserId,
owned));
} }
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;
}
private struct FetchResult private struct FetchResult
{ {
public HashSet<string> OwnedGuids; public HashSet<string> OwnedGuids;

View File

@ -47,7 +47,7 @@ namespace Setlist
return; return;
} }
var entries = new List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)>(); var entries = new List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>();
foreach (var playlist in playlists) foreach (var playlist in playlists)
{ {
var hasSyncUrl = false; var hasSyncUrl = false;
@ -58,26 +58,22 @@ namespace Setlist
hasSyncUrl = !string.IsNullOrWhiteSpace(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; string blGuid = null;
if (hasSyncUrl && BeatLeaderPlaylistOwnership.TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g)) if (hasSyncUrl && BeatLeaderPlaylistOwnership.TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g))
{ {
blGuid = g; blGuid = g;
} }
entries.Add((playlist.Title, hasSyncUrl, blGuid)); entries.Add((playlist.Title, hasSyncUrl, blGuid, ownerId));
} }
if (entries.Any(e => e.BeatLeaderGuid != null)) BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
{
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
}
else
{
foreach (var e in entries)
{
Log.Info(FormatPlaylistLogLine(e.Title, e.HasSyncUrl, e.BeatLeaderGuid, ownedGuids: null));
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -94,31 +90,41 @@ namespace Setlist
string title, string title,
bool hasSyncUrl, bool hasSyncUrl,
string beatLeaderGuid, string beatLeaderGuid,
string ownerId,
string platformUserId,
HashSet<string> ownedGuids) HashSet<string> ownedGuids)
{ {
string ownerPart; var ownerToken = string.IsNullOrEmpty(ownerId) ? "owner=n/a" : $"owner={ownerId}";
string confirmPart;
if (!hasSyncUrl) if (!hasSyncUrl)
{ {
ownerPart = "beatLeaderOwnerConfirmed=n/a (no sync URL)"; confirmPart = "beatLeaderOwnerConfirmed=n/a (no sync URL)";
} }
else if (string.IsNullOrEmpty(beatLeaderGuid)) else if (string.IsNullOrEmpty(beatLeaderGuid))
{ {
ownerPart = "beatLeaderOwnerConfirmed=n/a (sync URL is not a BeatLeader playlist)"; confirmPart = "beatLeaderOwnerConfirmed=n/a (sync URL is not a BeatLeader playlist)";
}
else if (!string.IsNullOrEmpty(ownerId)
&& !string.IsNullOrEmpty(platformUserId)
&& string.Equals(ownerId, platformUserId, StringComparison.Ordinal))
{
confirmPart = "beatLeaderOwnerConfirmed=true (owner matches platform user id)";
} }
else if (ownedGuids == null) else if (ownedGuids == null)
{ {
ownerPart = "beatLeaderOwnerConfirmed=unknown (BeatLeader /user/playlists did not succeed; see prior log line)"; confirmPart = "beatLeaderOwnerConfirmed=unknown (BeatLeader /user/playlists did not succeed; see prior log line)";
} }
else if (ownedGuids.Contains(beatLeaderGuid)) else if (ownedGuids.Contains(beatLeaderGuid))
{ {
ownerPart = "beatLeaderOwnerConfirmed=true"; confirmPart = "beatLeaderOwnerConfirmed=true";
} }
else else
{ {
ownerPart = "beatLeaderOwnerConfirmed=false"; confirmPart = "beatLeaderOwnerConfirmed=false";
} }
return $"Playlist \"{title}\": hasSyncUrl={hasSyncUrl}, {ownerPart}"; return $"Playlist \"{title}\": hasSyncUrl={hasSyncUrl}, {ownerToken}, {confirmPart}";
} }
} }
} }

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.2.0")] [assembly: AssemblyVersion("0.0.5.0")]
[assembly: AssemblyFileVersion("0.0.2.0")] [assembly: AssemblyFileVersion("0.0.5.0")]

View File

@ -50,6 +50,14 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath> <HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="DataModels">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\DataModels.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlatformUserModel">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\PlatformUserModel.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="HMLib"> <Reference Include="HMLib">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll</HintPath> <HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll</HintPath>
<Private>False</Private> <Private>False</Private>

View File

@ -3,7 +3,7 @@
"id": "Setlist", "id": "Setlist",
"name": "Setlist", "name": "Setlist",
"author": "", "author": "",
"version": "0.0.2", "version": "0.0.5",
"description": "Syncs playlists with external sources.", "description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8", "gameVersion": "1.40.8",
"dependsOn": { "dependsOn": {