2 Commits

Author SHA1 Message Date
pleb e44e5e852d Release Setlist 0.1.2 2026-07-19 20:39:54 -07:00
pleb a3b8169fd6 Update Setlist for Beat Saber 1.44.1 startup scan 2026-07-19 20:25:22 -07:00
8 changed files with 282 additions and 87 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ device that consumes them, without alt-tabbing or running a sync script.
## Requirements ## Requirements
- Beat Saber `1.40.8` (PC), via BSIPA. - Beat Saber `1.44.1` (PC), via BSIPA.
- [BeatLeader](https://github.com/BeatLeader/beatleader-mod) `^0.9.0` — must be - [BeatLeader](https://github.com/BeatLeader/beatleader-mod) `>=0.9.0` — must be
installed **and signed in** for the sync to authenticate. installed **and signed in** for the sync to authenticate.
- [PlaylistManager](https://github.com/rithik-b/PlaylistManager) `^1.7.0`. - [PlaylistManager](https://github.com/rithik-b/PlaylistManager) `^1.7.0`.
- [BeatSaberPlaylistsLib](https://github.com/Zingabopp/BeatSaberPlaylistsLib) - [BeatSaberPlaylistsLib](https://github.com/Zingabopp/BeatSaberPlaylistsLib)
+24 -54
View File
@@ -3,8 +3,6 @@ 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 BeatSaberPlaylistsLib.Types; using BeatSaberPlaylistsLib.Types;
using UnityEngine; using UnityEngine;
using IPALogger = IPA.Logging.Logger; using IPALogger = IPA.Logging.Logger;
@@ -20,7 +18,6 @@ namespace Setlist
private const float PlatformUserPollStepSeconds = 0.5f; 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> /// <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 PlatformUserWaitTimeoutSeconds = 30f;
private const float PlatformUserGetUserInfoRetrySeconds = 3f;
internal static bool TryExtractBeatLeaderPlaylistGuid(string syncUrl, out string guid) internal static bool TryExtractBeatLeaderPlaylistGuid(string syncUrl, out string guid)
{ {
@@ -111,55 +108,18 @@ namespace Setlist
internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone) internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone)
{ {
var waited = 0f; var waited = 0f;
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
while (waited < PlatformUserWaitTimeoutSeconds) while (waited < PlatformUserWaitTimeoutSeconds)
{ {
foreach (var plm in EnumerateLeaderboardModels()) foreach (var plm in EnumerateLeaderboardModels())
{ {
var pid = plm.playerId; var pid = ReadPlatformUserId(plm);
if (!string.IsNullOrEmpty(pid)) if (!string.IsNullOrEmpty(pid))
{ {
onDone(pid.Trim()); onDone(pid);
yield break; 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; waited += PlatformUserPollStepSeconds;
yield return new WaitForSeconds(PlatformUserPollStepSeconds); yield return new WaitForSeconds(PlatformUserPollStepSeconds);
} }
@@ -188,26 +148,36 @@ namespace Setlist
} }
} }
private static IPlatformUserModel ResolvePlatformUserModel() private static string ReadPlatformUserId(PlatformLeaderboardsModel model)
{ {
var field = typeof(PlatformLeaderboardsModel).GetField( if (model == null)
"_platformUserModel",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null)
{ {
return null; return null;
} }
IPlatformUserModel last = null; var id = NormalizeUserId(model.playerId);
foreach (var plm in EnumerateLeaderboardModels()) if (!string.IsNullOrEmpty(id))
{ {
if (field.GetValue(plm) is IPlatformUserModel m) return id;
{
last = m;
}
} }
return last; var platformField = typeof(PlatformLeaderboardsModel).GetField(
"_platform",
BindingFlags.Instance | BindingFlags.NonPublic);
var platform = platformField?.GetValue(model);
var user = platform?.GetType().GetProperty("user")?.GetValue(platform, null);
return NormalizeUserId(user?.GetType().GetProperty("userId")?.GetValue(user, null));
}
private static string NormalizeUserId(object raw)
{
if (raw == null)
{
return null;
}
var value = raw.ToString()?.Trim();
return string.IsNullOrEmpty(value) || value == "0" ? null : value;
} }
/// <summary> /// <summary>
+39 -22
View File
@@ -1,8 +1,10 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using BeatSaberPlaylistsLib.Types; using BeatSaberPlaylistsLib.Types;
using IPA; using IPA;
using PlaylistManager.Utilities; using PlaylistManager.Utilities;
using UnityEngine;
using IPALogger = IPA.Logging.Logger; using IPALogger = IPA.Logging.Logger;
namespace Setlist namespace Setlist
@@ -20,6 +22,9 @@ namespace Setlist
/// <summary>Set after the ownership scan resolves the platform user id; used when syncing after PlaylistManager adds a song.</summary> /// <summary>Set after the ownership scan resolves the platform user id; used when syncing after PlaylistManager adds a song.</summary>
internal static string CachedPlatformUserId { get; set; } internal static string CachedPlatformUserId { get; set; }
private const float PlaylistScanPollStepSeconds = 0.5f;
private const float PlaylistScanWaitTimeoutSeconds = 10f;
[Init] [Init]
public Plugin(IPALogger logger) public Plugin(IPALogger logger)
{ {
@@ -36,43 +41,55 @@ namespace Setlist
Events.playlistSongAdded += OnPlaylistSongAdded; Events.playlistSongAdded += OnPlaylistSongAdded;
SetlistSyncHost.Instance.StartPlaylistOwnershipScan(Log);
}
catch (Exception ex)
{
Log.Error(ex.ToString());
}
}
internal static IEnumerator CoScanAndLogPlaylistOwnership(IPALogger log)
{
var waited = 0f;
while (waited <= PlaylistScanWaitTimeoutSeconds)
{
var playlists = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager.GetAllPlaylists( var playlists = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager.GetAllPlaylists(
includeChildren: true, includeChildren: true,
out AggregateException loadErrors); out AggregateException loadErrors);
if (loadErrors != null) if (loadErrors != null)
{ {
Log.Error(loadErrors.Message); log.Error(loadErrors.Message);
foreach (var inner in loadErrors.InnerExceptions) foreach (var inner in loadErrors.InnerExceptions)
{ {
Log.Error(inner.ToString()); log.Error(inner.ToString());
} }
} }
if (playlists == null || playlists.Length == 0) if (playlists != null && playlists.Length > 0)
{ {
Log.Info("No playlists loaded (or playlist library not initialized yet)."); var entries = new List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>();
return; foreach (var playlist in playlists)
{
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(
playlist,
out var hasSyncUrl,
out var blGuid,
out var ownerId);
entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
}
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, log);
yield break;
} }
var entries = new List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>(); waited += PlaylistScanPollStepSeconds;
foreach (var playlist in playlists) yield return new WaitForSeconds(PlaylistScanPollStepSeconds);
{
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(
playlist,
out var hasSyncUrl,
out var blGuid,
out var ownerId);
entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
}
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
}
catch (Exception ex)
{
Log.Error(ex.ToString());
} }
log.Info("No playlists loaded after startup wait.");
} }
[OnExit] [OnExit]
+2 -2
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.1.0.0")] [assembly: AssemblyVersion("0.1.2.0")]
[assembly: AssemblyFileVersion("0.1.0.0")] [assembly: AssemblyFileVersion("0.1.2.0")]
+5
View File
@@ -28,5 +28,10 @@ namespace Setlist
{ {
StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log)); StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log));
} }
internal void StartPlaylistOwnershipScan(IPALogger log)
{
StartCoroutine(Plugin.CoScanAndLogPlaylistOwnership(log));
}
} }
} }
+3 -3
View File
@@ -3,13 +3,13 @@
"id": "Setlist", "id": "Setlist",
"name": "Setlist", "name": "Setlist",
"author": "", "author": "",
"version": "0.1.0", "version": "0.1.2",
"description": "Syncs playlists with external sources.", "description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8", "gameVersion": "1.44.1",
"dependsOn": { "dependsOn": {
"BSIPA": "^4.3.0", "BSIPA": "^4.3.0",
"BeatSaberPlaylistsLib": "^1.7.0", "BeatSaberPlaylistsLib": "^1.7.0",
"BeatLeader": "^0.9.0", "BeatLeader": ">=0.9.0",
"PlaylistManager": "^1.7.0" "PlaylistManager": "^1.7.0"
} }
} }
+4 -4
View File
@@ -3,13 +3,13 @@
Gamedir Gamedir
```sh ```sh
cd ~/.local/share/BSManager/BSInstances/1.40.8 cd ~/.local/share/BSManager/BSInstances/1.44.1
``` ```
## Logs ## Logs
```sh ```sh
tail -f Logs/_latest.log tail -F Logs/_latest.log
``` ```
## FPFC Mode ## FPFC Mode
@@ -23,7 +23,7 @@ Mouse input is broken because Wayland is not yet supported.
To run beat saber, you can mimic the launch execution that bs-manager performs with the following: To run beat saber, you can mimic the launch execution that bs-manager performs with the following:
```sh ```sh
cd "/home/pleb/.local/share/BSManager/BSInstances/1.40.8" cd "/home/pleb/.local/share/BSManager/BSInstances/1.44.1"
export SteamAppId=620980 SteamOverlayGameId=620980 SteamGameId=620980 export SteamAppId=620980 SteamOverlayGameId=620980 SteamGameId=620980
export WINEDLLOVERRIDES='winhttp=n,b' export WINEDLLOVERRIDES='winhttp=n,b'
@@ -40,7 +40,7 @@ steam-run "/home/pleb/.local/share/Steam/steamapps/common/Proton - Experimental/
Then, after a few seconds you can read the log messages after BS starts up Then, after a few seconds you can read the log messages after BS starts up
``` ```
cat ~/.local/share/BSManager/BSInstances/1.40.8/Logs/_latest.log | grep Setlist cat ~/.local/share/BSManager/BSInstances/1.44.1/Logs/_latest.log | grep Setlist
``` ```
Thereafter, kill the process. Thereafter, kill the process.
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
if [[ -f .env ]]; then
set -a
# shellcheck source=/dev/null
. ./.env
set +a
fi
project="Setlist/Setlist.csproj"
manifest="Setlist/manifest.json"
assembly_info="Setlist/Properties/AssemblyInfo.cs"
version="${1:-$(jq -r '.version' "$manifest")}"
tag="v${version}"
game_version="$(jq -r '.gameVersion' "$manifest")"
require() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: required command not found: $1" >&2
exit 1
fi
}
require curl
require dotnet
require git
require jq
require rg
require zip
if [[ -z "${GITEA_AGENT_TOKEN:-}" ]]; then
echo "error: GITEA_AGENT_TOKEN is not set; add it to .env or export it" >&2
exit 1
fi
manifest_version="$(jq -r '.version' "$manifest")"
if [[ "$version" != "$manifest_version" ]]; then
echo "error: requested version $version does not match manifest version $manifest_version" >&2
exit 1
fi
if ! rg -q "\\[assembly: AssemblyVersion\\(\"${version}\\.0\"\\)\\]" "$assembly_info"; then
echo "error: AssemblyVersion does not match ${version}.0" >&2
exit 1
fi
if ! rg -q "\\[assembly: AssemblyFileVersion\\(\"${version}\\.0\"\\)\\]" "$assembly_info"; then
echo "error: AssemblyFileVersion does not match ${version}.0" >&2
exit 1
fi
if ! git diff --quiet --exit-code -- . ':(exclude)Setlist/bin' ':(exclude)Setlist/obj'; then
echo "error: tracked worktree changes are present; commit them before releasing" >&2
exit 1
fi
branch="$(git symbolic-ref --quiet --short HEAD)"
if [[ -z "$branch" ]]; then
echo "error: releases must be run from a branch, not detached HEAD" >&2
exit 1
fi
origin_url="$(git remote get-url origin)"
case "$origin_url" in
git@*:*|gitea@*:*)
host_part="${origin_url%%:*}"
host="${host_part#*@}"
repo_path="${origin_url#*:}"
;;
ssh://*@*/*)
without_scheme="${origin_url#ssh://}"
without_user="${without_scheme#*@}"
host="${without_user%%/*}"
repo_path="${without_user#*/}"
;;
http://*/*|https://*/*)
without_scheme="${origin_url#*://}"
host="${without_scheme%%/*}"
repo_path="${without_scheme#*/}"
;;
*)
echo "error: cannot parse origin URL: $origin_url" >&2
exit 1
;;
esac
repo_path="${repo_path%.git}"
api_base="${GITEA_API_BASE:-https://${host}/api/v1}"
echo "Restoring $project"
dotnet restore "$project"
echo "Building $project Release"
dotnet build "$project" -c Release
short_sha="$(git rev-parse --short=7 HEAD)"
asset_name="Setlist-${version}-bs${game_version}-${short_sha}.zip"
asset_dir="Setlist/bin/Release/zip"
asset_path="${asset_dir}/${asset_name}"
package_root="$(mktemp -d)"
response_body="$(mktemp)"
request_body="$(mktemp)"
curl_config="$(mktemp)"
cleanup() {
rm -rf "$package_root"
rm -f "$response_body" "$request_body" "$curl_config"
}
trap cleanup EXIT
chmod 600 "$curl_config"
{
printf 'silent\n'
printf 'show-error\n'
printf 'header = "Authorization: token %s"\n' "$GITEA_AGENT_TOKEN"
} > "$curl_config"
mkdir -p "$asset_dir" "$package_root/Plugins"
cp Setlist/bin/Release/Setlist.dll "$package_root/Plugins/Setlist.dll"
cp Setlist/bin/Release/Setlist.pdb "$package_root/Plugins/Setlist.pdb"
rm -f "$asset_path"
(cd "$package_root" && zip -qr "$repo_root/$asset_path" Plugins)
echo "Packaged $asset_path"
sha256sum "$asset_path"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
tag_target="$(git rev-list -n 1 "$tag")"
head_target="$(git rev-parse HEAD)"
if [[ "$tag_target" != "$head_target" ]]; then
echo "error: local tag $tag points at $tag_target, not HEAD $head_target" >&2
exit 1
fi
else
git tag -a "$tag" -m "Setlist $tag"
fi
echo "Pushing $branch and $tag to origin"
git push origin "HEAD:${branch}"
git push origin "$tag"
release_url="${api_base}/repos/${repo_path}/releases/tags/${tag}"
status="$(curl --config "$curl_config" -o "$response_body" -w '%{http_code}' "$release_url" || true)"
if [[ "$status" == "200" ]]; then
release_id="$(jq -r '.id' "$response_body")"
echo "Using existing Gitea release $tag"
elif [[ "$status" == "404" ]]; then
jq -n \
--arg tag "$tag" \
--arg name "$tag" \
--arg body "Release ${tag} for Beat Saber ${game_version}." \
--arg target "$branch" \
'{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:false}' \
> "$request_body"
status="$(curl --config "$curl_config" -X POST -H 'Content-Type: application/json' --data @"$request_body" -o "$response_body" -w '%{http_code}' "${api_base}/repos/${repo_path}/releases" || true)"
if [[ "$status" != "201" ]]; then
echo "error: failed to create release; Gitea returned HTTP $status" >&2
cat "$response_body" >&2
exit 1
fi
release_id="$(jq -r '.id' "$response_body")"
echo "Created Gitea release $tag"
else
echo "error: failed to inspect release; Gitea returned HTTP $status" >&2
cat "$response_body" >&2
exit 1
fi
assets_url="${api_base}/repos/${repo_path}/releases/${release_id}/assets"
status="$(curl --config "$curl_config" -o "$response_body" -w '%{http_code}' "$assets_url" || true)"
if [[ "$status" != "200" ]]; then
echo "error: failed to list release assets; Gitea returned HTTP $status" >&2
cat "$response_body" >&2
exit 1
fi
existing_asset_id="$(jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' "$response_body" | head -n 1)"
if [[ -n "$existing_asset_id" ]]; then
echo "Deleting existing asset $asset_name"
status="$(curl --config "$curl_config" -X DELETE -o "$response_body" -w '%{http_code}' "${assets_url}/${existing_asset_id}" || true)"
if [[ "$status" != "204" ]]; then
echo "error: failed to delete existing asset; Gitea returned HTTP $status" >&2
cat "$response_body" >&2
exit 1
fi
fi
status="$(curl --config "$curl_config" -X POST -H 'Content-Type: application/zip' --data-binary @"$asset_path" -o "$response_body" -w '%{http_code}' "${assets_url}?name=${asset_name}" || true)"
if [[ "$status" != "201" ]]; then
echo "error: failed to upload release asset; Gitea returned HTTP $status" >&2
cat "$response_body" >&2
exit 1
fi
html_url="$(jq -r '.browser_download_url // .download_url // empty' "$response_body")"
echo "Uploaded $asset_name"
if [[ -n "$html_url" ]]; then
echo "$html_url"
fi