Omnisong is one PHP contract for listening to a musician's catalogue. A site asks every catalogue
service the same three questions - where is this recording, what is on this release, what did this
artist put out - and gets the same answers back: PlatformLinks, a Release with its Tracks and
their previews, a list of Releases. Each service is a package of its own (omnisong/itunes,
omnisong/odesli) that turns those questions into its API calls; the core's aggregator,
Catalog\Catalog, asks every configured catalogue in turn and merges what they say.
$release = $catalog->release(Reference::upc('4015372820954'), new Label('ES-DUR', 'https://www.es-dur.de'));
$release->tracks[0]->previewUrl; // 30 seconds, for an <audio> element
$release->links->first(); // ES-DUR, then the stores and streamers in order
$embedder->embed($release->links->get(Platform::SPOTIFY)); // the platform's own player, as an iframe
$catalog->releases('Anaëlle Tourret'); // what an artist put out, newest first
$catalog->incomplete; // the catalogues that could not be reached
It is about listening, not publishing (publishing is omnisense/omnipost's job), and it puts the
label first: a musician's site shows "released by ES-DUR, buy it there" before "listen on Spotify".
PlatformLinks keep that order (Platform::rank()). Besides the catalogues, the core holds the
Player\Embedder, which turns a link into the platform's own iframe from its public embed URL, with
no key and no call.
The core needs no framework: it requires symfony/http-client-contracts and nothing else; each
catalogue package requires symfony/http-client, a library that stands alone. A Symfony bundle and
a Twig extension are included for applications that want the wiring done (Symfony).
Published as omnisense/omnisong, formerly glitchr/omnisong and omniscient/omnisong: the package
replaces both names, so a composer.json that still requires one of them resolves to this package
and never installs two. The namespace is Omnisong\; the catalogues keep the omnisong/ vendor and
the Omnisong\<Catalogue>\ namespace.
When to use it
- A musician's or a label's site that shows its releases with their tracks, 30-second previews, covers, and the links to every store and streaming service, the label's own shop first.
- A page that plays a release with the platform's own player (Spotify, Apple Music, Deezer, YouTube, SoundCloud) without an SDK or a key.
- A discography read from a store's catalogue (
releases($artist)), rather than typed by hand.
It does not host or stream audio, does not read play counts or any account's data, and stores
nothing: caching the answers (they are remote and rate limited) is the application's job, and an
answer the aggregator marks incomplete must not be cached.
Catalogues
Two packages. The capabilities are what each catalogue's code answers (read from its source, and
checked by its tests on recorded answers); "no" means the call throws NotSupportedException, which
the aggregator treats as "ask the next one".
| Package | Name | Key | links() |
release() |
releases() |
References read |
|---|---|---|---|---|---|---|
omnisong/itunes |
itunes |
none | Apple Music and iTunes Store only | yes: tracks, previews, cover, label, genre | yes, by artist name, newest first | UPC/EAN, ISRC, Apple Music / iTunes URL, an itunes or apple_music id |
omnisong/odesli |
odesli |
yes, since song.link withdrew keyless access | the 17 song.link platforms Omnisong maps | title, artist, cover and links; no tracks | no | a URL on any platform, the id of one of 16 platforms |
Neither reads what the other is best at, which is why the aggregator exists: iTunes finds a release by its UPC and gives its tracks; Odesli, which reads no UPC, is asked again by the iTunes id the first one found and gives the other platforms (Catalogues and the aggregator).
The players are the core's, not a catalogue's: Spotify, Apple Music, Deezer, YouTube, YouTube Music and SoundCloud (Players).
How this manual is organised
- Installation: requirements, a first script in plain PHP, several catalogues.
- Symfony and Laravel: wiring the registry, the aggregator and the embedder in a framework; the Twig functions.
- Concepts: catalogues, factories, the registry, the aggregator, every model and the
Platformenum. - Usage: catalogues and the aggregator, players.
- Configuration reference: every option of every factory, of the registry and of the embedder.
- Errors: the exceptions and when each is thrown.
- Testing: testing an application that uses Omnisong.
- The Docker harness: every catalogue installed, a console to call them.
- Writing a catalogue.
- Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omnisong omnisong/itunes # no key
composer require omnisong/odesli # with a key from developers@song.link
Requirements
- PHP 8.2 or later; no extension beyond those every PHP build has (JSON, PCRE).
omnisense/omnisongrequiressymfony/http-client-contracts(^3.0) and nothing else.- Each catalogue package (
omnisong/itunes,omnisong/odesli) requiresomnisense/omnisongandsymfony/http-client(^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework. - For the Symfony bundle:
symfony/config,symfony/dependency-injectionandsymfony/http-kernel(6.4, 7 or 8), andtwig/twig(3) for the template functions. They are suggested, not required: a Symfony application has them.
Omnisong runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).
Plain PHP
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnisong\Catalog\Catalog;
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Model\Label;
use Omnisong\Model\Reference;
use Omnisong\Platform;
use Omnisong\Player\Embedder;
use Omnisong\Registry;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry([new ItunesCatalogFactory($http)], [
'itunes' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
]);
$catalog = new Catalog($registry->all()); // asks every configured catalogue in turn and merges what they say
// A release by its UPC, the label first
$release = $catalog->release(Reference::upc('4015372820954'), new Label('ES-DUR', 'https://www.es-dur.de'));
echo $release->title, ' (', $release->releasedAt->format('Y'), ', ', $release->label->name, ")\n\n";
foreach ($release->tracks as $track) {
printf("%d. %s\n", $track->position, $track->title);
}
echo "\n", 'preview: ', $release->tracks[0]->previewUrl, "\n\n";
foreach ($release->links as $link) {
printf("%-12s %s\n", $link->platform->value, $link->url);
}
echo "\n", (new Embedder())->embed($release->links->get(Platform::APPLE_MUSIC))->html, "\n";
$ php bare.php
Perspectives Concertantes (2025, ES-DUR)
1. Concerto for Harp and Orchestra in E-Flat Major, Op. 74: I. Allegro Moderato
2. Concerto for Harp and Orchestra in E-Flat Major, Op. 74: II. Tema con Variazioni
3. Concerto for Harp and Orchestra in E-Flat Major, Op. 74: III. Allegro Giocoso
4. Concertino for Harp & Chamber Orchestra, Op. 45: I. Andante
5. Concertino for Harp & Chamber Orchestra, Op. 45: II. Allegretto Vivace
6. Concertino for Harp & Chamber Orchestra, Op. 45: III. Adagio non Troppo
7. Deux Danses pour harpe avec accompagnement d'orchestre: I. Danse sacrée
8. Deux Danses pour harpe avec accompagnement d'orchestre: II. Danse profane
preview: https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/02/3f/df/023fdf56-9772-8b41-3e98-d0985a4b50a6/mzaf_17993483565663500051.plus.aac.p.m4a
label https://www.es-dur.de
apple_music https://music.apple.com/de/album/perspectives-concertantes/1793146044
itunes https://music.apple.com/de/album/perspectives-concertantes/1793146044?app=itunes
<iframe src="https://embed.music.apple.com/de/album/perspectives-concertantes/1793146044" title="Apple Music" width="100%" height="450" style="border:0;border-radius:12px" frameborder="0" loading="lazy" allowfullscreen allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"></iframe>
(as answered by the iTunes Search API on 2026-10-05, in an empty directory after
composer require glitchr/omnisong omnisong/itunes, the package's name then)
No key, no account: the script asks the iTunes Search API once (about 20 calls a minute are allowed). The pieces:
- a factory per catalogue package (
ItunesCatalogFactory,OdesliCatalogFactory), which takes the HTTP client to call with - the application's, aMockHttpClientin a test - and with none given makes its own (HttpClient::create()); - the registry, built from the factories and the catalogues' options, by name:
get(),all(),names(),has(),factories(); - the aggregator,
Catalog\Catalog, over the catalogues in the order they are configured:release(),links(),releases(), and$incompleteafter each call; - the embedder,
Player\Embedder, which turns a link into the platform's own player - no call, no key.
No class of a framework is loaded on the way: Tests/BareTest.php checks it in a process of its
own, and so does docker compose run --rm omnisong bare (harness).
One catalogue
A catalogue can be used alone, without the registry or the aggregator:
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Model\Reference;
use Symfony\Component\HttpClient\HttpClient;
$itunes = (new ItunesCatalogFactory(HttpClient::create()))->create(['country' => 'de']);
$release = $itunes->release(Reference::upc('4015372820954'));
$release->tracks[0]->previewUrl; // 30 seconds, for an <audio> element
$itunes->releases('Anaëlle Tourret', 10); // what an artist put out, newest first
$itunes->links(Reference::parse('https://music.apple.com/de/album/perspectives-concertantes/1793146044'));
A reference is a URL on any platform, a UPC/EAN (a release), an ISRC (a recording) or a platform's
own id; Reference::parse() reads a URL, a UPC/EAN or an ISRC from a string. What a catalogue
cannot read is a NotSupportedException: used alone, the caller sees it; under the aggregator, the
next catalogue is asked.
Several catalogues
use Omnisong\Catalog\Catalog;
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Model\Label;
use Omnisong\Model\Reference;
use Omnisong\Odesli\OdesliCatalogFactory;
use Omnisong\Registry;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry(
[new OdesliCatalogFactory($http), new ItunesCatalogFactory($http)],
[
'odesli' => ['factory' => 'odesli', 'options' => ['api_key' => getenv('ODESLI_API_KEY') ?: null, 'country' => 'DE']],
'itunes' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
],
);
$catalog = new Catalog($registry->all());
$release = $catalog->release(Reference::upc('4015372820954'), new Label('ES-DUR', 'https://www.es-dur.de'));
$catalog->incomplete; // the catalogues that could not be reached: do not cache that half answer
$registry->get('itunes'); // one catalogue, by its name
Odesli is configured first, but reads no UPC: the aggregator goes on to iTunes, which finds the
release and its tracks, then asks Odesli again by the iTunes id it learnt and merges the other
platforms' links. The same merge, on the answers the harness keeps in docker/harness/recorded/
(iTunes' as answered on 2026-10-03; Odesli's written from what album.link showed that day):
$ OMNISONG_AUTOLOAD=vendor/autoload.php php core/docker/harness/bin/bare --recorded
Omnisong in bare PHP: the registry built by hand, no bundle, no container.
1. odesli
2. itunes
The release with the UPC 4015372820954, from the answers kept in recorded/:
Perspectives Concertantes - Anaelle Tourret, NDR Elbphilharmonie Orchester, Vasily Petrenko, Stuttgarter Kammerorchester & Bar Avni
album of 2025-02-28, ES-DUR
8 tracks, 8 with a preview; the first: Concerto for Harp and Orchestra in E-Flat Major, Op. 74: I. Allegro Moderato (11:17)
https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/02/3f/df/023fdf56-9772-8b41-3e98-d0985a4b50a6/mzaf_17993483565663500051.plus.aac.p.m4a
where it is, the label first:
label https://www.es-dur.de
apple_music https://music.apple.com/de/album/perspectives-concertantes/1793146044
tidal https://listen.tidal.com/album/414051173
amazon_music https://music.amazon.com/albums/B0DV5P8V78
itunes https://music.apple.com/de/album/perspectives-concertantes/1793146044?app=itunes
amazon https://amazon.com/dp/B0DV5P8V78
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig): none
(the harness's bare script, run on 2026-10-26 on the packages' checkouts, PHP 8.4, no network;
docker compose run --rm omnisong bare --recorded prints the same). Without a key song.link
answers 401: Odesli is then skipped, $catalog->incomplete is ['odesli'], and the release comes
with iTunes' tracks and Apple's two links.
In a framework
- Symfony:
Omnisong\Bridge\Symfony\OmnisongBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnisong.yaml, autowires the aggregator, each catalogue by its name and the embedder, and with Twig gives the templates the players: Symfony. - Laravel: there is no bridge package; a service provider builds the registry and the aggregator once: Laravel.
- Any other: build the
Registryand theCatalogonce, where the framework builds its services (a container definition, a factory), as the scripts above do.
Propose a change docs/installation.md
Symfony
Omnisong runs without a framework (Installation); in a Symfony application its
bundle does the wiring. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel (6.4, 7 or 8), and twig/twig for the template functions - are not required
by omnisense/omnisong: the application has them, and nothing of them is loaded outside Symfony.
The bundle is part of the core (Omnisong\Bridge\Symfony\OmnisongBundle); omnibridge has no
Omnisong bridge, and none is needed.
Registering the bundle
There is no Flex recipe: add the bundle by hand.
// config/bundles.php
return [
// ...
Omnisong\Bridge\Symfony\OmnisongBundle::class => ['all' => true],
];
Configuration
# config/packages/omnisong.yaml
omnisong:
catalogs: # by name, in the order the aggregator asks them
odesli: { factory: odesli, options: { api_key: '%env(default::ODESLI_API_KEY)%', country: 'DE' } }
itunes: { factory: itunes, options: { country: 'de' } }
player:
theme: light # light|dark, the Embedder's default
country: ~ # overrides the store Apple Music opens
The whole tree, from the bundle's configure():
| Key | Type | Default | |
|---|---|---|---|
omnisong.catalogs |
map, keyed by the catalogue's name | {} |
the catalogues, in the order the aggregator asks them |
omnisong.catalogs.<name>.factory |
string, required, not empty | - | a factory's name: itunes, odesli, or an application's own factory's getName() |
omnisong.catalogs.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create() (Configuration) |
omnisong.player.theme |
enum light, dark |
light |
the embedder's default theme |
omnisong.player.country |
string or null | null |
ISO 3166-1 alpha-2: the store Apple Music's player opens, over the one in the link |
Processed with nothing set, the tree is {"catalogs": [], "player": {"theme": "light", "country": null}}. A theme other than the two is refused when the container compiles (The value "blue" is not allowed for path "omnisong.player.theme". Permissible values: "light", "dark".), and so is an
empty factory (The path "omnisong.catalogs.a.factory" cannot contain an empty value, but got "".). The options are not validated then - the tree accepts any value - but when the catalogue is
built, the first time it is asked for: a required option missing is an InvalidConfigException
naming it.
The player's compact, autoplay and color have no configuration key: the embedder's defaults
are always compact: false, autoplay: false, color: null; a call or a Twig function gives them
(Players).
Services
Each catalogue package the bundle knows - omnisong/odesli (Omnisong\Odesli\OdesliCatalogFactory)
and omnisong/itunes (Omnisong\Itunes\ItunesCatalogFactory) - has its factory registered when its
class exists, built on the application's http_client service, or with none when the application
has no such service (the factory then makes its own client).
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnisong\Registry |
Registry |
Registry $registry |
every configured catalogue by name. Public. |
Omnisong\Catalog\Catalog |
Catalog |
CatalogInterface $catalog (any argument name not taken by a catalogue), Catalog $catalog |
the aggregator over every configured catalogue, in the configured order: OmnisongBundle::aggregate($registry) |
omnisong.catalog.<name> |
CatalogInterface |
CatalogInterface $<name> |
one catalogue, built by Registry::get('<name>') the first time it is injected |
Omnisong\Player\EmbedOptions |
EmbedOptions |
EmbedOptions $options |
the player's defaults: the configured theme and country |
Omnisong\Player\Embedder |
Embedder |
EmbedderInterface $embedder, Embedder $embedder |
the embedder, with those defaults |
Omnisong\Bridge\Twig\OmnisongExtension |
the Twig extension | - | registered only when Twig is installed, tagged twig.extension |
Omnisong\Odesli\OdesliCatalogFactory, Omnisong\Itunes\ItunesCatalogFactory |
the factories | by their class | tagged omnisong.catalog_factory |
use Omnisong\Catalog\CatalogInterface;
use Omnisong\Player\EmbedderInterface;
final class DiscographyController
{
public function __construct(
private readonly CatalogInterface $catalog, // the aggregator
private readonly CatalogInterface $itunes, // the "itunes" catalogue alone
private readonly EmbedderInterface $embedder,
) {
}
}
The argument's name selects a catalogue: CatalogInterface $itunes is omnisong.catalog.itunes
(Symfony's named autowiring aliases, registerAliasForArgument()); any other name is the
aggregator. A catalogue's name that is not a valid PHP variable name is still reachable by its
service id or by the registry.
The aggregator is a shared service: its $incomplete is that of the last call made through it, in
this request or worker loop. Read it right after the call it describes.
Nothing is built when the container compiles: the aggregator builds every catalogue
(Registry::all()) the first time it is injected, a single catalogue the first time it is asked
for.
An application's own catalogue
A class of the application that implements Omnisong\Catalog\CatalogFactoryInterface (usually by
extending Omnisong\Catalog\CatalogFactory) is autoconfigured: tagged omnisong.catalog_factory,
it joins the registry, and its getName() can be named as a factory:
omnisong:
catalogs:
label:
factory: label_shop # App\Music\LabelShopCatalogFactory::getName()
itunes:
factory: itunes
Writing a catalogue says what such a factory and its catalogue must do.
Twig
With twig/twig installed, Omnisong\Bridge\Twig\OmnisongExtension gives the templates:
| Name | Kind | Arguments | Returns |
|---|---|---|---|
omnisong_embed |
function, safe HTML | a PlatformLink, a URL string, or null; options {theme, compact, autoplay, country, color} over the configured defaults |
the iframe's HTML, or null when there is no player for it |
omnisong_platform |
function | a platform's value ('apple_music') |
the Platform case, or null |
omnisong_platform_label |
filter | a Platform or its value |
its name (Apple Music); what is not a platform is returned as it is |
{{ omnisong_embed(release.links.get(platform)) }}
{{ omnisong_embed('https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy', {theme: 'dark', compact: true}) }}
{{ omnisong_platform('apple_music').label }} {# Apple Music #}
{{ link.platform.value|omnisong_platform_label }}
{{ 'myspace'|omnisong_platform_label }} {# myspace #}
A URL string is read with Platform::fromUrl(); one on no known platform, an empty string and null
give null, so {% if omnisong_embed(url) %} is not needed - a null prints nothing. The function's
options are cast (theme to a string, compact and autoplay to booleans); a key not given takes
the configured default.
The bundle registers no command, event, route or controller.
Caching
The catalogues call remote services that limit the calls (iTunes allows about 20 a minute): cache what they answer, with the application's cache, keyed by the reference - and never an answer the aggregator marks incomplete:
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
$release = $cache->get('release.'.$upc, function (ItemInterface $item) use ($catalog, $upc, $label) {
$release = $catalog->release(Reference::upc($upc), $label);
$item->expiresAfter($catalog->incomplete ? 60 : 86400); // a half answer is kept one minute only
return $release;
});
Propose a change docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnisong provider, and there
is no other Laravel package for it. The registry, the aggregator and the embedder are plain PHP: a
service provider of the application builds them once, from a config file, with a
symfony/http-client client (both catalogue packages require it; Laravel's own HTTP client is
Guzzle's, which the catalogues do not take).
// config/omnisong.php
return [
'catalogs' => [ // in the order the aggregator asks them
'odesli' => ['factory' => 'odesli', 'options' => [
'api_key' => env('ODESLI_API_KEY'),
'country' => 'DE',
]],
'itunes' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
],
'player' => ['theme' => 'light', 'country' => null],
];
// app/Providers/OmnisongServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnisong\Catalog\Catalog;
use Omnisong\Catalog\CatalogInterface;
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Odesli\OdesliCatalogFactory;
use Omnisong\Player\Embedder;
use Omnisong\Player\EmbedderInterface;
use Omnisong\Player\EmbedOptions;
use Omnisong\Registry;
use Symfony\Component\HttpClient\HttpClient;
final class OmnisongServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function (): Registry {
$http = HttpClient::create();
return new Registry([
new OdesliCatalogFactory($http),
new ItunesCatalogFactory($http),
], config('omnisong.catalogs'));
});
$this->app->singleton(CatalogInterface::class, fn ($app) => new Catalog($app->make(Registry::class)->all()));
$this->app->singleton(EmbedderInterface::class, fn () => new Embedder(new EmbedOptions(
config('omnisong.player.theme', EmbedOptions::LIGHT),
country: config('omnisong.player.country'),
)));
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject what is
needed:
use Omnisong\Catalog\CatalogInterface;
use Omnisong\Model\Reference;
use Omnisong\Player\EmbedderInterface;
final class DiscographyController
{
public function show(string $upc, CatalogInterface $catalog, EmbedderInterface $embedder)
{
$release = $catalog->release(Reference::upc($upc));
// ...
}
}
Differences from the Symfony bundle:
- list in the provider every catalogue factory the application uses: a hand-built registry knows only the factories it is given;
- there is no Blade counterpart of the Twig functions: call
$embedder->embed($link)?->htmlin the controller or a view component, and print it unescaped ({!! $html !!}) - the embedder escapes the URL and the title it puts in the iframe; - the theme is not validated: a value other than
darkis the light theme.
Configuration caching (php artisan config:cache) keeps working: the options are plain arrays read
through config(). This page was written from the registry's and the embedder's API; it was not run
in a Laravel application.
Propose a change docs/laravel.md
Concepts
Catalogues
A catalogue is a service that knows recordings, behind Omnisong\Catalog\CatalogInterface:
interface CatalogInterface
{
public function getName(): string; // "itunes", "odesli"...
public function links(Reference $reference): PlatformLinks; // the same recording on every platform it knows
public function release(Reference $reference): ?Release; // the release the reference names, null when unknown
public function releases(string $artist, int $limit = 50, ?string $country = null): array; // list<Release>, newest first
}
The rule every catalogue follows: answer what it can, throw NotSupportedException for the rest -
a kind of reference it cannot read, or an operation it does not do. Unknown is not an error:
links() gives an empty PlatformLinks, release() gives null, releases() an empty list. A
service that cannot be reached, is down, refuses the key or asks to slow down is an
UnavailableException (or its child RateLimitedException), never "unknown": that is what lets a
caller tell a real "not found" from a half answer (Errors).
Factories and their Config
A catalogue is built by its factory, Omnisong\Catalog\CatalogFactoryInterface:
| Method | |
|---|---|
getName(): string |
the name catalogues are configured with: itunes, odesli |
create(array $options = []): CatalogInterface |
a catalogue built from those options |
Both packages extend Omnisong\Catalog\CatalogFactory, the Omnibus way at its simplest:
| Member | |
|---|---|
__construct(?HttpClientInterface $http = null) |
the HTTP client the catalogue calls with; null: the catalogue package makes its own (HttpClient::create()) |
getName() |
the omnisong.factory_name its populate() sets |
create($options) |
createConfig($options), checks that every key of omnisong.required_options is filled, then build() |
createConfig($options): Config |
the options given, with the factory's defaults under them |
populate(Config $c) (abstract, protected) |
sets the name, the required options and the defaults of the others, with $c->defaults([...]) |
build(Config $c) (abstract, protected) |
the catalogue, from a Config that holds everything it needs |
Omnisong\Config is an ArrayObject of the options:
| Method | |
|---|---|
defaults(array $defaults): self |
sets each key not set yet: what the caller gave wins, even null |
validateNotEmpty(array $keys): void |
throws InvalidConfigException naming the keys that are unset or null, '' or []: The "<factory name>" catalogue needs: <key>, <key>. |
get(string $key, mixed $default = null) |
the value, or the default when the key is not set |
Two keys are the factory's own, not options: omnisong.factory_name and omnisong.required_options
(a list of option names). Neither shipped catalogue has a required option.
The registry
Omnisong\Registry holds the catalogues by name:
$registry = new Registry(
[new OdesliCatalogFactory($http), new ItunesCatalogFactory($http)], // iterable<CatalogFactoryInterface>
[ // name => factory and options
'odesli' => ['factory' => 'odesli', 'options' => ['api_key' => '...']],
'itunes' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
],
);
| Method | |
|---|---|
get(string $name): CatalogInterface |
the catalogue, built once with its factory's create($options) and kept. A name not configured, or a factory not given, is an InvalidConfigException: No "spotify" catalogue; configured: odesli, itunes., No "deezer" factory for the "x" catalogue; installed: odesli, itunes. |
has(string $name): bool |
whether the name is configured (not whether it can be built) |
names(): list<string> |
the configured names, in the configured order |
all(): array<string, CatalogInterface> |
every configured catalogue, built, in the configured order |
factories(): list<string> |
the factories' names: what factory may name |
The configured order matters: it is the order the aggregator asks the catalogues in. A factory given twice under the same name: the last one wins.
The aggregator
Omnisong\Catalog\Catalog implements CatalogInterface over a list of catalogues, asked in the
order given (new Catalog($registry->all())). Its getName() is catalog.
| Member | |
|---|---|
__construct(iterable $catalogs) |
the catalogues, in the order they are asked |
names(): list<string> |
their names, in that order |
links(Reference $reference, ?Label $label = null): PlatformLinks |
every catalogue's links, merged; the label's first when given |
release(Reference $reference, ?Label $label = null): ?Release |
the release every catalogue knows, merged, with every catalogue's links |
releases(string $artist, int $limit = 50, ?string $country = null): list<Release> |
the first catalogue's non-empty answer |
public array $incomplete |
the names of the catalogues skipped as unavailable during the last call, reset at the start of each |
How each call asks and merges is in Catalogues and the aggregator.
References
Omnisong\Model\Reference is what a catalogue is asked about. One of its kinds is enough; it is
built by its named constructors only (the constructor is private):
| Constructor | Sets | Normalised |
|---|---|---|
Reference::url(string $url, ?string $country = null) |
url, and platform from Platform::fromUrl($url) (null when unknown) |
no |
Reference::isrc(string $isrc, ?string $country = null) |
isrc, type: song |
everything but letters and digits removed, upper case: DE-A12-25-00001 is DEA122500001 |
Reference::upc(string $upc, ?string $country = null) |
upc, type: album |
everything but digits removed: 0 4015372 820954 is 04015372820954 |
Reference::id(Platform $platform, string $id, string $type = Reference::ALBUM, ?string $country = null) |
platform, id, type |
no |
Reference::parse(string $value, ?string $country = null) |
one of the above, read from a loose string | trimmed |
parse() reads, in this order: http:// or https:// (any case) is a URL; 12 to 14 digits is a
UPC/EAN; two letters, three letters or digits and seven digits (dashes ignored) is an ISRC. Anything
else is an \InvalidArgumentException ("hello" is neither a URL, a UPC/EAN nor an ISRC.), and so
is a UPC written with spaces: parse() does not strip them, upc() does.
Reference::parse() throws an Omnisong\Exception\InvalidReferenceException for a value it cannot read: an OmnisongException, and an \InvalidArgumentException still.
| Property | Type | |
|---|---|---|
url |
?string |
a URL on any platform |
isrc |
?string |
an ISRC (a recording) |
upc |
?string |
a UPC or EAN (a release) |
platform |
?Platform |
the platform of url or of id |
id |
?string |
the platform's own id |
type |
?string |
Reference::SONG ('song') or Reference::ALBUM ('album'), when known; a URL's is null |
country |
?string |
ISO 3166-1 alpha-2: the store the links should point at; over the catalogue's configured country |
withCountry(?string $country) gives a copy for another store. (string) $reference is the URL,
else the ISRC, else the UPC, else platform:id (itunes:1793146044).
Releases and tracks
Omnisong\Model\Release is an album, a single or an EP. Immutable: every property is readonly.
| Property | Type | Default | |
|---|---|---|---|
title |
string |
- | without the store's " - Single" / " - EP" suffix |
artist |
?string |
null |
as the catalogue writes it |
label |
?Label |
null |
the label it came out on |
upc |
?string |
null |
the UPC/EAN that names it |
releasedAt |
?\DateTimeImmutable |
null |
|
coverUrl |
?string |
null |
the cover image (iTunes: 1200 x 1200) |
type |
string |
Release::ALBUM |
Release::ALBUM ('album'), Release::SINGLE ('single') or Release::EP ('ep') |
tracks |
list<Track> |
[] |
iTunes gives them by disc, then position |
links |
?PlatformLinks |
null |
every platform it is on, the label's first |
genre |
?string |
null |
the store's primary genre |
trackCount |
?int |
null |
as the catalogue says, or the number of tracks set by withTracks() |
ids |
array<string, string> |
[] |
a platform's value => its id for the release (['itunes' => '1793146044', ...]) |
copyright |
?string |
null |
the store's copyright line (℗ 2025 ES-DUR) |
| Method | |
|---|---|
withLinks(PlatformLinks $links) |
a copy with these links |
withTracks(list<Track> $tracks) |
a copy with these tracks; trackCount becomes their number when it was null |
withLabel(?Label $label) |
a copy with this label |
merge(?Release $other) |
what another catalogue knew that this one did not, below |
merge() keeps this release's title and type, fills every other null from $other, adds the ids
$other has and this one has not, and merges the links (this one's first). The tracks: when only
one side has some, those; when both have, this side's, each given the preview of its twin on the
other side when it has none - the twin found by ISRC first, else by the same place in the list.
Omnisong\Model\Track is one recording of a release:
| Property | Type | Default | |
|---|---|---|---|
title |
string |
- | |
position |
?int |
null |
its number on the disc |
disc |
?int |
null |
|
duration |
?int |
null |
seconds |
isrc |
?string |
null |
the ISRC that names it everywhere |
previewUrl |
?string |
null |
the preview a catalogue offers (iTunes: 30 seconds of AAC, licensed for promotion next to a store link) |
artist |
?string |
null |
|
composer |
?string |
null |
|
ids |
array<string, string> |
[] |
a platform's value => its id for the track |
url |
?string |
null |
the track's page on the store |
withPreview(?string $previewUrl) gives a copy with another preview.
Labels
Omnisong\Model\Label is the label a release came out on, who a site shows first:
| Property | Type | |
|---|---|---|
name |
string |
ES-DUR |
url |
?string |
the label's site, or the release's page on it |
shopUrl |
?string |
where the label sells the release |
A catalogue gives a label by name only (iTunes reads it from the copyright line); the site's own
Label, given to links() or release(), carries the URLs and replaces it.
Links
Omnisong\Model\PlatformLink is one place the recording is:
| Property | Type | |
|---|---|---|
platform |
Platform |
|
url |
string |
|
id |
?string |
the platform's own id, when known (Odesli gives one per platform, iTunes the collection id) |
title |
?string |
for LABEL and SHOP: the name to show (ES-DUR) |
country |
?string |
the store the URL points at, when the catalogue says |
title() is title, else the platform's label().
Omnisong\Model\PlatformLinks holds the links of one recording, one per platform, in the order a
site shows them: by Platform::rank(), then by the platform's value. It is immutable (every method
that changes it gives a new one), Countable, and iterable as platform value => PlatformLink.
| Method | |
|---|---|
new PlatformLinks(iterable $links = []) |
the first link of each platform is kept, the others dropped |
PlatformLinks::none() |
an empty one |
get(Platform $platform): ?PlatformLink, has(Platform $platform): bool |
|
first(): ?PlatformLink |
the first in order: the label's when there is one |
with(PlatformLink $link) |
this link added, replacing the platform's link when there was one |
withLabel(Label $label) |
a LABEL link to the label's url (else its shopUrl) named after it, and a SHOP link to its shopUrl when it differs; both replace the links of those platforms. A label with neither URL changes nothing |
merge(?PlatformLinks $other) |
both sides' links; a platform both have keeps this side's |
all(): list<PlatformLink> |
in order |
embeddable(): list<PlatformLink> |
those the embedder has a player for |
toArray(): array<string, string> |
platform value => URL, in order: what a site stores |
PlatformLinks::fromArray(array $urls, ?string $labelName = null) |
back from that array; a key that is not a platform's value, a URL that is not a non-empty string are skipped; $labelName becomes the title of the label and shop links |
Platforms
Omnisong\Platform is a string-backed enum of the places a recording can be listened to or bought,
in rank() order:
| Case | Value | label() |
rank() |
embeddable() |
song.link key |
|---|---|---|---|---|---|
LABEL |
label |
Label | 0 | - | - |
SHOP |
shop |
Shop | 1 | - | - |
BANDCAMP |
bandcamp |
Bandcamp | 2 | - | bandcamp |
SPOTIFY |
spotify |
Spotify | 10 | yes | spotify |
APPLE_MUSIC |
apple_music |
Apple Music | 11 | yes | appleMusic |
YOUTUBE_MUSIC |
youtube_music |
YouTube Music | 12 | yes | youtubeMusic |
YOUTUBE |
youtube |
YouTube | 13 | yes | youtube |
DEEZER |
deezer |
Deezer | 14 | yes | deezer |
QOBUZ |
qobuz |
Qobuz | 15 | - | - |
IDAGIO |
idagio |
Idagio | 16 | - | - |
TIDAL |
tidal |
Tidal | 17 | - | tidal |
AMAZON_MUSIC |
amazon_music |
Amazon Music | 18 | - | amazonMusic |
ITUNES |
itunes |
iTunes Store | 20 | - | itunes |
AMAZON |
amazon |
Amazon | 21 | - | amazonStore |
PRESTO |
presto |
Presto Music | 22 | - | - |
HIGHRESAUDIO |
highresaudio |
HIGHRESAUDIO | 23 | - | - |
SOUNDCLOUD |
soundcloud |
SoundCloud | 30 | yes | soundcloud |
NAPSTER |
napster |
Napster | 31 | - | napster |
PANDORA |
pandora |
Pandora | 32 | - | pandora |
ANGHAMI |
anghami |
Anghami | 33 | - | anghami |
BOOMPLAY |
boomplay |
Boomplay | 34 | - | boomplay |
AUDIOMACK |
audiomack |
Audiomack | 35 | - | audiomack |
AUDIUS |
audius |
Audius | 36 | - | audius |
OTHER |
other |
Other | 99 | - | - |
(generated from the enum on 2026-10-26). The rank puts the label first, then the shops and Bandcamp, then the streamers by their weight in classical music, then the stores, then the rest.
Platform::fromOdesli(string $key): ?Platformmaps song.link'slinksByPlatformkeys (last column); any other key is null.Platform::fromUrl(string $url): ?Platformreads the URL's host, without a leadingwww.,open.,music.,listen.,play.orgeo.:spotify.com;apple.comwhen the path has/album/,/song/,/playlist/,/artist/or/music-video/(Apple Music; another Apple page is null);youtube.comandyoutu.be(YouTube Music when the host starts withmusic.);deezer.com,qobuz.com,idagio.com,tidal.com;amazon.com,.fr,.deand.co.uk(Amazon Music when the URL has/music/or/albums/, else Amazon);prestomusic.com,highresaudio.com,bandcamp.com(any subdomain),soundcloud.com,napster.com,pandora.com. Anghami, Boomplay, Audiomack and Audius URLs, and any other host, are null.
LABEL, SHOP, QOBUZ, IDAGIO, PRESTO, HIGHRESAUDIO and OTHER come from no catalogue:
the first two from a Label, the others from a URL the site gives (Reference::url(),
PlatformLinks::fromArray()).
Players
| Class | |
|---|---|
Omnisong\Player\EmbedderInterface |
supports(Platform $platform): bool; embed(PlatformLink $link, ?EmbedOptions $options = null): ?Embed, null when the URL is not one the platform embeds |
Omnisong\Player\Embedder |
the core's implementation, from the platforms' public embed URLs: new Embedder(?EmbedOptions $defaults = null); supports() is Platform::embeddable() |
Omnisong\Player\EmbedOptions |
how an embed looks, below |
Omnisong\Model\Embed |
the result: platform (Platform), html (the iframe), src (its URL: what a Content-Security-Policy's frame-src must allow), width (always 100: the iframe is 100 % wide), height (pixels) |
EmbedOptions, all readonly:
| Property | Type | Default | |
|---|---|---|---|
theme |
string |
EmbedOptions::LIGHT ('light') |
EmbedOptions::DARK ('dark') for a dark page; any other value is light |
compact |
bool |
false |
the smaller player |
autoplay |
bool |
false |
read by YouTube and SoundCloud only |
country |
?string |
null |
ISO 3166-1 alpha-2: the store Apple Music opens, over the one in its URL |
color |
?string |
null |
a hex colour, for SoundCloud |
dark(): bool is theme === 'dark'. What each platform does with them: Players.
Declared, not used
Two models are part of the package and used by none of its classes nor by either catalogue:
Omnisong\Model\Artist:name(string),ids(platform value => id),url,imageUrl- a performer as a catalogue would name it.releases()takes the artist's name as a string.Omnisong\Model\Source:url,kind(Source::FILE'file'orSource::PREVIEW'preview'),mime,duration(seconds) - something an<audio>element can play. A track's preview is itspreviewUrlstring.
An application may use them for its own data; no catalogue returns them.
Propose a change docs/concepts.md
Catalogues and the aggregator
The examples below use $catalog, the aggregator over the two catalogues, Odesli first:
use Omnisong\Catalog\Catalog;
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Odesli\OdesliCatalogFactory;
use Omnisong\Registry;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry([new OdesliCatalogFactory($http), new ItunesCatalogFactory($http)], [
'odesli' => ['factory' => 'odesli', 'options' => ['api_key' => getenv('ODESLI_API_KEY') ?: null, 'country' => 'DE']],
'itunes' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
]);
$catalog = new Catalog($registry->all());
Each catalogue can also be called alone ($registry->get('itunes')->release(...)): then a
NotSupportedException or an UnavailableException reaches the caller instead of being absorbed.
A release
use Omnisong\Model\Label;
use Omnisong\Model\Reference;
$release = $catalog->release(
Reference::upc('4015372820954'),
new Label('ES-DUR', 'https://www.es-dur.de', 'https://www.es-dur.de/shop/perspectives'),
);
if (null === $release) {
// no catalogue knows it (or every one that could was unavailable: check $catalog->incomplete)
}
$release->title; // Perspectives Concertantes
$release->tracks; // list<Track>, each with its previewUrl
$release->links; // label, shop, then every platform
What the aggregator does, in order:
- Each catalogue is asked
release()in turn. Before the first answer, by the reference given. Once one catalogue found the release, the next ones are asked by the reference given and then by what was learnt: the release's UPC first when the reference had none, then its id on each platform it has one for (Release::$ids), asReference::id()of typealbum- orsongfor a single without tracks. A catalogue is asked by the first of these it can read; its first answer that is not aNotSupportedExceptionis taken, evennull. - Each answer is merged into the first (
Release::merge()): the first catalogue's title and type stay, what it did not know is filled, and its tracks get the previews the other's tracks have. - Nobody found it:
null. - The links: the release's own (a store's canonical URL beats a redirect), then every catalogue's
links(), asked as in Links by the reference given and what was learnt. - The label given replaces the release's; the label's links go first.
With the recorded answers of the harness (iTunes' of 2026-10-03, Odesli's written from album.link), the calls the script above makes are:
GET https://itunes.apple.com/lookup?upc=4015372820954&entity=song&country=de&limit=200
GET https://api.song.link/v1-alpha.1/links?platform=itunes&type=album&id=1793146044&userCountry=DE&key=...
Odesli is first, but cannot read a UPC: its release() throws NotSupportedException without a
call, and it has no second turn - every reference it could read was learnt after it. The release is
iTunes'; Odesli, asked for links by the iTunes id iTunes gave, adds the other platforms. The
result:
Perspectives Concertantes | album | 4015372820954 | ES-DUR | 8 tracks
label ES-DUR https://www.es-dur.de
shop ES-DUR https://www.es-dur.de/shop/perspectives
apple_music Apple Music https://music.apple.com/de/album/perspectives-concertantes/1793146044
tidal Tidal https://listen.tidal.com/album/414051173
amazon_music Amazon Music https://music.amazon.com/albums/B0DV5P8V78
itunes iTunes Store https://music.apple.com/de/album/perspectives-concertantes/1793146044?app=itunes
amazon Amazon https://amazon.com/dp/B0DV5P8V78
incomplete: []
(run on 2026-10-26 on a MockHttpClient serving those files, PHP 8.4). Without a key, song.link
answers 401: the same calls, Odesli named in incomplete (["odesli"]), and only the label, shop,
Apple Music and iTunes Store links.
The catalogues keep what they were answered for the life of the object (by query): the
second links() that release() makes of iTunes, by the UPC it was first asked, calls nothing.
Links
$links = $catalog->links(Reference::parse('https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'), $label);
foreach ($links as $value => $link) { // 'label' => PlatformLink, 'spotify' => ...
echo $link->title(), ': ', $link->url, "\n";
}
$links->get(Platform::DEEZER)?->url;
$links->toArray(); // ['label' => 'https://...', 'spotify' => 'https://...']: store it
PlatformLinks::fromArray($stored, 'ES-DUR'); // and read it back
Every catalogue is asked links() by the reference. One that can read it adds its links; when
several give a link to the same platform, the catalogue asked first wins. A catalogue that can read
none of the references (Odesli and a UPC or an ISRC) is asked again, once every other catalogue has
answered, by the platform ids those links carry - in the links' order, so Apple Music's id before
the iTunes Store's - as Reference::id() of an album: those links name the release that holds the recording (iTunes
answers an ISRC with its album's links), so links(Reference::isrc(...)) asks Odesli
?platform=appleMusic&type=album&id=1793146044 and gives the album's links on every platform.
The $label argument is optional; given, its links go first (Concepts).
An artist's releases
foreach ($catalog->releases('Anaëlle Tourret', limit: 10, country: 'de') as $release) {
echo $release->releasedAt?->format('Y'), ' ', $release->title, ' (', $release->type, ")\n";
}
The catalogues are asked in turn; the first non-empty answer is the answer, not merged with the
others'. Odesli does not list releases (NotSupportedException, skipped); iTunes searches albums by
artist name (attribute=artistTerm), newest first, without their tracks: ask release() for one of
them to get its tracks. The search is by name, so it may return another artist of the same name.
A half answer
$release = $catalog->release($reference);
if ($catalog->incomplete) {
// ['odesli']: a catalogue was down, refused the key or asked to slow down.
// Show what came, do not cache it as the answer.
}
A catalogue's UnavailableException (and RateLimitedException, its child) is caught: the
catalogue is skipped for the rest of the call and its name added to $incomplete. A
ProviderException that is not one of those (iTunes or Odesli answering 400) is not caught: it
reaches the caller. $incomplete is reset at the start of each links(), release() and
releases().
References
| To ask about | Build | Read by |
|---|---|---|
| a release by its barcode | Reference::upc('4015372820954') |
iTunes |
| a recording | Reference::isrc('DEA122500001') |
iTunes (its album) |
| anything with a URL | Reference::url('https://...') or Reference::parse('https://...') |
Odesli (any platform); iTunes (Apple URLs with an id) |
| a platform's id | Reference::id(Platform::ITUNES, '1793146044'), Reference::id(Platform::SPOTIFY, '4uLU6hMCjMI75M1A2tKUQC', Reference::SONG) |
iTunes (itunes, apple_music); Odesli (16 platforms) |
| a string a user typed | Reference::parse($value) |
the kind it reads |
The country argument of each constructor is the store asked: it overrides the catalogue's
configured country for that call (iTunes' country, Odesli's userCountry).
Propose a change docs/usage-catalog.md
Players
Omnisong\Player\Embedder turns a PlatformLink into the platform's own player, an iframe built
from the platform's public embed URL: no key, no SDK, no call.
use Omnisong\Model\PlatformLink;
use Omnisong\Platform;
use Omnisong\Player\Embedder;
use Omnisong\Player\EmbedOptions;
$embedder = new Embedder(new EmbedOptions(EmbedOptions::DARK)); // the site's defaults
$embed = $embedder->embed($release->links->get(Platform::SPOTIFY)); // ?Embed
$embed = $embedder->embed(new PlatformLink(Platform::DEEZER, 'https://www.deezer.com/fr/album/302127'),
new EmbedOptions(compact: true)); // these options instead of the defaults
echo $embed?->html; // the iframe
$embed->src; // https://widget.deezer.com/...: what frame-src must allow
$embed->height; // pixels; the width is always 100 %
foreach ($release->links->embeddable() as $link) { // those a player exists for
// ...
}
The options given to embed() replace the defaults given to the constructor as a whole: they are
not merged key by key (the Twig function does merge them, Symfony). With neither,
new EmbedOptions(): light, full size, no autoplay.
embed() returns null when the platform has no player (supports() is false: only Spotify,
Apple Music, Deezer, YouTube, YouTube Music and SoundCloud have one) or when the URL is not one the
platform embeds (a Spotify search page, an Apple Music artist page).
The iframe
Every player is the same iframe, its src and height the platform's:
<iframe src="https://embed.music.apple.com/de/album/perspectives-concertantes/1793146044" title="Apple Music" width="100%" height="450" style="border:0;border-radius:12px" frameborder="0" loading="lazy" allowfullscreen allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"></iframe>
loading="lazy"and a sandbox that lets the player run, open its own pages on a click, and use its storage after the listener's gesture;- the
titleis the link'stitle()(the platform's name), and both it andsrcare HTML-escaped; - no
widthchoice: the iframe fills its container, andEmbed::$widthis100.
Load the player on a click, not with the page: it sets the platform's cookies, which is a matter of the site's consent policy. Spotify, Apple Music and Deezer play the whole track to a listener signed in with a subscription, and a 30-second preview to everyone else.
Per platform
The URLs each player reads and what it builds (checked on 2026-10-26 by running the embedder on each URL below):
| Platform | Reads | src |
Height (full / compact) | Options read |
|---|---|---|---|---|
| Spotify | open.spotify.com/[intl-xx/]{album,track,playlist,artist,episode,show}/<id> |
https://open.spotify.com/embed/<kind>/<id>?utm_source=omnisong&theme=1 (theme=0 dark) |
352 / 152; a track is always 152 | theme, compact |
| Apple Music | music.apple.com/<cc>/{album,playlist,song}/<slug>/<id>[?i=<track>] (also geo.music.apple.com) |
https://embed.music.apple.com/<cc>/<kind>/<slug>/<id>, with i kept and theme=dark when dark |
450 / 175; a song or ?i= is always 175 |
theme, compact, country (replaces <cc>, lower case) |
| Deezer | deezer.com/[xx/]{album,track,playlist,artist}/<digits> |
https://widget.deezer.com/widget/{light,dark}/<kind>/<id> |
300 / 90; a track is always 90 | theme, compact |
| YouTube, YouTube Music | youtu.be/<id>, youtube.com/watch?v=<id>, /embed/<id>, /shorts/<id>, /live/<id>, any URL with list= |
https://www.youtube-nocookie.com/embed/<id>?[list=..&][autoplay=1&]rel=0&modestbranding=1, or /embed/videoseries?list=... for a playlist alone |
315 / 200 | compact, autoplay (no theme) |
| SoundCloud | any URL containing soundcloud.com/ |
https://w.soundcloud.com/player/?url=<the URL>&auto_play=false&visual=false&show_comments=false&hide_related=true[&color=...] |
166 / 20 | compact, autoplay, color (no theme) |
Examples, as the embedder answered them:
https://open.spotify.com/intl-de/album/4aawyAB9vmqN3uQ7FjRGTy
src=https://open.spotify.com/embed/album/4aawyAB9vmqN3uQ7FjRGTy?utm_source=omnisong&theme=1 height=352
https://music.apple.com/de/album/perspectives-concertantes/1793146044?i=1793146045
src=https://embed.music.apple.com/de/album/perspectives-concertantes/1793146044?i=1793146045 height=175
https://www.deezer.com/fr/album/302127
src=https://widget.deezer.com/widget/light/album/302127 height=300
https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123
src=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?list=PL123&rel=0&modestbranding=1 height=315
https://music.youtube.com/playlist?list=OLAK5uy_abc
src=https://www.youtube-nocookie.com/embed/videoseries?list=OLAK5uy_abc&rel=0&modestbranding=1 height=315
https://soundcloud.com/artist/track
src=https://w.soundcloud.com/player/?url=https%3A%2F%2Fsoundcloud.com%2Fartist%2Ftrack&auto_play=false&visual=false&show_comments=false&hide_related=true height=166
https://open.spotify.com/search/foo
no player (null)
Spotify's player is built for an episode or a show too, although Platform::fromUrl() names any
spotify.com URL Spotify. YouTube has no theme: the dark option changes nothing there.
Content-Security-Policy
Embed::$src names the host a page's frame-src must allow. The hosts, by platform:
| Platform | frame-src |
|---|---|
| Spotify | https://open.spotify.com |
| Apple Music | https://embed.music.apple.com |
| Deezer | https://widget.deezer.com |
| YouTube, YouTube Music | https://www.youtube-nocookie.com |
| SoundCloud | https://w.soundcloud.com |
The players load further resources of their own (scripts, audio); a strict policy on the page does not reach inside the iframe.
Previews without a player
A track's previewUrl (iTunes: 30 seconds of AAC in an .m4a) plays in an <audio> element with
no third-party iframe and no cookie:
{% for track in release.tracks %}
{{ track.position }}. {{ track.title }}
{% if track.previewUrl %}<audio controls preload="none" src="{{ track.previewUrl }}"></audio>{% endif %}
{% endfor %}
{% set apple = release.links.get(constant('Omnisong\\Platform::APPLE_MUSIC')) %}
{% if apple %}<a href="{{ apple.url }}">Apple Music</a>{% endif %}
Apple licenses the previews for promotion next to a link to its store: keep the Apple Music link
beside them (the omnisong/itunes README's Responsibility section names the terms).
Propose a change docs/usage-players.md
Configuration reference
The registry
new Registry(iterable $factories, array $config);
| Key | Type | Required | |
|---|---|---|---|
<name> |
the catalogue's name, a string | - | how it is asked for: get('<name>'), and in Symfony the argument's name. The configured order is the aggregator's. $incomplete and Catalog::names() name a catalogue by its own getName() (itunes), not by this name |
<name>.factory |
string | yes | a factory's getName(): itunes, odesli, or an application's own |
<name>.options |
map | no, default [] |
handed to the factory's create() |
The same tree is the Symfony bundle's omnisong.catalogs (Symfony).
The same factory can serve several catalogues - two iTunes stores, for example:
new Registry([new ItunesCatalogFactory($http)], [
'itunes_de' => ['factory' => 'itunes', 'options' => ['country' => 'de']],
'itunes_fr' => ['factory' => 'itunes', 'options' => ['country' => 'fr']],
]);
Both are named itunes by the aggregator, in $incomplete too.
iTunes (itunes)
From Omnisong\Itunes\ItunesCatalogFactory::populate() and build():
| Option | Type | Default | Required | |
|---|---|---|---|---|
country |
string | us |
no | the store asked (ISO 3166-1 alpha-2, sent in lower case): what is sold there, in its language. Empty or null is us. A reference's own country overrides it for that call |
base_uri |
string | https://itunes.apple.com/ |
no | the API's root; lookup and search are appended. Empty or null is the default |
Odesli (odesli)
From Omnisong\Odesli\OdesliCatalogFactory::populate() and build():
| Option | Type | Default | Required | |
|---|---|---|---|---|
api_key |
string or null | null |
no (but see below) | sent as key; empty is null, and then no key is sent |
country |
string or null | null |
no | sent as userCountry (upper case): the store the links point at. Null: song.link's default (US). A reference's own country overrides it |
base_uri |
string | https://api.song.link/v1-alpha.1/ |
no | the API's root; links is appended. Empty or null is the default |
api_key is not declared required, so a catalogue without one is built - but song.link answers 401
without a key since it withdrew keyless access (seen on 2026-10-03), and every call is then an
UnavailableException: the aggregator skips Odesli and names it in $incomplete.
Neither factory reads any other option: an unknown key is ignored, not refused.
The embedder
new Embedder(?EmbedOptions $defaults = null);
new EmbedOptions(string $theme = 'light', bool $compact = false, bool $autoplay = false, ?string $country = null, ?string $color = null);
| Option | Type | Default | |
|---|---|---|---|
theme |
string | light |
dark (EmbedOptions::DARK) for Spotify, Apple Music and Deezer's dark players; any other value is light |
compact |
bool | false |
the smaller player (heights) |
autoplay |
bool | false |
YouTube (autoplay=1) and SoundCloud (auto_play=true) only |
country |
string or null | null |
Apple Music: the store in the embed URL, over the one in the link |
color |
string or null | null |
SoundCloud: a hex colour, with or without # |
In Symfony, omnisong.player.theme and omnisong.player.country set the embedder's defaults;
compact, autoplay and color have no key there (Symfony).
Environment variables
The packages read no environment variable themselves. The names used in these pages and in the harness are conventions:
| Variable | Used for |
|---|---|
ODESLI_API_KEY |
Odesli's api_key (%env(default::ODESLI_API_KEY)% in Symfony: empty when unset) |
ODESLI_COUNTRY, ITUNES_COUNTRY |
the harness's country options (harness) |
OMNISONG_PLUGINS, OMNISONG_AUTOLOAD |
the harness only |
Propose a change docs/configuration.md
Errors
Every exception of the package implements Omnisong\Exception\OmnisongException (an interface that
extends \Throwable), except the \InvalidArgumentException of Reference::parse().
| Exception | Parent | Thrown when |
|---|---|---|
InvalidConfigException |
\LogicException |
the registry is asked for a name not configured, or one whose factory was not given; a factory's required option is missing or empty |
NotSupportedException |
\LogicException |
a catalogue cannot read that kind of reference, or does not do that operation |
ProviderException |
\RuntimeException |
the service answered with an error that is neither "unknown" nor "unavailable" |
UnavailableException |
ProviderException |
the service could not be reached, is down, answered something that is not JSON, or refused the key |
RateLimitedException |
UnavailableException |
the service asks to slow down |
\InvalidArgumentException |
- | Reference::parse() read neither a URL, a UPC/EAN nor an ISRC |
A catalogue that calls with a key (Odesli's key=) keeps it out of what it throws: the HTTP client's
message with the key masked (key=***), the previous exception a copy of the client's
(Http\Redaction::copy(): the same class and code), no trace of the chain keeping its arguments,
where the query stood (zend.exception_ignore_args off).
Not found is never an exception: null, an empty PlatformLinks, an empty list.
Properties and messages
ProviderException and its children carry:
| Property | Type | |
|---|---|---|
catalog |
string |
the catalogue's name: itunes, odesli |
status |
?int |
the HTTP status, null for a transport error |
getMessage() |
[<catalog>] <message>: [odesli] HTTP 401 PUBLIC_API_ACCESS_DEPRECATED: a key may now be required (options: api_key, ODESLI_API_KEY) |
|
getPrevious() |
the HTTP client's transport exception, for a transport error |
RateLimitedException adds retryAfter (?int, seconds), from the Retry-After header; its
message is [<catalog>] rate limited, retry in <n> s, or [<catalog>] rate limited without one. Its
status is always 429, also when the service answered 403 (iTunes).
retryAfter reads Retry-After in seconds or as an HTTP date (the seconds until then, at least 1); null without the header.
NotSupportedException has two named constructors, whose messages say which:
NotSupportedException::reference('odesli', $reference) (The "odesli" catalogue cannot look up "4015372820954".) and NotSupportedException::operation('odesli', "list an artist's releases")
(The "odesli" catalogue does not list an artist's releases.).
By catalogue
| Answer | iTunes | Odesli |
|---|---|---|
| 2xx, JSON | the answer | the answer |
| 2xx, not JSON | UnavailableException |
UnavailableException |
400 could_not_resolve_entity |
ProviderException |
unknown (null, empty links) |
| 401, 403 | 403: RateLimitedException; 401: ProviderException |
UnavailableException, naming the key |
| 404 | unknown (no results) | unknown |
| 429 | RateLimitedException |
RateLimitedException |
| other 4xx | ProviderException (HTTP <status> <errorMessage>) |
ProviderException (HTTP <status> <code>) |
| 5xx | UnavailableException |
UnavailableException |
| transport error, timeout | UnavailableException |
UnavailableException |
What to catch
use Omnisong\Exception\NotSupportedException;
use Omnisong\Exception\ProviderException;
use Omnisong\Exception\RateLimitedException;
use Omnisong\Exception\UnavailableException;
try {
$release = $itunes->release($reference); // one catalogue, called alone
} catch (NotSupportedException) {
// this catalogue cannot read that reference: ask another
} catch (RateLimitedException $e) {
// come back in $e->retryAfter seconds (null: unknown)
} catch (UnavailableException) {
// down or unreachable: show what is cached, do not cache "nothing"
} catch (ProviderException $e) {
// the service refused the request: $e->status, $e->getMessage()
}
Under the aggregator, NotSupportedException and UnavailableException (with its child) are
caught and the next catalogue asked; the unavailable ones are named in $catalog->incomplete. A
ProviderException that is not an UnavailableException is not caught: it reaches the caller of
Catalog. InvalidConfigException is a programming or configuration error: let it surface.
Propose a change docs/errors.md
Testing
An application that uses Omnisong tests its own code, not the services: the catalogues take the
HTTP client they call with, so a test hands them a MockHttpClient and the answers it wants; and
the aggregator takes any CatalogInterface, so a test can give it catalogues of its own. The
embedder calls nothing and needs no double.
Omnisong ships no fake catalogue and no conformance suite: there is no Testing\ namespace in the
package. The core's own stubs (Omnisong\Tests\StubCatalog, StubCatalogFactory) are in its
Tests/ directory, autoloaded for its development only.
Two ways, in one test case
use Omnisong\Catalog\Catalog;
use Omnisong\Catalog\CatalogInterface;
use Omnisong\Exception\UnavailableException;
use Omnisong\Itunes\ItunesCatalogFactory;
use Omnisong\Model\Label;
use Omnisong\Model\PlatformLink;
use Omnisong\Model\PlatformLinks;
use Omnisong\Model\Reference;
use Omnisong\Model\Release;
use Omnisong\Model\Track;
use Omnisong\Platform;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class DiscographyTest extends TestCase
{
public function testTheReleaseComesFromTheRecordedAnswer(): void
{
$answer = new MockResponse(json_encode(['resultCount' => 2, 'results' => [
['wrapperType' => 'collection', 'collectionId' => 42, 'collectionName' => 'Harp - EP', 'artistName' => 'Anaëlle Tourret',
'collectionViewUrl' => 'https://music.apple.com/de/album/harp/42?uo=4', 'copyright' => '℗ 2025 ES-DUR', 'releaseDate' => '2025-02-28T08:00:00Z'],
['wrapperType' => 'track', 'kind' => 'song', 'collectionId' => 42, 'trackId' => 43, 'trackName' => 'Allegro', 'trackNumber' => 1,
'discNumber' => 1, 'trackTimeMillis' => 677000, 'previewUrl' => 'https://audio.example/43.m4a'],
]]));
$http = new MockHttpClient([$answer]);
$itunes = (new ItunesCatalogFactory($http))->create(['country' => 'de']);
$release = $itunes->release(Reference::upc('4015372820954'));
self::assertSame('Harp', $release->title);
self::assertSame(Release::EP, $release->type);
self::assertSame('ES-DUR', $release->label->name);
self::assertSame(677, $release->tracks[0]->duration);
self::assertSame('https://music.apple.com/de/album/harp/42', $release->links->get(Platform::APPLE_MUSIC)->url);
self::assertSame('https://itunes.apple.com/lookup?upc=4015372820954&entity=song&country=de&limit=200', $answer->getRequestUrl());
}
public function testADownCatalogueIsSkippedAndSaidSo(): void
{
$down = new class implements CatalogInterface {
public function getName(): string { return 'down'; }
public function links(Reference $reference): PlatformLinks { throw new UnavailableException('down', 'HTTP 503', 503); }
public function release(Reference $reference): ?Release { throw new UnavailableException('down', 'HTTP 503', 503); }
public function releases(string $artist, int $limit = 50, ?string $country = null): array { return []; }
};
$known = new class implements CatalogInterface {
public function getName(): string { return 'known'; }
public function links(Reference $reference): PlatformLinks { return new PlatformLinks([new PlatformLink(Platform::DEEZER, 'https://www.deezer.com/album/302127')]); }
public function release(Reference $reference): ?Release { return new Release('Harp', tracks: [new Track('Allegro', 1)]); }
public function releases(string $artist, int $limit = 50, ?string $country = null): array { return []; }
};
$catalog = new Catalog([$down, $known]);
$release = $catalog->release(Reference::upc('4015372820954'), new Label('ES-DUR', 'https://www.es-dur.de'));
self::assertSame(['down'], $catalog->incomplete);
self::assertSame(['label', 'deezer'], array_keys($release->links->toArray()));
}
}
$ vendor/bin/phpunit --bootstrap vendor/autoload.php DiscographyTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.27
.. 2 / 2 (100%)
Time: 00:00.017, Memory: 8.00 MB
OK (2 tests, 8 assertions)
(run on 2026-10-26 on the packages' checkouts, no network)
- The first test gives iTunes one recorded answer and checks what the application relies on: the
store's " - EP" read as the type, the label from the
℗line, the duration in seconds, the view URL without itsuotracking parameter, and the request sent (MockResponse::getRequestUrl()). iTunes answers with atext/javascriptcontent type; the catalogue reads the body as JSON whatever the header says, so aMockResponseneeds no header. - The second gives the aggregator two catalogues of its own: one that is down, one that knows the
release. It checks that the half answer is said (
$incomplete) and that the label goes first.
Recorded answers
The packages' own tests keep real answers as fixtures; an application can reuse their shape:
| File | What |
|---|---|
omnisong/itunes: Tests/Fixtures/lookup-upc.json |
lookup?upc=4015372820954&entity=song: the album and its 8 tracks (store de, 2026-10-03) |
omnisong/itunes: Tests/Fixtures/lookup-track.json |
a lookup that answers with one track (an ISRC, a track's id) |
omnisong/itunes: Tests/Fixtures/search-albums.json |
search?entity=album&attribute=artistTerm |
omnisong/odesli: Tests/Fixtures/links-album.json |
links for the same album, written from what album.link showed |
core: docker/harness/recorded/*.json |
the same iTunes and Odesli answers, for the harness's bare --recorded |
A MockHttpClient built with a callable can serve them by URL, as the harness's bare script does:
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$http = new MockHttpClient(static function (string $method, string $url): MockResponse {
return match (true) {
str_contains($url, 'itunes.apple.com/lookup') => new MockResponse(file_get_contents(__DIR__.'/fixtures/itunes-lookup.json')),
str_contains($url, 'api.song.link') => new MockResponse(file_get_contents(__DIR__.'/fixtures/odesli-links.json')),
default => new MockResponse('{"resultCount":0,"results":[]}'),
};
});
Failures are answers too: new MockResponse('', ['http_code' => 503]) is an
UnavailableException, ['http_code' => 429, 'response_headers' => ['Retry-After' => '30']] a
RateLimitedException with retryAfter 30, and a 401 from song.link the "key refused" case.
In Symfony
The bundle builds the factories on the http_client service: in the test environment, replace it
with a MockHttpClient and every catalogue calls it.
# config/packages/test/framework.yaml
framework:
http_client:
mock_response_factory: App\Tests\MusicResponses # an invokable class returning MockResponse
Or replace one catalogue: omnisong.catalog.<name> is a service like another, and
static::getContainer()->set('omnisong.catalog.itunes', $stub) works in a KernelTestCase before
it is first used. The aggregator (Omnisong\Catalog\Catalog) is built from the registry, not from
those services: to replace what it asks, replace http_client or the registry.
The package's own tests
docker compose run --rm omnisong test runs every package's tests in the harness;
Tests/BareTest.php checks in a process of its own that no class of a framework is loaded in plain
PHP, and Tests/Bridge/OmnisongBundleTest.php boots a kernel with the bundle and checks the
configured order, the named catalogues, the embedder's theme and the Twig functions.
Propose a change docs/testing.md
The Docker harness
docker/ runs this package with every omnisong/* catalogue installed - from GitHub (branch
1.x), or from the checkouts beside this one when OMNISONG_PLUGINS=../.. is set in docker/.env
- and a console that asks the real services. iTunes needs no key; Odesli refuses keyless calls
(401) and is skipped, and said so, until
ODESLI_API_KEYis set.
cd docker && cp .env.dist .env
docker compose run --rm omnisong catalogs
| Command | |
|---|---|
catalogs |
the catalogues installed and configured |
links <reference> |
the recording on every platform, the label first (--label, --label-url, --label-shop, --country) |
release <reference> |
the release every catalogue knows, merged: its tracks, their previews, its links (JSON) |
releases <artist> |
what an artist put out, newest first (--limit, --country) |
embed <url> |
the iframe that plays a link (--dark, --compact, --country) |
bare |
plain PHP: the registry built by hand, a release asked by its UPC, what PHP loaded |
test |
every package's tests |
docker compose run --rm omnisong links 4015372820954 --label ES-DUR --label-url https://www.es-dur.de
docker compose run --rm omnisong release 4015372820954 --label ES-DUR --label-url https://www.es-dur.de
docker compose run --rm omnisong releases "Brieuc Vourch" --limit 10
docker compose run --rm omnisong embed https://music.apple.com/de/album/perspectives-concertantes/1793146044 --dark
Bare: no bundle, no container
The console above is a symfony/console application over a registry built by hand; bare is
less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing
else. It builds the Registry from the catalogue packages installed, puts the aggregator over
it, asks for the release with the UPC 4015372820954, its label first - the real services (iTunes;
Odesli when its key is set), or with --recorded the answers kept in docker/harness/recorded/
- then lists what PHP loaded and exits 1 if a class of a framework is among it
(
Symfony\Component\DependencyInjection,Config,HttpKernel,HttpFoundation, a bundle, a bridge, Doctrine, Twig):
$ docker compose run --rm omnisong bare --recorded
Omnisong in bare PHP: the registry built by hand, no bundle, no container.
1. odesli
2. itunes
The release with the UPC 4015372820954, from the answers kept in recorded/:
Perspectives Concertantes - Anaelle Tourret, NDR Elbphilharmonie Orchester, Vasily Petrenko, Stuttgarter Kammerorchester & Bar Avni
album of 2025-02-28, ES-DUR
8 tracks, 8 with a preview; the first: Concerto for Harp and Orchestra in E-Flat Major, Op. 74: I. Allegro Moderato (11:17)
https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview221/v4/02/3f/df/023fdf56-9772-8b41-3e98-d0985a4b50a6/mzaf_17993483565663500051.plus.aac.p.m4a
where it is, the label first:
label https://www.es-dur.de
apple_music https://music.apple.com/de/album/perspectives-concertantes/1793146044
tidal https://listen.tidal.com/album/414051173
amazon_music https://music.amazon.com/albums/B0DV5P8V78
itunes https://music.apple.com/de/album/perspectives-concertantes/1793146044?app=itunes
amazon https://amazon.com/dp/B0DV5P8V78
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig): none
Without --recorded and without an Odesli key, the same from iTunes alone (the label, Apple
Music and the iTunes Store). The answers in recorded/ are the catalogue packages' fixtures:
iTunes' as answered on 2026-10-03, Odesli's written from what album.link showed that day - the
API itself answers 401 without a key.
bare --recorded --json prints the same whole - every class loaded, every file since the
autoloader - without a call: Tests/BareTest.php runs it in a process of its own and checks the
list. The files Composer requires together with the autoloader are not counted: where Twig is
installed (for the bridge's tests), its function files are loaded by vendor/autoload.php
itself, whatever the script does.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnisong-harness project.
The entrypoint
docker/entrypoint.sh runs docker/harness/setup.php first: it writes the harness's
composer.json and phpunit.xml in the harness volume - this core from its checkout, each
catalogue from its checkout under OMNISONG_PLUGINS when one is there (its composer.json naming
omnisong/<slug>), from https://github.com/glitchr-omnisense/omnisong-<slug>.git (branch 1.x)
otherwise - and rewrites them only when they change. Then composer install the first time,
composer update when composer.json is newer than the lock, and the command:
| First argument | Runs |
|---|---|
test |
vendor/bin/phpunit with the other arguments: every package's tests (suites core, odesli, itunes) |
bare |
docker/harness/bin/bare with the other arguments (--recorded, --json) |
composer, sh, php |
that command as it is |
| anything else, or nothing | the console, docker/harness/bin/omnisong (default: catalogs) |
The console's settings come from docker/.env (copied from .env.dist, git-ignored):
ODESLI_API_KEY, ODESLI_COUNTRY (DE in .env.dist), ITUNES_COUNTRY (de; us when unset),
and OMNISONG_PLUGINS. A catalogue is configured when its factory is installed; an option left
empty is not passed, so the factory's default applies. The console's errors: a reference it cannot
parse or a configuration error exits 2 (invalid), a catalogue's error exits 1, each with its
message.
The image (omnisense/omnisong-harness) is built from docker/Dockerfile the first time
docker compose run needs it; the first run also installs the packages into the volume, which
needs the network.
Propose a change docs/harness.md
Writing a catalogue
A catalogue is a class that implements Omnisong\Catalog\CatalogInterface, built by a factory
that extends Omnisong\Catalog\CatalogFactory. It can live in the application (a label's own shop,
a hand-kept discography) or be a package of its own, as omnisong/itunes and omnisong/odesli are.
The rules
Every catalogue keeps the contract the aggregator relies on:
- Answer what it can, refuse the rest. A kind of reference it cannot read (a UPC for Odesli, a
Spotify URL for iTunes), or an operation it does not do (
releases()for Odesli), is aNotSupportedException-NotSupportedException::reference($name, $reference)or::operation($name, 'list an artist\'s releases')- thrown before any call. The aggregator then asks the next catalogue, or the same one again by a reference it learnt. - Unknown is not an error.
links()givesPlatformLinks::none(),release()givesnull,releases()gives[]. - Unavailable is not unknown. The service unreachable, down (5xx), answering something that is
not JSON, or refusing the key: an
UnavailableException($name, $message, $status, $previous). Asked to slow down: aRateLimitedException($name, $retryAfter). The aggregator skips the catalogue and names it in$incomplete, so a site does not cache a half answer. - Anything else the service refuses is a
ProviderException($name, $message, $status); the aggregator lets it through to the caller. - Give ids. A
PlatformLink'sid, and aRelease'sids(platform value => id), are what the aggregator asks the other catalogues by: a catalogue that gives them lets one that cannot read the original reference answer anyway. - Keep what was answered. The aggregator asks
release()and thenlinks()of the same reference: both shipped catalogues keep their answers by query for the life of the object, so the second call costs nothing. - Use the reference's country over the configured one when it has one.
- Newest first for
releases(), at most$limit.
A catalogue of the application's
A label's shop, which knows what it sells by UPC:
<?php // shop.php
require __DIR__.'/vendor/autoload.php';
use Omnisong\Catalog\Catalog;
use Omnisong\Catalog\CatalogFactory;
use Omnisong\Catalog\CatalogInterface;
use Omnisong\Config;
use Omnisong\Exception\NotSupportedException;
use Omnisong\Model\PlatformLink;
use Omnisong\Model\PlatformLinks;
use Omnisong\Model\Reference;
use Omnisong\Model\Release;
use Omnisong\Platform;
use Omnisong\Registry;
/** The label's own shop: what it sells, by UPC, from a list the site keeps. */
final class LabelShopCatalog implements CatalogInterface
{
/** @param array<string, array{title: string, url: string}> $releases by UPC */
public function __construct(private readonly string $label, private readonly array $releases)
{
}
public function getName(): string
{
return 'label_shop';
}
public function links(Reference $reference): PlatformLinks
{
$release = $this->release($reference);
return $release?->links ?? PlatformLinks::none();
}
public function release(Reference $reference): ?Release
{
if (null === $reference->upc) {
throw NotSupportedException::reference($this->getName(), $reference); // the next catalogue is asked
}
$known = $this->releases[$reference->upc] ?? null;
if (null === $known) {
return null; // unknown: not an error
}
return new Release($known['title'], upc: $reference->upc,
links: new PlatformLinks([new PlatformLink(Platform::SHOP, $known['url'], null, $this->label)]));
}
public function releases(string $artist, int $limit = 50, ?string $country = null): array
{
throw NotSupportedException::operation($this->getName(), 'list an artist\'s releases');
}
}
final class LabelShopCatalogFactory extends CatalogFactory
{
protected function populate(Config $c): void
{
$c->defaults([
'omnisong.factory_name' => 'label_shop',
'omnisong.required_options' => ['label'],
'releases' => [],
]);
}
protected function build(Config $c): CatalogInterface
{
return new LabelShopCatalog((string) $c['label'], (array) $c['releases']);
}
}
$registry = new Registry([new LabelShopCatalogFactory()], [
'shop' => ['factory' => 'label_shop', 'options' => [
'label' => 'ES-DUR',
'releases' => ['4015372820954' => ['title' => 'Perspectives Concertantes', 'url' => 'https://www.es-dur.de/shop/perspectives']],
]],
]);
$catalog = new Catalog($registry->all());
$release = $catalog->release(Reference::upc('4015372820954'));
echo $release->title, "\n";
foreach ($release->links as $link) {
echo ' ', $link->platform->value, ' ', $link->title(), ' ', $link->url, "\n";
}
var_dump($catalog->release(Reference::upc('0000000000000')));
var_dump(count($catalog->links(Reference::parse('https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'))));
try {
(new LabelShopCatalogFactory())->create([]);
} catch (\Omnisong\Exception\InvalidConfigException $e) {
echo $e->getMessage(), "\n";
}
$ php shop.php
Perspectives Concertantes
shop ES-DUR https://www.es-dur.de/shop/perspectives
NULL
int(0)
The "label_shop" catalogue needs: label.
(run on 2026-10-26, PHP 8.4, no network). It reads UPCs only, so a Spotify URL gives no link - not
an error: the aggregator caught the NotSupportedException and had no other catalogue to ask.
Put it first in the registry, before iTunes and Odesli, and its SHOP link joins theirs; a
Label given to the call still replaces it.
In Symfony, the factory is autoconfigured (tagged omnisong.catalog_factory) as soon as it is a
service, and named in omnisong.catalogs.<name>.factory (Symfony).
The factory
populate(Config $c) sets, with $c->defaults([...]):
| Key | |
|---|---|
omnisong.factory_name |
the factory's name, what factory: names: itunes, label_shop |
omnisong.required_options |
the options create() refuses to go without (InvalidConfigException, naming them) |
| every other option | its default |
build(Config $c) makes the catalogue from the filled config. A catalogue that calls a service
takes $this->http, the client the factory was given, or makes its own when it is null
($this->http ?? HttpClient::create(), which needs symfony/http-client). Nothing is called while
building: the first call is made by the first question.
A package of its own
The shipped catalogues' layout, to copy:
- name
omnisong/<slug>, namespaceOmnisong\<Name>\, PSR-4 from the package root,Tests/excluded from the class map; require:php>=8.2,omnisense/omnisong^1.0@dev, andsymfony/http-client(^6.4|^7.0|^8.0) when it calls a service;- the catalogue's base URL as a
BASE_URIconstant, overridable by abase_urioption (a test server, a proxy); - tests on recorded answers (
Tests/Fixtures/*.json) through aMockHttpClient: every failure of the table in Errors; - a README with a "Responsibility" section naming the service's terms.
For the bundle to register the factory without the application declaring it, its class must be in
OmnisongBundle::FACTORIES; for the harness to install it, in docker/harness/plugins.php (slug,
tests' namespace, factory class) and docker/harness/config/catalogs.php (its options from the
environment).
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 on the three packages' checkouts (the workspace's autoloader), PHP 8.4.27, PHPUnit 11.5.57, no network: 51 tests, 373 assertions, green.
- The core (
Tests/):Reference(URLs, UPC/EAN, ISRC, ids, whatparse()refuses),Platform(rank, names, players, URLs, song.link's keys),PlatformLinks(order, the label and its shop first, merges, storage as an array),Release::merge()(nulls filled, previews matched by ISRC then by place), the aggregator (CatalogTest: each catalogue's share, the UPC learnt asked next, a catalogue down skipped and named in$incomplete, unknown everywhere is null, an artist's releases from the first catalogue that has some), theEmbedder(each platform, the dark theme, the iframe lazy, sandboxed and escaped), the Symfony bundle (Tests/Bridge/OmnisongBundleTest.php: a kernel boots, the catalogues are asked in the configured order, one catalogue by its argument name, the embedder's theme, the Twig functions), and the plain-PHP run with no framework class loaded (Tests/BareTest.php). omnisong/itunes: a UPC gives the release with its tracks and previews; an Apple Music URL or its id the same release; a track's id or an ISRC its album; the links are Apple's; an artist's albums newest first; what is not Apple's is not read; 403, 429 and 5xx are unavailable - on answers recorded on 2026-10-03.omnisong/odesli: a URL gives the album on every platform; a platform's id is asked by platform, type and id; the release is title, artist, cover and links without tracks; neither a UPC nor an ISRC is read; unknown is empty; 429 says when to come back; down, unreachable or without its key is unavailable, naming the key; any other refusal is the provider's error - on an answer written from what album.link showed on 2026-10-03.
The examples of these pages were run on 2026-10-26 on the same checkouts, with a MockHttpClient
serving the recorded answers: the embedder's URLs and heights, the aggregator's calls and merge,
the failures' exceptions, an application's own catalogue, a test case.
What is not verified
- That the services answer today as recorded. iTunes was last called live for these manuals'
first version (2026-10-05, the installation script). song.link's API was
never answered with a key: it answers 401
PUBLIC_API_ACCESS_DEPRECATEDwithout one (seen 2026-10-03), and the Odesli fixture is written from album.link's page, not from an API answer. - That each player URL plays: the embed URLs are built from the platforms' public embed formats; the tests check the URLs, not the players in a browser.
- The Laravel page was written from the API; it was not run in a Laravel application.
- The known defects found while writing these pages:
Reference::parse()'s exception outside theOmnisongExceptionfamily (Concepts), Odesli askedtype=songwith an album's id for an ISRC (Catalogues), SoundCloud's colour encoded twice (Players),Retry-Afteras a date read as 0 (Errors). Fixed since, each with its test: the exception, the ISRC, the colour,Retry-After.
Responsibility
omnisense/omnisong and its omnisong/* catalogues are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the services it reaches. Each service keeps its own
terms (keys, promotional use of previews and attribution, rate limits), named with a link in the
README of each catalogue. The MIT licence covers this code only and grants no right to any service
or to its data; whoever runs these packages complies with those terms.