Compare commits
3 Commits
7879e03cc5
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| e44e5e852d | |||
| a3b8169fd6 | |||
| 14d21ad6a3 |
@@ -0,0 +1,104 @@
|
||||
# Setlist
|
||||
|
||||
A small Beat Saber (PC, BSIPA) plugin that syncs in-game playlist edits back to
|
||||
[BeatLeader](https://beatleader.com) so your playlists stay current outside the
|
||||
game — on the website, on your phone, or shared with friends.
|
||||
|
||||
## What it does
|
||||
|
||||
When you add a map to a playlist in-game (via the standard
|
||||
[PlaylistManager](https://github.com/rithik-b/PlaylistManager) "Add to playlist"
|
||||
flow), Setlist:
|
||||
|
||||
1. Watches `PlaylistManager.Utilities.Events.playlistSongAdded`.
|
||||
2. Checks whether the playlist is a **BeatLeader-synced playlist owned by you**
|
||||
— i.e. its `customData.syncURL` is an `api.beatleader.com/playlist/guid/…`
|
||||
URL and its `customData.owner` matches your platform user id.
|
||||
3. Serializes the updated playlist and **POSTs it back to BeatLeader**
|
||||
(`POST /user/playlist?id=…&shared=…`).
|
||||
|
||||
The POST reuses the [BeatLeader mod](https://github.com/BeatLeader/beatleader-mod)'s
|
||||
authenticated session — no extra login, no API token in your config, nothing to
|
||||
copy/paste. If BeatLeader is signed in, Setlist is signed in.
|
||||
|
||||
The result: edit playlists in VR; see them updated on the website and any
|
||||
device that consumes them, without alt-tabbing or running a sync script.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Beat Saber `1.44.1` (PC), via BSIPA.
|
||||
- [BeatLeader](https://github.com/BeatLeader/beatleader-mod) `>=0.9.0` — must be
|
||||
installed **and signed in** for the sync to authenticate.
|
||||
- [PlaylistManager](https://github.com/rithik-b/PlaylistManager) `^1.7.0`.
|
||||
- [BeatSaberPlaylistsLib](https://github.com/Zingabopp/BeatSaberPlaylistsLib)
|
||||
`^1.7.0` (transitive via PlaylistManager).
|
||||
|
||||
See `Setlist/manifest.json` for the authoritative list.
|
||||
|
||||
## Install
|
||||
|
||||
Drop `Setlist.dll` into `Beat Saber/Plugins/` alongside BeatLeader and
|
||||
PlaylistManager (BSManager works fine). Confirm in `Logs/_latest.log`:
|
||||
|
||||
```
|
||||
[INFO @ ...] [Setlist|...] platformUserId=...
|
||||
[INFO @ ...] [Setlist|...] Playlist "...": hasSyncUrl=True, owner=..., ownerMatchesPlatform=true (...)
|
||||
```
|
||||
|
||||
When you add a map to one of those playlists in-game you should then see:
|
||||
|
||||
```
|
||||
[INFO @ ...] [Setlist|...] Setlist BeatLeader sync: OK HTTP 200 playlist="..." body=...
|
||||
```
|
||||
|
||||
## Build (Linux / NixOS, no Visual Studio / Rider)
|
||||
|
||||
This whole plugin is built from the `dotnet` CLI on Linux — including NixOS —
|
||||
no BSMT IDE extension required. The Linux + Cursor toolchain (.NET SDK 9, BSMT
|
||||
MSBuild tasks, the missing-`net472`-reference-assemblies workaround, and the
|
||||
"copy DLL into `Plugins/` on a Unix host" target) is documented in:
|
||||
|
||||
- [`docs/pc-modding.md`](docs/pc-modding.md) — full guide, adapted from the BSMG
|
||||
wiki for Linux + Cursor + LLM-assisted dev.
|
||||
- [`docs/bootstrap.md`](docs/bootstrap.md) — exactly how this project was
|
||||
scaffolded (template, NuGet pins, `BeatSaberDir`, etc.).
|
||||
|
||||
Quick start once `Setlist.csproj.user` points at your install:
|
||||
|
||||
```bash
|
||||
cd Setlist
|
||||
dotnet restore
|
||||
dotnet build -c Debug
|
||||
```
|
||||
|
||||
Output: `Setlist/bin/Debug/Setlist.dll`, also copied into
|
||||
`$BeatSaberDir/Plugins/Setlist.dll` by the Unix-host post-build target.
|
||||
|
||||
## How it integrates with the two host plugins
|
||||
|
||||
Both integrations are deliberately thin and live in a single project so they
|
||||
can be read end-to-end:
|
||||
|
||||
- **PlaylistManager** — Setlist subscribes to its public
|
||||
`Events.playlistSongAdded` hook (same one used by the in-game UI flow).
|
||||
Notes on that flow live in [`docs/playlistmanager-add-map.md`](docs/playlistmanager-add-map.md).
|
||||
|
||||
- **BeatLeader** — Setlist references the shipped `BeatLeader.dll`, waits on
|
||||
BeatLeader's own `Authentication._signedIn` flag, and POSTs through Unity's
|
||||
`UnityWebRequest` so the cookies BeatLeader's `HttpClient` already has on the
|
||||
shared `CookieContainer` are reused. Background and the alternative
|
||||
"go through `WebRequestFactory` directly" approach are in
|
||||
[`docs/beatleader-playlist-api.md`](docs/beatleader-playlist-api.md) and
|
||||
[`docs/beatleader-api-samples.md`](docs/beatleader-api-samples.md).
|
||||
|
||||
## Status
|
||||
|
||||
Early / personal-use. Currently scoped to:
|
||||
|
||||
- Adds (one new song → one POST). Removes, reorders, renames, and bulk edits
|
||||
are not yet wired.
|
||||
- BeatLeader as the sync target. The same model would extend to other backends
|
||||
that accept a full-playlist upload.
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) for repo conventions (version-bump-on-build,
|
||||
smoketest, etc.) if you're contributing or letting an agent work in this repo.
|
||||
@@ -3,8 +3,6 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BeatSaberPlaylistsLib.Types;
|
||||
using UnityEngine;
|
||||
using IPALogger = IPA.Logging.Logger;
|
||||
@@ -20,7 +18,6 @@ namespace Setlist
|
||||
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;
|
||||
|
||||
internal static bool TryExtractBeatLeaderPlaylistGuid(string syncUrl, out string guid)
|
||||
{
|
||||
@@ -111,55 +108,18 @@ namespace Setlist
|
||||
internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone)
|
||||
{
|
||||
var waited = 0f;
|
||||
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
|
||||
while (waited < PlatformUserWaitTimeoutSeconds)
|
||||
{
|
||||
foreach (var plm in EnumerateLeaderboardModels())
|
||||
{
|
||||
var pid = plm.playerId;
|
||||
var pid = ReadPlatformUserId(plm);
|
||||
if (!string.IsNullOrEmpty(pid))
|
||||
{
|
||||
onDone(pid.Trim());
|
||||
onDone(pid);
|
||||
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);
|
||||
}
|
||||
@@ -188,26 +148,36 @@ namespace Setlist
|
||||
}
|
||||
}
|
||||
|
||||
private static IPlatformUserModel ResolvePlatformUserModel()
|
||||
private static string ReadPlatformUserId(PlatformLeaderboardsModel model)
|
||||
{
|
||||
var field = typeof(PlatformLeaderboardsModel).GetField(
|
||||
"_platformUserModel",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
if (field == null)
|
||||
if (model == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IPlatformUserModel last = null;
|
||||
foreach (var plm in EnumerateLeaderboardModels())
|
||||
var id = NormalizeUserId(model.playerId);
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
if (field.GetValue(plm) is IPlatformUserModel m)
|
||||
{
|
||||
last = m;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
+28
-11
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using BeatSaberPlaylistsLib.Types;
|
||||
using IPA;
|
||||
using PlaylistManager.Utilities;
|
||||
using UnityEngine;
|
||||
using IPALogger = IPA.Logging.Logger;
|
||||
|
||||
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>
|
||||
internal static string CachedPlatformUserId { get; set; }
|
||||
|
||||
private const float PlaylistScanPollStepSeconds = 0.5f;
|
||||
private const float PlaylistScanWaitTimeoutSeconds = 10f;
|
||||
|
||||
[Init]
|
||||
public Plugin(IPALogger logger)
|
||||
{
|
||||
@@ -36,25 +41,34 @@ namespace Setlist
|
||||
|
||||
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(
|
||||
includeChildren: true,
|
||||
out AggregateException loadErrors);
|
||||
|
||||
if (loadErrors != null)
|
||||
{
|
||||
Log.Error(loadErrors.Message);
|
||||
log.Error(loadErrors.Message);
|
||||
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).");
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = new List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>();
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
@@ -67,12 +81,15 @@ namespace Setlist
|
||||
entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
|
||||
}
|
||||
|
||||
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
|
||||
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, log);
|
||||
yield break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex.ToString());
|
||||
|
||||
waited += PlaylistScanPollStepSeconds;
|
||||
yield return new WaitForSeconds(PlaylistScanPollStepSeconds);
|
||||
}
|
||||
|
||||
log.Info("No playlists loaded after startup wait.");
|
||||
}
|
||||
|
||||
[OnExit]
|
||||
|
||||
@@ -11,5 +11,5 @@ using System.Runtime.InteropServices;
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("50F53E6E-21D5-4780-8E67-273877DAA28C")]
|
||||
[assembly: AssemblyVersion("0.1.0.0")]
|
||||
[assembly: AssemblyFileVersion("0.1.0.0")]
|
||||
[assembly: AssemblyVersion("0.1.2.0")]
|
||||
[assembly: AssemblyFileVersion("0.1.2.0")]
|
||||
|
||||
@@ -28,5 +28,10 @@ namespace Setlist
|
||||
{
|
||||
StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log));
|
||||
}
|
||||
|
||||
internal void StartPlaylistOwnershipScan(IPALogger log)
|
||||
{
|
||||
StartCoroutine(Plugin.CoScanAndLogPlaylistOwnership(log));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"id": "Setlist",
|
||||
"name": "Setlist",
|
||||
"author": "",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Syncs playlists with external sources.",
|
||||
"gameVersion": "1.40.8",
|
||||
"gameVersion": "1.44.1",
|
||||
"dependsOn": {
|
||||
"BSIPA": "^4.3.0",
|
||||
"BeatSaberPlaylistsLib": "^1.7.0",
|
||||
"BeatLeader": "^0.9.0",
|
||||
"BeatLeader": ">=0.9.0",
|
||||
"PlaylistManager": "^1.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,13 +3,13 @@
|
||||
Gamedir
|
||||
|
||||
```sh
|
||||
cd ~/.local/share/BSManager/BSInstances/1.40.8
|
||||
cd ~/.local/share/BSManager/BSInstances/1.44.1
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```sh
|
||||
tail -f Logs/_latest.log
|
||||
tail -F Logs/_latest.log
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
```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 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
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
Executable
+203
@@ -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
|
||||
Reference in New Issue
Block a user