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
- 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.
## 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.
- 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).
- **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)
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.
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).
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.
3. Confirm: `grep Setlist Logs/_latest.log | tail -1` — expect `hasSyncUrl=True, beatLeaderOwnerConfirmed=…` (or your current message shape).
4. When done, `timeout` may already have stopped the run; if `Beat Saber.exe` / Wine is still running, kill that process tree.
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.
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` — expect current Setlist `INFO` lines (e.g. `platformUserId=…`, playlist lines with `hasSyncUrl=…`, `beatLeaderOwnerConfirmed=…`).
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.

View File

@ -3,6 +3,8 @@ using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using BeatLeader.Utils;
using Newtonsoft.Json;
using UnityEngine;
@ -24,6 +26,10 @@ namespace Setlist
private const string AuthenticationTypeName = "BeatLeader.API.Authentication";
private const string SignedInFieldName = "_signedIn";
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 sealed class UserPlaylistSummary
@ -78,7 +84,7 @@ namespace Setlist
/// Must be called from the Unity main thread (BSIPA's <c>OnApplicationStart</c> is fine).
/// </summary>
internal static void ScheduleVerifyAndLog(
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries,
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
var go = new GameObject("Setlist.OwnershipRunner");
@ -93,11 +99,11 @@ namespace Setlist
/// </summary>
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;
public void Initialize(
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries,
List<(string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
_entries = entries;
@ -107,9 +113,17 @@ namespace Setlist
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;
string failure = null;
if (_entries.Any(e => e.BeatLeaderGuid != null))
{
FieldInfo signedInField;
try
{
@ -146,6 +160,7 @@ namespace Setlist
yield return StartCoroutine(fetchEnumerator);
}
}
}
if (owned == null && failure != null)
{
@ -154,12 +169,124 @@ namespace Setlist
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);
}
/// <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
{
public HashSet<string> OwnedGuids;

View File

@ -47,7 +47,7 @@ namespace Setlist
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)
{
var hasSyncUrl = false;
@ -58,27 +58,23 @@ namespace Setlist
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.Title, hasSyncUrl, blGuid));
entries.Add((playlist.Title, hasSyncUrl, blGuid, ownerId));
}
if (entries.Any(e => e.BeatLeaderGuid != null))
{
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
}
else
{
foreach (var e in entries)
{
Log.Info(FormatPlaylistLogLine(e.Title, e.HasSyncUrl, e.BeatLeaderGuid, ownedGuids: null));
}
}
}
catch (Exception ex)
{
Log.Error(ex.ToString());
@ -94,31 +90,41 @@ namespace Setlist
string title,
bool hasSyncUrl,
string beatLeaderGuid,
string ownerId,
string platformUserId,
HashSet<string> ownedGuids)
{
string ownerPart;
var ownerToken = string.IsNullOrEmpty(ownerId) ? "owner=n/a" : $"owner={ownerId}";
string confirmPart;
if (!hasSyncUrl)
{
ownerPart = "beatLeaderOwnerConfirmed=n/a (no sync URL)";
confirmPart = "beatLeaderOwnerConfirmed=n/a (no sync URL)";
}
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)
{
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))
{
ownerPart = "beatLeaderOwnerConfirmed=true";
confirmPart = "beatLeaderOwnerConfirmed=true";
}
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: ComVisible(false)]
[assembly: Guid("50F53E6E-21D5-4780-8E67-273877DAA28C")]
[assembly: AssemblyVersion("0.0.2.0")]
[assembly: AssemblyFileVersion("0.0.2.0")]
[assembly: AssemblyVersion("0.0.5.0")]
[assembly: AssemblyFileVersion("0.0.5.0")]

View File

@ -50,6 +50,14 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>False</Private>
</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">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll</HintPath>
<Private>False</Private>

View File

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