Search by

spora-ai / spora-plugin-media-archive

fabeat

Admin UI for browsing, filtering, and downloading media archived by Spora agents.

Package info

github.com/spora-ai/spora-plugin-media-archive

Type:spora-plugin

pkg:composer/spora-ai/spora-plugin-media-archive

Statistics

Installs: 523

Dependents: 0

Suggesters: 5

Stars: 0

Open Issues: 0

v0.8.0 2026-11-05 21:49 UTC

README

Admin UI for browsing, filtering, and downloading media archived by Spora agents.

This plugin contributes the Media Archive app to the host's Apps dropdown. The panel is a pre-built Vue SPA delivered as a separate Composer package (spora-ai/spora-plugin-media-archive-frontend, type spora-plugin-frontend). The two-package split lets the frontend evolve on its own release cadence and lets backend-only operators skip the bundle entirely.

Install

composer require spora-ai/spora-plugin-media-archive
composer require spora-ai/spora-plugin-media-archive-frontend

Both packages are required: the PHP package contributes the admin-panel metadata (MediaArchiveApp → VueAppInterface), and the frontend package ships the Vue IIFE bundle that the host SPA lazy-loads at runtime.

Requires spora-ai/spora-core ≥ 0.33.0 — the release that carries core's folders (core #310). The asset editor hard-requires Spora\Services\MediaArchive\MediaFolderService and Spora\Services\MediaArchive\MediaAssetKind, so the floor is where the plugin's dependency starts: below it the installer refuses to resolve, and the operator upgrades core or stays on the previous plugin version. The floor before the folders was 0.30.0, which is where Spora\Search\SearchProviderInterface landed (core #279).

Until 0.33.0 is on Packagist, the plugin's own composer.json accepts dev-main — core's main, the branch the release is cut from — so that the plugin's CI and its development install resolve a core that has the folders. An operator never sees that alternative: composer's default minimum-stability refuses a dev version, so the install needs the release either way. The clause leaves the manifest when the release is published.

What it does

  • Surfaces rows from the media_assets table (indexed by MediaArchiveService) as a filterable grid in the admin UI.
  • Filters by media type, plugin, tool, agent, and date range.
  • Scope chip row (mirrors the dashboard's ALL / My Media / Group pattern) so a user with multiple groups can isolate each group's media. The chip row reads /principals/me + /groups to populate the labels; the controller intersects ?principal_id= with the caller's visiblePrincipalIds() so an out-of-scope principal id is silently dropped.
  • Click-through detail drawer with metadata (dimensions, duration, mime type, source URL).
  • A derivatives strip below the asset row (format chip + producer + asset URL). Populated from the media_derivatives join table; the controller reads it via MediaDerivativeService::listFor() so chips survive a hard reload, not just the in-memory splice on derivative production.
  • One-click download via the existing AssetController::show() route.
  • Filing and marking: PATCH /api/v1/media/{id} takes folder_id — a folder of the asset's own tenant, or null for the root — and kind, one of asset, template, example. The mark is advisory: a filter hint for the operator, never a gate. Filenames are unique within a folder, and the folder owns that rule: filing an asset into a folder that already holds that name, or renaming a file inside a folder to a name it holds, is refused with the folder's own message (The folder "Corporate" already holds a file named "report.md".) — the folder's uniqueness rule, not a broken edit.
  • The two paths answer the same rule differently. A kind outside the vocabulary is a 400 on core's upload (POST /api/v1/media) — a malformed field in the request body — and a 422 (INVALID_KIND) on this plugin's PATCH — a semantic refusal of a value the server read and will not store. The house rule is that a malformed value is a 400 and a semantic refusal is a 422, and each path answers on its own side of that line.
  • An empty folder_id is refused, not read as the root. "" names no folder, so the PATCH answers 422 (UNKNOWN_FOLDER) and leaves the asset where it is. Core's folder service reads an empty string as the root — the root is null.
  • Palette search (⌘K): MediaAssetSearchProvider makes archive assets findable by name or by content, under the media-archive section of the host palette. See Palette search (⌘K) below.

The plugin itself adds no tools, drivers, recipes, or migrations.

Palette search (⌘K)

MediaAssetSearchProvider implements Spora\Search\SearchProviderInterface — the seam core ships no implementation of, so every section the palette renders from the server comes from a plugin. The host calls GET /api/v1/search on every debounced keystroke and this plugin answers with archive rows.

  • Section bucket media-archive — the plugin.json slug, not a human-facing label. The palette title-cases type into the section header and the registry de-duplicates on type::id, so the string is a key.
  • Matched fields: filename, plus prompt, transcript and tags — so an asset is findable by what it contains, not only by what it is called. All four are matched in SQL; tags is matched against its serialised JSON form, which is substring matching over the tag text rather than an exact-tag lookup.
  • Ranked filename-first: exact name, then prefix, then substring, then prompt, then tags, then transcript-only. A prompt, a tag and especially a transcript are prose, and prose is weak evidence; a filename is a name, and a name is what somebody means to find. The ranking runs in SQL rather than in PHP, so the cap keeps the twenty best rows instead of twenty of an arbitrary window.
  • Scoped to the caller's principals: the query is built from SearchContext::principalIds() and nothing else — no branch to widen, and an empty context returns nothing without reaching the database. There is deliberately no admin bypass; an admin's context is the admin's own visible principals. Rows with no attribution at all (principal_id and agent_id both null) are consequently not findable — the class docblock records why, and what would close that gap.
  • Bounded at 20 hits, globally across the context's principals — a busy group can fill every slot and hide another principal's asset. The archive's own scope chips are where "I mean that group" belongs.
  • Not offered: derivatives (the md rendered out of a pdf is not a second document) and temporary rows, matching what the archive grid hides.

A hit shows the filename as its label, a truncated prompt as its sublabel and the media type — or the mime type as a fallback — as its badge, and opens /apps/media-archive/asset/{id} on select.

Companion plugins

This plugin has no suggest block. Producer plugins (spora-plugin-minimax, spora-plugin-openai-image, spora-plugin-typst) declare media-archive as a companion suggestion in their own detail dialogs, so installing any of them surfaces media-archive as an installable companion on the producer side. See each producer's README for the current list.

Translations

This plugin ships a German catalogue at locales/de.json, declared through translationsPaths() in src/MediaArchivePlugin.php. The host merges it under core's own catalogue, so a German install reads the plugin's operator copy in German.

The key is the English source string, byte for byte — editing the English in the source changes the key, so the old entry stops matching and the new English has no entry. The gate is this repository's own tests/Unit/LocaleCatalogTest.php: it fails on an empty translation, on a translation identical to its key, and on a key the source no longer produces.

Translated

  • the admin app's displayName() and description()
  • the plugin.json description

That is the whole of this plugin's operator-visible surface: it ships no tool and declares no setting.

Never translated — these strings are read by the model, so the operator sees exactly what the model sees:

  • #[Tool].description
  • #[ToolOperation].description
  • #[ToolParameter].description
  • every #[ToolSetting].label and its option labels, including the labels of settings that carry exposeToLlm: true

See the plugin author guide → Translations.

Reference

The canonical reference (REST contract, ingestion API, retention guidance, configuration flags) lives on the docs site:

docs.spora-ai.com/develop/plugins/reference/media-archive

License

MIT — see LICENSE.