survos / media-bundle
Manage media without direct relationships
Fund package maintenance!
Requires
- php: ^8.5
- doctrine/dbal: ^4.3.2
- doctrine/doctrine-bundle: ^3.0
- league/flysystem: ^3.0
- survos/data-contracts: ^2.27.1
- survos/field-bundle: ^2.5
- survos/iiif-bundle: ^2.7
- survos/imgproxy-bundle: ^2.28.6
- survos/kit-bundle: ^2.5
- survos/state-bundle: ^2.0
- symfony/config: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/http-kernel: ^8.1
- symfony/messenger: ^8.1
- symfony/remote-event: ^8.1
- symfony/ux-twig-component: ^3.0
- symfony/webhook: ^8.1
- twig/twig: ^3.4|^4.0
Requires (Dev)
- nyholm/psr7: ^1.8
- openai-php/client: ^v0.19.0
- phpstan/phpstan: ^2.0
- symfony/browser-kit: ^8.1
- symfony/framework-bundle: ^8.1
- symfony/http-client: ^8.1
- symfony/phpunit-bridge: ^8.1
- symfony/twig-bundle: ^8.1
- symfony/var-dumper: ^8.1
Suggests
- survos/tabler-bundle: Admin navbar menu and the search/UI templates. Optional: MediaMenuSubscriber is only registered when TablerBundle is present (config/services.php), so a headless/CLI client does not need it. Was ^2.5.
- symfony/ai-platform: Required for #[With] JSON Schema constraints on MediaSyncItem properties
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.29.0
- 2.28.6
- 2.28.5
- 2.27.7
- 2.27.6
- 2.27.5
- 2.27.4
- 2.27.3
- 2.27.2
- 2.27.1
- 2.27.0
- 2.26.22
- 2.24.20
- 2.24.16
- 2.24.15
- 2.24.7
- 2.18.18
- 2.18.3
- 2.18.2
- 2.17.3
- 2.17.2
- 2.12.12
- 2.11.4
- 2.10.24
- 2.10.19
- 2.10.18
- 2.10.17
- 2.10.16
- 2.10.15
- 2.10.14
- 2.10.13
- 2.10.12
- 2.10.11
- 2.10.10
- 2.10.9
- 2.10.8
- 2.10.7
- 2.10.6
- 2.10.5
- 2.10.4
- 2.10.3
- 2.10.2
- 2.10.1
- 2.10.0
- 2.9.4
- 2.9.3
- 2.9.2
- 2.9.1
- 2.9.0
- 2.8.4
- 2.8.3
- 2.8.2
- 2.8.1
- 2.8.0
- 2.7.23
- 2.7.22
- 2.7.21
- 2.7.20
- 2.7.19
- 2.7.18
- 2.7.17
- 2.7.16
- 2.7.15
- 2.7.14
- 2.7.13
- 2.7.12
- 2.7.11
- 2.7.10
- 2.7.9
- 2.7.8
- 2.7.7
- 2.7.6
- 2.7.5
- 2.7.4
- 2.7.3
- 2.7.2
- 2.7.1
- 2.7.0
- 2.6.0
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.0
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.2
- 2.1.1
- 2.0.220
- 2.0.219
- 2.0.218
- 2.0.217
- 2.0.216
- 2.0.215
- 2.0.214
- 2.0.213
- 2.0.212
- 2.0.211
- 2.0.210
- 2.0.209
- 2.0.208
- 2.0.207
- 2.0.206
- 2.0.205
- 2.0.204
- 2.0.203
- 2.0.202
- 2.0.201
- 2.0.200
- 2.0.199
- 2.0.198
- 2.0.197
- 2.0.196
- 2.0.195
- 2.0.194
- 2.0.193
- 2.0.192
- 2.0.191
- 2.0.190
- 2.0.189
- 2.0.188
- 2.0.187
- 2.0.186
- 2.0.185
- 2.0.184
- 2.0.183
- 2.0.182
- 2.0.181
- 2.0.180
- 2.0.179
- 2.0.178
- 2.0.177
- 2.0.176
- 2.0.175
- 2.0.174
- 2.0.173
- 2.0.172
- 2.0.171
- 2.0.170
- 2.0.169
- 2.0.168
- 2.0.167
- 2.0.166
- 2.0.165
- 2.0.164
- 2.0.163
- 2.0.162
- 2.0.161
- 2.0.160
- 2.0.159
- 2.0.158
- 2.0.156
- 2.0.155
- 2.0.154
- 2.0.146
- 2.0.145
- 2.0.144
- 2.0.143
- 2.0.142
- 2.0.141
- 2.0.140
- 2.0.139
- 2.0.138
- 2.0.137
- 2.0.136
- 2.0.135
- 2.0.134
- 2.0.133
- 2.0.132
- 2.0.131
- 2.0.130
- 2.0.129
- 2.0.128
- 2.0.127
- 2.0.126
- 2.0.125
- 2.0.124
- 2.0.123
- 2.0.122
- 2.0.121
- 2.0.120
- 2.0.119
- 2.0.117
- 2.0.116
- 2.0.115
- 2.0.114
- 2.0.113
- 2.0.112
- 2.0.111
- 2.0.110
- 2.0.109
- 2.0.108
- 2.0.107
- 2.0.106
- 2.0.105
- 2.0.104
- 2.0.83
- 2.0.82
- 2.0.81
- 2.0.80
- 2.0.79
- 2.0.78
- 2.0.77
- 2.0.76
- 2.0.75
- 2.0.74
- 2.0.73
- 2.0.72
- 2.0.71
- 2.0.70
- 2.0.69
- 2.0.68
- 2.0.67
- 2.0.66
- 2.0.65
- 2.0.64
- 2.0.63
- 2.0.62
- 2.0.61
- 2.0.60
- 2.0.59
- 2.0.58
- 2.0.57
- 2.0.56
- 2.0.55
- 2.0.54
- 2.0.53
- 2.0.51
- 2.0.50
- 2.0.49
- 2.0.48
- 2.0.47
- 2.0.46
- 2.0.45
- 2.0.44
- 2.0.43
- 2.0.42
- 2.0.41
- 2.0.40
- 2.0.39
- 2.0.38
- 2.0.37
- 2.0.36
- 2.0.35
- 2.0.34
- 2.0.33
- 2.0.32
- 2.0.31
- 2.0.30
- 2.0.29
- 2.0.28
- 2.0.27
- 2.0.26
This package is auto-updated.
Last update: 2026-09-15 13:17:47 UTC
README
A client for mediary, the central media server — not a general-purpose media manager. An application installs this bundle to register the images it knows about, hand them to mediary, and receive back what mediary learned. mediary owns the binaries, the S3 archive, the AI, and the canonical URLs; the application owns only its own rows.
Renaming. The package is scheduled to be renamed
survos/mediary-bundle, which is what it has actually been for a while. The namemedia-bundlepredates the split and reads like generic media management, which is the one thing this is not.
This mirrors babel-bundle ↔ lingua-server: applications own their tables, a central service owns the heavy lifting.
Two encodings, and they are not the same thing
This trips people up, so it is spelled out. There are two derivations from a URL, both live, both deliberate.
1. The imgproxy-style key — reversible
use Survos\DataContracts\Util\MediaKeyService; $key = MediaKeyService::keyFromString('https://example.com/image.jpg'); $url = MediaKeyService::stringFromEncoded($key); // round-trips
URL-safe base64: base64_encode, then +/ → -_, then padding stripped. This is the same
philosophy imgproxy uses, and it is reversible on purpose — a resize URL carries its own
source, so no lookup is needed to render one. MediaUrlGenerator::resize() uses it whenever it
is handed a bare URL string rather than a Media row.
2. The asset id — not reversible
use Survos\DataContracts\Util\MediaIdentity; $id = MediaIdentity::idFromOriginalUrl('https://example.com/image.jpg'); // => 16 lowercase hex chars, e.g. "0c05e2ff8ac3dd8f"
xxh3 of the trimmed URL. This is Media::$id and mediary's Asset::$id — the primary key both
sides agree on. Fixed width, so it is safe in a column, a Meilisearch id, and a directory name;
the URL is not recoverable from it, and nothing should try.
MediaKeyService::archivePathFromKey() uses both: it hashes the key to pick a bucket, then
stores the key itself as the filename — o/<1 hex>/<2 hex>/<key>.<ext>, so the stored path is
still reversible to the source URL. Note the bucket split is one char then two (16 × 256), not the
symmetric aa/bb the name suggests.
This is not the layout mediary uses for archived originals, which are
orig/<2>/<2>/<long hex>.<ext>. Two different schemes; don't assume one function produces the
other.
The
MediaRegistry::idFromUrl()example that used to head this README does not exist — the method was never added or was removed.tests/Service/MediaRegistryTest.php:75still calls it.
Why both live in data-contracts
mediary computes the same values from the same URL without depending on this bundle, and this bundle computes them without depending on mediary. Two copies of either algorithm would be free to drift, and a drift looks like "mediary answered about an image we never asked about."
Same rule for the preset names (MediaPreset), the batch payload shape (BatchPayloadDto /
BatchItemDto), and the sync protocol keys (MediaSyncKeys).
Why it matters
- No database lookup to resolve a URL to an id
- Same URL → same id in every app and in mediary
- Safe primary key for Meilisearch
The Media workflow — how rows actually reach mediary
This is the supported path. Registering a Media row is the only manual step.
BaseMedia carries a marking, and the app declares a workflow for it:
use Survos\MediaBundle\Entity\BaseMedia; use Survos\MediaBundle\Workflow\MediaWorkflowDefinition; use Survos\StateBundle\Attribute\Workflow; #[Workflow(supports: [BaseMedia::class], name: self::WORKFLOW_NAME)] final class MediaWorkflow extends MediaWorkflowDefinition { public const WORKFLOW_NAME = 'media'; }
An empty body is the normal case — you inherit new → dispatch → dispatched → sync → synced
(plus failed). MediaWorkflowDefinition deliberately carries no #[Workflow] attribute, so
the base never self-registers; the app decides. An app that needs its own steps redeclares just
the constant it wants to re-point (ssai, for instance, gives PLACE_NEW a next that runs triage
before dispatch), and gets application-specific behaviour without forking the definition.
PLACE_NEW declares next: [TRANSITION_DISPATCH], so state-bundle's InitialPlaceKickoffListener
queues the dispatch on postFlush. Persist a row and the rest runs unattended:
$media = $mediaRegistry->ensureMedia($imageUrl); $em->flush(); // → media.dispatch queued → mediary → callback → status applied
supports is BaseMedia rather than Photo so Video and Audio get the same lifecycle;
Symfony matches by instanceof and the marking lives on the base.
Requires state-bundle, which is why it moved from
suggesttorequire. It used to be optional back when the plan was to keep image URLs inMediaand generate thumbnails locally. That plan is gone: allMediarows interact with mediary, and that interaction has state.Needs state-bundle ≥ 2.27.4. Before that,
InitialPlaceKickoffListenerresolved the workflow by exact class name, so a workflow declaredsupports: [BaseMedia::class]never kicked off for thePhotorows anyone actually persists — silently, with no log line. Rows sat atmarking=newforever.
What this replaces
media:ensure + media:sync, where "which media still needs pushing" was a WHERE status='new'
scan and a foreground batch loop. Those commands remain for one-off debugging (media:sync --url=…
to push a single image through), but a pipeline that calls them is doing it the old way.
Registering media
foreach ($data->images as $imageUrl) { $media = $mediaRegistry->ensureMedia($imageUrl); // bulk-safe, no flush, no network }
- Defaults to
Photo - No duplicate URLs
- Local files supported (
ensureMedia($uploadedFile)), assigned a temporarylocal://URL
Receiving mediary's callback
mediary POSTs a signed asset.analyzed webhook to /webhook/mediary when an image finishes
analysis. This bundle supplies the two pieces that belong to it — a request parser
(Survos\MediaBundle\Webhook\MediaWebhookRequestParser) and a consumer that calls
MediaUpdateApplier. It contributes no route and no controller; the endpoint is
FrameworkBundle's own.
# config/routes/webhook.yaml webhook: resource: '@FrameworkBundle/Resources/config/routing/webhook.php' prefix: /webhook # config/packages/webhook.yaml framework: webhook: routing: mediary: service: Survos\MediaBundle\Webhook\MediaWebhookRequestParser secret: '%env(default::MEDIARY_WEBHOOK_SECRET)%' # config/packages/messenger.yaml — REQUIRED, or the endpoint's 202 is a lie framework: messenger: routing: 'Symfony\Component\RemoteEvent\Messenger\ConsumeRemoteEventMessage': media_callback
Set MEDIARY_WEBHOOK_SECRET to the same value mediary signs with, and point
MEDIA_CALLBACK_URL at https://your-app/webhook/mediary.
To react to updates, listen for MediaUpdatedEvent — mediary never learns your entity shape.
Run a consumer for that transport. A queued callback that nobody consumes looks exactly like a mediary that never answered: rows stay at their pre-callback status indefinitely, with the evidence sitting in a queue table rather than in a log.
Full contract, including how to run several webhooks on separate queues: kit-bundle/docs/webhooks.md.
Replaced the unauthenticated
MediaCallbackControllerat/media/callback, where anyone who could reach the URL could rewrite a media row. See survos-sites/mediary#8.
AI-task sidecars (SidecarClient)
Cached AI-task results are read and written through mediary, over JSON-RPC:
$data = $sidecarClient->read($mediaId, 'observe'); // null on a miss $data = $sidecarClient->remember($mediaId, 'observe', fn () => $this->runAi(...));
SidecarService used to live here and be injected directly, which made every client a second
writer into mediary's bucket namespace — its own S3 credentials, its own copy of the path
convention, its own idea of when a sidecar was stale. That is the same shape as an app keeping a
Media table beside mediary's Asset: two owners of one thing, each free to drift. The service
now lives in mediary behind sidecarGet / sidecarPut, so caching, batching or a Redis layer can
appear there without any client changing.
Two deliberate exceptions:
path()is computed locally fromMediaKeyService— asking mediary for it would be a network round trip to learn something both sides can already derive, plus a chance to disagree.remember()'s compute-if-missing branch stays client-side: a producer callable cannot cross the wire, and the work it represents (a paid AI call) belongs to whoever wanted the answer.
Probing mediary (polling fallback)
When webhooks are unavailable — a local dev tunnel is down, say — poll mediary directly:
$result = $mediaBatchDispatcher->dispatch('museum', [$url], [ 'callback_url' => 'https://my-app.example/webhook/media', ]); $probe = $mediaBatchDispatcher->probe($result->media[0]->mediaKey); if ($probe->isComplete()) { // $probe->meta / ->context / ->ocr / ->ai }
probe(string $assetId): MediaProbeResultprobeMany(array $assetIds): array<MediaProbeResult>
Both go through JSON-RPC probeAssets on POST /api/v1, and both need MEDIARY_API_TOKEN to
match mediary's.
They used to call
GET /fetch/media/{id}andPOST /fetch/media/by-ids, which no API client could reach. mediary's security.yaml grants PUBLIC_ACCESS to only^/[^/]+/batch$,^/api/v1$and^/api/claim-store/; everything else falls through- { path: ^/, roles: ROLE_USER }and 302s to/login. That failed in the least legible way possible — Symfony's HttpClient follows redirects, so the call returned 200 with the login page's HTML and threw insidetoArray()as a JSON parse error, which looks like mediary sent garbage rather than like the client was never admitted.
/api/v1is public at the firewall precisely so unauthenticated clients can reach it, with each method authenticating on a token in its params. Same rows either way: mediary serves both transports from one AssetProbeService.
The payload includes mediary's workflow state (marking), variants/thumb URLs, metadata, and any
OCR/AI context written so far.
bin/console media:probe 5c4e0c2d6f8a1b9e bin/console media:probe "https://example.org/image.jpg" bin/console media:probe --url "upload://sha256/abcd..."
Publishing claims to mediary
Apps run AI with survos/ai-workflow-bundle and store tracked metadata as claims. Publishing
sends the image plus selected source/AI/human claims to mediary, while mediary stays responsible
for global media access and canonical image URLs. See docs/publishing.md.
Optional dependencies
survos/tabler-bundle is a suggest, not a require. MediaMenuSubscriber and the search/UI
templates are registered only when TablerBundle is present, so a headless or CLI-only client — a
consumer that does nothing but register rows and drain queues — needs no theme.
What this bundle does not do
- Download media
- Resize images
- Cache thumbnails
- Perform OCR, tagging, or EXIF extraction
- Hold an
Assettable (that is mediary's; the app hasMedia, and only one of the two owns the file)
Those belong to mediary and imgproxy.
Status
Known rough edges, recorded rather than hidden:
BaseMediais brittle.imageUrl/thumbnailUrl/s3Urlare three flat columns whose relationship is conventional rather than enforced, and the constructor seeds bothstatusandmarkingbecause the two state fields have not been reconciled —statusis mediary's answer,markingis the workflow's, and nothing guarantees they agree. It should be an interface, and the URL fields should be one addressable thing.- No transition listeners. The workflow moves markings;
statusis still written by the callback. Nothing firesTRANSITION_SYNC, so a row that mediary has fully processed sits atdispatchedrather than reachingsynced. - Provider detection (YouTube, Flickr, …) is partial.