diff --git a/.env.example b/.env.example index d26f0173..b066a9ed 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,11 @@ ALLSTARR_IMAGE=ghcr.io/sopat712/allstarr:3.0.0-beta.1 APPLE_DOWNLOAD_URL= APPLE_DOWNLOAD_QUALITY=alac-16-44 +# Optional Apple profile build settings. `./allstarr.sh prepare-apple` writes the matching values. +APPLE_WRAPPER_TARGET_ARCH=x86_64 +APPLE_WRAPPER_RUNTIME_PLATFORM=linux/amd64 +APPLE_GATEWAY_STOREFRONT=us + # Optional AudioMuse-AI recommendation service. This is the AudioMuse base URL, not a Jellyfin URL. # Allstarr checks /api/health and uses /api/sonic_fingerprint/generate. The current adapter does not # support an AudioMuse authorization header, so leave this unset if the endpoint requires separate auth. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68c27d78..f032cb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,13 @@ jobs: - name: Test run: dotnet test --configuration Release --no-build --verbosity normal + - name: Test Apple gateway contracts + working-directory: sidecars/apple-gateway + run: | + python -m pip install uv==0.11.29 + UV_CACHE_DIR=/tmp/allstarr-uv-cache uv sync --frozen --extra test + UV_CACHE_DIR=/tmp/allstarr-uv-cache uv run --frozen --extra test pytest + - name: Validate Compose contracts env: SPOTIFY_API_SESSION_COOKIE: ci-placeholder @@ -91,3 +98,4 @@ jobs: docker compose -f docker-compose.yml -f docker-compose.dev.yml config --quiet docker compose -f docker-compose.yml -f docker-compose.aio.yml config --quiet docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.apple.yml config --quiet diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1bd8ccd7..a5569bd2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -84,11 +84,19 @@ jobs: - name: Test run: dotnet test --configuration Release --no-build --verbosity normal + - name: Test Apple gateway contracts + working-directory: sidecars/apple-gateway + run: | + python -m pip install uv==0.11.29 + UV_CACHE_DIR=/tmp/allstarr-uv-cache uv sync --frozen --extra test + UV_CACHE_DIR=/tmp/allstarr-uv-cache uv run --frozen --extra test pytest + - name: Validate Compose contracts run: | docker compose -f docker-compose.yml config --quiet docker compose -f docker-compose.yml -f docker-compose.dev.yml config --quiet docker compose -f docker-compose.yml -f docker-compose.aio.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.apple.yml config --quiet docker: needs: build-and-test diff --git a/.gitignore b/.gitignore index 2ef6eccd..dc6a99a1 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ obj/ /.env /.env.before-* +/.allstarr-profiles +/.apple-provider/ /secrets/ /allstarr-backups/ /state-transfers/ @@ -95,6 +97,8 @@ __pycache__/ # Docker volumes /valkey-data/ +/.apple-provider/ +/apple-downloads/ # Keep only the exact versioned OpenAPI artifact. Local feature notes, raw captures, cloned repositories, # ad-hoc scripts, and credentials under apis/ are intentionally ignored. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e79da764..8c9f92ea 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -2,6 +2,9 @@ Start with [.env.example](.env.example). It is the checked-in list of deployment-facing settings for standard Compose. The dashboard can manage supported runtime settings, but secrets should still enter through protected bootstrap files or the encrypted provider-account flow, not source control. +For the turnkey install, optional-service, update, and custom-overlay commands, use the +[deployment profile guide](docs/operations/deployment-profiles.md). + `v3.0.0-beta.1` is a fresh-install baseline. Recreate the deployment instead of copying an old `.env` wholesale. After the new database is ready, the administrator WebUI can preview and import the safe allowlisted subset without replacing existing settings. Old Redis, mapping, extension, and job state is not imported automatically. Follow the @@ -56,11 +59,11 @@ docker compose -f docker-compose.yml -f docker-compose.aio.yml up -d Mounting that bundle does not bypass package state or permission review. Entries marked blocked remain inactive. -Apple downloads use a separately deployed compatible provider gateway. They are not part of Standard or AIO and -this repository does not build or update GAMDL or wrapper-v2 for the operator. Configure the gateway URL in the -dashboard or as `APPLE_DOWNLOAD_URL`. The URL must be the gateway's Allstarr-compatible HTTP API, not the raw -wrapper-v2 address. Adding or removing it does not replace the database, Valkey, application state, or media -volumes. Follow [the Apple download provider procedure](docs/operations/apple-download-provider.md). +Apple downloads are not part of Standard or AIO. The optional `docker-compose.apple.yml` profile builds Allstarr's +gateway with GAMDL 3.8.2 and the source-locked official wrapper-v2 0.0.2 checkout. It does not contain or download +Apple code. Prepare it with `./allstarr.sh prepare-apple FILE [ARCH]`, using a legally obtained compatible APK/APKM. +Adding or removing the profile does not replace the database, Valkey, application state, media volumes, or the +wrapper session volume. Follow [the Apple download provider procedure](docs/operations/apple-download-provider.md). The optional Spotify lyrics service is likewise absent from Standard and AIO. Add the pinned `docker-compose.spotify-lyrics.yml` overlay only when needed, following diff --git a/OVERHAUL.md b/OVERHAUL.md index 952e731f..d42bda20 100644 --- a/OVERHAUL.md +++ b/OVERHAUL.md @@ -117,9 +117,9 @@ These defaults are confirmed product decisions: - The SDK design must leave room for later automation, enrichment, recommendations, and UI extensions. - Standard Docker Compose is the recommended default: core app, Postgres, and Valkey. - AIO Compose remains available for the verified first-party package bundle; provider sidecars stay separate opt-ins. -- Allstarr does not bundle or manage GAMDL, wrapper-v2, or an Apple gateway in Standard or AIO. An operator may - connect a separately deployed compatible gateway by URL. This repository does not ship or maintain an Apple - gateway Compose overlay. +- Standard and AIO do not start Apple services. The separate Apple overlay builds the repository gateway with + GAMDL 3.8.2 and the source-locked official wrapper-v2 0.0.2 checkout after the operator supplies verified legal + Apple libraries. Removing the overlay degrades only Apple capabilities and preserves its session volume. - Low-resource alternatives must be supported. Removing optional services should reduce capability, not break the app. - Apple is split into `apple-download` and `apple-musickit`. - Extension registry trust starts with checksum verification. Add signatures later. @@ -172,7 +172,7 @@ Likely credentials for manual/live validation: | 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 download gateway | [AppleMusicController.cs](allstarr/Controllers/AppleMusicController.cs), [Apple Music services](allstarr/Services/AppleMusic) | Allstarr calls one separately deployed compatible gateway by URL. The repository does not vendor GAMDL or wrapper-v2. The gateway contract, health probe, and runtime manifest decide which capabilities can be advertised. | +| Apple download gateway | [AppleMusicController.cs](allstarr/Controllers/AppleMusicController.cs), [Apple Music services](allstarr/Services/AppleMusic), [gateway](sidecars/apple-gateway) | The optional profile calls the repository gateway, which runs pinned GAMDL against a locked official wrapper-v2 build. The gateway contract, health probe, and runtime manifest decide which capabilities can be advertised. | | 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. | @@ -236,7 +236,8 @@ Prioritize structural improvements that make behavior easier to reason about: se - Postgres: durable state for users, providers, health, libraries, matches, playlists, jobs, events, and job/outbox records in Docker deployments. - SQLite: an explicit manual/small-install storage mode, not an automatic failover database. - Valkey or Redis: cache, short-lived probe state, locks, and queue acceleration. It must not be the only durable record of a job or side effect. -- External services: optional lyrics tools, AudioMuse-AI, and provider-specific runtimes. Apple downloads use an operator-owned compatible gateway; GAMDL and wrapper-v2 are not repository services. +- External services: optional lyrics tools, AudioMuse-AI, and provider-specific runtimes. Apple downloads use the + separate source-locked Apple profile; GAMDL and wrapper-v2 are never part of Standard or AIO. - Extensions: installed packages with manifests, capability declarations, permissions, settings, health checks, logs, and optional UI panels. ## Capability Contracts @@ -319,7 +320,7 @@ Provider identifiers are lowercase, stable, and never inferred from display name Provider split: -- `apple-download`: an operator-owned compatible gateway for download and optional streaming. A GAMDL-backed +- `apple-download`: the optional compatible gateway for download and download-backed streaming. Its GAMDL-backed gateway can produce managed song, music-video, synced-lyrics, cover-art, and rich-tagging artifacts where its advertised contract, account, source, and codec support them. - `apple-musickit`: per-user MusicKit API access for personal-library playlists and playlist items, library songs/albums/artists, and documented library or favorite-state actions. It uses the Apple developer token plus that user's Music User Token. @@ -813,8 +814,8 @@ Files: - `docker-compose.yml`: core app, Postgres, Valkey. - `docker-compose.aio.yml`: verified offline first-party package bundle, with no provider sidecars. - `docker-compose.dev.yml`: local build override. -- The operator deploys any Apple/GAMDL provider gateway separately. It is not an Allstarr Compose file or part of - the Standard or AIO product contract. Allstarr receives only its URL and provider configuration. +- `docker-compose.apple.yml`: optional repository gateway plus the source-locked wrapper-v2 build. It remains + outside Standard and AIO and requires operator-supplied, hash-verified legal Apple libraries. Lyrics and low-RAM overrides are ideas for later releases. They are not checked in, tested, or part of the current version 3 beta deployment contract. Optional lyrics services run separately and connect by URL today. @@ -832,8 +833,8 @@ docker compose -f docker-compose.yml -f docker-compose.aio.yml up -d docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ``` -An optional compatible Apple provider gateway is deployed and upgraded separately. After it is healthy, an admin adds its URL -as an `apple-download` provider in the WebUI. Allstarr probes the endpoint before activation and registers only +The optional Apple profile is prepared with `allstarr.sh prepare-apple` and started with the saved deployment +profile. Allstarr probes the endpoint before activation and registers only the capabilities the compatible endpoint actually advertises and passes contract checks for. Provider cards, source selectors, routing priority controls, jobs, and diagnostics show each capability as available, degraded, unsupported, disabled, or needing configuration. Removing the endpoint disables only those routes. Re-adding a @@ -1066,9 +1067,9 @@ Phase 5 replaces trusted extension folders with a reviewed SDK v1 provider runti - The admin UI manages registries, checksum staging, per-permission approval or denial, activation, disable, rollback, retained-account uninstall, logs, revisions, and failure state without exposing package paths, manifests, content hashes, or credentials. The author guide is [Extension SDK v1](docs/extensions/sdk-v1.md). -- The Apple download provider uses a URL-configured external gateway. Allstarr validates its API, health, - authentication state, and capability manifest but does not build, publish, update, or roll back the gateway. - Standard and AIO do not include GAMDL or wrapper-v2. +- The Apple download provider uses the optional repository gateway and typed managed-artifact adapter. Allstarr + validates its API, health, authentication state, capability manifest, MIME, size, checksum, and workspace + ownership. Standard and AIO still do not include GAMDL or wrapper-v2. The Phase 5 exit gate passed 1,249 .NET tests with no skips. The focused extension, registry, router, host, storage, and WebUI gate passed 133 tests; the final capability-adapter gate passed 31 tests. The release build has @@ -1206,7 +1207,7 @@ The final pass reconciles the repository with the implemented version 3 beta bas provenance or an explicit `not-declared` status. Production-derived captures and private clone material are explicitly prohibited. - Standard, development, and AIO Compose files render. Standard stays small and AIO adds only the checksum-locked - offline first-party bundle. Apple downloads are an independent external gateway configured by URL. + offline first-party bundle. Apple downloads are an independent optional, source-locked Compose profile. - The user-visible provider support matrix now reflects durable Last.fm and ListenBrainz scrobbling, Last.fm and ListenBrainz recommendations, MusicBrainz-informed local similarity, managed enrichment, and the real SDK v1 capability boundary. @@ -1280,12 +1281,11 @@ Exit: a user can review a match and playlist preview, run duplicate-safe materia - Expand `ExtensionManager` into the documented constrained provider runtime. - Add registry management without default third-party registries, mandatory registry checksum verification, permission review, logs, staged updates, rollback, and route integration. - Add version-pinned external-service compatibility checks, contract tests, capability diffs, and rollback guidance. Do not use `docker compose up` as a source-code updater. -- Make the operator-owned compatible gateway an optional external `apple-download` provider configured by URL in the WebUI. Discover and +- Make the compatible gateway an optional `apple-download` provider. Discover and verify its API version, authentication state, health, and per-feature capability manifest before registration. Display those capabilities and their recovery actions consistently in provider cards, source and priority controls, jobs, diagnostics, and routing decisions. Standard and AIO must work without it. Document how to add, - replace, disable, and re-add the endpoint later without database or media loss. Do not ship or maintain its - Compose stack in this repository. + replace, disable, and re-add the profile later without database, session, or media loss. - Add fake and malicious-package coverage plus extension-authoring documentation. Exit: an enabled test provider participates in routing only after its package, permissions, health, and account scope pass validation. diff --git a/README.md b/README.md index 614b7939..8e1133f8 100644 --- a/README.md +++ b/README.md @@ -19,26 +19,20 @@ Standard Compose runs Allstarr, Postgres, and Valkey. It exposes one client prot ```bash git clone https://github.com/SoPat712/allstarr.git cd allstarr -cp .env.example .env -mkdir -p secrets downloads kept -umask 077 -openssl rand -base64 32 > secrets/postgres-password.txt -key="$(openssl rand -base64 32)" -printf '{"activeKeyId":"key-1","keys":{"key-1":"%s"}}\n' "$key" > secrets/allstarr-keyring.json -unset key -chmod 600 .env secrets/postgres-password.txt secrets/allstarr-keyring.json +./allstarr.sh init ``` Edit `.env`. At minimum, select `BACKEND_TYPE`, set the matching backend URL, and review the image tag and mounted paths. Jellyfin server-side library operations also need its API key and user ID. ```bash -docker compose config --quiet -docker compose pull -docker compose up -d -docker compose ps +./allstarr.sh up curl --fail http://127.0.0.1:5274/health/ready ``` +`allstarr.sh` remembers optional profiles, validates the merged Compose model, creates secrets with private file +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 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: @@ -49,13 +43,15 @@ docker compose -f docker-compose.yml -f docker-compose.aio.yml up -d The bundle lock is still authoritative. A bundled package marked blocked is not staged or activated merely because the AIO files are mounted. -Apple downloads are optional and are not bundled with Standard or AIO. Run a compatible Apple provider gateway -separately, then give Allstarr its URL through the dashboard or `APPLE_DOWNLOAD_URL`. The URL must point to the -gateway API, not directly to wrapper-v2. Removing that URL disables Apple download routes without changing Postgres -or media volumes. See [Apple download provider setup](docs/operations/apple-download-provider.md). +Apple downloads are optional and are not bundled with Standard or AIO. The Apple profile builds the repository's +small gateway with GAMDL 3.8.2 and the official wrapper-v2 0.0.2 source. Allstarr never supplies Apple binaries, so +the operator provides one legally obtained compatible APK/APKM and runs +`./allstarr.sh prepare-apple /path/to/file.apkm`. Removing the profile disables Apple download routes without +changing Postgres, media, or the persistent wrapper login session. See +[Apple download provider setup](docs/operations/apple-download-provider.md). Spotify lyrics are optional too. To run the pinned private-network sidecar, add -`docker-compose.spotify-lyrics.yml` to the Compose command and follow the +it with `./allstarr.sh enable spotify`, then run `./allstarr.sh up`. Follow the [Spotify lyrics sidecar guide](docs/operations/spotify-lyrics-sidecar.md). Importing an old `.env` can restore the endpoint URL, but it cannot start the sidecar or pass a cookie to it. @@ -100,6 +96,7 @@ Allstarr supports Jellyfin clients and Subsonic/OpenSubsonic clients through the - [Configuration](CONFIGURATION.md) - [Client compatibility](CLIENTS.md) - [Storage operations](docs/operations/storage.md) +- [Deployment profiles and optional services](docs/operations/deployment-profiles.md) - [Extension SDK](docs/extensions/sdk-v1.md) - [Contributing](CONTRIBUTING.md) - [Implementation charter and phase history](OVERHAUL.md) diff --git a/allstarr.Tests/AppleDownloadCapabilityAdapterTests.cs b/allstarr.Tests/AppleDownloadCapabilityAdapterTests.cs new file mode 100644 index 00000000..8e1a2905 --- /dev/null +++ b/allstarr.Tests/AppleDownloadCapabilityAdapterTests.cs @@ -0,0 +1,203 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using allstarr.Core.Capabilities; +using allstarr.Core.Downloads; +using allstarr.Core.Providers.AppleDownload; +using allstarr.Models.Settings; +using allstarr.Services.AppleMusic; +using Microsoft.Extensions.Options; + +namespace allstarr.Tests; + +public sealed class AppleDownloadCapabilityAdapterTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "allstarr-apple-capability", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Download_UsesDiscoveredGatewayAndResolvesHostOwnedArtifact() + { + var audio = Encoding.UTF8.GetBytes("fake flac audio bytes"); + var gateway = new GatewayHandler(audio, "audio/flac"); + var settings = new AppleDownloadSettings { BaseUrl = "https://gateway.test/", Quality = "alac-16-44" }; + var client = new HttpClient(gateway); + var discovery = new AppleDownloadEndpointDiscovery( + new StaticClientFactory(client), Options.Create(settings)); + var store = new MemoryStore(); + var resolver = new ProviderDownloadArtifactResolver(store, new() { RootPath = root }); + var adapter = new AppleDownloadCapabilityAdapter(client, settings, discovery, resolver, 1024 * 1024); + var tenant = Guid.CreateVersion7(); + var user = Guid.CreateVersion7(); + var job = Guid.CreateVersion7(); + var workspace = await resolver.CreateWorkspaceAsync(new( + tenant, user, job, AppleDownloadCapabilityAdapter.StableProviderId, null, "favorite:apple-track")); + var context = Context(tenant, user); + var track = new ProviderExternalResourceId( + AppleDownloadCapabilityAdapter.StableProviderId, ProviderResourceKind.Track, "apple/track 1"); + + var availability = await adapter.CheckAvailabilityAsync(context, new(track)); + var outcome = await adapter.DownloadAsync(context, new( + track, job, workspace.Reference, ProviderAudioQuality.Lossless)); + + Assert.True(availability.IsSuccess); + Assert.Equal(ProviderDownloadAvailabilityState.Available, availability.RequireValue().State); + var output = outcome.RequireValue(); + Assert.EndsWith(".flac", output.ArtifactId, StringComparison.Ordinal); + Assert.DoesNotContain("apple/track", output.ArtifactId, StringComparison.Ordinal); + Assert.Equal(audio.Length, output.SizeBytes); + Assert.Equal(Convert.ToHexString(SHA256.HashData(audio)).ToLowerInvariant(), output.Sha256); + Assert.Equal("flac", output.Media.Codec); + + var verified = await resolver.ResolveAsync(workspace.Reference, output); + Assert.Equal(audio, await File.ReadAllBytesAsync(verified.SourcePath)); + Assert.Equal(ProviderDownloadArtifactState.Verified, verified.State); + Assert.Single(store.Artifacts); + Assert.Contains(gateway.Requests, uri => + uri.PathAndQuery == "/api/download/apple%2Ftrack%201?quality=alac-16-44"); + } + + [Fact] + public async Task Download_RejectsUnrecognizedMediaBeforeWritingArtifact() + { + var gateway = new GatewayHandler(Encoding.UTF8.GetBytes("not audio"), "text/html"); + var settings = new AppleDownloadSettings { BaseUrl = "https://gateway.test/" }; + var client = new HttpClient(gateway); + var discovery = new AppleDownloadEndpointDiscovery( + new StaticClientFactory(client), Options.Create(settings)); + var store = new MemoryStore(); + var resolver = new ProviderDownloadArtifactResolver(store, new() { RootPath = root }); + var adapter = new AppleDownloadCapabilityAdapter(client, settings, discovery, resolver, 1024); + var tenant = Guid.CreateVersion7(); + var user = Guid.CreateVersion7(); + var job = Guid.CreateVersion7(); + var workspace = await resolver.CreateWorkspaceAsync(new( + tenant, user, job, AppleDownloadCapabilityAdapter.StableProviderId, null, "favorite:bad-media")); + var track = new ProviderExternalResourceId( + AppleDownloadCapabilityAdapter.StableProviderId, ProviderResourceKind.Track, "bad-media"); + + var outcome = await adapter.DownloadAsync(Context(tenant, user), new( + track, job, workspace.Reference, ProviderAudioQuality.Any)); + + Assert.False(outcome.IsSuccess); + Assert.Equal(ProviderErrorKind.IncompatibleMedia, outcome.Error!.Kind); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(root, workspace.Reference.WorkspaceId))); + Assert.Empty(store.Artifacts); + } + + [Fact] + public async Task Availability_DoesNotAdvertiseDownloadWhenManifestOmitsRoute() + { + var gateway = new GatewayHandler([], "audio/flac") { AdvertiseDownload = false }; + var settings = new AppleDownloadSettings { BaseUrl = "https://gateway.test/" }; + var client = new HttpClient(gateway); + var discovery = new AppleDownloadEndpointDiscovery( + new StaticClientFactory(client), Options.Create(settings)); + var resolver = new ProviderDownloadArtifactResolver(new MemoryStore(), new() { RootPath = root }); + var adapter = new AppleDownloadCapabilityAdapter(client, settings, discovery, resolver, 1024); + var tenant = Guid.CreateVersion7(); + var user = Guid.CreateVersion7(); + var track = new ProviderExternalResourceId( + AppleDownloadCapabilityAdapter.StableProviderId, ProviderResourceKind.Track, "missing-route"); + + var outcome = await adapter.CheckAvailabilityAsync(Context(tenant, user), new(track)); + + Assert.Equal(ProviderDownloadAvailabilityState.Unavailable, outcome.RequireValue().State); + } + + private static ProviderExecutionContext Context(Guid tenant, Guid user) => new( + new ProviderActorContext(tenant, ProviderActorKind.User, user, + new ProviderBackendPrincipal("jellyfin", "primary", "user")), + AppleDownloadCapabilityAdapter.StableProviderId, + account: null, + library: null, + new ProviderExecutionPolicy( + new ProviderQualityPolicy(ProviderAudioQuality.Any, ProviderAudioQuality.HighResolution, true), + ProviderExplicitContentPolicy.Allow, + allowFallback: true, + allowSharedAccount: false, + allowManagedDownloads: true, + [AppleDownloadCapabilityAdapter.StableProviderId]), + "download-test", + "correlation-test", + DateTimeOffset.UtcNow.AddMinutes(1), + CancellationToken.None, + "favorite:test"); + + public void Dispose() + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + + private sealed class StaticClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class GatewayHandler(byte[] audio, string contentType) : HttpMessageHandler + { + public bool AdvertiseDownload { get; init; } = true; + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!); + var path = request.RequestUri!.AbsolutePath; + HttpResponseMessage response = path switch + { + "/api/capabilities" => Json($$""" + {"sidecarApiVersion":"1.0","capabilities":[ + {"id":"metadata-search-song","state":"supported"}, + {"id":"metadata-song","state":"supported"}, + {"id":"stream-audio-song","state":"supported"} + {{(AdvertiseDownload ? ",{\"id\":\"download-audio-song\",\"state\":\"supported\"}" : string.Empty)}} + ]} + """), + "/api/health" => Json("{\"staged\":true,\"daemon_running\":true,\"wrapper_healthy\":true,\"logged_in\":true}"), + "/api/me" => Json("{\"authenticated\":true}"), + _ when path.StartsWith("/api/download/", StringComparison.Ordinal) => Audio(), + _ => new(HttpStatusCode.NotFound) + }; + response.RequestMessage = request; + return Task.FromResult(response); + } + + private HttpResponseMessage Audio() + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(audio) }; + response.Content.Headers.ContentType = new(contentType); + return response; + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + private sealed class MemoryStore : IProviderDownloadArtifactStore + { + public List Workspaces { get; } = []; + public List Artifacts { get; } = []; + public Task CreateWorkspaceAsync(ProviderDownloadWorkspaceEntity value, CancellationToken token) + { + var existing = Workspaces.SingleOrDefault(item => item.WorkspaceId == value.WorkspaceId); + if (existing != null) return Task.FromResult(existing); + Workspaces.Add(value); + return Task.FromResult(value); + } + public Task GetWorkspaceAsync(string id, CancellationToken token) => + Task.FromResult(Workspaces.SingleOrDefault(item => item.WorkspaceId == id)); + public Task AddVerifiedAsync(ProviderDownloadArtifactEntity value, CancellationToken token) + { + var existing = Artifacts.SingleOrDefault(item => + item.WorkspaceRecordId == value.WorkspaceRecordId && item.ProviderArtifactId == value.ProviderArtifactId); + if (existing != null) return Task.FromResult(existing); + Artifacts.Add(value); + return Task.FromResult(value); + } + public Task FindByJobAsync(Guid tenantId, Guid jobId, string provider, CancellationToken token) => + Task.FromResult(Artifacts.SingleOrDefault(item => + item.TenantId == tenantId && item.DurableJobId == jobId && item.ProviderId == provider)); + public Task MarkPlacedAsync(Guid id, Guid managedId, CancellationToken token) => Task.CompletedTask; + } +} diff --git a/allstarr.Tests/BuiltInProviderDescriptorCatalogTests.cs b/allstarr.Tests/BuiltInProviderDescriptorCatalogTests.cs index a2f11980..5b898c1f 100644 --- a/allstarr.Tests/BuiltInProviderDescriptorCatalogTests.cs +++ b/allstarr.Tests/BuiltInProviderDescriptorCatalogTests.cs @@ -2,6 +2,10 @@ using allstarr.Core.Capabilities; using allstarr.Core.Providers; using allstarr.Core.Providers.Deezer; using allstarr.Core.Providers.AppleMusicKit; +using allstarr.Core.Providers.AppleDownload; +using allstarr.Core.Downloads; +using allstarr.Models.Settings; +using allstarr.Services.AppleMusic; using allstarr.Core.Providers.Spotify; using allstarr.Services; using Moq; @@ -21,11 +25,20 @@ public sealed class BuiltInProviderDescriptorCatalogTests var spotify = new SpotifyPlaylistCapabilityAdapter( new HttpClient(new Mock().Object), new Mock(MockBehavior.Strict).Object); + var appleDownload = new AppleDownloadCapabilityAdapter( + new HttpClient(new Mock().Object), + new AppleDownloadSettings(), + new Mock(MockBehavior.Strict).Object, + new ProviderDownloadArtifactResolver( + new Mock(MockBehavior.Strict).Object, + new ProviderDownloadWorkspaceOptions { RootPath = Path.GetTempPath() }), + 1024); var registry = new ProviderRegistry( [ DeezerMetadataCapabilityAdapter.CreateRegistration(deezer), AppleMusicKitPlaylistCapabilityAdapter.CreateRegistration(apple), SpotifyPlaylistCapabilityAdapter.CreateRegistration(spotify), + AppleDownloadCapabilityAdapter.CreateRegistration(appleDownload), .. BuiltInProviderDescriptorCatalog.LegacyRegistrations ]); diff --git a/allstarr.Tests/ComposeContractTests.cs b/allstarr.Tests/ComposeContractTests.cs index 3151965c..6cc5211a 100644 --- a/allstarr.Tests/ComposeContractTests.cs +++ b/allstarr.Tests/ComposeContractTests.cs @@ -102,6 +102,29 @@ public sealed class ComposeContractTests Assert.DoesNotContain("/var/run/docker.sock", overlay, StringComparison.Ordinal); } + [Fact] + public void AppleOverlay_IsOptionalSourceLockedAndPrivate() + { + RenderCompose("docker-compose.yml", "docker-compose.apple.yml"); + var overlay = File.ReadAllText(Path.Combine(_repositoryRoot, "docker-compose.apple.yml")); + var gatewayDockerfile = File.ReadAllText(Path.Combine( + _repositoryRoot, "sidecars", "apple-gateway", "Dockerfile")); + var gatewayProject = File.ReadAllText(Path.Combine( + _repositoryRoot, "sidecars", "apple-gateway", "pyproject.toml")); + var sourceLock = File.ReadAllText(Path.Combine( + _repositoryRoot, "tools", "apple-provider", "source-lock.json")); + + Assert.Contains("AppleDownload__BaseUrl: http://apple-gateway:8000", overlay, StringComparison.Ordinal); + Assert.Contains("APPLE_GATEWAY_WRAPPER_DECRYPT_PORT: 10020", overlay, StringComparison.Ordinal); + Assert.Contains("./.apple-provider/wrapper-v2", overlay, StringComparison.Ordinal); + Assert.Contains("apple-wrapper-session:", overlay, StringComparison.Ordinal); + Assert.DoesNotContain("ports:", overlay, StringComparison.Ordinal); + Assert.DoesNotContain("/var/run/docker.sock", overlay, StringComparison.Ordinal); + Assert.Contains("python:3.12.11-slim-bookworm@sha256:", gatewayDockerfile, StringComparison.Ordinal); + Assert.Contains("gamdl==3.8.2", gatewayProject, StringComparison.Ordinal); + Assert.Contains("0bc16acb55f557b5c98d49f21d7af685410f7f8b", sourceLock, StringComparison.Ordinal); + } + [Fact] public void RuntimeImage_ContainsBackupToolsAndPinnedDotnetBases() { @@ -144,6 +167,11 @@ public sealed class ComposeContractTests "docker compose -f docker-compose.yml -f docker-compose.spotify-lyrics.yml config --quiet", workflow, StringComparison.Ordinal); + Assert.Contains( + "docker compose -f docker-compose.yml -f docker-compose.apple.yml config --quiet", + workflow, + StringComparison.Ordinal); + Assert.Contains("uv sync --frozen --extra test", workflow, StringComparison.Ordinal); Assert.DoesNotContain("continue-on-error: true", workflow, StringComparison.Ordinal); } @@ -183,6 +211,10 @@ public sealed class ComposeContractTests "docker compose -f docker-compose.yml -f docker-compose.aio.yml config --quiet", workflow, StringComparison.Ordinal); + Assert.Contains( + "docker compose -f docker-compose.yml -f docker-compose.apple.yml config --quiet", + workflow, + StringComparison.Ordinal); Assert.Contains("needs: build-and-test", workflow, StringComparison.Ordinal); Assert.Contains( "uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7", diff --git a/allstarr.sh b/allstarr.sh new file mode 100755 index 00000000..cd6f5d18 --- /dev/null +++ b/allstarr.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" +PROFILE_FILE="$ROOT/.allstarr-profiles" + +die() { echo "allstarr: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +profiles() { + local values=(standard) + if [[ -f "$PROFILE_FILE" ]]; then + while IFS= read -r value; do + case "$value" in spotify|apple|aio) values+=("$value") ;; esac + done < "$PROFILE_FILE" + fi + printf '%s\n' "${values[@]}" +} + +compose_args() { + COMPOSE=(-f "$ROOT/docker-compose.yml") + while IFS= read -r profile; do + case "$profile" in + spotify) COMPOSE+=(-f "$ROOT/docker-compose.spotify-lyrics.yml") ;; + apple) COMPOSE+=(-f "$ROOT/docker-compose.apple.yml") ;; + aio) COMPOSE+=(-f "$ROOT/docker-compose.aio.yml") ;; + esac + done < <(profiles) +} + +remember_profile() { + local wanted="$1" + touch "$PROFILE_FILE" + grep -qxF "$wanted" "$PROFILE_FILE" 2>/dev/null || printf '%s\n' "$wanted" >> "$PROFILE_FILE" +} + +forget_profile() { + local unwanted="$1" temporary + temporary="$(mktemp)" + if [[ -f "$PROFILE_FILE" ]]; then + grep -vxF "$unwanted" "$PROFILE_FILE" > "$temporary" || true + fi + mv "$temporary" "$PROFILE_FILE" +} + +init() { + need docker + need openssl + docker compose version >/dev/null + [[ -f "$ROOT/.env" ]] || cp "$ROOT/.env.example" "$ROOT/.env" + install -d -m 700 "$ROOT/secrets" + install -d -m 755 "$ROOT/downloads" "$ROOT/kept" + if [[ ! -s "$ROOT/secrets/postgres-password.txt" ]]; then + umask 077 + openssl rand -base64 36 > "$ROOT/secrets/postgres-password.txt" + fi + if [[ ! -s "$ROOT/secrets/allstarr-keyring.json" ]]; then + umask 077 + local key + key="$(openssl rand -base64 32)" + printf '{"activeKeyId":"key-1","keys":{"key-1":"%s"}}\n' "$key" > "$ROOT/secrets/allstarr-keyring.json" + fi + chmod 600 "$ROOT/secrets/postgres-password.txt" "$ROOT/secrets/allstarr-keyring.json" + touch "$PROFILE_FILE" + echo "Allstarr is initialized. Edit .env, then run: ./allstarr.sh up" +} + +prepare_apple() { + local bundle="${1:-}" arch="${2:-x86_64}" runtime="linux/amd64" + [[ -f "$ROOT/.env" ]] || die "run ./allstarr.sh init before enabling providers" + [[ -n "$bundle" && -f "$bundle" ]] || die "usage: ./allstarr.sh prepare-apple /path/to/apple-music.apk[m] [x86_64|arm64-v8a]" + case "$arch" in + x86_64) ;; + arm64-v8a) runtime=linux/arm64 ;; + *) die "Apple architecture must be x86_64 or arm64-v8a" ;; + esac + bash "$ROOT/tools/apple-provider/prepare.sh" --apkm "$bundle" --arch "$arch" + if grep -q '^APPLE_WRAPPER_TARGET_ARCH=' "$ROOT/.env"; then + sed -i.bak "s|^APPLE_WRAPPER_TARGET_ARCH=.*|APPLE_WRAPPER_TARGET_ARCH=$arch|" "$ROOT/.env" + sed -i.bak "s|^APPLE_WRAPPER_RUNTIME_PLATFORM=.*|APPLE_WRAPPER_RUNTIME_PLATFORM=$runtime|" "$ROOT/.env" + rm -f "$ROOT/.env.bak" + else + printf '\nAPPLE_WRAPPER_TARGET_ARCH=%s\nAPPLE_WRAPPER_RUNTIME_PLATFORM=%s\n' "$arch" "$runtime" >> "$ROOT/.env" + fi + remember_profile apple + echo "Apple provider source and verified native libraries are ready. Run: ./allstarr.sh up" +} + +up() { + compose_args + docker compose "${COMPOSE[@]}" config --quiet + if profiles | grep -qx apple; then + docker compose "${COMPOSE[@]}" up -d --build --remove-orphans + else + docker compose "${COMPOSE[@]}" up -d --remove-orphans + fi + docker compose "${COMPOSE[@]}" ps +} + +update() { + compose_args + docker compose "${COMPOSE[@]}" config --quiet + docker compose "${COMPOSE[@]}" pull --ignore-buildable + if profiles | grep -qx apple; then + docker compose "${COMPOSE[@]}" build apple-wrapper apple-gateway + fi + docker compose "${COMPOSE[@]}" up -d --remove-orphans + docker compose "${COMPOSE[@]}" ps +} + +usage() { + cat <<'EOF' +Usage: ./allstarr.sh COMMAND + + init Create .env, directories, and secrets + up Start the saved deployment profile + update Pull reviewed images and safely recreate + status Show containers and the saved profile + logs [service] Follow redacted container logs + enable spotify|aio Add an optional saved profile + disable spotify|apple|aio Remove an optional profile on next up + prepare-apple FILE [ARCH] Verify/stage a legal APK/APKM and enable Apple + down Stop containers without deleting data + +Optional profiles are saved in .allstarr-profiles. No command deletes volumes, +Postgres data, managed music, provider sessions, or imported settings. +EOF +} + +command="${1:-help}" +shift || true +cd "$ROOT" +case "$command" in + init) init ;; + up) up ;; + update) update ;; + status) compose_args; echo "Profiles: $(profiles | paste -sd, -)"; docker compose "${COMPOSE[@]}" ps ;; + logs) compose_args; docker compose "${COMPOSE[@]}" logs --tail=200 -f "$@" ;; + enable) + case "${1:-}" in + spotify|aio) remember_profile "$1" ;; + apple) die "use prepare-apple for Apple so its libraries are verified first" ;; + *) die "choose spotify or aio" ;; + esac + echo "Profile enabled. Run: ./allstarr.sh up" + ;; + disable) + case "${1:-}" in spotify|apple|aio) forget_profile "$1" ;; *) die "choose spotify, apple, or aio" ;; esac + echo "Profile disabled. Run ./allstarr.sh up to apply; stored data is preserved." + ;; + down) compose_args; docker compose "${COMPOSE[@]}" down ;; + help|-h|--help) usage ;; + *) usage; exit 2 ;; +esac diff --git a/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs b/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs index b8ca258a..aa52ed93 100644 --- a/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs +++ b/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs @@ -32,6 +32,76 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt return new(entity.Id, new ProviderManagedWorkspaceReference(entity.WorkspaceId)); } + /// + /// Copies provider bytes into a registered private workspace. Providers never receive + /// a host filesystem path, and the returned contract is derived from bytes the host wrote. + /// + public async Task WriteAsync( + ProviderDownloadArtifactWriteRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.DurableJobId == Guid.Empty || string.IsNullOrWhiteSpace(request.ProviderId) || + request.MaximumBytes < 1 || request.ExpectedBytes is < 0) + throw new ArgumentException("Download artifact write constraints are invalid.", nameof(request)); + if (request.ExpectedBytes > request.MaximumBytes) + throw new InvalidDataException("The provider download exceeds the managed artifact size limit."); + + var persistedWorkspace = await store.GetWorkspaceAsync(request.Workspace.WorkspaceId, cancellationToken) + ?? throw new InvalidOperationException("The provider download workspace is not registered."); + var providerId = request.ProviderId.Trim().ToLowerInvariant(); + if (persistedWorkspace.DurableJobId != request.DurableJobId || + !persistedWorkspace.ProviderId.Equals(providerId, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("The provider download does not belong to this workspace."); + + var relative = NormalizeArtifactReference(request.ArtifactId); + var workspaceRoot = Contained(WorkspaceRoot(), persistedWorkspace.WorkspaceId); + RejectSymlink(workspaceRoot); + var destination = Contained(workspaceRoot, relative); + var parent = Path.GetDirectoryName(destination)!; + Directory.CreateDirectory(parent); + RejectPathSymlinks(workspaceRoot, parent); + + if (File.Exists(destination)) + return await DescribeExistingAsync(relative, destination, request.MaximumBytes, cancellationToken); + + var partial = destination + ".partial-" + Guid.NewGuid().ToString("N"); + try + { + await using (var output = new FileStream(partial, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 128 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + using (var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) + { + var buffer = new byte[128 * 1024]; + long written = 0; + while (true) + { + var read = await request.Content.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + written = checked(written + read); + if (written > request.MaximumBytes) + throw new InvalidDataException("The provider download exceeds the managed artifact size limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + hasher.AppendData(buffer, 0, read); + request.Progress?.Invoke(written, request.ExpectedBytes); + } + if (written < 1) + throw new InvalidDataException("The provider returned an empty download artifact."); + if (request.ExpectedBytes.HasValue && written != request.ExpectedBytes.Value) + throw new InvalidDataException("The provider download length does not match its response contract."); + await output.FlushAsync(cancellationToken); + output.Close(); + File.Move(partial, destination, overwrite: false); + return new(relative, Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant(), written); + } + } + catch + { + if (File.Exists(partial)) File.Delete(partial); + throw; + } + } + public async Task ResolveAsync(ProviderManagedWorkspaceReference workspace, ProviderDownloadedArtifact output, CancellationToken cancellationToken = default) { @@ -154,6 +224,22 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt } } + private static async Task DescribeExistingAsync( + string relative, + string path, + long maximumBytes, + CancellationToken cancellationToken) + { + RejectSymlink(path); + var info = new FileInfo(path); + if (info.Length < 1 || info.Length > maximumBytes) + throw new InvalidDataException("The existing managed artifact has an invalid size."); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + return new(relative, Convert.ToHexString(hash).ToLowerInvariant(), info.Length); + } + private static VerifiedProviderDownloadArtifact Result(ProviderDownloadArtifactEntity item, string path) => new( item.Id, item.WorkspaceRecordId, path, item.ContentSha256, item.Length, item.TenantId, item.OwnerUserId, item.DurableJobId, item.ProviderId, item.ProviderAccountId, item.State, item.ManagedFileId) diff --git a/allstarr/Core/Downloads/ProviderDownloadArtifacts.cs b/allstarr/Core/Downloads/ProviderDownloadArtifacts.cs index bf8a85c5..aae28d51 100644 --- a/allstarr/Core/Downloads/ProviderDownloadArtifacts.cs +++ b/allstarr/Core/Downloads/ProviderDownloadArtifacts.cs @@ -52,6 +52,23 @@ public sealed record ProviderDownloadWorkspaceRequest(Guid TenantId, Guid? Owner public sealed record ProviderDownloadWorkspace(Guid RecordId, ProviderManagedWorkspaceReference Reference); +public sealed record ProviderDownloadArtifactWriteRequest( + ProviderManagedWorkspaceReference Workspace, + Guid DurableJobId, + string ProviderId, + string ArtifactId, + Stream Content, + long MaximumBytes) +{ + public long? ExpectedBytes { get; init; } + public Action? Progress { get; init; } +} + +public sealed record ProviderDownloadArtifactWriteResult( + string ArtifactId, + string Sha256, + long SizeBytes); + public sealed record VerifiedProviderDownloadArtifact(Guid Id, Guid WorkspaceRecordId, [property: JsonIgnore] string SourcePath, string ContentSha256, long Length, Guid TenantId, Guid? OwnerUserId, Guid DurableJobId, string ProviderId, Guid? ProviderAccountId, ProviderDownloadArtifactState State, Guid? ManagedFileId) @@ -63,6 +80,7 @@ public sealed class ProviderDownloadWorkspaceOptions { public const string SectionName = "Downloads:Workspace"; public string RootPath { get; set; } = "./downloads/workspaces"; + public long MaximumArtifactBytes { get; set; } = 2L * 1024 * 1024 * 1024; } public interface IProviderDownloadArtifactStore diff --git a/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityAdapter.cs b/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityAdapter.cs new file mode 100644 index 00000000..8a22e449 --- /dev/null +++ b/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityAdapter.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using allstarr.Core.Capabilities; +using allstarr.Core.Downloads; +using allstarr.Models.Settings; +using allstarr.Services.AppleMusic; +using allstarr.Services.Common; +using Microsoft.Extensions.Options; + +namespace allstarr.Core.Providers.AppleDownload; + +public sealed class AppleDownloadCapabilityAdapter : IProviderDownloadCapability +{ + public const string StableProviderId = "apple-download"; + public const string HttpClientName = "AppleDownloadCapability"; + + private readonly HttpClient http; + private readonly AppleDownloadSettings settings; + private readonly IAppleDownloadEndpointDiscovery discovery; + private readonly ProviderDownloadArtifactResolver artifacts; + private readonly long maximumArtifactBytes; + + [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] + public AppleDownloadCapabilityAdapter( + IHttpClientFactory clients, + IOptions settings, + IAppleDownloadEndpointDiscovery discovery, + ProviderDownloadArtifactResolver artifacts, + ProviderDownloadWorkspaceOptions workspaceOptions) + : this(clients.CreateClient(HttpClientName), settings.Value, discovery, artifacts, + workspaceOptions.MaximumArtifactBytes) + { + } + + public AppleDownloadCapabilityAdapter( + HttpClient http, + AppleDownloadSettings settings, + IAppleDownloadEndpointDiscovery discovery, + ProviderDownloadArtifactResolver artifacts, + long maximumArtifactBytes) + { + this.http = http; + this.settings = settings; + this.discovery = discovery; + this.artifacts = artifacts; + this.maximumArtifactBytes = maximumArtifactBytes > 0 + ? maximumArtifactBytes + : throw new ArgumentOutOfRangeException(nameof(maximumArtifactBytes)); + } + + public string ProviderId => StableProviderId; + public ProviderCapabilityKind Capability => ProviderCapabilityKind.Download; + + public async Task> CheckAvailabilityAsync( + ProviderExecutionContext context, + ProviderDownloadAvailabilityRequest request) + { + var error = Validate(context, request.TrackId); + if (error != null) return ProviderOutcome.Failure(error); + try + { + var snapshot = await discovery.DiscoverAsync(context.CancellationToken); + var state = Availability(snapshot); + var qualities = state == ProviderDownloadAvailabilityState.Available + ? Enum.GetValues() + : []; + return ProviderOutcome.Success(new(state, qualities)); + } + catch (OperationCanceledException) + { + return ProviderOutcome.Failure(new(ProviderErrorKind.Canceled)); + } + catch + { + return ProviderOutcome.Failure(new(ProviderErrorKind.TransientFailure)); + } + } + + public async Task> DownloadAsync( + ProviderExecutionContext context, + ProviderDownloadRequest request, + IProgress? progress = null) + { + var error = Validate(context, request.TrackId); + if (error != null) return ProviderOutcome.Failure(error); + if (!context.Policy.AllowManagedDownloads) + return ProviderOutcome.Failure(new(ProviderErrorKind.Forbidden)); + if (request.DurableJobId == Guid.Empty) + return ProviderOutcome.Failure(new(ProviderErrorKind.PermanentFailure)); + + try + { + progress?.Report(new(ProviderDownloadProgressStage.Resolving, 0)); + var snapshot = await discovery.DiscoverAsync(context.CancellationToken); + var availability = Availability(snapshot); + if (availability != ProviderDownloadAvailabilityState.Available) + return ProviderOutcome.Failure(new(ErrorFor(snapshot.State))); + if (!OutboundRequestGuard.TryCreateConfiguredServiceUri(settings.BaseUrl, out var baseUri, out _)) + return ProviderOutcome.Failure(new(ProviderErrorKind.AccountNeedsConfiguration)); + + var quality = Quality(request.RequestedQuality, settings.Quality); + var endpoint = new Uri(baseUri!, $"api/download/{Uri.EscapeDataString(request.TrackId.Value)}?quality={Uri.EscapeDataString(quality)}"); + using var response = await http.GetAsync(endpoint, HttpCompletionOption.ResponseHeadersRead, context.CancellationToken); + if (response.StatusCode is >= HttpStatusCode.MultipleChoices and < HttpStatusCode.BadRequest || + response.RequestMessage?.RequestUri is not { } responseUri || !SameOrigin(baseUri!, responseUri)) + return ProviderOutcome.Failure(new(ProviderErrorKind.PermanentFailure)); + if (!response.IsSuccessStatusCode) + return ProviderOutcome.Failure(ErrorFor(response)); + + var media = Media(response.Content.Headers.ContentType?.MediaType, quality); + if (media == null) + return ProviderOutcome.Failure(new(ProviderErrorKind.IncompatibleMedia)); + var expectedBytes = response.Content.Headers.ContentLength; + if (expectedBytes is <= 0 || expectedBytes > maximumArtifactBytes) + return ProviderOutcome.Failure(new(ProviderErrorKind.IncompatibleMedia)); + + var artifactId = ArtifactId(request.TrackId.Value, media.Container); + await using var content = await response.Content.ReadAsStreamAsync(context.CancellationToken); + progress?.Report(new(ProviderDownloadProgressStage.Transferring, 0, expectedBytes)); + var written = await artifacts.WriteAsync(new( + request.Workspace, + request.DurableJobId, + StableProviderId, + artifactId, + content, + maximumArtifactBytes) + { + ExpectedBytes = expectedBytes, + Progress = (complete, total) => progress?.Report( + new ProviderDownloadProgress(ProviderDownloadProgressStage.Transferring, complete, total)) + }, context.CancellationToken); + progress?.Report(new(ProviderDownloadProgressStage.Verifying, written.SizeBytes, written.SizeBytes)); + var output = new ProviderDownloadedArtifact( + written.ArtifactId, + written.Sha256, + written.SizeBytes, + media, + verified: true); + progress?.Report(new(ProviderDownloadProgressStage.Completed, written.SizeBytes, written.SizeBytes)); + return ProviderOutcome.Success(output); + } + catch (OperationCanceledException) + { + return ProviderOutcome.Failure(new(ProviderErrorKind.Canceled)); + } + catch (InvalidDataException) + { + return ProviderOutcome.Failure(new(ProviderErrorKind.IncompatibleMedia)); + } + catch (UnauthorizedAccessException) + { + return ProviderOutcome.Failure(new(ProviderErrorKind.Forbidden)); + } + catch (Exception exception) when (exception is HttpRequestException or IOException) + { + return ProviderOutcome.Failure(new(ProviderErrorKind.TransientFailure)); + } + catch + { + return ProviderOutcome.Failure(new(ProviderErrorKind.PermanentFailure)); + } + } + + public static ProviderRegistration CreateRegistration(AppleDownloadCapabilityAdapter adapter) => new( + new ProviderDescriptor( + StableProviderId, + "Apple download", + "Optional operator-managed Apple audio downloads through a discovered compatible gateway.", + ProviderOrigin.BuiltIn, + sdkVersion: "1", + compatibilityVersion: "apple-download-gateway-v1", + capabilities: + [ + ConfiguredLane(ProviderCapabilityKind.Metadata), + ConfiguredLane(ProviderCapabilityKind.Streaming), + new ProviderCapabilityDescriptor( + ProviderCapabilityKind.Download, + ProviderCapabilitySupportState.Supported, + ProviderAccountRequirement.None, + compatibilityVersion: "1", + hooks: ["checkAvailability", "download"]), + ConfiguredLane(ProviderCapabilityKind.Health) + ], + permissions: new ProviderPermissionDescriptor()), + [adapter]); + + private static ProviderCapabilityDescriptor ConfiguredLane(ProviderCapabilityKind capability) => new( + capability, + ProviderCapabilitySupportState.ConfiguredOnly, + ProviderAccountRequirement.None, + compatibilityVersion: "legacy-seam-v1"); + + private static ProviderError? Validate(ProviderExecutionContext context, ProviderExternalResourceId trackId) + { + ArgumentNullException.ThrowIfNull(context); + try + { + context.RequireResourceOwner(trackId, ProviderResourceKind.Track); + } + catch (Exception exception) when (exception is ArgumentException or UnauthorizedAccessException) + { + return new(ProviderErrorKind.Forbidden); + } + if (!context.ProviderId.Equals(StableProviderId, StringComparison.Ordinal) || + !context.Policy.AllowsProvider(StableProviderId)) + return new(ProviderErrorKind.Forbidden); + if (context.CancellationToken.IsCancellationRequested) + return new(ProviderErrorKind.Canceled); + return context.IsExpired(DateTimeOffset.UtcNow) + ? new(ProviderErrorKind.CapabilityUnavailable) + : null; + } + + private static ProviderDownloadAvailabilityState Availability(AppleDownloadEndpointSnapshot snapshot) + { + if (snapshot.State == AppleDownloadEndpointState.Available && + snapshot.Capability(ProviderCapabilities.Download).State == AppleDownloadCapabilityState.Available && + snapshot.Capability("download-audio-song").State == AppleDownloadCapabilityState.Available) + return ProviderDownloadAvailabilityState.Available; + return snapshot.State switch + { + AppleDownloadEndpointState.NeedsAuthentication => ProviderDownloadAvailabilityState.AccountRequired, + AppleDownloadEndpointState.Incompatible => ProviderDownloadAvailabilityState.Incompatible, + _ => ProviderDownloadAvailabilityState.Unavailable + }; + } + + private static ProviderErrorKind ErrorFor(AppleDownloadEndpointState state) => state switch + { + AppleDownloadEndpointState.NeedsConfiguration => ProviderErrorKind.AccountNeedsConfiguration, + AppleDownloadEndpointState.NeedsAuthentication => ProviderErrorKind.Unauthorized, + AppleDownloadEndpointState.Incompatible => ProviderErrorKind.NotSupported, + _ => ProviderErrorKind.CapabilityUnavailable + }; + + private static ProviderError ErrorFor(HttpResponseMessage response) => response.StatusCode switch + { + HttpStatusCode.Unauthorized => new(ProviderErrorKind.Unauthorized), + HttpStatusCode.Forbidden => new(ProviderErrorKind.Forbidden), + HttpStatusCode.NotFound => new(ProviderErrorKind.NotFound), + HttpStatusCode.TooManyRequests => new(ProviderErrorKind.RateLimited, RetryAfter(response)), + >= HttpStatusCode.InternalServerError => new(ProviderErrorKind.TransientFailure), + _ => new(ProviderErrorKind.PermanentFailure) + }; + + private static TimeSpan RetryAfter(HttpResponseMessage response) => + response.Headers.RetryAfter?.Delta is { } delta && delta >= TimeSpan.Zero + ? delta + : TimeSpan.FromSeconds(30); + + private static string Quality(ProviderAudioQuality requested, string? configured) => requested switch + { + ProviderAudioQuality.Lossy => "aac-320", + ProviderAudioQuality.Lossless => configured?.StartsWith("alac-", StringComparison.OrdinalIgnoreCase) == true + ? configured : "alac-16-44", + ProviderAudioQuality.HighResolution => configured is "alac-24-192" or "alac-24-96" + ? configured : "alac-24-96", + _ => string.IsNullOrWhiteSpace(configured) ? "alac-16-44" : configured.Trim() + }; + + private static ProviderMediaFormat? Media(string? mimeType, string quality) => mimeType?.ToLowerInvariant() switch + { + "audio/flac" or "audio/x-flac" => new(mimeType.ToLowerInvariant(), "flac", "flac"), + "audio/mp4" or "audio/x-m4a" or "audio/m4a" => new(mimeType.ToLowerInvariant(), "m4a", + quality.StartsWith("aac-", StringComparison.OrdinalIgnoreCase) ? "aac" : "alac"), + "audio/aac" => new(mimeType.ToLowerInvariant(), "aac", "aac"), + _ => null + }; + + private static string ArtifactId(string trackId, string extension) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(trackId))).ToLowerInvariant(); + return $"apple-{hash[..32]}.{extension}"; + } + + private static bool SameOrigin(Uri expected, Uri actual) => + expected.Scheme.Equals(actual.Scheme, StringComparison.OrdinalIgnoreCase) && + expected.Host.Equals(actual.Host, StringComparison.OrdinalIgnoreCase) && + expected.Port == actual.Port; +} diff --git a/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityRegistration.cs b/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityRegistration.cs new file mode 100644 index 00000000..39b24db8 --- /dev/null +++ b/allstarr/Core/Providers/AppleDownload/AppleDownloadCapabilityRegistration.cs @@ -0,0 +1,24 @@ +using allstarr.Core.Capabilities; + +namespace allstarr.Core.Providers.AppleDownload; + +public static class AppleDownloadCapabilityRegistration +{ + public static IServiceCollection AddAppleDownloadCapability(this IServiceCollection services) + { + services.AddHttpClient(AppleDownloadCapabilityAdapter.HttpClientName, client => + client.Timeout = TimeSpan.FromMinutes(30)) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + AllowAutoRedirect = false, + UseCookies = false, + MaxConnectionsPerServer = 2, + PooledConnectionLifetime = TimeSpan.FromMinutes(2) + }); + services.AddSingleton(); + services.AddSingleton(provider => + AppleDownloadCapabilityAdapter.CreateRegistration( + provider.GetRequiredService())); + return services; + } +} diff --git a/allstarr/Core/Providers/BuiltInProviderDescriptorCatalog.cs b/allstarr/Core/Providers/BuiltInProviderDescriptorCatalog.cs index bf055e51..557cd8cd 100644 --- a/allstarr/Core/Providers/BuiltInProviderDescriptorCatalog.cs +++ b/allstarr/Core/Providers/BuiltInProviderDescriptorCatalog.cs @@ -18,14 +18,6 @@ public static class BuiltInProviderDescriptorCatalog public static IReadOnlyList LegacyRegistrations { get; } = [ - Registration( - "apple-download", - "Apple download", - "The existing gamdl-backed Apple catalog and managed download lane.", - Optional(ProviderCapabilityKind.Metadata), - Required(ProviderCapabilityKind.Streaming), - Required(ProviderCapabilityKind.Download), - Required(ProviderCapabilityKind.Health)), Registration( "qobuz", "Qobuz", diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 890001c7..6f873da9 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -25,6 +25,7 @@ using allstarr.Core.Operations; using allstarr.Core.Providers.Deezer; using allstarr.Core.Providers.Spotify; using allstarr.Core.Providers.AppleMusicKit; +using allstarr.Core.Providers.AppleDownload; using allstarr.Core.Providers; using allstarr.Core.Protocols; using allstarr.Core.Protocols.Jellyfin; @@ -257,7 +258,7 @@ builder.Services.ConfigureAll(options => { options.HttpMessageHandlerBuilderActions.Add(builder => { - if (builder.Name is "AppleDownloadDiscovery" or "AppleMusic") + if (builder.Name is "AppleDownloadDiscovery" or "AppleDownloadCapability" or "AppleMusic") { return; } @@ -416,6 +417,7 @@ builder.Services.AddSingleton(sp => builder.Services.AddDeezerMetadataCapability(); builder.Services.AddSpotifyPlaylistCapability(); builder.Services.AddAppleMusicKitPlaylistCapability(); +builder.Services.AddAppleDownloadCapability(); builder.Services.AddLegacyBuiltInProviderDescriptors(); // 2. Concrete Download Services diff --git a/docker-compose.apple.yml b/docker-compose.apple.yml new file mode 100644 index 00000000..20f45447 --- /dev/null +++ b/docker-compose.apple.yml @@ -0,0 +1,67 @@ +services: + allstarr: + environment: + AppleDownload__BaseUrl: http://apple-gateway:8000 + + apple-gateway: + build: + context: ./sidecars/apple-gateway + image: ${APPLE_GATEWAY_IMAGE:-allstarr/apple-gateway:1.0.0} + restart: unless-stopped + depends_on: + apple-wrapper: + condition: service_started + environment: + APPLE_GATEWAY_WRAPPER_URL: http://apple-wrapper:80 + APPLE_GATEWAY_WRAPPER_DECRYPT_HOST: apple-wrapper + APPLE_GATEWAY_WRAPPER_DECRYPT_PORT: 10020 + APPLE_GATEWAY_STOREFRONT: ${APPLE_GATEWAY_STOREFRONT:-us} + APPLE_GATEWAY_MAX_CONCURRENCY: ${APPLE_GATEWAY_MAX_CONCURRENCY:-2} + APPLE_GATEWAY_PROCESS_TIMEOUT_SECONDS: ${APPLE_GATEWAY_PROCESS_TIMEOUT_SECONDS:-900} + volumes: + - apple-gateway-data:/data + networks: + - allstarr + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + tmpfs: + - /tmp:size=128m,mode=1777 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + apple-wrapper: + build: + context: ${APPLE_WRAPPER_CONTEXT:-./.apple-provider/wrapper-v2} + dockerfile: Dockerfile + args: + TARGET_ARCH: ${APPLE_WRAPPER_TARGET_ARCH:-x86_64} + BUILD_PLATFORM: linux/amd64 + RUNTIME_PLATFORM: ${APPLE_WRAPPER_RUNTIME_PLATFORM:-linux/amd64} + image: ${APPLE_WRAPPER_IMAGE:-allstarr/wrapper-v2:0.0.2} + restart: unless-stopped + environment: + WRAPPER_HOST: 0.0.0.0 + WRAPPER_PORT: 80 + WRAPPER_DECRYPT_HOST: 0.0.0.0 + WRAPPER_DECRYPT_PORT: 10020 + WRAPPER_BASE_DIR: /data/data/com.apple.android.music/files + WRAPPER_RESTORE_SESSION: "1" + volumes: + - apple-wrapper-session:/app/rootfs/data/data/com.apple.android.music/files + networks: + - allstarr + cap_add: + - SYS_ADMIN + - SYS_CHROOT + - SYS_PTRACE + security_opt: + - apparmor:unconfined +volumes: + apple-gateway-data: + apple-wrapper-session: diff --git a/docs/operations/apple-download-provider.md b/docs/operations/apple-download-provider.md index 40be6adf..e490cd0e 100644 --- a/docs/operations/apple-download-provider.md +++ b/docs/operations/apple-download-provider.md @@ -1,24 +1,47 @@ -# External Apple download provider +# Apple download provider -Apple downloads are optional. Standard Compose and AIO do not include GAMDL, wrapper-v2, or an Apple provider -gateway. Allstarr connects to a separately deployed compatible gateway by URL. +Apple downloads are optional. Standard Compose and AIO do not start them. The optional +`docker-compose.apple.yml` profile adds Allstarr's Apple gateway, GAMDL 3.8.2, and a locally built, source-locked +wrapper-v2 0.0.2 service. It does not contain or download Apple code. The URL must point to a gateway that wraps GAMDL and wrapper-v2 and implements the API contract Allstarr expects. Do not enter the raw wrapper-v2 URL. wrapper-v2 supplies account, playback, and decryption services, but it is not a GAMDL search and download HTTP gateway by itself. -## Prepare the external gateway +## Prepare wrapper-v2 -Deploy the gateway in its own stack by following its maintainer's instructions. Keep its session state, downloaded -artifacts, backups, upgrades, and rollback outside the Allstarr stack. The upstream projects are +Obtain Apple Music for Android legally from a source you are permitted to use. Allstarr does not download, +redistribute, or retain that package. Then run: + +```bash +./allstarr.sh prepare-apple /private/path/apple-music.apkm x86_64 +./allstarr.sh up +``` + +Use `--apkm` for an APKM bundle. For ARM64 use `--arch arm64-v8a`, then set +`APPLE_WRAPPER_TARGET_ARCH=arm64-v8a` and `APPLE_WRAPPER_RUNTIME_PLATFORM=linux/arm64`. + +An old installation's archived libraries can be reused only when they pass the official hash lock: + +```bash +tools/apple-provider/prepare.sh --staged-libs /backup/rootfs/system/lib64 --arch x86_64 +``` + +The controller clones official wrapper-v2 tag `0.0.2`, verifies its full locked commit, stages the official +AOSP runtime, and rejects Apple or AOSP libraries that do not match wrapper-v2's `LIBS_VERSION.json`. The generated +`.apple-provider/wrapper-v2` directory is local deployment input and must not be committed. + +## What the profile runs + +The repository gateway is a narrow HTTP adapter around the official upstream projects [GAMDL](https://github.com/glomatico/gamdl) and -[wrapper-v2](https://github.com/glomatico/wrapper-v2). Neither project by itself implements the gateway contract -below. The gateway is the HTTP adapter between those tools and Allstarr. +[wrapper-v2](https://github.com/glomatico/wrapper-v2). It exposes catalog song lookup, download-backed streaming, +managed song downloads, health, login, and 2FA. It runs on the private Compose network and publishes no host port. -For a GAMDL-backed gateway, use GAMDL 3.8.2 or newer with wrapper-v2 0.0.2 or the exact compatible pair required by -the gateway. GAMDL 3.8.2 introduced compatibility with wrapper-v2 0.0.2. Do not update one half of the pair without -the other. The gateway's runtime capability and version manifest is authoritative; an upstream feature does not -become an Allstarr capability until the gateway advertises it and Allstarr's probe accepts it. +The gateway advertises only routes that Allstarr has implemented and tested. GAMDL can do more upstream, but album, +playlist, artist, library, video, lyrics-artifact, and artwork lanes stay unavailable in Allstarr until their +managed-artifact contracts exist. That keeps the UI honest and prevents an upstream feature name from becoming a +false promise. Before connecting it, confirm that: @@ -44,10 +67,8 @@ Allstarr first requests `GET /api/capabilities`. A compatible version 1 response } ``` -The gateway may additionally advertise `metadata-album`, `metadata-artist`, `download-album`, -`download-playlist`, `library-read`, `stream-music-video`, `synced-lyrics-artifact`, `tagging-artwork`, -`codec-alac`, and `codec-aac`. Allstarr displays absent features as unsupported. It never turns an upstream GAMDL -feature into an Allstarr route merely because GAMDL supports it. +The included gateway also advertises its verified ALAC and AAC input support. Allstarr displays absent features as +unsupported. The current typed lanes use these routes: @@ -66,12 +87,8 @@ an unexpected host. ## Connect Allstarr -Open the dashboard, find the Apple download provider, and set its gateway URL. This durable WebUI setting takes -effect without recreating Allstarr. A Compose deployment may instead supply the initial bootstrap value: - -```dotenv -APPLE_DOWNLOAD_URL=https://apple-gateway.example.internal -``` +Open the dashboard and find the Apple download provider. The Compose profile configures its private gateway URL. +Finish Apple login or 2FA there; credentials are forwarded only to wrapper-v2 and are not stored in Postgres. The legacy `APPLE_MUSIC_AIO_URL` name is recognized only by the migration review so an operator can identify the old endpoint. New deployments use `APPLE_DOWNLOAD_URL`. @@ -94,10 +111,9 @@ access. It is not a GAMDL or wrapper account and must not be copied into the dow ## Disable or replace the gateway -Clear the configured URL in the dashboard. This disables only the Apple download provider and takes effect without -restarting Allstarr. It does not delete Postgres records, Allstarr-managed media, the external gateway's session -state, or a user's Apple MusicKit account. If the URL came from `APPLE_DOWNLOAD_URL`, remove that bootstrap value -and recreate only the Allstarr container. +Run `./allstarr.sh disable apple` followed by `./allstarr.sh up`. This removes the optional containers from the +active profile. It does not delete Postgres records, Allstarr-managed media, the wrapper session volume, gateway +state, or a user's Apple MusicKit account. To replace the gateway, validate the replacement independently, change the URL in the dashboard, and repeat the provider health and capability checks. Do not combine that change with a database restore or media move. Keeping diff --git a/docs/operations/deployment-profiles.md b/docs/operations/deployment-profiles.md new file mode 100644 index 00000000..89ce4d01 --- /dev/null +++ b/docs/operations/deployment-profiles.md @@ -0,0 +1,88 @@ +# Deployment profiles + +`allstarr.sh` is the normal Compose entry point. It keeps Standard small, remembers optional services, validates the +merged Compose model before starting it, and never deletes volumes. + +## First install + +```bash +./allstarr.sh init +``` + +This copies `.env.example` when needed, creates the media directories, and generates the Postgres password and +Allstarr encryption key ring with owner-only permissions. Edit `.env`, then start the core stack: + +```bash +./allstarr.sh up +``` + +## Optional services + +Spotify lyrics needs only its cookie in the protected `.env`: + +```bash +./allstarr.sh enable spotify +./allstarr.sh up +``` + +Apple needs a legally obtained Apple Music Android 3.6.0-beta build 1109 APK/APKM. Allstarr verifies every native +library against the official wrapper-v2 lock before it will build: + +```bash +./allstarr.sh prepare-apple /private/path/apple-music.apkm x86_64 +./allstarr.sh up +``` + +Use `arm64-v8a` instead of `x86_64` on an ARM64 Docker host. Finish Apple login and 2FA in the WebUI. The Apple +download login is separate from every user's Apple MusicKit account. + +The AIO override remains an offline first-party extension bundle. It is not an everything-in-one image and does not +quietly enable provider accounts or sidecars. + +```bash +./allstarr.sh enable aio +./allstarr.sh up +``` + +## Updates + +```bash +./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. + +Use `./allstarr.sh status` to see the active profile and `./allstarr.sh logs SERVICE` for a bounded starting log +window followed by new events. + +## Removing and re-adding a service + +```bash +./allstarr.sh disable spotify +./allstarr.sh disable apple +./allstarr.sh up +``` + +Disabling a profile removes its containers on the next `up` but keeps its volumes and durable Allstarr settings. +Re-enable Spotify with `enable spotify`; re-enable Apple by running `prepare-apple` again or adding `apple` to the +local `.allstarr-profiles` file after the locked wrapper context is present. + +## Adding another sidecar + +Keep custom services in a separate Compose override. Give the service a fixed image digest or locked build source, +join the existing `allstarr` network, publish no host ports unless an operator truly needs them, persist only the +state it owns, and point Allstarr at its private service URL. Do not mount the Docker socket into Allstarr or a +provider. Do not put provider source pulls in container startup. + +Before using a new override: + +```bash +docker compose -f docker-compose.yml -f docker-compose.my-provider.yml config --quiet +docker compose -f docker-compose.yml -f docker-compose.my-provider.yml up -d +``` + +An extension package is different from a sidecar. Provider SDK packages are installed through the WebUI registry +flow, where checksum, manifest, permissions, settings, health, activation, rollback, and uninstall state are +reviewed. See [Extension SDK v1](../extensions/sdk-v1.md). diff --git a/docs/steering/PROVIDERS.md b/docs/steering/PROVIDERS.md index 382f0cce..dc883f3d 100644 --- a/docs/steering/PROVIDERS.md +++ b/docs/steering/PROVIDERS.md @@ -59,8 +59,9 @@ Typed routes must register an accurate descriptor and matching capability implem ### Apple -- `apple-download` is an optional, separately deployed compatible gateway configured by URL. Standard and AIO do - not bundle GAMDL, wrapper-v2, or the gateway stack. +- `apple-download` is an optional repository gateway and typed managed-download adapter. Its separate Compose + profile uses GAMDL 3.8.2 and locked wrapper-v2 0.0.2 source after legal Apple libraries pass upstream hashes. + Standard and AIO do not start that stack. - `apple-musickit` is a separate per-user MusicKit account for personal playlists and library operations. - Never pass a Music User Token to the download gateway or treat gateway login state as MusicKit authorization. diff --git a/docs/steering/references/code-map.md b/docs/steering/references/code-map.md index 2c7802a3..afa00033 100644 --- a/docs/steering/references/code-map.md +++ b/docs/steering/references/code-map.md @@ -39,7 +39,7 @@ only where it explains why a compatibility path still exists. | Provider enablement and runtime status: [ProviderStatusManager.cs](../../../allstarr/Services/Common/ProviderStatusManager.cs), [ProviderRuntimeStatus.cs](../../../allstarr/Services/Common/ProviderRuntimeStatus.cs) | The compatibility projection separates configured state from in-memory `Unknown`, `Testing`, `Healthy`, or `Degraded` observations. Typed routes use durable account/capability samples, rollups, circuits, and resolved accounts. Disabled providers leave every lane, and SquidWTF is metadata-only. | **Keep the compatibility projection; use durable health and account policy for typed routes.** | `ProviderStatusManagerTests` covers pure compatibility reads and probe isolation. `Unknown` may be attempted only on eligible compatibility paths and is never reported ready. | | Built-in provider adapters: [Services/AppleMusic](../../../allstarr/Services/AppleMusic), [Services/Deezer](../../../allstarr/Services/Deezer), [Services/Qobuz](../../../allstarr/Services/Qobuz), [Services/SquidWTF](../../../allstarr/Services/SquidWTF) | Current services expose uneven metadata, download-backed streaming, download, playlist-read, and health behavior through global configuration. | **Wrap individually.** Publish only capabilities listed by `CurrentProviderSupportCatalog`; replace internals only behind common typed contracts. | Provider-specific fake HTTP tests are rollback anchors. Missing download credentials must not suppress public metadata. A capability must not inherit another capability's failed probe. | | SquidWTF stream/download paths | Legacy download/stream classes remain present, but current status/routing excludes SquidWTF from stream, download, and playlist lanes. | **Retire from current routing; retain metadata adapter.** Reintroduce another capability only with a working endpoint and contract fixture. | `ProviderStatusManagerTests` guards the quarantine. The optional uptime source is discovery information, not proof of a working media endpoint. | -| External Apple download gateway: [AppleMusicController.cs](../../../allstarr/Controllers/AppleMusicController.cs), [Services/AppleMusic](../../../allstarr/Services/AppleMusic) | The admin API calls one configured gateway URL, understands nested account state, preserves 2FA/pending statuses, and strips tokens. Current media support is song-focused. | **Keep the HTTP boundary; retire the vendored deployment.** Require a compatible external gateway and advertise only capabilities its contract and health probe verify. | `AppleMusicControllerTests` protects status/login/secret shaping. Fake-gateway tests remain the rollback anchor. A raw wrapper-v2 URL is not a compatible search/download gateway. | +| Optional Apple download gateway: [AppleMusicController.cs](../../../allstarr/Controllers/AppleMusicController.cs), [Apple typed adapter](../../../allstarr/Core/Providers/AppleDownload), [gateway](../../../sidecars/apple-gateway) | The admin API calls the private optional gateway, preserves 2FA/pending statuses, and strips tokens. The typed adapter verifies song artifacts in host-owned workspaces. | **Keep the HTTP boundary and optional profile.** Advertise only capabilities its contract and health probe verify. | Controller and fake-gateway tests protect login, discovery, streamed artifact, MIME, size, checksum, and ownership behavior. | | Playlist and matching stack: [Core/Playlists](../../../allstarr/Core/Playlists), [Core/Matching](../../../allstarr/Core/Matching), plus legacy [Services/Spotify](../../../allstarr/Services/Spotify) and [JellyfinController.Spotify.cs](../../../allstarr/Controllers/JellyfinController.Spotify.cs) | New Spotify and Apple MusicKit sources are account-bound and provider-neutral. Durable snapshots, matches, virtual reads, schedules, and Jellyfin/Subsonic materialization use the core. The specialized Redis mapping/injection path remains compatibility-only. | **Keep the provider-neutral core; retire legacy compatibility after its fixtures and routes are removed deliberately.** | Playlist orchestration, target, virtual protocol, scanner, tenant-isolation, WebUI, and mapping-review tests cover the new path. Fresh installs do not import legacy cache mappings. | | Lyrics, playback, and scrobbling: [Services/Lyrics](../../../allstarr/Services/Lyrics), [Services/Scrobbling](../../../allstarr/Services/Scrobbling), [Core/Playback](../../../allstarr/Core/Playback) | Lyrics sources remain orchestrated services. Authenticated playback observations and optional Last.fm or ListenBrainz delivery now cross a durable, idempotent job boundary with exact actor scope. | **Keep the provider services behind the durable core.** Preserve native protocol results, dedupe, scoped credentials, retention, and sanitized failures. Do not add controller fire-and-forget work. | Playback pipeline, scrobbling orchestrator, protocol, and admin tests cover thresholds, retries, scope, and failure shaping. Do not log raw upstream bodies or tokens. | | Recommendations and generated sets: [Core/Intelligence](../../../allstarr/Core/Intelligence), [IntelligenceController.cs](../../../allstarr/Controllers/IntelligenceController.cs) | Exact-scope policy controls retained signals, habit profiles, provider readiness, recommendation runs, explanations, and generated playlists. Jellyfin and Subsonic materializers reconcile exact local matches through shared playlist targets. | **Extend through typed sources and targets.** Keep MusicBrainz claims limited to local enriched relationships, require explicit Subsonic credentials, and treat AudioMuse as optional until healthy and contract-compatible. | Intelligence, provider-source, controller, and generated-set materializer tests cover opt-in, purge, seeds, isolation, ordering, skips, retries, and cancellation. | @@ -51,7 +51,7 @@ only where it explains why a compatibility path still exists. | Cache and current runtime persistence: [RedisCacheService.cs](../../../allstarr/Services/Common/RedisCacheService.cs), [RedisPersistenceService.cs](../../../allstarr/Services/Common/RedisPersistenceService.cs), `.env`, `/app/cache` | Redis/Valkey, cache snapshots, JSON files, and `.env` hold current cache, mapping, session, extension, and configuration state. They are not a transactional job/account store. | **Retire legacy durable ownership; keep only cache-compatible seams where still needed.** | The overhaul uses a fresh database and configuration, so legacy cache and runtime files are not backfilled. Never silently infer SQLite or treat Redis/Valkey snapshots as the durable outbox. | | Hosted maintenance and background work: [Program.cs](../../../allstarr/Program.cs), [Services/Common](../../../allstarr/Services/Common), [Services/Spotify](../../../allstarr/Services/Spotify), [Core](../../../allstarr/Core) | Startup validation, cache warming/cleanup/persistence, Spotify compatibility fetch/match, durable jobs/outbox, favorite actions, library refresh, provider health, and sidecar monitoring run in process. Automatic pre-overhaul environment, favorite, mapping, and version-state migration services were retired. Optional provider startup probes are off by default and live validators do not run in `Testing`. | **Keep mutations leased, durable, scoped, and idempotent.** Keep the fresh-install boundary instead of adding startup imports. | Host and worker tests use temporary state and fake HTTP so CI never contacts live providers or backend libraries. | | WebUI and schema: [wwwroot](../../../allstarr/wwwroot), [AdminUiSchema.cs](../../../allstarr/Models/Admin/AdminUiSchema.cs), [CurrentProviderSupportCatalog.cs](../../../allstarr/Services/Common/CurrentProviderSupportCatalog.cs) | The Lit UI consumes an admin schema and shows the current provider support matrix, distinct configuration/observed-health states, compact support chips, responsive grids/tables, and keyboard-operable mobile navigation. | **Refactor in place.** Keep current workflows; add sections only with durable APIs and complete states. | `JavaScriptSyntaxTests`, `CurrentProviderSupportCatalogTests`, and `WebUiResponsiveContractTests` are static regression anchors. Browser functional, focus, narrow-screen, and accessibility validation remain release checks. | -| Compose and optional services: [docker-compose.yml](../../../docker-compose.yml), [docker-compose.aio.yml](../../../docker-compose.aio.yml) | The fresh standard stack is core/Postgres/Valkey. AIO mounts only verified first-party packages. Apple downloads require an operator-owned compatible gateway configured by URL. | **Keep the shipped stack small.** Do not vendor or publish the Apple gateway from this repository. Optional endpoints need an explicit contract, health gate, and resource boundary. | Compose contract tests validate Standard and AIO. External-provider contract tests validate gateway behavior without a live account. Never hide a storage-provider change in an override. | +| Compose and optional services: [docker-compose.yml](../../../docker-compose.yml), [docker-compose.aio.yml](../../../docker-compose.aio.yml), [docker-compose.apple.yml](../../../docker-compose.apple.yml) | The fresh standard stack is core/Postgres/Valkey. AIO mounts only verified first-party packages. Apple remains an explicit source-locked profile requiring operator-supplied legal libraries. | **Keep the default stack small and optional profiles turnkey.** Optional endpoints need an explicit contract, health gate, and resource boundary. | Compose and gateway contract tests validate the profiles without a live account. Never hide a storage-provider change in an override. | | Test and fixture infrastructure: [allstarr.Tests](../../../allstarr.Tests), [Fixtures/Protocols](../../../allstarr.Tests/Fixtures/Protocols) | xUnit covers current helpers, services, controllers, real-host composition, protocol source locks/support matrix, route/auth fixtures, provider status, extensions, redaction, file safety, and UI contracts. | **Keep and expand.** Fixtures are compatibility inputs, not generated promises. | `dotnet test allstarr.sln` is the minimum gate. No automated test may require a live provider account, backend library, or provider traffic. | ## Source And Fixture Anchors diff --git a/docs/steering/references/providers-and-extensions.md b/docs/steering/references/providers-and-extensions.md index e5037195..455a31eb 100644 --- a/docs/steering/references/providers-and-extensions.md +++ b/docs/steering/references/providers-and-extensions.md @@ -247,9 +247,9 @@ Uninstall requires explicit confirmation. It disables the extension first, remov Apple is represented by two separate provider instances: -- `apple-download`: download and optional stream behavior exposed by a separately deployed compatible provider - gateway. A GAMDL-backed gateway can create managed song, music-video, synced-lyrics, cover-art, and rich-tagging - artifacts where its account, source, codec, and advertised contract allow it. +- `apple-download`: download and download-backed stream behavior exposed by the optional repository gateway. The + typed lane currently accepts verified song audio only; broader GAMDL outputs remain unavailable until their + managed-artifact contracts and tests exist. - `apple-musickit`: per-user MusicKit API access to personal-library playlists and playlist items, library songs/albums/artists, and documented library or favorite-state actions. References: @@ -270,9 +270,9 @@ Implementation notes: - `apple-musickit` is normally per-user because playlists, library songs, and liked/library state belong to the signed-in Apple Music user. - gamdl supports catalog and library song, album, playlist, artist, and music-video URLs. Its download provider should preserve the media kind, chosen codec/quality, source URL identity, and output artifact metadata in the managed-download record. - gamdl can output synced lyrics and rich tags with a managed download. Ingest that output after validation; expose it through the lyrics lane only when format, ownership, and routing policy permit. This is not a claim of generic on-demand lyric availability for every Apple track. -- Allstarr does not ship the gateway. The configured URL must point to an Allstarr-compatible HTTP gateway, not - directly to wrapper-v2. wrapper-v2 has account, playback, and decryption endpoints but is not a GAMDL search and - download gateway by itself. +- The optional profile ships the HTTP gateway but not Apple libraries. Its configured URL points to that gateway, + never directly to wrapper-v2. wrapper-v2 has account, playback, and decryption endpoints but is not a GAMDL + search and download gateway by itself. - The gateway login UI must call its status endpoint before showing configured and must surface pending 2FA. - If the gateway has session files but no usable token, show Needs Config. - Apple lyric availability exists in public APIs, but full lyric retrieval should be optional sidecar or extension behavior. @@ -288,26 +288,26 @@ upstream GAMDL support from a gateway route that Allstarr has actually implement | Rich tags, artwork, and downloaded-file metadata | `apple-download` artifact metadata plus enrichment | Upstream capability; preserve only verified output fields. | Artifact parser/validation and metadata-merge tests. | | Synced LRC/SRT/TTML lyrics | Download artifact, then optional `ILyricsProvider` source | Not advertised without a compatible artifact route. | Explicit gateway output, format/ownership validation, and lyrics response tests. | | Music videos up to available upstream quality | Download media of kind `music-video`; protocol exposure only where the selected backend/client supports it | No verified gateway video contract yet. | Media-kind contract, video route/job, codec/container validation, and protocol fixtures. | -| Wrapper-backed playback/decryption and high-quality codecs | `apple-download` stream/download policy | External gateway contract required. | Account-scoped health, codec fallback, and restart/recovery tests. | +| Wrapper-backed playback/decryption and high-quality codecs | `apple-download` stream/download policy | Included gateway uses the raw TCP wrapper protocol and advertises verified codecs. | Live account checks remain manual, opt-in, and redacted. | Every provider descriptor must maintain the same kind of feature-coverage record: upstream capability, Allstarr capability mapping, current implementation state, configuration/account prerequisites, protocol limitations, and contract-test location. A feature may be marked deferred or unsupported only with a visible reason; it must never be silently omitted or advertised before it works. -GAMDL 3.8.2 introduced compatibility with wrapper-v2 0.0.2 and its raw TCP decrypt protocol. A compatible -external gateway should use GAMDL 3.8.2 or newer with wrapper-v2 0.0.2, or the exact pair required by its runtime -manifest. Treat them as one compatibility pair. Allstarr's repository does not vendor, build, publish, or update -that pair. +GAMDL 3.8.2 introduced compatibility with wrapper-v2 0.0.2 and its raw TCP decrypt protocol. The optional profile +locks that exact pair and passes wrapper HTTP and TCP endpoints separately. Treat them as one compatibility pair. +The repository never contains or downloads Apple's native libraries. -### External Gateway Update Policy +### Apple Profile Update Policy -The gateway operator owns upstream selection, image provenance, session compatibility, upgrade testing, and -rollback. Allstarr owns only its endpoint contract and capability probe. +The source lock owns upstream selection. An update changes GAMDL and wrapper-v2 together, then passes gateway, +Compose, architecture, and fake-account contract tests before release. The persistent wrapper session volume is +not replaced during a normal update. -- Configure only an explicit gateway URL. Never discover or pull provider code during Allstarr startup. +- Never discover or pull provider code during Allstarr startup. Preparation is an explicit operator command. - Probe the API version, authentication state, health, and capability manifest before activation and after a URL change. - Use fake-gateway contract tests in CI. Automated tests must not require a live Apple account. - Replace or remove a gateway without deleting provider records, Allstarr-managed media, or MusicKit accounts. -- Follow [the external Apple download runbook](../../operations/apple-download-provider.md) for setup and cutover. +- Follow [the Apple download runbook](../../operations/apple-download-provider.md) for setup and cutover. ## Other Built-In Providers diff --git a/docs/steering/references/runtime-and-compose.md b/docs/steering/references/runtime-and-compose.md index c8c7f158..88270885 100644 --- a/docs/steering/references/runtime-and-compose.md +++ b/docs/steering/references/runtime-and-compose.md @@ -115,8 +115,9 @@ Possible later repository profiles remain separate work: - `docker-compose.lyrics.yml`: lyrics sidecars only. - `docker-compose.lowram.yml`: lower memory settings and fewer services. -Allstarr does not ship or maintain a GAMDL Compose overlay. An operator may run a compatible Apple provider -gateway in another stack and configure its URL. Standard and AIO remain complete without it. +`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. 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. diff --git a/sidecars/apple-gateway/Dockerfile b/sidecars/apple-gateway/Dockerfile new file mode 100644 index 00000000..c69a668f --- /dev/null +++ b/sidecars/apple-gateway/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + APPLE_GATEWAY_DATA_ROOT=/data \ + PATH=/app/.venv/bin:$PATH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin gateway + +WORKDIR /app +COPY pyproject.toml uv.lock README.md /app/ +COPY apple_gateway /app/apple_gateway +RUN pip install --no-cache-dir uv==0.11.29 \ + && uv sync --frozen --no-dev --no-editable \ + && pip uninstall -y uv + +RUN install -d -o gateway -g gateway /data +USER gateway +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)" +CMD ["uvicorn", "apple_gateway.app:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/sidecars/apple-gateway/README.md b/sidecars/apple-gateway/README.md new file mode 100644 index 00000000..32403dc5 --- /dev/null +++ b/sidecars/apple-gateway/README.md @@ -0,0 +1,54 @@ +# Allstarr Apple download gateway + +This optional service implements Allstarr's Apple download gateway API. It installs the official `gamdl` 3.8.2 +Python package and calls its documented CLI. It does not contain wrapper-v2 source, Apple native libraries, +credentials, or session data. + +The optional root Compose profile builds the locked official wrapper-v2 0.0.2 source beside this gateway and puts +both on Allstarr's private network. Do not expose the gateway or wrapper login endpoints to the public Internet. + +## Runtime contract + +- `GET /api/capabilities` +- `GET /api/health` +- `GET /api/me` +- `POST /api/login` +- `POST /api/login/2fa` +- `GET /api/search?q=...&type=song&limit=...` +- `GET /api/song/{songId}` +- `GET /api/download/{songId}?quality=...` +- `GET /api/stream/{songId}?quality=...` +- `POST /api/jobs/download` for supported Apple catalog or library URLs +- `GET /api/jobs/download/{jobId}` + +Song downloads return a FLAC artifact because that is the current Allstarr managed-song contract. Generic jobs +preserve GAMDL's own media, artwork, tag, and synced-lyrics outputs in the gateway data volume. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `APPLE_GATEWAY_WRAPPER_URL` | `http://wrapper-v2` | Separate wrapper-v2 origin | +| `APPLE_GATEWAY_WRAPPER_DECRYPT_HOST` | `wrapper-v2` | Wrapper-v2 raw decrypt host reachable from this container | +| `APPLE_GATEWAY_WRAPPER_DECRYPT_PORT` | `10020` | Wrapper-v2 raw decrypt port reachable from this container | +| `APPLE_GATEWAY_STOREFRONT` | `us` | Two-letter Apple storefront | +| `APPLE_GATEWAY_DATA_ROOT` | `/data` | Persistent artifact and temporary root | +| `APPLE_GATEWAY_COOKIES_PATH` | empty | Optional cookies file used by GAMDL | +| `APPLE_GATEWAY_MAX_CONCURRENCY` | `2` | Shared GAMDL/FFmpeg subprocess limit | +| `APPLE_GATEWAY_PROCESS_TIMEOUT_SECONDS` | `900` | Hard subprocess deadline | +| `APPLE_GATEWAY_WRAPPER_TIMEOUT_SECONDS` | `45` | Wrapper request deadline | +| `APPLE_GATEWAY_MAX_PROCESS_OUTPUT_BYTES` | `32768` | Maximum retained stdout and stderr per process | + +Prepare wrapper-v2 with the operator's legal APK/APKM, then use the saved Apple profile: + +```bash +./allstarr.sh prepare-apple /private/path/apple-music.apkm x86_64 +./allstarr.sh up +``` + +The root overlay configures `http://apple-gateway:8000` internally. Removing the profile does not change +Allstarr's Postgres state, existing media, gateway state, or wrapper login session. + +The supported GAMDL URL kinds are songs, albums, playlists, artists, music videos, posts, and Apple library URLs. +The gateway passes arguments as an exec array, never through a shell. It rejects non-Apple hosts, URL credentials, +fragments, unsafe artifact paths, symlinks, redirects from wrapper-v2, and unbounded process output. diff --git a/sidecars/apple-gateway/apple_gateway/__init__.py b/sidecars/apple-gateway/apple_gateway/__init__.py new file mode 100644 index 00000000..8787f631 --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/__init__.py @@ -0,0 +1 @@ +"""Allstarr's optional external Apple download gateway.""" diff --git a/sidecars/apple-gateway/apple_gateway/app.py b/sidecars/apple-gateway/apple_gateway/app.py new file mode 100644 index 00000000..328067da --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/app.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import importlib.metadata +import shutil +import uuid +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse +from starlette.background import BackgroundTask + +from .catalog import CatalogClient +from .config import Settings +from .jobs import DownloadJobManager +from .models import DownloadJobRequest, DownloadJobView, Login2faRequest, LoginRequest +from .runner import BoundedProcessRunner, ProcessFailure +from .security import safe_files, song_url +from .wrapper import WrapperClient, WrapperResponse + +API_VERSION = "1.0.0" +CAPABILITIES = ( + "metadata-search-song", + "metadata-song", + "stream-audio-song", + "download-audio-song", + "codec-alac", + "codec-aac", +) + + +def _version(distribution: str) -> str: + try: + return importlib.metadata.version(distribution) + except importlib.metadata.PackageNotFoundError: + return "unavailable" + + +def _authenticated(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + if payload.get("logged_in") is True or payload.get("authenticated") is True: + return True + auth = payload.get("auth") + return isinstance(auth, dict) and ( + auth.get("logged_in") is True + or str(auth.get("state", "")).lower() in {"authenticated", "logged_in", "ready"} + ) + + +def _codec(quality: str) -> str: + mapping = { + "alac": "alac", + "alac-16-44": "alac", + "alac-24-96": "alac", + "alac-24-192": "alac", + "aac": "aac", + "aac-320": "aac", + "aac-web": "aac-web", + "aac-96": "aac-he", + "aac-he": "aac-he", + "aac-he-web": "aac-he-web", + } + try: + return mapping[quality.lower()] + except KeyError as exc: + raise HTTPException(status_code=400, detail="unsupported_quality") from exc + + +def _forward(response: WrapperResponse) -> JSONResponse: + return JSONResponse(status_code=response.status_code, content=response.payload) + + +def create_app( + settings: Settings | None = None, + wrapper: WrapperClient | None = None, + catalog: CatalogClient | None = None, + runner: BoundedProcessRunner | None = None, +) -> FastAPI: + config = settings or Settings.from_env() + wrapper_client = wrapper or WrapperClient(config.wrapper_url, config.wrapper_timeout_seconds) + catalog_client = catalog or CatalogClient(config.storefront) + process_runner = runner or BoundedProcessRunner(config) + jobs = DownloadJobManager(process_runner, config.data_root) + + @asynccontextmanager + async def lifespan(_: FastAPI): + config.prepare() + yield + await jobs.close() + await wrapper_client.close() + await catalog_client.close() + + application = FastAPI( + title="Allstarr Apple download gateway", + version=API_VERSION, + docs_url=None, + redoc_url=None, + lifespan=lifespan, + ) + + @application.get("/api/capabilities") + async def capabilities() -> dict[str, Any]: + return { + "sidecarApiVersion": API_VERSION, + "runtime": {"gateway": API_VERSION, "gamdl": _version("gamdl")}, + "capabilities": [{"id": item, "state": "supported"} for item in CAPABILITIES], + } + + @application.get("/api/health") + async def health() -> dict[str, Any]: + wrapper_health = await wrapper_client.health() + me = await wrapper_client.me() if wrapper_health.status_code == 200 else WrapperResponse(503, {}) + wrapper_payload = wrapper_health.payload if isinstance(wrapper_health.payload, dict) else {} + wrapper_version = str(wrapper_payload.get("version") or "unknown") + executable = shutil.which(config.gamdl_path) is not None + return { + "status": "ok" if executable and wrapper_health.status_code == 200 else "degraded", + "staged": executable, + "daemon_running": wrapper_health.status_code == 200, + "wrapper_healthy": wrapper_health.status_code == 200, + "logged_in": _authenticated(me.payload), + "versions": {"gateway": API_VERSION, "gamdl": _version("gamdl"), "wrapper": wrapper_version}, + } + + @application.get("/api/me") + async def me() -> JSONResponse: + return _forward(await wrapper_client.me()) + + @application.post("/api/login") + async def login(request: LoginRequest) -> JSONResponse: + return _forward(await wrapper_client.login(request.username, request.password)) + + @application.post("/api/login/2fa") + async def login_2fa(request: Login2faRequest) -> JSONResponse: + return _forward(await wrapper_client.login_2fa(request.code)) + + @application.get("/api/search") + async def search( + q: str = Query(min_length=1, max_length=500), + type: str = Query(default="song", pattern="^song$"), + limit: int = Query(default=20, ge=1, le=100), + ) -> list[dict[str, Any]]: + del type + try: + return await catalog_client.search_songs(q, limit) + except (httpx.HTTPError, ValueError): + raise HTTPException(status_code=502, detail="catalog_unavailable") from None + + @application.get("/api/song/{song_id}") + async def song(song_id: str) -> dict[str, Any]: + try: + result = await catalog_client.song(song_id) + except (httpx.HTTPError, ValueError): + raise HTTPException(status_code=502, detail="catalog_unavailable") from None + if result is None: + raise HTTPException(status_code=404, detail="song_not_found") + return result + + @application.get("/api/download/{song_id}") + @application.get("/api/stream/{song_id}") + async def download_song(song_id: str, quality: str = "alac-16-44") -> FileResponse: + try: + url = song_url(config.storefront, song_id) + except ValueError: + raise HTTPException(status_code=400, detail="invalid_song_id") from None + request_id = uuid.uuid4().hex + root = config.data_root / "artifacts" / request_id + try: + artifacts = await process_runner.download(url, _codec(quality), root / "output", root / "temporary") + audio = safe_files(root / "output", {".m4a", ".flac"}) + if not audio: + raise ProcessFailure("audio_artifact_missing") + artifact = await process_runner.to_flac(audio[0], root / f"{song_id}.flac") + except ProcessFailure as exc: + shutil.rmtree(root, ignore_errors=True) + status = 504 if exc.code == "process_timeout" else 502 + raise HTTPException(status_code=status, detail=exc.code) from None + except Exception: + shutil.rmtree(root, ignore_errors=True) + raise HTTPException(status_code=502, detail="download_failed") from None + return FileResponse( + artifact, + media_type="audio/flac", + filename=f"{song_id}.flac", + background=BackgroundTask(shutil.rmtree, root, ignore_errors=True), + ) + + @application.post("/api/jobs/download", status_code=202, response_model=DownloadJobView) + async def enqueue_download(request: DownloadJobRequest) -> DownloadJobView: + try: + return jobs.enqueue(request.url, _codec(request.quality)) + except ValueError: + raise HTTPException(status_code=400, detail="unsupported_apple_music_url") from None + + @application.get("/api/jobs/download/{job_id}", response_model=DownloadJobView) + async def get_download_job(job_id: str) -> DownloadJobView: + job = jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job_not_found") + return job + + return application + + +app = create_app() diff --git a/sidecars/apple-gateway/apple_gateway/catalog.py b/sidecars/apple-gateway/apple_gateway/catalog.py new file mode 100644 index 00000000..66099009 --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/catalog.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +import httpx + + +class CatalogClient: + """Small metadata seam backed by Apple's public iTunes Search API.""" + + def __init__(self, storefront: str, client: httpx.AsyncClient | None = None): + self._storefront = storefront.upper() + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + base_url="https://itunes.apple.com/", + timeout=httpx.Timeout(10), + follow_redirects=False, + trust_env=False, + ) + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def search_songs(self, query: str, limit: int) -> list[dict[str, Any]]: + response = await self._client.get("search", params={ + "term": query, + "country": self._storefront, + "media": "music", + "entity": "song", + "limit": limit, + }) + response.raise_for_status() + return [self._map(item) for item in response.json().get("results", []) if item.get("trackId")] + + async def song(self, song_id: str) -> dict[str, Any] | None: + response = await self._client.get("lookup", params={"id": song_id, "country": self._storefront}) + response.raise_for_status() + item = next((item for item in response.json().get("results", []) if item.get("trackId")), None) + return self._map(item) if item else None + + @staticmethod + def _map(item: dict[str, Any]) -> dict[str, Any]: + artwork = str(item.get("artworkUrl100") or "").replace("100x100", "1200x1200") + release = item.get("releaseDate") + return { + "id": str(item["trackId"]), + "title": str(item.get("trackName") or "Unknown title"), + "artist": str(item.get("artistName") or "Unknown artist"), + "album": str(item.get("collectionName") or "Unknown album"), + "duration": max(0, int(item.get("trackTimeMillis") or 0) // 1000), + "cover_url": artwork, + "track_number": item.get("trackNumber"), + "disc_number": item.get("discNumber"), + "isrc": item.get("isrc"), + "release_date": release[:10] if isinstance(release, str) and len(release) >= 10 else None, + "copyright": item.get("copyright"), + "composer": None, + "genre": item.get("primaryGenreName"), + } diff --git a/sidecars/apple-gateway/apple_gateway/config.py b/sidecars/apple-gateway/apple_gateway/config.py new file mode 100644 index 00000000..7a880ea5 --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/config.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + + +def _positive_int(name: str, default: int, minimum: int, maximum: int) -> int: + raw = os.getenv(name, str(default)) + try: + value = int(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + if not minimum <= value <= maximum: + raise RuntimeError(f"{name} must be between {minimum} and {maximum}") + return value + + +def _http_url(name: str, default: str) -> str: + value = os.getenv(name, default).rstrip("/") + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: + raise RuntimeError(f"{name} must be an HTTP(S) origin without credentials") + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise RuntimeError(f"{name} must not contain a path, query, or fragment") + return value + + +def _host(name: str, default: str) -> str: + value = os.getenv(name, default).strip() + if not value or any(character in value for character in "/:@?#[]"): + raise RuntimeError(f"{name} must be a hostname or address without a port") + return value + + +@dataclass(frozen=True, slots=True) +class Settings: + wrapper_url: str + wrapper_decrypt_host: str + wrapper_decrypt_port: int + data_root: Path + cookies_path: Path | None + storefront: str + gamdl_path: str + ffmpeg_path: str + subprocess_timeout_seconds: int + wrapper_timeout_seconds: int + max_concurrency: int + max_process_output_bytes: int + + @classmethod + def from_env(cls) -> "Settings": + root = Path(os.getenv("APPLE_GATEWAY_DATA_ROOT", "/data")).expanduser().resolve() + cookies = os.getenv("APPLE_GATEWAY_COOKIES_PATH", "").strip() + storefront = os.getenv("APPLE_GATEWAY_STOREFRONT", "us").strip().lower() + if len(storefront) != 2 or not storefront.isascii() or not storefront.isalpha(): + raise RuntimeError("APPLE_GATEWAY_STOREFRONT must be a two-letter storefront") + return cls( + wrapper_url=_http_url("APPLE_GATEWAY_WRAPPER_URL", "http://wrapper-v2"), + wrapper_decrypt_host=_host("APPLE_GATEWAY_WRAPPER_DECRYPT_HOST", "wrapper-v2"), + wrapper_decrypt_port=_positive_int("APPLE_GATEWAY_WRAPPER_DECRYPT_PORT", 10020, 1, 65535), + data_root=root, + cookies_path=Path(cookies).expanduser().resolve() if cookies else None, + storefront=storefront, + gamdl_path=os.getenv("APPLE_GATEWAY_GAMDL_PATH", "gamdl"), + ffmpeg_path=os.getenv("APPLE_GATEWAY_FFMPEG_PATH", "ffmpeg"), + subprocess_timeout_seconds=_positive_int("APPLE_GATEWAY_PROCESS_TIMEOUT_SECONDS", 900, 10, 7200), + wrapper_timeout_seconds=_positive_int("APPLE_GATEWAY_WRAPPER_TIMEOUT_SECONDS", 45, 2, 300), + max_concurrency=_positive_int("APPLE_GATEWAY_MAX_CONCURRENCY", 2, 1, 8), + max_process_output_bytes=_positive_int("APPLE_GATEWAY_MAX_PROCESS_OUTPUT_BYTES", 32768, 1024, 1048576), + ) + + def prepare(self) -> None: + self.data_root.mkdir(parents=True, exist_ok=True, mode=0o750) + for child in ("artifacts", "jobs", "temporary"): + (self.data_root / child).mkdir(exist_ok=True, mode=0o750) diff --git a/sidecars/apple-gateway/apple_gateway/jobs.py b/sidecars/apple-gateway/apple_gateway/jobs.py new file mode 100644 index 00000000..2b9cf42a --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/jobs.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import dataclass, field +from pathlib import Path + +from .models import DownloadJobView +from .runner import BoundedProcessRunner, ProcessFailure +from .security import safe_apple_url + + +@dataclass(slots=True) +class JobState: + id: str + media_kind: str + state: str = "queued" + artifacts: list[Path] = field(default_factory=list) + error_code: str | None = None + + def view(self) -> DownloadJobView: + return DownloadJobView( + id=self.id, + state=self.state, + media_kind=self.media_kind, + artifact_count=len(self.artifacts), + error_code=self.error_code, + ) + + +class DownloadJobManager: + def __init__(self, runner: BoundedProcessRunner, data_root: Path): + self._runner = runner + self._data_root = data_root + self._jobs: dict[str, JobState] = {} + self._tasks: set[asyncio.Task[None]] = set() + + 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 + task = asyncio.create_task(self._run(job, url, quality)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return job.view() + + def get(self, job_id: str) -> DownloadJobView | None: + job = self._jobs.get(job_id) + return job.view() if job else None + + async def close(self) -> None: + if not self._tasks: + return + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + + async def _run(self, job: JobState, url: str, quality: str) -> None: + job.state = "running" + root = self._data_root / "jobs" / job.id + try: + job.artifacts = await self._runner.download(url, quality, root / "artifacts", root / "temporary") + job.state = "succeeded" + except asyncio.CancelledError: + job.state = "failed" + job.error_code = "gateway_stopping" + raise + except ProcessFailure as exc: + job.state = "failed" + job.error_code = exc.code + except Exception: + job.state = "failed" + job.error_code = "download_failed" diff --git a/sidecars/apple-gateway/apple_gateway/models.py b/sidecars/apple-gateway/apple_gateway/models.py new file mode 100644 index 00000000..ba3095bd --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/models.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class LoginRequest(StrictModel): + username: str = Field(min_length=1, max_length=320) + password: str = Field(min_length=1, max_length=1024) + + +class Login2faRequest(StrictModel): + code: str = Field(pattern=r"^[0-9]{4,8}$") + + +class DownloadJobRequest(StrictModel): + url: str = Field(min_length=1, max_length=2048) + quality: str = Field(default="alac", pattern=r"^(alac|aac|aac-web|aac-he|aac-he-web)$") + + +class DownloadJobView(StrictModel): + id: str + state: Literal["queued", "running", "succeeded", "failed"] + media_kind: str + artifact_count: int = 0 + error_code: str | None = None diff --git a/sidecars/apple-gateway/apple_gateway/runner.py b/sidecars/apple-gateway/apple_gateway/runner.py new file mode 100644 index 00000000..9abf9e0c --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/runner.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +import os +import signal +from dataclasses import dataclass +from pathlib import Path + +from .config import Settings +from .security import safe_files + + +@dataclass(frozen=True, slots=True) +class ProcessResult: + return_code: int + stdout: str + stderr: str + + +class ProcessFailure(RuntimeError): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +class BoundedProcessRunner: + def __init__(self, settings: Settings): + self._settings = settings + self._semaphore = asyncio.Semaphore(settings.max_concurrency) + + async def execute(self, argv: list[str], cwd: Path) -> ProcessResult: + async with self._semaphore: + process = await asyncio.create_subprocess_exec( + *argv, + cwd=cwd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + stdout_task = asyncio.create_task(self._read_limited(process.stdout)) + stderr_task = asyncio.create_task(self._read_limited(process.stderr)) + try: + await asyncio.wait_for(process.wait(), timeout=self._settings.subprocess_timeout_seconds) + except TimeoutError as exc: + os.killpg(process.pid, signal.SIGKILL) + await process.wait() + await asyncio.gather(stdout_task, stderr_task) + raise ProcessFailure("process_timeout") from exc + stdout, stderr = await asyncio.gather(stdout_task, stderr_task) + return ProcessResult(process.returncode or 0, stdout, stderr) + + async def _read_limited(self, stream: asyncio.StreamReader | None) -> str: + if stream is None: + return "" + retained = bytearray() + while chunk := await stream.read(8192): + remaining = self._settings.max_process_output_bytes - len(retained) + if remaining > 0: + retained.extend(chunk[:remaining]) + return retained.decode("utf-8", errors="replace") + + async def download(self, url: str, quality: str, output: Path, temporary: Path) -> list[Path]: + output.mkdir(parents=True, exist_ok=False, mode=0o750) + temporary.mkdir(parents=True, exist_ok=False, mode=0o750) + argv = [ + self._settings.gamdl_path, + "--no-config-file", + "--no-exceptions", + "--use-wrapper", + "--wrapper-url", self._settings.wrapper_url, + "--wrapper-decrypt-host", self._settings.wrapper_decrypt_host, + "--wrapper-decrypt-port", str(self._settings.wrapper_decrypt_port), + "--output-path", str(output), + "--temp-path", str(temporary), + "--song-codec-priority", quality, + "--artist-auto-select", "all-albums", + ] + if self._settings.cookies_path: + argv.extend(["--cookies-path", str(self._settings.cookies_path)]) + argv.append(url) + result = await self.execute(argv, temporary) + if result.return_code != 0: + raise ProcessFailure("gamdl_failed") + artifacts = safe_files(output, {".m4a", ".flac", ".mp4", ".m4v", ".lrc", ".srt", ".ttml", ".jpg", ".png"}) + if not artifacts: + raise ProcessFailure("artifact_missing") + return artifacts + + async def to_flac(self, source: Path, target: Path) -> Path: + if source.suffix.lower() == ".flac": + return source + result = await self.execute([ + self._settings.ffmpeg_path, "-nostdin", "-v", "error", "-y", + "-i", str(source), "-map", "0:a:0", "-c:a", "flac", str(target), + ], target.parent) + if result.return_code != 0 or not target.is_file(): + raise ProcessFailure("transcode_failed") + return target.resolve() diff --git a/sidecars/apple-gateway/apple_gateway/security.py b/sidecars/apple-gateway/apple_gateway/security.py new file mode 100644 index 00000000..c21cb565 --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/security.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + + +SECRET_KEY = re.compile(r"(authorization|cookie|password|secret|session|token)", re.IGNORECASE) +APPLE_ID = re.compile(r"^[0-9]{1,24}$") +SUPPORTED_APPLE_PATH = re.compile( + r"^/(?:[a-z]{2}/)?(?:album|song|playlist|artist|music-video|post|library)(?:/|$)", + re.IGNORECASE, +) + + +def sanitize_json(value: Any) -> Any: + if isinstance(value, dict): + return {key: sanitize_json(item) for key, item in value.items() if not SECRET_KEY.search(str(key))} + if isinstance(value, list): + return [sanitize_json(item) for item in value] + return value + + +def safe_apple_url(raw: str) -> tuple[str, str]: + parsed = urlsplit(raw.strip()) + if ( + parsed.scheme != "https" + or parsed.hostname != "music.apple.com" + or parsed.username + or parsed.password + or parsed.fragment + or not SUPPORTED_APPLE_PATH.match(parsed.path) + ): + raise ValueError("unsupported_apple_music_url") + sanitized = urlunsplit(("https", "music.apple.com", parsed.path, parsed.query, "")) + segment = next((part for part in parsed.path.lower().split("/") if part in { + "album", "song", "playlist", "artist", "music-video", "post", "library" + }), "catalog") + return sanitized, segment + + +def song_url(storefront: str, song_id: str) -> str: + if not APPLE_ID.fullmatch(song_id): + raise ValueError("invalid_song_id") + return f"https://music.apple.com/{storefront}/song/{song_id}" + + +def safe_files(root: Path, suffixes: set[str]) -> list[Path]: + resolved_root = root.resolve() + result: list[Path] = [] + for candidate in root.rglob("*"): + if candidate.is_symlink() or not candidate.is_file() or candidate.suffix.lower() not in suffixes: + continue + resolved = candidate.resolve() + if not resolved.is_relative_to(resolved_root): + continue + result.append(resolved) + return sorted(result, key=lambda item: item.as_posix()) diff --git a/sidecars/apple-gateway/apple_gateway/wrapper.py b/sidecars/apple-gateway/apple_gateway/wrapper.py new file mode 100644 index 00000000..d0540a40 --- /dev/null +++ b/sidecars/apple-gateway/apple_gateway/wrapper.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from .security import sanitize_json + + +class WrapperResponse: + def __init__(self, status_code: int, payload: Any): + self.status_code = status_code + self.payload = payload + + +class WrapperClient: + def __init__(self, base_url: str, timeout_seconds: int, client: httpx.AsyncClient | None = None): + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + base_url=f"{base_url}/", + timeout=httpx.Timeout(timeout_seconds), + follow_redirects=False, + trust_env=False, + ) + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def request(self, method: str, path: str, payload: dict[str, str] | None = None) -> WrapperResponse: + try: + response = await self._client.request(method, path.lstrip("/"), json=payload) + except (httpx.TimeoutException, httpx.NetworkError): + return WrapperResponse(503, {"error": "wrapper_unavailable"}) + if response.is_redirect: + return WrapperResponse(502, {"error": "wrapper_redirect_rejected"}) + try: + body: Any = response.json() + except ValueError: + body = {"error": "invalid_wrapper_response"} + return WrapperResponse(response.status_code, sanitize_json(body)) + + async def health(self) -> WrapperResponse: + return await self.request("GET", "health") + + async def me(self) -> WrapperResponse: + return await self.request("GET", "me") + + async def login(self, username: str, password: str) -> WrapperResponse: + return await self.request("POST", "login", {"username": username, "password": password}) + + async def login_2fa(self, code: str) -> WrapperResponse: + return await self.request("POST", "login/2fa", {"code": code}) diff --git a/sidecars/apple-gateway/pyproject.toml b/sidecars/apple-gateway/pyproject.toml new file mode 100644 index 00000000..2e03e6f0 --- /dev/null +++ b/sidecars/apple-gateway/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools==80.9.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "allstarr-apple-gateway" +version = "1.0.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi==0.116.1", + "gamdl==3.8.2", + "httpx==0.28.1", + "pydantic==2.11.7", + "uvicorn==0.35.0", +] + +[project.optional-dependencies] +test = [ + "pytest==8.4.1", + "pytest-asyncio==1.1.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.setuptools.packages.find] +include = ["apple_gateway*"] diff --git a/sidecars/apple-gateway/tests/test_gateway.py b/sidecars/apple-gateway/tests/test_gateway.py new file mode 100644 index 00000000..616ad615 --- /dev/null +++ b/sidecars/apple-gateway/tests/test_gateway.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi.testclient import TestClient + +from apple_gateway.app import API_VERSION, create_app +from apple_gateway.catalog import CatalogClient +from apple_gateway.config import Settings +from apple_gateway.runner import BoundedProcessRunner, ProcessFailure +from apple_gateway.wrapper import WrapperClient, WrapperResponse + + +class FakeWrapper: + def __init__(self, logged_in: bool = True): + self.logged_in = logged_in + self.login_payload: tuple[str, str] | None = None + + async def close(self) -> None: + return None + + async def health(self) -> WrapperResponse: + return WrapperResponse(200, {"status": "ok", "version": "0.0.2"}) + + async def me(self) -> WrapperResponse: + return WrapperResponse(200, {"auth": {"state": "authenticated" if self.logged_in else "logged_out"}}) + + async def login(self, username: str, password: str) -> WrapperResponse: + self.login_payload = (username, password) + return WrapperResponse(202, {"auth": {"state": "awaiting_2fa"}}) + + async def login_2fa(self, code: str) -> WrapperResponse: + return WrapperResponse(200, {"auth": {"state": "authenticated"}}) + + +class FakeCatalog: + async def close(self) -> None: + return None + + async def search_songs(self, query: str, limit: int) -> list[dict[str, Any]]: + return [song("101", query)][:limit] + + async def song(self, song_id: str) -> dict[str, Any] | None: + return None if song_id == "404" else song(song_id, "Fixture") + + +class FakeRunner: + def __init__(self): + self.calls: list[tuple[str, str]] = [] + + async def download(self, url: str, quality: str, output: Path, temporary: Path) -> list[Path]: + self.calls.append((url, quality)) + output.mkdir(parents=True, exist_ok=False) + temporary.mkdir(parents=True, exist_ok=False) + artifact = output / "fixture.m4a" + artifact.write_bytes(b"source") + return [artifact] + + async def to_flac(self, source: Path, target: Path) -> Path: + target.write_bytes(b"fLaCfixture") + return target.resolve() + + +def song(song_id: str, title: str) -> dict[str, Any]: + return { + "id": song_id, + "title": title, + "artist": "Artist", + "album": "Album", + "duration": 123, + "cover_url": "https://example.test/art.jpg", + "track_number": 1, + "disc_number": 1, + "isrc": "USAAA0000001", + "release_date": "2026-01-01", + "copyright": None, + "composer": None, + "genre": "Pop", + } + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return Settings( + wrapper_url="http://wrapper-v2", + wrapper_decrypt_host="wrapper-v2", + wrapper_decrypt_port=18080, + data_root=tmp_path, + cookies_path=None, + storefront="us", + gamdl_path=sys.executable, + ffmpeg_path=sys.executable, + subprocess_timeout_seconds=2, + wrapper_timeout_seconds=2, + max_concurrency=1, + max_process_output_bytes=64, + ) + + +@pytest.fixture +def client(settings: Settings) -> tuple[TestClient, FakeWrapper, FakeRunner]: + wrapper = FakeWrapper() + runner = FakeRunner() + app = create_app(settings, wrapper, FakeCatalog(), runner) + with TestClient(app) as test_client: + yield test_client, wrapper, runner + + +def test_capabilities_are_versioned_and_truthful(client): + response = client[0].get("/api/capabilities") + assert response.status_code == 200 + assert response.json()["sidecarApiVersion"] == API_VERSION + ids = {item["id"] for item in response.json()["capabilities"]} + assert {"metadata-search-song", "metadata-song", "download-audio-song"} <= ids + + +def test_health_reports_wrapper_and_authentication(client): + payload = client[0].get("/api/health").json() + assert payload["wrapper_healthy"] is True + assert payload["logged_in"] is True + assert payload["versions"]["wrapper"] == "0.0.2" + + +def test_login_and_2fa_preserve_pending_status_without_returning_secrets(client): + response = client[0].post("/api/login", json={"username": "user@example.test", "password": "private"}) + assert response.status_code == 202 + assert "private" not in response.text + assert client[1].login_payload == ("user@example.test", "private") + assert client[0].post("/api/login/2fa", json={"code": "123456"}).status_code == 200 + + +def test_search_song_and_missing_song_contract(client): + search = client[0].get("/api/search", params={"q": "Needle", "type": "song", "limit": 2}) + assert search.status_code == 200 + assert search.json()[0]["title"] == "Needle" + assert client[0].get("/api/song/101").json()["id"] == "101" + assert client[0].get("/api/song/404").status_code == 404 + + +def test_song_download_uses_safe_id_quality_mapping_and_flac_contract(client): + response = client[0].get("/api/download/101", params={"quality": "alac-16-44"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("audio/flac") + assert response.content == b"fLaCfixture" + assert client[2].calls == [("https://music.apple.com/us/song/101", "alac")] + assert client[0].get("/api/stream/102", params={"quality": "aac-320"}).status_code == 200 + assert client[2].calls[-1] == ("https://music.apple.com/us/song/102", "aac") + assert client[0].get("/api/download/not-an-id").status_code == 400 + + +@pytest.mark.asyncio +async def test_gamdl_command_targets_separate_wrapper_decrypt_socket(settings: Settings, tmp_path: Path): + class CapturingRunner(BoundedProcessRunner): + def __init__(self, configured: Settings): + super().__init__(configured) + self.argv: list[str] = [] + + async def execute(self, argv: list[str], cwd: Path): + from apple_gateway.runner import ProcessResult + self.argv = argv + (tmp_path / "output" / "fixture.m4a").parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "output" / "fixture.m4a").write_bytes(b"fixture") + return ProcessResult(0, "", "") + + runner = CapturingRunner(settings) + await runner.download( + "https://music.apple.com/us/song/101", + "alac", + tmp_path / "output", + tmp_path / "temporary", + ) + host_index = runner.argv.index("--wrapper-decrypt-host") + port_index = runner.argv.index("--wrapper-decrypt-port") + assert runner.argv[host_index + 1] == "wrapper-v2" + assert runner.argv[port_index + 1] == "18080" + + +def test_generic_catalog_and_library_download_jobs_are_bounded_to_apple_urls(client): + accepted = client[0].post("/api/jobs/download", json={ + "url": "https://music.apple.com/us/album/fixture/123?i=101", + "quality": "aac-web", + }) + assert accepted.status_code == 202 + job_id = accepted.json()["id"] + for _ in range(30): + state = client[0].get(f"/api/jobs/download/{job_id}").json() + if state["state"] == "succeeded": + break + asyncio.run(asyncio.sleep(0.01)) + assert state["artifact_count"] == 1 + + library = client[0].post("/api/jobs/download", json={ + "url": "https://music.apple.com/library/playlist/p.ABC123", + "quality": "alac", + }) + assert library.status_code == 202 + assert client[0].post("/api/jobs/download", json={ + "url": "https://evil.example/album/123", + "quality": "alac", + }).status_code == 400 + + +@pytest.mark.asyncio +async def test_process_runner_caps_output_and_times_out(settings: Settings, tmp_path: Path): + runner = BoundedProcessRunner(settings) + result = await runner.execute([sys.executable, "-c", "print('x' * 1000)"], tmp_path) + assert len(result.stdout.encode()) == settings.max_process_output_bytes + + timeout_settings = replace(settings, subprocess_timeout_seconds=0.05) + timeout_runner = BoundedProcessRunner(timeout_settings) + with pytest.raises(ProcessFailure, match="process_timeout"): + await timeout_runner.execute([sys.executable, "-c", "import time; time.sleep(2)"], tmp_path) + + +@pytest.mark.asyncio +async def test_wrapper_client_rejects_redirects_and_redacts_nested_tokens(): + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/redirect": + return httpx.Response(302, headers={"location": "https://evil.example"}) + return httpx.Response(200, json={"auth": {"state": "authenticated", "token": "secret"}}) + + http_client = httpx.AsyncClient(base_url="http://wrapper/", transport=httpx.MockTransport(handler), follow_redirects=False) + wrapper = WrapperClient("http://wrapper", 2, http_client) + assert (await wrapper.request("GET", "redirect")).payload["error"] == "wrapper_redirect_rejected" + payload = (await wrapper.me()).payload + assert payload == {"auth": {"state": "authenticated"}} + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_catalog_mapping_is_deterministic(): + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{ + "trackId": 101, + "trackName": "Fixture", + "artistName": "Artist", + "collectionName": "Album", + "trackTimeMillis": 123900, + "artworkUrl100": "https://img/100x100.jpg", + "releaseDate": "2026-01-02T00:00:00Z", + }]}) + + http_client = httpx.AsyncClient(base_url="https://itunes.apple.com/", transport=httpx.MockTransport(handler)) + catalog = CatalogClient("us", http_client) + result = await catalog.search_songs("Fixture", 1) + assert result[0]["duration"] == 123 + assert result[0]["cover_url"] == "https://img/1200x1200.jpg" + await http_client.aclose() diff --git a/sidecars/apple-gateway/uv.lock b/sidecars/apple-gateway/uv.lock new file mode 100644 index 00000000..40e03477 --- /dev/null +++ b/sidecars/apple-gateway/uv.lock @@ -0,0 +1,986 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "allstarr-apple-gateway" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "gamdl" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.116.1" }, + { name = "gamdl", specifier = "==3.8.2" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "pydantic", specifier = "==2.11.7" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.1.0" }, + { name = "uvicorn", specifier = "==0.35.0" }, +] +provides-extras = ["test"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "construct" +version = "2.8.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2c/66bab4fef920ef8caa3e180ea601475b2cbbe196255b18f1c58215940607/construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157", size = 717694, upload-time = "2016-10-20T22:29:12.563Z" } + +[[package]] +name = "dataclass-click" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/82/5b6035efd90621771fa039960eab3e1ec7ff2a8625033272856843e8bd27/dataclass_click-1.0.4.tar.gz", hash = "sha256:10e7de638dd9e68ae9abd5086f61d8ddee42b1873a70f5fd9fd2167856afac11", size = 7580, upload-time = "2025-10-10T21:11:31.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/dc/38a94a2eb5f756724a6dc87a7aea38f7b747fe7b2e9daabc34a65e6cd9ac/dataclass_click-1.0.4-py3-none-any.whl", hash = "sha256:a225d30c04e4abbdba411cc3d5ec0a2ea829e1dca6500afe5f87cc243e5ead72", size = 8553, upload-time = "2025-10-10T21:11:30.514Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + +[[package]] +name = "gamdl" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "click" }, + { name = "colorama" }, + { name = "dataclass-click" }, + { name = "httpx" }, + { name = "httpx-retries" }, + { name = "inquirerpy" }, + { name = "m3u8" }, + { name = "mutagen" }, + { name = "pillow" }, + { name = "pywidevine" }, + { name = "structlog" }, + { name = "yt-dlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/83/8d9979d75969a870cdbeae54bb9abfe0517de44810b666627e672b790d85/gamdl-3.8.2.tar.gz", hash = "sha256:6d70c292d9fee33bb36c62edcb559ac29a3a28413c257742fcfb9efc0d4d8b90", size = 76969, upload-time = "2026-07-09T15:37:24.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/3a/6c81ac32e98d4c71e0a4774a25efd47360211141f25de4bd9a2cd08eb3ce/gamdl-3.8.2-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0f4c6d3d846ce9708c62a8841d63892ee8e6783ba002882926785e5420e70cd", size = 814872, upload-time = "2026-07-10T02:54:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/78/7a/a3730cf0b91668fa1f8b5d16232c8864d114afd0de48012b5e25e6973a5a/gamdl-3.8.2-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3bf059a6dd2b3222db005d5a4cb23d5ca9bcb79e8031ac6563182dd034cc67bb", size = 487669, upload-time = "2026-07-10T03:03:45.823Z" }, + { url = "https://files.pythonhosted.org/packages/11/ef/ead06441f3b272d805ee54adab2574923a04792617d8ab1dfab68017a15d/gamdl-3.8.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e22e3a23f490249d7fcc7fd951257ab11dbf6659ccc38517787b53c448c40946", size = 498644, upload-time = "2026-07-10T02:54:37.145Z" }, + { url = "https://files.pythonhosted.org/packages/95/ed/a727ff2983ceee794bfbd3c5ab1c0ebaffd57d31b0723b51fc4c69825186/gamdl-3.8.2-cp310-abi3-win_amd64.whl", hash = "sha256:173c1105b11887955e103c3cba3e525b159c6f6bb5337b558d2eefaeac0a3937", size = 368640, upload-time = "2026-07-10T02:54:38.913Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/c3988b2b4a75a15a47dfdcc1e9ba819aeb7d1834b401644fcbc28f7a89cc/gamdl-3.8.2-cp310-abi3-win_arm64.whl", hash = "sha256:acf6a2130786901dd1266966b5a2e69f38eb98d8c47d9c9add54157e31b2aa96", size = 357054, upload-time = "2026-07-10T03:03:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/85/2a/10d19638599c25e24b22ae237e56666ebdc6a203a8ca2fd51cd3f3587a6b/gamdl-3.8.2-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:6da94f7429d7b72449c41e12d953a84037234dac9007977614ce48b596825772", size = 35279033, upload-time = "2026-07-09T15:33:41.09Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-retries" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/d3/b7a8bb09543af40009717a08a2ceba90b6d4c6f0cdf171404217d8f4c37d/httpx_retries-0.6.0.tar.gz", hash = "sha256:3e0b404969a564829d368417964fd21e6b400a10d17c92d29b8bc247ce8186e3", size = 21122, upload-time = "2026-07-06T00:52:30.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c6/7f3d6ab3549267a1959161b38df4c0fb435eceaf2d531d8addfac01abaca/httpx_retries-0.6.0-py3-none-any.whl", hash = "sha256:d1e52a8f68a5df42de75ab89049d5020b2d0ab2f5f8bceacda008d12aa1257a3", size = 11776, upload-time = "2026-07-06T00:52:31.033Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inquirerpy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pfzy" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, +] + +[[package]] +name = "m3u8" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" }, +] + +[[package]] +name = "mutagen" +version = "1.48.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/70/1675da133ea92227da41bf5b24e1c66be597ff736a1533ade41da986852f/mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173", size = 1276978, upload-time = "2026-06-25T09:47:32.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/d8/a29e4e3991765e7ce4ed1f7e4074fe1ba9da03e0048639734de60f9cadb9/mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7", size = 195706, upload-time = "2026-06-25T09:47:30.296Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pfzy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymp4" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "construct" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/46/dfb3f5363fc71adaf419147fdcb93341029ca638634a5cc6f7e7446416b2/pymp4-1.4.0.tar.gz", hash = "sha256:bc9e77732a8a143d34c38aa862a54180716246938e4bf3e07585d19252b77bb5", size = 13018, upload-time = "2023-05-07T15:01:34.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pywidevine" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "protobuf" }, + { name = "pycryptodome" }, + { name = "pymp4" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/4855cb958892653029f3cafa8a4724d554b847de0a43a3808cea109b9e78/pywidevine-1.9.0.tar.gz", hash = "sha256:6742daf5fd797c5a4813eb1300efb3181ffcddd0c8c478ee28c7c536aa0e51b2", size = 75511, upload-time = "2025-10-27T09:13:15.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/e2/e692647f12008495654868e99c972696f2b8cb39637b507c409f5f99b4b6/pywidevine-1.9.0-py3-none-any.whl", hash = "sha256:70b5726abc2c3fe763f070da853b7c87d6aeb6131a27778743187258bf97e492", size = 70419, upload-time = "2025-10-27T09:13:14.303Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2026.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" }, +] diff --git a/tools/apple-provider/prepare.sh b/tools/apple-provider/prepare.sh new file mode 100755 index 00000000..b8d99561 --- /dev/null +++ b/tools/apple-provider/prepare.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +LOCK="$ROOT/tools/apple-provider/source-lock.json" +OUTPUT="$ROOT/.apple-provider/wrapper-v2" +ARCH=x86_64 +PACKAGE="" +STAGED_LIBS="" + +usage() { + echo "Usage: $0 [--apk FILE | --apkm FILE | --staged-libs DIR] [--arch x86_64|arm64-v8a] [--output DIR]" >&2 +} + +while (($#)); do + case "$1" in + --apk|--apkm) PACKAGE=${2:?missing package path}; shift 2 ;; + --staged-libs) STAGED_LIBS=${2:?missing staged library directory}; shift 2 ;; + --arch) ARCH=${2:?missing architecture}; shift 2 ;; + --output) OUTPUT=${2:?missing output directory}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage; exit 2 ;; + esac +done + +[[ "$ARCH" == x86_64 || "$ARCH" == arm64-v8a ]] || { echo "Unsupported architecture: $ARCH" >&2; exit 2; } +[[ -n "$PACKAGE" || -n "$STAGED_LIBS" ]] || { usage; exit 2; } +[[ ! ( -n "$PACKAGE" && -n "$STAGED_LIBS" ) ]] || { echo "Choose a package or --staged-libs, not both." >&2; exit 2; } +for command in git jq unzip; do command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }; done + +repository=$(jq -r '.wrapper.repository' "$LOCK") +tag=$(jq -r '.wrapper.tag' "$LOCK") +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")" + git clone --filter=blob:none --branch "$tag" --single-branch "$repository" "$OUTPUT" +fi + +git -C "$OUTPUT" fetch --force --tags origin "refs/tags/$tag:refs/tags/$tag" +tag_commit=$(git -C "$OUTPUT" rev-list -n 1 "$tag") +[[ "$tag_commit" == "$commit" ]] || { echo "wrapper-v2 tag $tag resolved to unexpected commit $tag_commit" >&2; exit 1; } +git -C "$OUTPUT" checkout --detach "$commit" +[[ -f "$OUTPUT/LIBS_VERSION.json" ]] || { echo "Pinned wrapper source lacks LIBS_VERSION.json" >&2; exit 1; } + +if [[ -n "$PACKAGE" ]]; then + [[ -f "$PACKAGE" ]] || { echo "Package not found: $PACKAGE" >&2; exit 1; } + 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" + 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" + done < <(jq -r --arg arch "$ARCH" '.libs[$arch] | keys[]' "$OUTPUT/LIBS_VERSION.json") +fi + +bash "$OUTPUT/tools/stage-system.sh" --arch "$ARCH" + +sha256_file() { command -v sha256sum >/dev/null && sha256sum "$1" | awk '{print $1}' || shasum -a 256 "$1" | awk '{print $1}'; } +verify_group() { + local group=$1 base=$2 + while IFS=$'\t' read -r relative expected; do + file="$base/$relative" + [[ -f "$file" ]] || { echo "Missing pinned file: $relative" >&2; exit 1; } + actual=$(sha256_file "$file") + [[ "$actual" == "$expected" ]] || { echo "Hash mismatch for $relative" >&2; exit 1; } + done < <(jq -r --arg arch "$ARCH" --arg group "${group#.}" '.[$group][$arch] | to_entries[] | [.key,.value] | @tsv' "$OUTPUT/LIBS_VERSION.json") +} + +verify_group android_system "$OUTPUT/rootfs/system" +verify_group libs "$OUTPUT/rootfs/system/lib64" +printf 'Prepared verified wrapper-v2 %s (%s) at %s\n' "$tag" "$tag_commit" "$OUTPUT" +printf 'Next: run ./allstarr.sh up, then finish Apple login in the WebUI.\n' diff --git a/tools/apple-provider/source-lock.json b/tools/apple-provider/source-lock.json new file mode 100644 index 00000000..1e5dc206 --- /dev/null +++ b/tools/apple-provider/source-lock.json @@ -0,0 +1,12 @@ +{ + "wrapper": { + "repository": "https://github.com/glomatico/wrapper-v2.git", + "tag": "0.0.2", + "commit": "0bc16acb55f557b5c98d49f21d7af685410f7f8b" + }, + "appleMusic": { + "package": "com.apple.android.music", + "version": "3.6.0-beta", + "build": "1109" + } +}