Aller au contenu

omnibase/broadcast

A broadcast's site on omnibase: a programme, its episodes, what was played on stage (performances), the songs and who sang them - on omnibase/video's films and omnibase/music's performers.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/broadcast:dev-main

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, and Catalogue, 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 Performer for 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

Proposer une modification docs/testing.md