omnibase/broadcast is a broadcast's site on omnibase: a programme, its
episodes, and for each film of an episode what it is - a song played live (by
whom, their own or a cover, a duet), an interview, the whole show, a bonus.
It was written for one music show and is no more general than that needed.
Its namespace is Base\Broadcast\.
What it adds
- Five entities:
Programme,Episode,Performance,Song,Profile(The model). TitleParser, which reads a film's title into its artists, song, original artist, year and kind, andCatalogue, which finds or makes the artists and songs a title names (Reading titles).- Eight pages: the episodes by year, an episode, the artists, an artist, the songs played more than once, a song, the covers, the duets (Pages and Twig).
- Two Twig functions and the partials a film's page and a home page include.
- An affinity for omnibase/video's suggestions: same artist, same episode.
- Five back office screens and a tile counting the titles not understood (Back office).
- English and French messages (domain
broadcast).
What it builds on
- omnibase/video's films:
Video,Channel, the player, comments, lists, suggestions; - omnibase/music's
Performerfor the artists; - omnisense/omnibase and omnibase/admin.
The bundle imports nothing by itself: a site reads its source (a channel's
films, a list of titles) and makes episodes and performances with
TitleParser and Catalogue.
Pages
| Page | |
|---|---|
| Installation | the bundle, its routes, its stylesheet, its configuration |
| The model | Programme, Episode, Performance, Song, Profile, Catalogue |
| Reading titles | TitleParser: what it understands, what it lists for the back office |
| Pages and Twig | the routes, the partials, the film page's block |
| Back office | the screens, the tile, a known limit |
| Configuration reference | broadcast: dumped, the services |
| Testing | the suite, what it checks |
Proposer une modification docs/index.md
Installation
omnibase/video is installed first (its installation).
composer require omnibase/broadcast:dev-main
// config/bundles.php
Omnisong\Bridge\Symfony\OmnisongBundle::class => ['all' => true], // omnibase/music's dependency
Base\Music\MusicBundle::class => ['all' => true],
Base\Broadcast\BroadcastBundle::class => ['all' => true],
# config/routes.yaml
broadcast_controller:
resource: "@BroadcastBundle/src/Controller/Client"
type: attribute
prefix: /
# config/packages/broadcast.yaml
broadcast:
programme: taratata # the site's programme (its slug); null: the first one
anniversary: 30 # "thirty years ago this week" looks that many years back
{# the layout, after omnibase/video's stylesheet #}
<link rel="stylesheet" href="{{ asset('bundles/broadcast/css/broadcast.css') }}">
Then a migration: broadcast_programme, broadcast_episode,
broadcast_performance, broadcast_performance_performer, broadcast_song,
broadcast_profile (and omnibase/music's tables).
A show's site usually closes omnibase/video's uploads and plays its films where they are:
# config/packages/video.yaml
video:
upload: { enabled: false }
remote: { hosts: [the-show.example] }
suggest: { per_channel: 0 } # one channel for the whole show
Requirements
- PHP 8.2 or later;
- doctrine/orm 2.15 or 3;
- omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin;
- omnibase/video and omnibase/music (and omnisong, omnibase/music's dependency);
- Symfony 7 or 8 (config, dependency-injection, http-kernel, twig-bundle).
Filling it
The bundle has no importer: a site writes the one its source needs (the show
the bundle was written for reads its channel's films and their sections),
with TitleParser to read each title and Catalogue to find or make its
artists and song (Reading titles). The titles it cannot read are
kept, marked not recognised, for the back office to complete.
Proposer une modification docs/installation.md
The model
| Entity | Table | |
|---|---|---|
Programme |
broadcast_programme |
the show: name, slug, description, the Channel its films are on |
Episode |
broadcast_episode |
one broadcast: number (unique in its programme), airedAt, picture (an address), full (the whole show as one Video), origin, its performances in order |
Performance |
broadcast_performance |
what one film is: its episode, its Video (one to one), its position, its kind (live, interview, full, bonus), its Song, cover, its performers (omnibase/music's Performer), the title as it was written, whether it was understood |
Song |
broadcast_song |
a title and who made it known; its page gathers every version |
Profile |
broadcast_profile |
an artist's page: the Performer, a slug, a biography, a picture |
Performance::isDuet(): a live with two performers or more.
Performance::isCover(): its song is somebody else's.
Catalogue
$profile = $catalogue->artist('Jean-Louis Aubert'); // the Performer and its Profile, found by slug or made
$song = $catalogue->song('Diego, Libre Dans Sa Tête', 'France Gall');
One Performer for a name however often it comes; one Song for a title
and its original artist (the same title by another original artist is
another song; a version that does not say whose it is joins the one that
exists). What it makes in one run is remembered until reset().
Repositories
EpisodeRepository:findLatest(),findByYear(),countByYear(),findAnniversary($years)- the episodes aired that many years ago within three days of today, or the closest one of that year.PerformanceRepository:findForPerformer(),findForSong(),findCovers(),findDuets()(the most seen first),findUnrecognised(),findOneForVideo().SongRepository::findWithSeveralVersions().
Suggestions
Suggest\BroadcastAffinity implements omnibase/video's AffinityInterface
(Pages and Twig).
Fields and methods
Episode: number is unique within its programme (broadcast_episode_number
on programme and number); airedAt is a date; image is an address, not a
stored file; full is the whole show as one omnibase/video Video (SET NULL
when the film goes); origin is free text, where the episode came from.
getYear(), getByKind($kind) (its performances of one kind, in order),
getPerformers() (everyone on it, once each).
Performance: one to one with its Video (deleted with it), a position in
its episode, a kind among Performance::KINDS (live, interview,
full, bonus; indexed), its song (SET NULL), cover, its performers
(table broadcast_performance_performer), rawTitle (the title as it was
written) and recognised (whether the parser read it). getBilling() is the
performers' names joined by /; isLive(); isDuet(), a live with two
performers or more; isCover(), the flag set when the song is somebody
else's.
Song: title, originalArtist (who made it known, or null), a unique
slug (190 characters).
Profile: one to one with a Performer (deleted with it), a unique slug,
biography, image (an address), looked (whether someone looked the
artist up already). getName() is the performer's.
Programme: slug (unique, 80), name, description, the omnibase/video
Channel its films are on (SET NULL).
Catalogue in detail
Catalogue::slug($text) is the French ASCII slug, lower case, sans-nom for
nothing. artist($name) looks for a profile of that slug, else makes a
Performer of that name and its Profile, both persisted (not flushed).
song($title, $originalArtist) tries the slug <title>-<original artist>,
then <title>: a song found with no original artist takes the one given; a
song whose original artist differs is another song, made with the slug
<title>-<original artist> when <title> is taken. What it finds or makes
is remembered until reset(), to call after an EntityManager::clear().
Repositories in detail
The queries of performances read only the films that are online: published and not taken down.
| Repository | Method | Returns |
|---|---|---|
EpisodeRepository |
findOneByNumber($number) |
an episode by number |
findLatest($limit = 12, $offset = 0) |
the last aired first | |
findByYear($year), countByYear() |
a year's episodes; year => count, newest year first | |
findAnniversary($years) |
the episodes aired that many years ago within three days of today, or the closest one of that year | |
PerformanceRepository |
findForPerformer($performer, $kinds = ['live'], $limit = 200) |
an artist's performances of those kinds, the latest aired first |
findForSong($song) |
every version of a song | |
findCovers($limit), findDuets($limit) |
the most seen first | |
findUnrecognised($limit), countUnrecognised() |
the titles not read | |
findOneForVideo($video) |
what a film is | |
findGroups($videoIds) |
the artist and episode groups of films, for the suggestions | |
SongRepository |
findWithSeveralVersions($limit = 24) |
the songs played more than once |
ProgrammeRepository |
findCurrent(?string $slug) |
the programme of that slug, else the first one |
ProfileRepository |
findAllByName(), findFor($performers) |
every profile by name; performer id => profile |
EpisodeRepository::findOneByNumber($number, ?Programme $programme) looks the number up in that
programme (a number is unique within one programme only), in any programme without one.
/emission/{number} looks it up in the site's programme: broadcast.programme (its slug), else
the first, through ProgrammeRepository::findCurrent().
Proposer une modification docs/model.md
Reading titles
$parsed = $parser->parse('Jean-Louis Aubert / Santa "Diego, Libre Dans Sa Tête" (France Gall) (2024)', 'Live', ['Jean-Louis Aubert', 'Santa']);
$parsed->artists; // ['Jean-Louis Aubert', 'Santa'] - the billing's order
$parsed->song; // 'Diego, Libre Dans Sa Tête'
$parsed->originalArtist; // 'France Gall'
$parsed->year; // 2024
$parsed->isCover(); // true: France Gall is not on stage
$parsed->isDuet(); // true
$parsed->recognised; // true
The second argument is the section the film is filed under (it says its
kind: Live, Interview, Intégrale, anything else is a bonus; without
it the title says); the third the artists it is filed under, used when the
title does not name them.
| Title | Read as |
|---|---|
Artist "Song" (1993) |
a live, its song, its year |
A / B "Song" (Original) (2024) |
a duet, a cover |
Artist "(Sittin' On) The Dock Of The Bay" (Otis Redding) (2024) |
brackets inside the title stay in it |
Artist "Ta Main" / "Toi + Moi" (2026) |
a medley: one song, Ta Main / Toi + Moi |
Artist "Song" (Inédit à la TV) (2026) |
a note, not an original artist (notes) |
Artist "Song" (1993 |
a bracket left open |
A / B "Caravane (2025) |
a quote left open: closed before the brackets |
Artist « Song » (2010), “Song” |
any quotes |
Interview A / B (2024), Interview et portrait A (1993) |
an interview, its artists |
Taratata Extra : Artist "Song" (2024) |
a bonus, with its song |
Artist au piano avec … (2024) filed under Live |
not recognised: no song in quotes - it keeps the artists it was filed under and is listed in the back office |
The parser's tests run on the show's own titles (tests/fixtures/titles.json).
The kind
TitleParser::kind($section, $title) reads the section first: Live is a
live, Interview an interview, Intégrale (or Integrale, Full) the whole
show, any other section a bonus. Without a section, the title: one that opens
with Interview is an interview, Taratata N° or Intégrale the whole show,
Taratata Extra, Teaser, Bande Annonce, Les coulisses or Répétitions
a bonus, anything else a live. Only a live needs a song to be recognised.
TitleParser::artists('A / B') splits a billing on /, in the order
written, each name once.
What the parser returns
Base\Broadcast\Model\ParsedTitle, read-only: raw (the title as given),
kind, artists, song, originalArtist, year, notes (the brackets
that describe the take rather than who made the song known: those opening
with inédit, version, acoustique, live, medley, extrait, remix,
piano, bonus, en duo or avec) and recognised;
isLive(), isCover() (an original artist who is not among the artists on
stage), isDuet() (a live with two artists or more).
An example
use Base\Broadcast\Service\TitleParser;
$parser = new TitleParser();
foreach ([
['Jean-Louis Aubert / Santa "Diego, Libre Dans Sa Tête" (France Gall) (2024)', 'Live', []],
['Zaz "Ta Main" / "Toi + Moi" (Inédit à la TV) (2026)', 'Live', []],
['Interview et portrait Alain Bashung (1993)', null, []],
['Feu! Chatterton au piano avec Pomme (2024)', 'Live', ['Feu! Chatterton']],
] as [$title, $section, $filed]) {
$p = $parser->parse($title, $section, $filed);
printf("%s\n kind=%s artists=%s song=%s original=%s year=%s notes=%s cover=%s duet=%s recognised=%s\n",
$title, $p->kind, json_encode($p->artists, JSON_UNESCAPED_UNICODE), var_export($p->song, true), var_export($p->originalArtist, true),
var_export($p->year, true), json_encode($p->notes, JSON_UNESCAPED_UNICODE), var_export($p->isCover(), true), var_export($p->isDuet(), true), var_export($p->recognised, true));
}
Its output, run in omnisense/omnibase-harness:
Jean-Louis Aubert / Santa "Diego, Libre Dans Sa Tête" (France Gall) (2024)
kind=live artists=["Jean-Louis Aubert","Santa"] song='Diego, Libre Dans Sa Tête' original='France Gall' year=2024 notes=[] cover=true duet=true recognised=true
Zaz "Ta Main" / "Toi + Moi" (Inédit à la TV) (2026)
kind=live artists=["Zaz"] song='Ta Main / Toi + Moi' original=NULL year=2026 notes=["Inédit à la TV"] cover=false duet=false recognised=true
Interview et portrait Alain Bashung (1993)
kind=interview artists=["Alain Bashung"] song=NULL original=NULL year=1993 notes=[] cover=false duet=false recognised=true
Feu! Chatterton au piano avec Pomme (2024)
kind=live artists=["Feu! Chatterton"] song=NULL original=NULL year=2024 notes=[] cover=false duet=false recognised=false
The last title has no song in quotes: it keeps the artist it was filed under and is not recognised.
From a title to a performance
A site's importer, for each film:
$parsed = $parser->parse($video->getTitle(), $section, $filedUnder);
$performance = new Performance($episode, $video, $parsed->kind, $position);
$performance->setRawTitle($parsed->raw)->setRecognised($parsed->recognised);
foreach ($parsed->artists as $name) {
$performance->addPerformer($catalogue->artist($name)->getPerformer());
}
if ($parsed->song) {
$performance->setSong($catalogue->song($parsed->song, $parsed->originalArtist))
->setCover($parsed->isCover());
}
Proposer une modification docs/titles.md
Pages and Twig
Routes
| Route | Path | |
|---|---|---|
broadcast_episodes |
/emissions?annee= |
a year's episodes; the years by decade |
broadcast_episode |
/emission/{number} |
the whole show, the order of passage, interviews, bonus |
broadcast_artists |
/artistes |
every artist, by letter |
broadcast_artist |
/artiste/{slug} |
their story, their lives (the latest first), interviews and bonus |
broadcast_songs |
/chansons |
the songs played more than once |
broadcast_song |
/chanson/{slug} |
every version of a song played on stage |
broadcast_covers, broadcast_duets |
/reprises, /duos |
the most seen first |
They extend layout1.html.twig and use omnibase/video's custom properties.
An unknown episode, artist or song is a 404.
What each page shows
/emissions: the episodes of?annee=(the latest year with episodes by default), the years grouped by decade (BroadcastController::decades(), newest decade first, its years in order) with their counts./emission/{number}: the episode, its whole show, its lives in the order of passage, its interviews, its bonus, and the artists' profiles for the links./artistes: every profile by name, under its first letter (accents removed;#for a name that does not start with a letter)./artiste/{slug}: the profile, its lives (the latest aired first), its interviews and bonus./chansons: up to 60 songs played more than once./chanson/{slug}: every version of the song played on stage, with the profiles of everyone who sang it./reprises,/duos: up to 60 covers or duets, the most seen first (@Broadcast/client/list.html.twig).
Only the films that are online (published, not taken down) are listed.
Twig
{% include '@Broadcast/client/_performance.html.twig' with {performance: p, profiles: profiles, show_episode: true} only %}
{% include '@Broadcast/client/_episode.html.twig' with {episode: e} only %}
{% set performance = broadcast_performance(video) %} {# what a film is, or null #}
{% set profiles = broadcast_profiles(performers) %} {# performer id => Profile, for the links #}
On a film's page, omnibase/video leaves a block for it - the application overrides the template once:
{# templates/bundles/VideoBundle/client/watch.html.twig #}
{% extends '@!Video/client/watch.html.twig' %}
{% block video_extra %}{% include '@Broadcast/client/_info.html.twig' with {video: video} %}{% endblock %}
A home page asks the repositories: EpisodeRepository::findLatest(1),
findAnniversary(30), countByYear() with
BroadcastController::decades(), PerformanceRepository::findCovers(),
findDuets().
| Function | Returns |
|---|---|
broadcast_performance(video) |
the Performance a film is, or null |
broadcast_profiles(performers) |
performer id => Profile, for the links |
| Partial | Variables |
|---|---|
@Broadcast/client/_performance.html.twig |
performance, profiles, show_episode |
@Broadcast/client/_episode.html.twig |
episode |
@Broadcast/client/_info.html.twig |
video: what the film is, under omnibase/video's player |
Suggestions
Base\Broadcast\Suggest\BroadcastAffinity implements omnibase/video's
AffinityInterface: groups($videos) returns, for each film, its artists and
its episode as groups, weighed like omnibase/video's own (an artist as a
channel, 3; an episode as a list, 2). omnibase/video's suggestions then put
the films of the same artist and of the same episode next to the one being
watched.
Proposer une modification docs/pages.md
Back office
yield MenuItem::block('broadcast_unrecognised', 'Import : titres à reprendre', 'fa-solid fa-microphone-lines')->setSize(2);
MenuItem::linkToCrud(\Base\Broadcast\Entity\Episode::class, 'Émissions', 'fa-solid fa-tv');
MenuItem::linkToCrud(\Base\Broadcast\Entity\Performance::class, 'Passages', 'fa-solid fa-microphone-lines');
MenuItem::linkToCrud(\Base\Broadcast\Entity\Profile::class, 'Artistes', 'fa-solid fa-user-large');
MenuItem::linkToCrud(\Base\Broadcast\Entity\Song::class, 'Chansons', 'fa-solid fa-music');
MenuItem::linkToCrud(\Base\Broadcast\Entity\Programme::class, 'Émission (fiche)', 'fa-solid fa-tower-broadcast');
Written by ROLE_ADMIN. The tile counts the lives whose title was not
understood and links to them (?filters[recognised]=0): an administrator
gives each its song and marks it recognised.
The screens
| Screen | Fields | Writes |
|---|---|---|
EpisodeCrudController |
number, aired on, title, programme, picture (an address), origin | create, edit, delete: ROLE_ADMIN |
PerformanceCrudController |
the title as written and the episode (read only), kind, position, recognised, song, cover; the performers on the index | edit, delete: ROLE_ADMIN; no "new": a performance comes from the import |
ProfileCrudController |
the performer (read only), slug, picture (an address), biography | edit, delete: ROLE_ADMIN; no "new": a profile comes with its artist |
SongCrudController |
title, original artist, slug | create, edit, delete: ROLE_ADMIN |
ProgrammeCrudController |
name, slug, channel, description | create, edit, delete: ROLE_ADMIN |
The performances screen filters on recognised, kind, cover and episode. The
screens grant their writes to ROLE_ADMIN with EasyAdmin's action
permissions rather than omnibase/admin's #[OpenToAdmins].
The tile
Base\Broadcast\Admin\Widget\BroadcastWidgetType is the widget
broadcast_unrecognised (@Broadcast/admin/widget/unrecognised.html.twig):
the number of titles not understood, the six latest, and the number of
episodes.
A known limit
A performance's artists are shown, not edited, on its screen:
omnibase/admin's association widget cannot list omnibase/music's
Performer (its kind is a PHP enum the widget turns into a string). The
artists of a performance come from its title; to change them, correct the
title's reading in code or the performers in the database until the widget
takes enums.
Proposer une modification docs/back-office.md
Configuration reference
The broadcast tree
Dumped with Symfony's YamlReferenceDumper from
Base\Broadcast\DependencyInjection\BroadcastConfiguration:
broadcast:
# The slug of the site's programme (a site is one show's); null: the first one.
programme: null
# "Il y a trente ans cette semaine": how many years back the home page looks.
anniversary: 30
| Key | Default | Parameter | Read by |
|---|---|---|---|
programme |
null | %broadcast.programme% |
nothing in the bundle; a site passes it to ProgrammeRepository::findCurrent() |
anniversary |
30 | %broadcast.anniversary% |
a site's home page, for EpisodeRepository::findAnniversary() |
A home page reads them as a controller argument:
public function index(
EpisodeRepository $episodes,
#[Autowire('%broadcast.anniversary%')] int $anniversary,
): Response {
return $this->render('home.html.twig', ['anniversary' => $episodes->findAnniversary($anniversary)]);
}
Services
config/services.php autowires and autoconfigures the bundle's classes; the
admin screens and the tile are loaded with omnibase/admin.
| Service | Role |
|---|---|
Base\Broadcast\Service\TitleParser |
parse(), kind(), artists() |
Base\Broadcast\Service\Catalogue |
artist(), song(), reset(), slug() |
Base\Broadcast\Suggest\BroadcastAffinity |
tagged video.affinity by omnibase/video's interface |
Base\Broadcast\Twig\BroadcastExtension |
broadcast_performance(), broadcast_profiles() |
| the five repositories | The model |
Translations
The domain broadcast, in English and French (ICU format). A site overrides
a message in its own translations/broadcast+intl-icu.<locale>.yaml.
Proposer une modification docs/configuration.md
Testing
Running the suite
From the package's checkout, with its dev dependencies:
vendor/bin/phpunit
In omnisense/omnibase-harness, mounting the checkout in place of the installed package:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/broadcast:ro \
omnisense/omnibase-harness:latest test broadcast
On this checkout, in the harness:
Tests: 37, Assertions: 265, Errors: 1.
The error is AffinityTest's: the harness has no omnibase/video, so
Base\Video\Suggest\AffinityInterface is not found. With omnibase/video and
omnibase/music on the autoloader (a host that has them), that test passes
(3 tests, 10 assertions in the harness with both checkouts mounted).
What the tests check
| Test | Checks |
|---|---|
Service\TitleParserTest |
24 of the show's own titles (tests/fixtures/titles.json) read as expected: lives, duets, covers, medleys, notes, brackets and quotes left open, interviews, bonus, whole shows, a title not recognised; curly and French quotes; an artist singing their own song named in brackets is no cover; without a section the title says the kind |
Service\AffinityTest |
the same artist weighs as a channel, the same episode as a list |
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the five screens |