fix(jellyfin): restore authenticated album artwork

This commit is contained in:
joshpatra committed 2026-07-18 01:01:31 -04:00
1 parent 875ef13811
commit 086a8063c3
24 files changed
+463 -63

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
.git
.apple-provider/
.github
.idea
.vscode
+1
View File
@@ -71,6 +71,7 @@ obj/
/.env
/.env.before-*
/.allstarr-profiles
/.allstarr-mode
/.apple-provider/
/secrets/
/allstarr-backups/
+2 -2
View File
@@ -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. |
+4
View File
@@ -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);
}
+10 -2
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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)
+17 -14
View File
@@ -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);
}
+14 -4
View File
@@ -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)
+4 -3
View File
@@ -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