mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
fix(jellyfin): restore authenticated album artwork
This commit is contained in:
24 files changed
+463
-63
No files matched your search
@@ -1,4 +1,5 @@
|
||||
.git
|
||||
.apple-provider/
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -71,6 +71,7 @@ obj/
|
||||
/.env
|
||||
/.env.before-*
|
||||
/.allstarr-profiles
|
||||
/.allstarr-mode
|
||||
/.apple-provider/
|
||||
/secrets/
|
||||
/allstarr-backups/
|
||||
|
||||
+2
-2
@@ -171,8 +171,8 @@ Likely credentials for manual/live validation:
|
||||
| Identity and accounts | [allstarr/Core/Identity](allstarr/Core/Identity), [allstarr/Controllers/ProviderAccountsController.cs](allstarr/Controllers/ProviderAccountsController.cs) | Backend principals resolve to tenant-scoped platform users. Global, user, and library provider accounts are stored durably and filtered by account policy. |
|
||||
| Secrets | [allstarr/Core/Secrets](allstarr/Core/Secrets) | Provider-account records hold secret references. Versioned secret values are protected with AES-GCM using an external key ring, with replace, rotate, revoke, and tenant access rules. |
|
||||
| Durable work | [allstarr/Core/Jobs](allstarr/Core/Jobs), [allstarr/Controllers/JobsController.cs](allstarr/Controllers/JobsController.cs) | Jobs, attempts, leases, idempotency keys, cancellation, retry state, sidecar deferrals, and transactional outbox messages live in the selected database. Users can inspect and cancel only their own jobs; admins can inspect all jobs. |
|
||||
| Operations | [allstarr/Core/Operations](allstarr/Core/Operations), [allstarr/Controllers/DiagnosticsController.cs](allstarr/Controllers/DiagnosticsController.cs) | Liveness, readiness, sidecar capability state, redacted structured logs, correlated diagnostics, and Prometheus-style metrics expose the durable foundation without leaking credentials, media URLs, or account names. |
|
||||
| Apple providers | [AppleMusicController.cs](allstarr/Controllers/AppleMusicController.cs), [Apple MusicKit adapters](allstarr/Core/Providers/AppleMusicKit), [Apple download services](allstarr/Services/AppleMusic), [gateway](sidecars/apple-gateway) | The user-scoped MusicKit lane reads personal-library songs, albums, artists, and playlists with the selected encrypted account. The optional download profile calls the repository gateway, which runs pinned GAMDL against a locked official wrapper-v2 build. Gateway discovery decides which download capabilities can be advertised. |
|
||||
| Operations | [allstarr/Core/Operations](allstarr/Core/Operations), [allstarr/Controllers/DiagnosticsController.cs](allstarr/Controllers/DiagnosticsController.cs) | Liveness, readiness, sidecar capability state, redacted structured logs, correlated diagnostics, and Prometheus-style metrics expose the durable foundation. Secret fields and sensitive URL query values are redacted, while useful route, host, item, playlist, and filesystem context remains visible. |
|
||||
| Apple providers | [AppleMusicController.cs](allstarr/Controllers/AppleMusicController.cs), [Apple MusicKit adapters](allstarr/Core/Providers/AppleMusicKit), [Apple download services](allstarr/Services/AppleMusic), [gateway](sidecars/apple-gateway) | The user-scoped MusicKit lane reads personal-library songs, albums, artists, playlists, and public artwork with the selected encrypted account. The optional download profile calls the repository gateway, which runs pinned GAMDL against a locked official wrapper-v2 build. Terminal generic gateway jobs survive restart. Gateway discovery still advertises only capabilities with complete host ingestion and contract coverage. |
|
||||
| Jellyfin protocol | [allstarr/Controllers/JellyfinController.Audio.cs](allstarr/Controllers/JellyfinController.Audio.cs), [allstarr/Controllers/JellyfinController.Search.cs](allstarr/Controllers/JellyfinController.Search.cs), [allstarr/Controllers/JellyfinController.PlaylistHandler.cs](allstarr/Controllers/JellyfinController.PlaylistHandler.cs) | Jellyfin compatibility stays the first protocol adapter. |
|
||||
| Spotify playlists | [allstarr/Controllers/JellyfinController.Spotify.cs](allstarr/Controllers/JellyfinController.Spotify.cs), [docs/steering/SPOTIFY.md](docs/steering/SPOTIFY.md) | Durable provider-neutral playlist links are the current path. Spotify injection remains a compatibility path. |
|
||||
| MusicBrainz | [allstarr/Services/MusicBrainz/MusicBrainzService.cs](allstarr/Services/MusicBrainz/MusicBrainzService.cs) | MusicBrainz contributes enrichment, canonical identity, matching, tagging, and local recommendation relationships. |
|
||||
|
||||
@@ -33,6 +33,10 @@ curl --fail http://127.0.0.1:5274/health/ready
|
||||
permissions, and never deletes volumes. Normal upgrades are `./allstarr.sh update`; they pull reviewed images and
|
||||
recreate the saved profile without requiring users to remember a growing list of `-f` arguments.
|
||||
|
||||
The default `release` mode runs reviewed images. Beta testers and contributors who want the checked-out commit can
|
||||
run `./allstarr.sh mode source`, then `./allstarr.sh up`. For later source updates, run `git pull --ff-only` followed
|
||||
by `./allstarr.sh update`. The same volumes and optional-provider profiles remain attached in either mode.
|
||||
|
||||
The standard stack is the smaller, recommended default. The AIO override mounts the checksum-locked offline
|
||||
first-party package bundle, but it does not force optional provider sidecars on anyone:
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ public sealed class AppleMusicKitMetadataCapabilityAdapterTests
|
||||
Assert.Equal("Library Album", album.RequireValue().Title);
|
||||
Assert.Equal(12, album.RequireValue().TrackCount);
|
||||
Assert.Equal("Library Artist", artist.RequireValue().Name);
|
||||
Assert.Equal("https://is1-ssl.mzstatic.com/image/thumb/library/1024x1024bb.jpg",
|
||||
track.RequireValue().Artwork?.PublicUri?.ToString());
|
||||
Assert.Equal("https://is1-ssl.mzstatic.com/image/thumb/library/1024x1024bb.jpg",
|
||||
album.RequireValue().Artwork?.PublicUri?.ToString());
|
||||
Assert.Equal("\"revision-1\"", track.RequireValue().SnapshotVersion);
|
||||
Assert.Equal(6, secrets.AccountIds.Count);
|
||||
Assert.All(handler.Authorization, value => Assert.Equal("Bearer developer-token", value));
|
||||
@@ -218,7 +222,13 @@ public sealed class AppleMusicKitMetadataCapabilityAdapterTests
|
||||
{
|
||||
id,
|
||||
type,
|
||||
attributes = new { name = "Library Album", artistName = "Library Artist", trackCount = 12 }
|
||||
attributes = new
|
||||
{
|
||||
name = "Library Album",
|
||||
artistName = "Library Artist",
|
||||
trackCount = 12,
|
||||
artwork = new { url = "https://is1-ssl.mzstatic.com/image/thumb/library/{w}x{h}bb.jpg" }
|
||||
}
|
||||
},
|
||||
"library-artists" => new
|
||||
{
|
||||
@@ -238,7 +248,8 @@ public sealed class AppleMusicKitMetadataCapabilityAdapterTests
|
||||
albumId = "i.album",
|
||||
durationInMillis = 180000,
|
||||
isrc = "USABC1234567",
|
||||
contentRating = "clean"
|
||||
contentRating = "clean",
|
||||
artwork = new { url = "https://is1-ssl.mzstatic.com/image/thumb/library/{w}x{h}bb.jpg" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,6 +77,34 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("USRC12345678", providerIds["ISRC"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertSongToJellyfinItem_ExternalRelationshipsRemainRoutableForAlbumArtwork()
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = "ext-apple-musickit-song-i.song",
|
||||
Title = "Library Song",
|
||||
Artist = "Library Artist",
|
||||
Artists = ["Library Artist"],
|
||||
ArtistId = "ext-apple-musickit-artist-i.artist",
|
||||
ArtistIds = ["ext-apple-musickit-artist-i.artist"],
|
||||
Album = "Library Album",
|
||||
AlbumId = "ext-apple-musickit-album-i.album",
|
||||
IsLocal = false,
|
||||
ExternalProvider = "apple-musickit",
|
||||
ExternalId = "i.song"
|
||||
};
|
||||
|
||||
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||
|
||||
Assert.Equal("ext-apple-musickit-song-i.song", result["Id"]);
|
||||
Assert.Equal("ext-apple-musickit-album-i.album", result["AlbumId"]);
|
||||
Assert.Equal("ext-apple-musickit-album-i.album", result["AlbumPrimaryImageTag"]);
|
||||
Assert.Equal("ext-apple-musickit-album-i.album", result["ParentLogoImageTag"]);
|
||||
var imageTags = Assert.IsType<Dictionary<string, string>>(result["ImageTags"]);
|
||||
Assert.Equal("ext-apple-musickit-song-i.song", imageTags["Primary"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertSongToJellyfinItem_ExternalExplicitSong_AppendsStreamingAndExplicitLabels()
|
||||
{
|
||||
|
||||
@@ -228,6 +228,8 @@ public class LocalLibraryServiceTests : IDisposable
|
||||
[InlineData("ext-deezer-album-789012", true, "deezer", "album", "789012")]
|
||||
[InlineData("ext-deezer-artist-259", true, "deezer", "artist", "259")]
|
||||
[InlineData("ext-spotify-song-abc123", true, "spotify", "song", "abc123")]
|
||||
[InlineData("ext-apple-musickit-album-i.album", true, "apple-musickit", "album", "i.album")]
|
||||
[InlineData("ext-apple-musickit-song-i-song-1", true, "apple-musickit", "song", "i-song-1")]
|
||||
[InlineData("ext-deezer-123", true, "deezer", "song", "123")] // Legacy format defaults to song
|
||||
[InlineData("ext-tidal-999", true, "tidal", "song", "999")] // Legacy format defaults to song
|
||||
[InlineData("123456", false, null, null, null)]
|
||||
|
||||
@@ -176,7 +176,7 @@ public sealed class OperationalObservabilityTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeLogger_RedactsStructuredSecretsUrlsPathsAndExceptionText()
|
||||
public void RuntimeLogger_RedactsSecretsButKeepsUsefulUrlAndPathContext()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var error = new StringWriter();
|
||||
@@ -204,8 +204,8 @@ public sealed class OperationalObservabilityTests : IAsyncLifetime
|
||||
Assert.Contains("deezer", log, StringComparison.Ordinal);
|
||||
Assert.Contains("redacted", log, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private-token", log, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("provider.invalid", log, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/media/private", log, StringComparison.Ordinal);
|
||||
Assert.Contains("provider.invalid", log, StringComparison.Ordinal);
|
||||
Assert.Contains("/media/private", log, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("request https", log, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("database-secret", log, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,13 @@ public sealed class ProtocolPlaylistGatewayTests
|
||||
|
||||
var result = await gateway.GetPlaylistTracksAsync(context, "spotify", "playlist-1");
|
||||
|
||||
Assert.Equal("Track", Assert.Single(result).Title);
|
||||
var track = Assert.Single(result);
|
||||
Assert.Equal("Track", track.Title);
|
||||
Assert.Equal("ext-spotify-song-track-1", track.Id);
|
||||
Assert.Equal("ext-spotify-album-album-1", track.AlbumId);
|
||||
Assert.Equal("ext-spotify-artist-artist-1", track.ArtistId);
|
||||
Assert.Equal(["ext-spotify-artist-artist-1"], track.ArtistIds);
|
||||
Assert.Equal("https://images.example.test/album-1.webp", track.CoverArtUrl);
|
||||
legacy.VerifyNoOtherCalls();
|
||||
capability.VerifyAll();
|
||||
}
|
||||
@@ -193,7 +199,9 @@ public sealed class ProtocolPlaylistGatewayTests
|
||||
"Artist",
|
||||
new ProviderExternalResourceId("spotify", ProviderResourceKind.Artist, "artist-1"))],
|
||||
new ProviderExternalResourceId("spotify", ProviderResourceKind.Album, "album-1"),
|
||||
"Album");
|
||||
"Album",
|
||||
artwork: new ProviderArtworkReference(
|
||||
publicUri: new Uri("https://images.example.test/album-1.webp")));
|
||||
return ProviderOutcome<ProviderPlaylistTrackPage>.Success(new(
|
||||
summary,
|
||||
new ProviderPage<ProviderPlaylistTrack>(
|
||||
|
||||
@@ -296,6 +296,64 @@ public sealed class ProtocolRouteFixtureTests
|
||||
Assert.Empty(await conditionalResponse.Content.ReadAsByteArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JellyfinExternalAlbumImage_ParsesHyphenatedProviderAndReturnsPositiveArtwork()
|
||||
{
|
||||
var artworkBytes = new byte[] { 10, 20, 30, 40 };
|
||||
var observedPaths = new List<string>();
|
||||
var gateway = new Mock<IProtocolProviderGateway>(MockBehavior.Strict);
|
||||
gateway.Setup(service => service.GetAlbumAsync(
|
||||
It.Is<ProtocolExecutionContext>(context => context.Protocol == ProtocolKind.Jellyfin),
|
||||
"apple-musickit",
|
||||
"i.album"))
|
||||
.ReturnsAsync(new Album
|
||||
{
|
||||
Id = "ext-apple-musickit-album-i.album",
|
||||
ExternalProvider = "apple-musickit",
|
||||
ExternalId = "i.album",
|
||||
Title = "Library Album",
|
||||
Artist = "Library Artist",
|
||||
CoverArtUrl = "https://is1-ssl.mzstatic.com/image/thumb/library/1024x1024bb.jpg",
|
||||
IsLocal = false
|
||||
});
|
||||
using var factory = new ProtocolFactory(
|
||||
"Jellyfin",
|
||||
request =>
|
||||
{
|
||||
observedPaths.Add(request.RequestUri!.PathAndQuery);
|
||||
if (request.RequestUri.AbsolutePath == "/Users/Me")
|
||||
return Json(StatusCodes.Status200OK, """{"Id":"verified-user"}""");
|
||||
if (request.RequestUri.Host == "is1-ssl.mzstatic.com")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(artworkBytes)
|
||||
{
|
||||
Headers = { ContentType = new("image/jpeg") }
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new InvalidOperationException($"Unexpected upstream request: {request.RequestUri}");
|
||||
},
|
||||
services =>
|
||||
{
|
||||
services.RemoveAll<IProtocolProviderGateway>();
|
||||
services.AddSingleton(gateway.Object);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
"/Items/ext-apple-musickit-album-i.album/Images/Primary?api_key=fixture-key&tag=revision-1");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(artworkBytes, await response.Content.ReadAsByteArrayAsync());
|
||||
Assert.NotNull(response.Headers.ETag);
|
||||
Assert.Contains(observedPaths, path => path == "/Users/Me?api_key=fixture-key");
|
||||
Assert.Contains(observedPaths, path => path == "/image/thumb/library/1024x1024bb.jpg");
|
||||
gateway.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JellyfinLyrics_PreservesLocalFirstFallbackAndNotFoundFixtures()
|
||||
{
|
||||
|
||||
+37
-6
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
|
||||
PROFILE_FILE="$ROOT/.allstarr-profiles"
|
||||
MODE_FILE="$ROOT/.allstarr-mode"
|
||||
|
||||
die() { echo "allstarr: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
|
||||
@@ -20,8 +21,21 @@ profiles() {
|
||||
printf '%s\n' "${values[@]}" | awk '!seen[$0]++'
|
||||
}
|
||||
|
||||
deployment_mode() {
|
||||
local mode="release"
|
||||
[[ -f "$MODE_FILE" ]] && read -r mode < "$MODE_FILE"
|
||||
case "$mode" in release|source) printf '%s\n' "$mode" ;; *) die "invalid deployment mode in .allstarr-mode" ;; esac
|
||||
}
|
||||
|
||||
set_mode() {
|
||||
local mode="${1:-}"
|
||||
case "$mode" in release|source) printf '%s\n' "$mode" > "$MODE_FILE" ;; *) die "mode must be release or source" ;; esac
|
||||
echo "Deployment mode set to $mode. Run: ./allstarr.sh up"
|
||||
}
|
||||
|
||||
compose_args() {
|
||||
COMPOSE=(-f "$ROOT/docker-compose.yml")
|
||||
[[ "$(deployment_mode)" == source ]] && COMPOSE+=(-f "$ROOT/docker-compose.dev.yml")
|
||||
while IFS= read -r profile; do
|
||||
case "$profile" in
|
||||
spotify-lyrics) COMPOSE+=(-f "$ROOT/docker-compose.spotify-lyrics.yml") ;;
|
||||
@@ -74,6 +88,8 @@ init() {
|
||||
fi
|
||||
chmod 600 "$ROOT/secrets/postgres-password.txt" "$ROOT/secrets/allstarr-keyring.json"
|
||||
touch "$PROFILE_FILE"
|
||||
[[ -f "$MODE_FILE" ]] || printf '%s\n' "${1:-release}" > "$MODE_FILE"
|
||||
deployment_mode >/dev/null
|
||||
echo "Allstarr is initialized. Edit .env, then run: ./allstarr.sh up"
|
||||
}
|
||||
|
||||
@@ -105,7 +121,7 @@ prepare_apple() {
|
||||
up() {
|
||||
compose_args
|
||||
docker compose "${COMPOSE[@]}" config --quiet
|
||||
if profiles | grep -qx apple; then
|
||||
if [[ "$(deployment_mode)" == source ]] || profiles | grep -qx apple; then
|
||||
docker compose "${COMPOSE[@]}" up -d --build --remove-orphans
|
||||
else
|
||||
docker compose "${COMPOSE[@]}" up -d --remove-orphans
|
||||
@@ -116,8 +132,15 @@ up() {
|
||||
update() {
|
||||
compose_args
|
||||
docker compose "${COMPOSE[@]}" config --quiet
|
||||
docker compose "${COMPOSE[@]}" pull --ignore-buildable
|
||||
if profiles | grep -qx apple; then
|
||||
if [[ "$(deployment_mode)" == release ]]; then
|
||||
docker compose "${COMPOSE[@]}" pull --ignore-buildable
|
||||
fi
|
||||
if [[ "$(deployment_mode)" == source ]]; then
|
||||
docker compose "${COMPOSE[@]}" build allstarr
|
||||
if profiles | grep -qx apple; then
|
||||
docker compose "${COMPOSE[@]}" build apple-wrapper apple-gateway
|
||||
fi
|
||||
elif profiles | grep -qx apple; then
|
||||
docker compose "${COMPOSE[@]}" build apple-wrapper apple-gateway
|
||||
fi
|
||||
docker compose "${COMPOSE[@]}" up -d --remove-orphans
|
||||
@@ -128,7 +151,8 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./allstarr.sh COMMAND
|
||||
|
||||
init Create .env, directories, and secrets
|
||||
init [release|source] Create config; default to release images
|
||||
mode [release|source] Show or change the saved deployment mode
|
||||
up Start the saved deployment profile
|
||||
update Pull reviewed images and safely recreate
|
||||
status Show containers and the saved profile
|
||||
@@ -138,6 +162,8 @@ Usage: ./allstarr.sh COMMAND
|
||||
prepare-apple INPUT [ARCH] Verify an APK/APKM or staged libs; enable Apple
|
||||
down Stop containers without deleting data
|
||||
|
||||
The deployment mode is saved in .allstarr-mode. Release mode pulls reviewed
|
||||
images; source mode builds the checked-out commit using docker-compose.dev.yml.
|
||||
Optional profiles are saved in .allstarr-profiles. No command deletes volumes,
|
||||
Postgres data, managed music, provider sessions, or imported settings.
|
||||
EOF
|
||||
@@ -147,11 +173,16 @@ command="${1:-help}"
|
||||
shift || true
|
||||
cd "$ROOT"
|
||||
case "$command" in
|
||||
init) init ;;
|
||||
init)
|
||||
case "${1:-release}" in release|source) init "${1:-release}" ;; *) die "init mode must be release or source" ;; esac
|
||||
;;
|
||||
mode)
|
||||
if [[ $# -eq 0 ]]; then deployment_mode; else set_mode "$1"; fi
|
||||
;;
|
||||
prepare-apple) prepare_apple "$@" ;;
|
||||
up) up ;;
|
||||
update) update ;;
|
||||
status) compose_args; echo "Profiles: $(profiles | paste -sd, -)"; docker compose "${COMPOSE[@]}" ps ;;
|
||||
status) compose_args; echo "Mode: $(deployment_mode)"; echo "Profiles: $(profiles | paste -sd, -)"; docker compose "${COMPOSE[@]}" ps ;;
|
||||
logs) compose_args; docker compose "${COMPOSE[@]}" logs --tail=200 -f "$@" ;;
|
||||
enable)
|
||||
case "${1:-}" in
|
||||
|
||||
@@ -707,7 +707,8 @@ public partial class JellyfinController : ControllerBase
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
effectiveImageTag);
|
||||
effectiveImageTag,
|
||||
Request.Headers);
|
||||
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
@@ -740,7 +741,8 @@ public partial class JellyfinController : ControllerBase
|
||||
fallbackItemId,
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight);
|
||||
maxHeight,
|
||||
clientHeaders: Request.Headers);
|
||||
|
||||
if (fallbackBytes != null && fallbackContentType != null)
|
||||
{
|
||||
|
||||
@@ -185,7 +185,7 @@ internal sealed partial class RedactingConsoleLogger(
|
||||
}
|
||||
|
||||
[GeneratedRegex(
|
||||
"(^key$)|(^name$)|(^file$)|(^error$)|(^message$)|([.]message$)|token|password|secret|cookie|authorization|credential|api.?key|cachekey|connectionstring|dsn|arl|url|uri|path|query|body|xml|json|header|commandtext|parameters|accountname|username|email|displayname|filename|title|artist|album|trackname|playlist|searchterm|isrc|externalid|spotifyid|tidalid|jellyfinid|itemid|trackid|songid|lyricsid|userid|deviceid|sessionid|playsessionid|endpoint|host|value|result|response|content|payload|exception|preview|reasonphrase",
|
||||
"(^key$)|(^error$)|(^message$)|([.]message$)|token|password|secret|cookie|authorization|credential|api.?key|client.?id|private.?key|cachekey|connectionstring|dsn|arl|body|xml|json|header|commandtext|parameters|sessionid|playsessionid|response|content|payload|exception|preview|reasonphrase",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SensitiveFieldName();
|
||||
}
|
||||
@@ -12,14 +12,43 @@ public static partial class SafeOperationalText
|
||||
}
|
||||
|
||||
var sanitized = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
sanitized = UrlPattern().Replace(sanitized, "<redacted-url>");
|
||||
sanitized = UrlPattern().Replace(sanitized, match => SanitizeUrl(match.Value));
|
||||
sanitized = CredentialPattern().Replace(sanitized, "$1=<redacted>");
|
||||
return sanitized.Length <= maxLength ? sanitized : sanitized[..maxLength];
|
||||
}
|
||||
|
||||
private static string SanitizeUrl(string value)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return "<redacted-url>";
|
||||
}
|
||||
|
||||
var authority = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
|
||||
var query = uri.Query.TrimStart('?');
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return $"{uri.Scheme}://{authority}{uri.AbsolutePath}";
|
||||
}
|
||||
|
||||
var safeQuery = string.Join("&", query.Split('&', StringSplitOptions.RemoveEmptyEntries).Select(part =>
|
||||
{
|
||||
var pieces = part.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString(pieces[0]);
|
||||
return SensitiveQueryKey().IsMatch(key)
|
||||
? $"{pieces[0]}=<redacted>"
|
||||
: part;
|
||||
}));
|
||||
return $"{uri.Scheme}://{authority}{uri.AbsolutePath}?{safeQuery}";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"https?://[^\s]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex UrlPattern();
|
||||
|
||||
[GeneratedRegex("token|password|secret|cookie|authorization|api.?key|client.?id|private.?key|arl|signature|sig|expires", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SensitiveQueryKey();
|
||||
|
||||
[GeneratedRegex(
|
||||
@"\b(token|password|secret|cookie|authorization|api[_-]?key|arl)\s*[=:]\s*[^\s,;]+",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
|
||||
@@ -521,20 +521,21 @@ public sealed class ProtocolProviderGateway(
|
||||
private static Song Map(ProviderTrackMetadata item)
|
||||
{
|
||||
var artists = item.Artists.Select(artist => artist.Name).ToList();
|
||||
var artistIds = item.Artists.Where(artist => artist.ArtistId != null)
|
||||
.Select(artist => artist.ArtistId!.Value).ToList();
|
||||
return new Song
|
||||
{
|
||||
Id = $"ext-{item.Id.ProviderId}-{item.Id.Value}",
|
||||
Id = ProtocolItemId(item.Id),
|
||||
ExternalProvider = item.Id.ProviderId,
|
||||
ExternalId = item.Id.Value,
|
||||
Title = item.Title,
|
||||
Artist = artists.FirstOrDefault() ?? string.Empty,
|
||||
Artists = artists,
|
||||
ArtistId = item.Artists.FirstOrDefault()?.ArtistId?.Value,
|
||||
ArtistIds = artistIds,
|
||||
ArtistId = item.Artists.FirstOrDefault()?.ArtistId is { } primaryArtist
|
||||
? ProtocolItemId(primaryArtist)
|
||||
: null,
|
||||
ArtistIds = item.Artists.Where(artist => artist.ArtistId != null)
|
||||
.Select(artist => ProtocolItemId(artist.ArtistId!)).ToList(),
|
||||
Album = item.AlbumTitle ?? string.Empty,
|
||||
AlbumId = item.AlbumId?.Value,
|
||||
AlbumId = item.AlbumId is { } albumId ? ProtocolItemId(albumId) : null,
|
||||
Duration = item.Duration.HasValue ? (int)item.Duration.Value.TotalSeconds : null,
|
||||
Isrc = item.Isrc,
|
||||
CoverArtUrl = item.Artwork?.PublicUri?.ToString(),
|
||||
@@ -550,7 +551,9 @@ public sealed class ProtocolProviderGateway(
|
||||
ExternalId = item.Id.Value,
|
||||
Title = item.Title,
|
||||
Artist = item.Artists.FirstOrDefault()?.Name ?? string.Empty,
|
||||
ArtistId = item.Artists.FirstOrDefault()?.ArtistId?.Value,
|
||||
ArtistId = item.Artists.FirstOrDefault()?.ArtistId is { } artistId
|
||||
? ProtocolItemId(artistId)
|
||||
: null,
|
||||
SongCount = item.TrackCount,
|
||||
CoverArtUrl = item.Artwork?.PublicUri?.ToString(),
|
||||
IsLocal = false
|
||||
@@ -566,6 +569,16 @@ public sealed class ProtocolProviderGateway(
|
||||
IsLocal = false
|
||||
};
|
||||
|
||||
private static string ProtocolItemId(ProviderExternalResourceId id) =>
|
||||
$"ext-{id.ProviderId}-{id.ResourceKind switch
|
||||
{
|
||||
ProviderResourceKind.Track => "song",
|
||||
ProviderResourceKind.Album => "album",
|
||||
ProviderResourceKind.Artist => "artist",
|
||||
ProviderResourceKind.Playlist => "playlist",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(id), id.ResourceKind, "Unsupported protocol resource kind.")
|
||||
}}-{id.Value}";
|
||||
|
||||
private static ExternalPlaylist Map(ProviderPlaylistSummary item) => new()
|
||||
{
|
||||
Id = $"ext-{item.Id.ProviderId}-playlist-{item.Id.Value}",
|
||||
|
||||
@@ -234,11 +234,22 @@ public sealed class AppleMusicKitMetadataCapabilityAdapter : IProviderMetadataCa
|
||||
}
|
||||
|
||||
private static ProviderArtworkReference? Artwork(
|
||||
ProviderExternalResourceId resource, JsonElement attributes, string? revision) =>
|
||||
attributes.TryGetProperty("artwork", out var artwork) && artwork.ValueKind == JsonValueKind.Object &&
|
||||
String(artwork, "url") != null
|
||||
? new ProviderArtworkReference(resource, revision: revision)
|
||||
: null;
|
||||
ProviderExternalResourceId resource, JsonElement attributes, string? revision)
|
||||
{
|
||||
if (!attributes.TryGetProperty("artwork", out var artwork) || artwork.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var template = String(artwork, "url");
|
||||
if (template == null) return null;
|
||||
|
||||
var resolved = template.Replace("{w}", "1024", StringComparison.Ordinal)
|
||||
.Replace("{h}", "1024", StringComparison.Ordinal);
|
||||
return Uri.TryCreate(resolved, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
|
||||
IsAppleArtworkHost(uri.Host) && uri.Port == 443 && string.IsNullOrEmpty(uri.UserInfo)
|
||||
? new ProviderArtworkReference(resource, uri, revision)
|
||||
: new ProviderArtworkReference(resource, revision: revision);
|
||||
}
|
||||
|
||||
private static ProviderExternalResourceId Resource(JsonElement item, ProviderResourceKind kind) =>
|
||||
new(AppleMusicKitPlaylistCapabilityAdapter.StableProviderId, kind, Required(item, "id"));
|
||||
@@ -268,6 +279,10 @@ public sealed class AppleMusicKitMetadataCapabilityAdapter : IProviderMetadataCa
|
||||
uri.Host.Equals(ApiOrigin.Host, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.Port == 443 && string.IsNullOrEmpty(uri.UserInfo);
|
||||
|
||||
private static bool IsAppleArtworkHost(string host) =>
|
||||
host.Equals("mzstatic.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith(".mzstatic.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static ProviderError Error(HttpResponseMessage response) => response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => new(ProviderErrorKind.Unauthorized),
|
||||
|
||||
@@ -623,18 +623,26 @@ public class JellyfinProxyService
|
||||
/// </summary>
|
||||
public async Task<(byte[]? Body, string? ContentType, bool Success)> GetBytesSafeAsync(
|
||||
string endpoint,
|
||||
Dictionary<string, string>? queryParams = null)
|
||||
Dictionary<string, string>? queryParams = null,
|
||||
IHeaderDictionary? clientHeaders = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await GetBytesAsync(endpoint, queryParams);
|
||||
return (result.Body, result.ContentType, true);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// 404s are expected for missing images - log at debug level
|
||||
_logger.LogDebug("Image not available for {Endpoint}", endpoint);
|
||||
return (null, null, false);
|
||||
var url = BuildUrl(endpoint, queryParams);
|
||||
using var request = CreateClientGetRequest(
|
||||
url, clientHeaders, out _, out _);
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
_logger.LogDebug("Image not available for {Endpoint}", endpoint);
|
||||
else
|
||||
_logger.LogWarning("Image request for {Endpoint} returned {StatusCode}", endpoint, response.StatusCode);
|
||||
return (null, null, false);
|
||||
}
|
||||
|
||||
return (await response.Content.ReadAsByteArrayAsync(),
|
||||
response.Content.Headers.ContentType?.ToString(), true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -921,7 +929,8 @@ public class JellyfinProxyService
|
||||
string imageType = "Primary",
|
||||
int? maxWidth = null,
|
||||
int? maxHeight = null,
|
||||
string? imageTag = null)
|
||||
string? imageTag = null,
|
||||
IHeaderDictionary? clientHeaders = null)
|
||||
{
|
||||
// Build cache key
|
||||
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}:{imageTag}";
|
||||
@@ -957,7 +966,8 @@ public class JellyfinProxyService
|
||||
queryParams["tag"] = imageTag;
|
||||
}
|
||||
|
||||
var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams);
|
||||
var result = await GetBytesSafeAsync(
|
||||
$"Items/{itemId}/Images/{imageType}", queryParams, clientHeaders);
|
||||
|
||||
// Cache for 7 days if successful
|
||||
if (result.Success && result.Body != null)
|
||||
|
||||
@@ -110,27 +110,30 @@ public class LocalLibraryService : ILocalLibraryService
|
||||
return (false, null, null, null);
|
||||
}
|
||||
|
||||
var parts = id.Split('-');
|
||||
var remainder = id[4..];
|
||||
|
||||
// Known types for the new format
|
||||
var knownTypes = new HashSet<string> { "song", "album", "artist" };
|
||||
|
||||
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
|
||||
// Only use new format if parts[2] is a known type
|
||||
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
|
||||
// Provider IDs may contain hyphens, so locate the typed resource marker instead
|
||||
// of assuming the provider occupies one dash-delimited segment.
|
||||
foreach (var type in new[] { "song", "album", "artist", "playlist" })
|
||||
{
|
||||
var provider = parts[1];
|
||||
var type = parts[2];
|
||||
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
|
||||
return (true, provider, type, externalId);
|
||||
var marker = $"-{type}-";
|
||||
var markerIndex = remainder.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex > 0 && markerIndex + marker.Length < remainder.Length)
|
||||
{
|
||||
return (true,
|
||||
remainder[..markerIndex],
|
||||
type,
|
||||
remainder[(markerIndex + marker.Length)..]);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
|
||||
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
|
||||
if (parts.Length >= 3)
|
||||
var firstSeparator = remainder.IndexOf('-');
|
||||
if (firstSeparator > 0 && firstSeparator + 1 < remainder.Length)
|
||||
{
|
||||
var provider = parts[1];
|
||||
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
|
||||
var provider = remainder[..firstSeparator];
|
||||
var externalId = remainder[(firstSeparator + 1)..];
|
||||
return (true, provider, "song", externalId);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,15 @@ Allstarr encryption key ring with owner-only permissions. Edit `.env`, then star
|
||||
./allstarr.sh up
|
||||
```
|
||||
|
||||
The default `release` mode uses reviewed published images. To run the checked-out commit instead:
|
||||
|
||||
```bash
|
||||
./allstarr.sh mode source
|
||||
./allstarr.sh up
|
||||
```
|
||||
|
||||
Source mode includes `docker-compose.dev.yml` automatically. Switch back with `./allstarr.sh mode release`.
|
||||
|
||||
## Optional services
|
||||
|
||||
Spotify lyrics needs only its cookie in the protected `.env`:
|
||||
@@ -57,11 +66,12 @@ quietly enable provider accounts or sidecars.
|
||||
./allstarr.sh update
|
||||
```
|
||||
|
||||
The command pulls reviewed images, rebuilds local Apple components only when that profile is enabled, recreates the
|
||||
saved profile, and shows the resulting containers. It does not run `git pull`; source updates are an explicit
|
||||
operator action. It does not remove Postgres, Valkey, Allstarr state, media, or provider-session volumes.
|
||||
In release mode, the command pulls reviewed images and rebuilds local Apple components only when that profile is
|
||||
enabled. In source mode, first run `git pull --ff-only`; `update` then rebuilds the checked-out Allstarr source and
|
||||
enabled Apple components. Both modes recreate the saved profile and show the resulting containers. The helper does
|
||||
not run `git pull` and does not remove Postgres, Valkey, Allstarr state, media, or provider-session volumes.
|
||||
|
||||
Use `./allstarr.sh status` to see the active profile and `./allstarr.sh logs SERVICE` for a bounded starting log
|
||||
Use `./allstarr.sh status` to see the deployment mode and active profile, and `./allstarr.sh logs SERVICE` for a bounded starting log
|
||||
window followed by new events.
|
||||
|
||||
## Removing and re-adding a service
|
||||
|
||||
@@ -26,6 +26,11 @@ For the fresh overhaul baseline, keep `.env` for deployment settings, initial se
|
||||
|
||||
Standard deployment is the default: core app, Postgres, and Valkey. Optional sidecars should be selectable, removable, and re-addable without breaking startup.
|
||||
|
||||
`allstarr.sh` persists an explicit `release` or `source` mode. Release mode uses reviewed images. Source mode adds
|
||||
the development override and builds the checked-out commit. Both modes reuse the same saved optional profiles and
|
||||
volumes. Private `.apple-provider` inputs are excluded from the core Docker context, and Apple preparation uses
|
||||
owner-only staging permissions.
|
||||
|
||||
## Historical Phase 1 Checkpoint
|
||||
|
||||
This section preserves the Phase 1 exit evidence. It is historical, not the current release test inventory. The
|
||||
@@ -118,6 +123,9 @@ Possible later repository profiles remain separate work:
|
||||
`docker-compose.apple.yml` is an explicit optional profile. It builds the repository gateway and the locked
|
||||
wrapper-v2 source only after the operator supplies hash-verified legal Apple libraries. Standard and AIO remain
|
||||
complete without it, and removing the profile preserves Postgres, media, gateway state, and wrapper login state.
|
||||
Terminal generic gateway jobs are written atomically beneath the gateway data volume and rehydrated after restart.
|
||||
Nonterminal or malformed records are ignored rather than exposed as successful work. Multi-artifact manifests and
|
||||
host ingestion are still required before broader GAMDL feature claims can be advertised.
|
||||
|
||||
Compose-file selection and any Compose profiles are explicit operator inputs. Deployment documentation should show the selected files/profile, database choice, persistent volume locations, and the rendered `docker compose config` output before an upgrade. Do not infer AIO or low-RAM mode from available memory, and do not hide a storage-provider change inside an override file.
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ def create_app(
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
config.prepare()
|
||||
jobs.start()
|
||||
yield
|
||||
await jobs.close()
|
||||
await wrapper_client.close()
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import DownloadJobView
|
||||
from .runner import BoundedProcessRunner, ProcessFailure
|
||||
from .security import safe_apple_url
|
||||
|
||||
|
||||
_JOB_ID = re.compile(r"^[0-9a-f]{32}$")
|
||||
_TERMINAL_STATES = frozenset({"succeeded", "failed"})
|
||||
_STATE_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class JobState:
|
||||
id: str
|
||||
@@ -35,11 +44,22 @@ class DownloadJobManager:
|
||||
self._jobs: dict[str, JobState] = {}
|
||||
self._tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def start(self) -> None:
|
||||
jobs_root = self._data_root / "jobs"
|
||||
jobs_root.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
for root in jobs_root.iterdir():
|
||||
if root.is_symlink() or not root.is_dir() or not _JOB_ID.fullmatch(root.name):
|
||||
continue
|
||||
job = self._load_terminal(root)
|
||||
if job is not None:
|
||||
self._jobs[job.id] = job
|
||||
|
||||
def enqueue(self, raw_url: str, quality: str) -> DownloadJobView:
|
||||
url, media_kind = safe_apple_url(raw_url)
|
||||
job_id = uuid.uuid4().hex
|
||||
job = JobState(job_id, media_kind)
|
||||
self._jobs[job_id] = job
|
||||
self._persist(job)
|
||||
task = asyncio.create_task(self._run(job, url, quality))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
@@ -58,6 +78,7 @@ class DownloadJobManager:
|
||||
|
||||
async def _run(self, job: JobState, url: str, quality: str) -> None:
|
||||
job.state = "running"
|
||||
self._persist(job)
|
||||
root = self._data_root / "jobs" / job.id
|
||||
try:
|
||||
job.artifacts = await self._runner.download(url, quality, root / "artifacts", root / "temporary")
|
||||
@@ -65,6 +86,7 @@ class DownloadJobManager:
|
||||
except asyncio.CancelledError:
|
||||
job.state = "failed"
|
||||
job.error_code = "gateway_stopping"
|
||||
self._persist(job)
|
||||
raise
|
||||
except ProcessFailure as exc:
|
||||
job.state = "failed"
|
||||
@@ -72,3 +94,81 @@ class DownloadJobManager:
|
||||
except Exception:
|
||||
job.state = "failed"
|
||||
job.error_code = "download_failed"
|
||||
self._persist(job)
|
||||
|
||||
def _persist(self, job: JobState) -> None:
|
||||
root = self._data_root / "jobs" / job.id
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
payload = {
|
||||
"version": _STATE_VERSION,
|
||||
"id": job.id,
|
||||
"media_kind": job.media_kind,
|
||||
"state": job.state,
|
||||
"artifacts": [self._relative_artifact(root, artifact) for artifact in job.artifacts],
|
||||
"error_code": job.error_code,
|
||||
}
|
||||
target = root / "job.json"
|
||||
partial = root / "job.json.partial"
|
||||
with partial.open("w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
partial.replace(target)
|
||||
try:
|
||||
directory = os.open(root, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _load_terminal(self, root: Path) -> JobState | None:
|
||||
state_file = root / "job.json"
|
||||
if state_file.is_symlink() or not state_file.is_file():
|
||||
return None
|
||||
try:
|
||||
payload: Any = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or set(payload) != {
|
||||
"version", "id", "media_kind", "state", "artifacts", "error_code"
|
||||
}:
|
||||
return None
|
||||
if payload["version"] != _STATE_VERSION or payload["id"] != root.name:
|
||||
return None
|
||||
media_kind = payload["media_kind"]
|
||||
state = payload["state"]
|
||||
error_code = payload["error_code"]
|
||||
artifacts = payload["artifacts"]
|
||||
if not isinstance(media_kind, str) or not media_kind or state not in _TERMINAL_STATES:
|
||||
return None
|
||||
if error_code is not None and not isinstance(error_code, str):
|
||||
return None
|
||||
if not isinstance(artifacts, list) or not all(isinstance(item, str) for item in artifacts):
|
||||
return None
|
||||
resolved_artifacts = [self._resolve_artifact(root, item) for item in artifacts]
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return JobState(root.name, media_kind, state, resolved_artifacts, error_code)
|
||||
|
||||
@staticmethod
|
||||
def _relative_artifact(root: Path, artifact: Path) -> str:
|
||||
resolved_root = root.resolve()
|
||||
resolved = artifact.resolve()
|
||||
try:
|
||||
return resolved.relative_to(resolved_root).as_posix()
|
||||
except ValueError as exc:
|
||||
raise ValueError("job artifact escaped its job directory") from exc
|
||||
|
||||
@staticmethod
|
||||
def _resolve_artifact(root: Path, relative: str) -> Path:
|
||||
path = Path(relative)
|
||||
if not relative or path.is_absolute():
|
||||
raise ValueError("invalid persisted artifact path")
|
||||
resolved_root = root.resolve()
|
||||
resolved = (root / path).resolve()
|
||||
try:
|
||||
resolved.relative_to(resolved_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("persisted artifact escaped its job directory") from exc
|
||||
return resolved
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
@@ -206,6 +207,69 @@ def test_generic_catalog_and_library_download_jobs_are_bounded_to_apple_urls(cli
|
||||
}).status_code == 400
|
||||
|
||||
|
||||
def test_terminal_download_job_is_persisted_and_rehydrated_after_restart(settings: Settings):
|
||||
runner = FakeRunner()
|
||||
first_app = create_app(settings, FakeWrapper(), FakeCatalog(), runner)
|
||||
with TestClient(first_app) as first_client:
|
||||
accepted = first_client.post("/api/jobs/download", json={
|
||||
"url": "https://music.apple.com/us/playlist/fixture/pl.123",
|
||||
"quality": "alac",
|
||||
})
|
||||
job_id = accepted.json()["id"]
|
||||
for _ in range(30):
|
||||
state = first_client.get(f"/api/jobs/download/{job_id}").json()
|
||||
if state["state"] == "succeeded":
|
||||
break
|
||||
asyncio.run(asyncio.sleep(0.01))
|
||||
assert state["state"] == "succeeded"
|
||||
|
||||
state_file = settings.data_root / "jobs" / job_id / "job.json"
|
||||
persisted = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert persisted["state"] == "succeeded"
|
||||
assert persisted["artifacts"] == ["artifacts/fixture.m4a"]
|
||||
assert not state_file.with_name("job.json.partial").exists()
|
||||
|
||||
second_runner = FakeRunner()
|
||||
second_app = create_app(settings, FakeWrapper(), FakeCatalog(), second_runner)
|
||||
with TestClient(second_app) as second_client:
|
||||
restored = second_client.get(f"/api/jobs/download/{job_id}")
|
||||
assert restored.status_code == 200
|
||||
assert restored.json() == {
|
||||
"id": job_id,
|
||||
"state": "succeeded",
|
||||
"media_kind": "playlist",
|
||||
"artifact_count": 1,
|
||||
"error_code": None,
|
||||
}
|
||||
assert second_runner.calls == []
|
||||
|
||||
|
||||
def test_restart_ignores_nonterminal_and_invalid_persisted_jobs(settings: Settings):
|
||||
settings.prepare()
|
||||
jobs_root = settings.data_root / "jobs"
|
||||
running_id = "a" * 32
|
||||
invalid_id = "b" * 32
|
||||
for job_id, state, artifacts in (
|
||||
(running_id, "running", []),
|
||||
(invalid_id, "succeeded", ["../../outside.m4a"]),
|
||||
):
|
||||
root = jobs_root / job_id
|
||||
root.mkdir()
|
||||
(root / "job.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"id": job_id,
|
||||
"media_kind": "album",
|
||||
"state": state,
|
||||
"artifacts": artifacts,
|
||||
"error_code": None,
|
||||
}), encoding="utf-8")
|
||||
|
||||
app = create_app(settings, FakeWrapper(), FakeCatalog(), FakeRunner())
|
||||
with TestClient(app) as test_client:
|
||||
assert test_client.get(f"/api/jobs/download/{running_id}").status_code == 404
|
||||
assert test_client.get(f"/api/jobs/download/{invalid_id}").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_runner_caps_output_and_times_out(settings: Settings, tmp_path: Path):
|
||||
runner = BoundedProcessRunner(settings)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
LOCK="$ROOT/tools/apple-provider/source-lock.json"
|
||||
@@ -34,7 +35,7 @@ commit=$(jq -r '.wrapper.commit' "$LOCK")
|
||||
if [[ -e "$OUTPUT" ]]; then
|
||||
[[ -d "$OUTPUT/.git" ]] || { echo "Output exists and is not a wrapper-v2 checkout: $OUTPUT" >&2; exit 1; }
|
||||
else
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
install -d -m 700 "$(dirname "$OUTPUT")"
|
||||
git clone --filter=blob:none --branch "$tag" --single-branch "$repository" "$OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -49,11 +50,11 @@ if [[ -n "$PACKAGE" ]]; then
|
||||
bash "$OUTPUT/tools/extract-libs.sh" --bundle "$PACKAGE" --arch "$ARCH"
|
||||
else
|
||||
[[ -d "$STAGED_LIBS" ]] || { echo "Staged library directory not found: $STAGED_LIBS" >&2; exit 1; }
|
||||
mkdir -p "$OUTPUT/rootfs/system/lib64"
|
||||
install -d -m 700 "$OUTPUT/rootfs/system/lib64"
|
||||
while IFS= read -r library; do
|
||||
source="$STAGED_LIBS/$library"
|
||||
[[ -f "$source" ]] || { echo "Missing pinned staged library: $library" >&2; exit 1; }
|
||||
install -m 0644 "$source" "$OUTPUT/rootfs/system/lib64/$library"
|
||||
install -m 0600 "$source" "$OUTPUT/rootfs/system/lib64/$library"
|
||||
done < <(jq -r --arg arch "$ARCH" '.libs[$arch] | keys[]' "$OUTPUT/LIBS_VERSION.json")
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user