Aller au contenu

omnisense/omnisong

Omnisong: one contract for listening to a musician's catalogue - releases, tracks, previews, the links to every platform with the label first, and the embeds that play from them - and its Symfony bundle.

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnisong:1.x-dev

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

Proposer une modification 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/omnisong requires symfony/http-client-contracts (^3.0) and nothing else.
  • Each catalogue package (omnisong/itunes, omnisong/odesli) requires omnisense/omnisong and symfony/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-injection and symfony/http-kernel (6.4, 7 or 8), and twig/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, a MockHttpClient in 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 $incomplete after 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\OmnisongBundle registers the factories on the application's http_client, builds the registry from config/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 Registry and the Catalog once, where the framework builds its services (a container definition, a factory), as the scripts above do.

Proposer une modification 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;
});

Proposer une modification 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)?->html in 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 dark is 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.

Proposer une modification 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.

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): ?Platform maps song.link's linksByPlatform keys (last column); any other key is null.
  • Platform::fromUrl(string $url): ?Platform reads the URL's host, without a leading www., open., music., listen., play. or geo.: spotify.com; apple.com when the path has /album/, /song/, /playlist/, /artist/ or /music-video/ (Apple Music; another Apple page is null); youtube.com and youtu.be (YouTube Music when the host starts with music.); deezer.com, qobuz.com, idagio.com, tidal.com; amazon.com, .fr, .de and .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' or Source::PREVIEW 'preview'), mime, duration (seconds) - something an <audio> element can play. A track's preview is its previewUrl string.

An application may use them for its own data; no catalogue returns them.

Proposer une modification 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:

  1. 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), as Reference::id() of type album - or song for a single without tracks. A catalogue is asked by the first of these it can read; its first answer that is not a NotSupportedException is taken, even null.
  2. 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.
  3. Nobody found it: null.
  4. 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.
  5. 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 = $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).

Proposer une modification 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 title is the link's title() (the platform's name), and both it and src are HTML-escaped;
  • no width choice: the iframe fills its container, and Embed::$width is 100.

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).

Proposer une modification 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

Proposer une modification 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.

Proposer une modification 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 its uo tracking parameter, and the request sent (MockResponse::getRequestUrl()). iTunes answers with a text/javascript content type; the catalogue reads the body as JSON whatever the header says, so a MockResponse needs 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.

Proposer une modification 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_KEY is 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.

Proposer une modification 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 a NotSupportedException - 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() gives PlatformLinks::none(), release() gives null, 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: a RateLimitedException($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's id, and a Release's ids (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 then links() 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>, namespace Omnisong\<Name>\, PSR-4 from the package root, Tests/ excluded from the class map;
  • require: php >=8.2, omnisense/omnisong ^1.0@dev, and symfony/http-client (^6.4|^7.0|^8.0) when it calls a service;
  • the catalogue's base URL as a BASE_URI constant, overridable by a base_uri option (a test server, a proxy);
  • tests on recorded answers (Tests/Fixtures/*.json) through a MockHttpClient: 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).

Proposer une modification 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, what parse() 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), the Embedder (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_DEPRECATED without 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 the OmnisongException family (Concepts), Odesli asked type=song with an album's id for an ISRC (Catalogues), SoundCloud's colour encoded twice (Players), Retry-After as 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.

Proposer une modification docs/verified.md