Hosted video for an omnibase site: a platform where members publish (uploads on), or a broadcaster's site whose films come from the back office or an import (uploads off).
| Page | |
|---|---|
| Installation | the bundle, the routes, the layout, the front-end packages, the containers |
| The pages | every route: watching, channels, search, the library, the studio |
| Configuration | every video.* option |
| Uploads | tusd, the upload token, the two hooks |
| Transcoding | the ladder, the files written, the worker |
| The player | the dock, engines, media:play, beacons, remote HLS, outside sources |
| Views and suggestions | the view rule, the flush, Related, Gorse |
| Search | the Typesense mapping, the fallback |
| Moderation and back office | comments, reports, take-downs, roles, screens |
| Testing | the suite, what it checks |
What is whose
| Thing | Where |
|---|---|
Thread, likes, comments, their guard and form, reports, trash, embed_url() |
omnisense/omnibase |
| CRUD screens, dashboard tiles, settings sections, the reports' screen | omnibase/admin |
Video, Source, Channel, Subscription, VideoComment (the moment), WatchEvent, Playlist, Related |
this bundle |
| Searching and playing other platforms' films (YouTube, Vimeo...) | not here: a Source keeps a reference (platform + id); the engines are another family's |
Proposer une modification docs/index.md
Installation
composer require omnibase/video:dev-main
// config/bundles.php
Typesense\Bundle\TypesenseBundle::class => ['all' => true],
Base\Video\VideoBundle::class => ['all' => true],
# config/routes.yaml
video_controller:
resource: "@VideoBundle/src/Controller/Client"
type: attribute
prefix: /
# config/packages/messenger.yaml - the bundle's messages go to the worker
framework:
messenger:
routing:
Base\Video\Message\TranscodeVideo: async
Base\Video\Message\FlushViews: async
Base\Video\Message\ComputeRelated: async
Base\Video\Message\SuggestFeedback: async
Then a migration (doctrine:migrations:diff, migrate): the tables
video, video_source, video_channel, video_channel_owner,
video_subscription, video_comment, video_watch, video_playlist,
video_playlist_item, video_related.
The layout
The player lives outside what transparent.js swaps (#content):
{# templates/layout/layout1.html.twig #}
<link rel="stylesheet" href="{{ asset('bundles/video/css/video.css') }}">
...
<main id="content">{% block content %}{% endblock %}</main>
...
{% include '@Video/client/_dock.html.twig' %}
The bundle's pages extend layout1.html.twig and fill its content,
title and description blocks. Colours and fonts are custom properties
(--video-bg, --video-surface, --video-ink, --video-soft,
--video-line, --video-accent, --video-on-accent, --video-radius,
--video-font, --video-font-display...): set them on your :root, for
each theme.
The player has shapes of its own, apart from the site's cards:
--video-player-bg (behind the picture), --video-player-radius (its
frame, .9rem), --video-player-control-radius (its buttons, round) and
--video-player-thumb (the cursor of the bars, white); the bars and the
focus ring take --video-accent-on-dark (then --video-accent). The
controls carry their name for assistive technologies only: media-chrome's
tooltips are off (notooltip), as placed above a button they would lay
"Pause" over the progress bar.
The front end
The bundle's scripts import no package: the application does, and hands them over.
yarn add media-chrome hls-video-element @uppy/core @uppy/dashboard @uppy/tus
// assets/app-defer.js
import 'media-chrome';
import 'media-chrome/menu';
import 'media-chrome/lang/fr';
import 'hls-video-element';
import VideoPlayer from '../vendor/omnibase/video/assets/player.js';
import Uppy from '@uppy/core';
import Dashboard from '@uppy/dashboard';
import Tus from '@uppy/tus';
import '@uppy/core/dist/style.min.css';
import '@uppy/dashboard/dist/style.min.css';
import Studio from '../vendor/omnibase/video/assets/studio.js';
Base.boot({ exceptions: ['/files/*', '/media/*', '/v/*/master.m3u8'] });
VideoPlayer.start();
Studio.start({ Uppy, Dashboard, Tus }); // only where members upload
A content security policy needs blob: in media-src (hls.js plays from
MediaSource).
The containers
- web / worker: ffmpeg and ffprobe in the image; the worker consumes
async. - upload (tusd), search (Typesense): see Uploads and Search.
- The web server serves the storage:
location ^~ /media/videos/ {
alias /srv/app/var/storage/videos/;
types { application/vnd.apple.mpegurl m3u8; video/mp4 mp4 m4s; text/vtt vtt; image/jpeg jpg; }
add_header Access-Control-Allow-Origin * always;
}
- cron (no symfony/scheduler: cron dispatches, Messenger works):
*/5 * * * * php bin/console video:views:flush --async
30 3 * * * php bin/console video:related --async
0 4 * * * php bin/console video:uploads:clean
web, worker and tusd must see the same var/storage (uploads and videos).
Proposer une modification docs/installation.md
The pages
Every route of the bundle's client controllers (src/Controller/Client).
The paths are French, as the platform's; the names are what a template's
path() uses.
Watching
| Name | Path | Methods | |
|---|---|---|---|
video_watch |
/v/{slug} |
GET | the watch page: the player, the film's text, the comments, "à suivre" (The player) |
video_manifest |
/v/{slug}/master.m3u8 |
GET | the HLS master of a film played from another host, read and kept (The player) |
video_beacon |
/v/{slug}/watch |
POST | the player's beacons: what was watched (Views and suggestions) |
video_rate |
/v/{slug}/rate |
POST | like or dislike, rate limited |
video_comment |
/v/{slug}/comment |
POST | a comment, through omnisense/omnibase's guard (Moderation and back office) |
video_report |
/v/{slug}/report |
POST | a report, rate limited |
The slug is [A-Za-z0-9_-]+.
Channels and search
| Name | Path | Methods | |
|---|---|---|---|
video_channel |
/chaine/{slug} |
GET | a channel: its films, its lists |
video_subscribe |
/chaine/{slug}/abonnement |
POST | subscribe or unsubscribe (a toggle); a visitor is sent to sign in (401 in JSON), a bad CSRF token refused (403 in JSON) |
video_search |
/recherche |
GET | q (120 characters at most), categorie (one of video.categories), chaine (a channel's slug), duree (short, medium, long), date (day, week, month, year) (Search) |
A member's library
The member's pages answer a visitor with a 403; a list's own page is shown to whoever the list is shown to.
| Name | Path | Methods | |
|---|---|---|---|
video_history |
/historique |
GET | what the member watched |
video_history_clear |
/historique/effacer |
POST | forget it |
video_later |
/plus-tard |
GET | "plus tard": private, newest first |
video_subscriptions |
/abonnements |
GET | the subscriptions' feed |
video_playlists |
/listes |
GET, POST | the member's lists; POST makes one |
video_playlist |
/liste/{slug} |
GET, POST | a list |
video_playlist_toggle |
/listes/video |
POST | a film in or out of a list |
video_playlist_delete |
/liste/{slug}/supprimer |
POST | delete a list |
video_playlist_order |
/liste/{slug}/ordre |
POST | the list's new order |
The studio
Under /studio, for a signed-in member (ROLE_USER); uploading needs
video.upload.enabled (Uploads).
| Name | Path | Methods | |
|---|---|---|---|
video_studio |
/studio |
GET | the member's films |
video_studio_upload |
/studio/envoyer |
GET | the upload page |
video_studio_uploaded |
/studio/envoi/{uploadId} |
GET | an upload finished: the film being made |
video_studio_link |
/studio/lien |
GET, POST | a film from an address instead of a file (LinkDetailsType: the address, a name) |
video_studio_edit |
/studio/video/{id} |
GET, POST | a film's details (VideoDetailsType): title, description, visibility, publication date, category, tags, language, licence, comments on or off, poster |
video_studio_status |
/studio/video/{id}/etat |
GET | where its transcoding stands |
video_studio_stats |
/studio/video/{id}/statistiques |
GET | its figures |
video_studio_comments |
/studio/video/{id}/commentaires |
GET, POST | its comments, moderated by its owner |
video_studio_replace |
/studio/video/{id}/remplacer |
GET | upload another file for it (it keeps playing the old one meanwhile) |
video_studio_delete |
/studio/video/{id}/supprimer |
POST | to the trash |
video_studio_trash |
/studio/corbeille |
GET, POST | the member's trash |
video_studio_channel |
/studio/chaine |
GET, POST | the member's channel (ChannelDetailsType: name, slug, description, shelves, avatar, banner) |
video_studio_subscribers |
/studio/abonnes |
GET | who subscribed |
The upload hook
video_upload_hook (POST /video/upload/hook) is tusd's, not a page
(Uploads).
Proposer une modification docs/pages.md
Uploads
Films never go through PHP. The studio's page mounts
Uppy with its tus plugin; the browser talks to
tusd through the proxy at /files/; tusd
writes into video.upload.directory and asks the site twice.
# docker-compose.yml
upload:
image: tusproject/tusd:v2.8.0
command:
- -upload-dir=/srv/app/var/storage/uploads
- -base-path=/files/
- -behind-proxy
- -hooks-http=http://web/video/upload/hook
- -hooks-enabled-events=pre-create,post-finish
- -hooks-http-forward-headers=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-Port,X-Forwarded-For
volumes:
- storage:/srv/app/var/storage:rw # the same volume as web and worker
# the proxy
location ^~ /files/ {
client_max_body_size 0;
proxy_request_buffering off;
proxy_pass http://upload:8080;
}
location ^~ /video/upload/hook { return 404; } # tusd reaches it from inside only
omnibase answers only the hosts it knows: the internal name tusd calls
(web) must be among them (HTTP_DOMAIN).
The token
Uploads::token($user, ?$replace) (Twig: video_upload_token()): who,
until when, and - to replace a film's file - which video; signed with the
kernel secret. Uppy sends it in the upload's metadata. Whoever calls the
hook, only a valid token makes it act.
The hooks (POST /video/upload/hook)
| tusd asks | the site answers |
|---|---|
pre-create |
{} or {RejectUpload: true, HTTPResponse: {StatusCode: 403, Body: "upload.refused.<why>"}}: token, account (banned, locked), verified, size, quota, limit (the video_upload limiter), replace (not their film); 404 disabled when uploads are off |
post-finish |
the Video is made - a private draft titled after the file, on the member's channel (created on their first upload) - or the replaced one found; TranscodeVideo is dispatched. Only a file inside the uploads directory is taken. A repeated call makes nothing twice. |
Resuming
A cut connection retries by itself (tus HEAD gives the offset, PATCH
continues). A closed tab resumes when the same file is dropped again from
the same browser (Uppy keeps the upload's address). video:uploads:clean
removes what was abandoned (--days=3).
Proposer une modification docs/upload.md
Transcoding
TranscodeVideoHandler -> Service\Processing::transcode() ->
Service\Transcoder (ffmpeg through symfony/process; the *Command()
methods only assemble arguments and are tested as they are).
A video's directory, <video.storage>/<random key>/:
| File | |
|---|---|
master.m3u8 |
the HLS master, one variant a rung |
<height>/index.m3u8, init_N.mp4, seg_000.m4s... |
each rung, fMP4, a key frame on every segment's start |
poster.jpg |
the still shown before it plays |
frame-1.jpg... |
stills the author picks the poster from |
storyboard.jpg, storyboard.vtt |
the time bar's previews: a sprite and its #xywh boxes |
- The ladder is
video.transcode.renditionsnot above the source: a 720p film gets 720, 480, 360; a film smaller than every rung keeps its own height; a portrait film is laddered on its short side. - One ffmpeg pass for the whole ladder (
split, onescalea rung,-var_stream_map,-master_pl_name). - A replacement is built beside the old ladder and swapped in when whole: the film keeps playing meanwhile, its address, views and comments stay.
- States (
Video::getProcessing()):uploaded->running->ready, orfailedwithgetFailure();nonefor a remote or embedded film. Its authors are told in omnibase's notification centre. - The original is removed once the ladder exists (
keep_originals: 0).
bin/console video:transcode <id|slug> /path/to/film.mov # here and now: a repair, an import
Proposer une modification docs/transcoding.md
The player
{# a film's page #}
{% include '@Video/client/_player.html.twig' with {video: video, resume: seconds, source: 'home', autoplay: false} %}
{# the layout, outside #content #}
{% include '@Video/client/_dock.html.twig' %}
The first is a stage ([data-video-player], its data-* say what to
play); the second the dock: one <media-controller> for the whole
visit. On each page (load, transparent:load) assets/player.js looks
for a stage:
- the film it already plays: it lays itself over the stage again;
- another film: it loads it there;
- no stage and a film playing: it shrinks into a corner (the mini-player: title, play/pause, close);
- no stage, nothing playing: it hides.
It resumes (?t= first, then the member's place on the server, then this
browser's), seeks on a[data-seek] (the comments' and descriptions'
moments: the |timecodes(video) filter), copies the link at the current
second ([data-video-share]), has a cinema mode ([data-video-cinema],
html.video-cinema), speed and quality menus, a storyboard on the time bar,
the keyboard of media-chrome.
A film's page (@Video/client/watch.html.twig) leaves two blocks to a
site: video_channel (the channel and its subscribe button - emptied on a
single-show site) and video_extra (under the description).
Engines
The player's commands never touch a <video>: they talk to an engine.
VideoPlayer.registerEngine('youtube', function (host, data) {
return {
load(data) {}, // src, platform, externalId, poster, start...
play() {}, pause() {}, seek(seconds) {},
time() {}, duration() {}, paused() {}, rate() {},
on(event, fn) {}, // 'ready' | 'play' | 'pause' | 'time' | 'seeking' | 'end'
destroy() {},
};
});
The native engine (hls, mp4) is the bundle's. A stage whose data-type
names a registered engine gets it. The beacons, the resume, the mini-player
and the timecodes work with any engine that gives the time.
One thing at a time: media:play
The player takes part in omnisense/omnibase's media:play contract through its
module (window.MediaPlay, bundles/base/js/media.js - the dock links it; a
site may also bundle it, import '../vendor/omnisense/omnibase/assets/media/media.js'):
- it joins (
MediaPlay.join(VideoPlayer, pause)): when anything else on the page starts sounding - omnibase/music's bar, another player, a plain<audio>or<video>- it pauses; - it announces itself when it starts
(
MediaPlay.announce(VideoPlayer, {kind: 'video', id, source: 'video', element})), which dispatches ondocument:
new CustomEvent('media:play', { detail: { player: VideoPlayer, kind: 'video', id, source: 'video', element } })
The dock carries data-media-play="off": its <video> is the player's to
announce, not a plain element of the page. Another script takes part the same
way (omnibase's docs/40-commons/front-end.md); the player has no pause logic
of its own any more.
Remote HLS, without a copy
A Source of kind remote-hls holds a master playlist's address on
another origin. GET /v/{slug}/master.m3u8 (video_manifest) reads it on
the server - only from video.remote.hosts, https - keeps it
video.remote.ttl seconds and serves it with its URIs made absolute: the
browser fetches the variant playlists and the segments from the origin
itself. Nothing else of the film passes through the site.
RemoteManifest::duration() sums a variant's #EXTINF.
Outside sources
A Source of kind embed keeps the film's page address and its reference
getPlatform(),getExternalId()- and no logic. Until an engine is registered for its platform it plays in the platform's own frame (omnibase'sembed_url()), loaded only once the visitor accepted theEMBEDSfeature of omnibase/consent. Members add one by its link in the studio (/studio/lien).
Beacons
POST /v/{slug}/watch with {token, event: start|progress|end, position, watched, source, referrer}: on the first play, every ten seconds, on
pause, at the end, and when the page goes. watched is seconds really
played (a seek is not watching).
Proposer une modification docs/player.md
Views and suggestions
Views
A beacon lands on a WatchEvent (one a sitting). It becomes a view
once min(video.views.min_seconds, video.views.ratio x duration) seconds
were really played, and only once per visitor and film every
video.views.dedupe seconds (the pool video.cache). The visitor is the
account, or a keyed hash of the address and the browser - never stored
readable. The server does not trust watched: it cannot grow faster than
the clock.
video:views:flush (cron, every five minutes, --async) adds the counted
views to Video::$views in one statement a film. Video::$legacyViews
keeps the views a film had where it was imported from;
getTotalViews() is what a card shows.
The same rows are a member's history (/historique, erasable) and the
studio's figures (WatchEventRepository::stats()): views, average
watched, completion, the retention curve, thirty days of views, where they
came from (home, search, channel, playlist, suggest, subscriptions,
history, external, direct).
Suggestions
interface SuggestInterface
{
public function feedback(string $type, User $user, Video $video): void; // read | watch | like
public function recommend(User $user, int $limit = 24): array; // a member's feed
public function session(array $videoIds, int $limit = 24): array; // a visitor's, from what they watched
public function similar(Video $video, int $limit = 12): array; // "à suivre"
}
Inject SuggestInterface: it is Suggestions, which asks Gorse when
provider: gorse and it answers, and the local ones otherwise.
Local (always there)
video:related (cron, at night) writes Related: for two listed films,
3 · same channel, or an affinity's 3.0 group (same artist)
+ 2 · same public list, or an affinity's 2.0 group (same episode)
+ 1 · each keyword in common (three at most)
+ 2.5 · co-watching (cosine of their audiences, ninety days)
+ 0.5 · freshness (e^(-age / 30 days))
+ 0.5 · popularity (log of the views against the most seen)
twenty-four neighbours a film, per_channel of one channel at most. A
feed is the neighbours of what was watched last, the seen ones left out,
one in five from the trends.
Another bundle adds what makes films close with an
AffinityInterface service (autoconfigured):
public function groups(array $videos): array // [videoId => ['artist:12' => 3.0, 'episode:5' => 2.0]]
Gorse (optional)
gorse-in-one over its REST API (symfony/http-client):
POST /api/feedback (through Messenger: SuggestFeedback),
GET /api/recommend/{user}, POST /api/session/recommend,
GET /api/item/{id}/neighbors. In gorse-in-one the API is on the master's
HTTP port (8088). The key is video.suggest.gorse_key, or api.gorse.key
typed in the back office. Any failure, any short answer: the local
suggestions fill in.
Proposer une modification docs/views-and-suggestions.md
Search
Search\VideoSearch::search($term, $filters, $page) asks the
typesense.finder.video finder of glitchr/typesense-bundle - typo
tolerance, prefixes, the listed films only - and the database
(VideoRepository::searchListed()) when there is no such mapping or the
server does not answer. Filters: category, channel (slug), duration
(short < 4 min, medium, long > 20 min), date (day, week,
month, year), sort (date, views).
# config/packages/typesense.yaml
typesense:
mappings:
video:
connection: default
class: 'Base\Video\Entity\Video'
default_sorting_field: published
fields:
title: { property: searchTitle, type: string, infix: true }
text: { property: searchText, type: string }
channel: { property: searchChannel, type: string }
channelSlug: { property: searchChannelSlug, type: string, facet: true }
tags: { property: searchTags, type: 'string[]', facet: true }
category: { property: searchCategory, type: string, facet: true }
duration: { property: searchDuration, type: int32, facet: true }
views: { property: searchViews, type: int32 }
published: { property: searchPublished, type: int64 }
listed: { property: searchListed, type: bool, facet: true }
bin/console typesense:create # the collection
bin/console typesense:action upsert # every film into it
The index follows Doctrine afterwards. Search\SafeIndexer decorates the
bundle's listener: a search server that is down never fails a save, and no
visitor is told about it; typesense:action upsert catches up.
Proposer une modification docs/search.md
Moderation and back office
Comments
A VideoComment is omnibase's Comment (states, one level of replies,
Akismet, CommentType, CommentGuard) with moment - where the player
stood - and pinned. 1:23 typed in a text seeks the player. A film's
author pins one, hides some, closes them (studio); the moderators approve,
mark as spam, trash (back office).
Reports
"Signaler" under a film (and per comment) makes an omnibase Complaint
(source: video, location: video:<id> / comment:<id>, the reason in
its context): handled on omnibase/admin's screen for them.
Take-downs
Moderation::takeDown($video, $reason) (the back office's "Retirer", a
reason is required): the film leaves every page, its authors are told why
(the DSA's statement of reasons) and still see it; restore() puts it
back, private.
Roles and screens
VIDEO_VIEW |
a film reachable, or its owners', or the moderators' |
VIDEO_EDIT |
its owners (and its channel's), or ROLE_ADMIN |
VIDEO_MODERATE |
ROLE_EDITOR and up |
// the dashboard
yield MenuItem::block('video_overview', 'La plateforme', 'fa-solid fa-tv')->setSize(4);
MenuItem::linkToCrud(\Base\Video\Entity\Video::class, 'Vidéos', 'fa-solid fa-film');
MenuItem::linkToCrud(\Base\Video\Entity\Channel::class, 'Chaînes', 'fa-solid fa-tv');
MenuItem::linkToCrud(\Base\Video\Entity\VideoComment::class, 'Commentaires', 'fa-solid fa-comments');
MenuItem::linkToCrud(\Base\Entity\User\Complaint::class, 'Signalements', 'fa-solid fa-hand');
The films and channels are written by ROLE_ADMIN; taking down, restoring
and the comments by ROLE_EDITOR. The tile: comments waiting, reports
open, conversions running or failed, views of thirty days.
Demonstration accounts
In omnisense/omnibase's demo environment (its docs/20-architecture/demo.md)
the sign-in page offers one button for each of a platform's people.
Base\Video\Demo\VideoDemoAccounts declares them:
| Identifier | Role | |
|---|---|---|
createur |
none (ROLE_USER) |
a channel and its films: the studio, uploading, the statistics, the comments received |
membre |
none (ROLE_USER) |
watches, likes, comments at a film's moment, subscribes, keeps lists |
moderation |
ROLE_EDITOR |
the comments to approve, the reports, taking a film down |
admin |
ROLE_ADMIN |
every film and channel in the back office |
The password is the identifier. A creator and a member hold the same role - every member has the studio: the fixtures tell them apart, by taking the accounts from omnibase's factory and giving the first a channel and films, the second subscriptions and a list:
public function __construct(private readonly \Base\Demo\DemoAccountFactory $accounts) {}
$createur = $this->accounts->account('createur', $manager); // created from its declaration, or the database's
$channel = new Channel('Atelier Mandelbrot', $createur);
Where ROLE_EDITOR stands above the super-administrator (omnibase's usual
hierarchy; here the moderators are below the administrators:
ROLE_EDITOR: [ROLE_STAFF], ROLE_ADMIN: [ROLE_EDITOR]), moderation is
not declared: a super-administrator is never a demonstration account. A
platform without one of the accounts leaves it out (base.demo.exclude).
The labels are demo.<identifier>.label and .description in the video
domain (fr, en).
Proposer une modification docs/moderation.md
Configuration
Every leaf is also a parameter of the same name (%video.upload.enabled%).
# config/packages/video.yaml
video:
storage: '%kernel.project_dir%/var/storage/videos' # one directory a video, named by a random key
public_path: /media/videos # where the web server serves it
upload:
enabled: true # false: a broadcaster's site - no upload page, tusd's hooks refuse
auto_publish: false # true: a converted upload goes public at once
quota: 21474836480 # bytes of originals a member may have sent; 0: none
max_size: 8589934592 # bytes a file
verified_only: true # only a member whose e-mail is verified
directory: '%kernel.project_dir%/var/storage/uploads' # tusd's -upload-dir, as the site sees it
endpoint: /files/
token_ttl: 86400 # seconds an upload token lives
keep_originals: 0 # 0: the original is removed once converted
transcode:
ffmpeg: ffmpeg
ffprobe: ffprobe
renditions: [1080, 720, 480, 360]
segment: 6 # seconds
preset: veryfast
timeout: 14400
thumbnails: 100 # pictures of the storyboard, at most
frames: 4 # stills offered as a poster
views:
min_seconds: 30 # a view after min(min_seconds, ratio x duration) really played
ratio: 0.5
dedupe: 21600 # once a visitor and film every six hours
comments:
auto_approve: true # a comment Akismet finds clean goes online at once
guests: false # visitors without an account may comment
search:
external: false # reserved (searching other platforms is not this bundle's)
suggest:
provider: local # local | gorse
gorse_url: '' # http://suggest:8088 ; empty: Gorse is off whatever the provider
gorse_key: ''
per_channel: 2 # 0: no cap (a single-channel site)
remote:
hosts: [] # hosts a "remote-hls" master may be read from (https only)
ttl: 3600
categories: [music, film, animation, documentary, education, science, sport, games, travel, cooking, news, comedy, diy, other]
licenses: [standard, cc-by, cc-by-sa, cc-by-nc, cc0]
The bundle also declares (override them under the same names):
- the cache pool
video.cache(oncache.app; point it at Redis:framework.cache.pools.video.cache: {adapter: cache.adapter.redis, provider: ...}); - four limiters:
video_comment(10 / 10 min),video_rate(60 / min),video_upload(20 / day),video_report(10 / hour).
Translations: domain video (fr, en). A category added to categories
needs its category.<key>.
Proposer une modification docs/configuration.md
Testing
Running the suite
The suite is units: no kernel, no database, no ffmpeg, no network.
vendor/bin/phpunit
Inside a host application:
php vendor/bin/phpunit -c vendor/omnibase/video/phpunit.xml.dist
omnisense/omnibase-harness does not install omnibase/video, its glitchr/typesense-bundle nor symfony/rate-limiter. Run there with the checkout and glitchr/typesense-bundle on the autoloader, the suite gives:
Tests: 60, Assertions: 253, Errors: 4, Skipped: 1.
The four errors are UploadsTest's, for want of symfony/rate-limiter (a
requirement of the bundle, so present in a host); the skipped test is
PrefetchTest's read itself.
What the tests check
| Test | Checks |
|---|---|
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the screens |
Css\WatchLayoutTest |
the stages row as tall as the player; a phone has one column and its own rows |
Demo\VideoDemoAccountsTest |
one account for each of the platform's people; nobody reaches the super-admin; where the editors are above it, the moderator is left out; each account's label and sentence in the catalogues |
Entity\PlaylistTest |
a list keeps its order and plays on; a film no longer reachable leaves it; "plus tard" is private and newest first; a sitting's seconds never run ahead of the clock |
Entity\VideoTest |
the visibility is the thread's state; a film taken down is out of sight with its reason; the player starts from the master; an outside source keeps its reference only; times and views; the moments typed in a comment |
Player\MediaPlayTest, Player\PrefetchTest |
the player takes part through media:play; the dock; what was read ahead is read first; its words in each language |
Repository\FiguresTest |
the studio's figures from raw sittings; no sitting, no division; the search filters |
Service\RemoteManifestTest |
the URIs made absolute against the origin; what is not a playlist refused; the length summed; only the allowed hosts read; the master read once, then kept; a failing origin is a remote exception |
Service\TranscoderTest |
the ladder never above the source; a portrait film laddered on its short side; one HLS variant a rung in fMP4; a silent film maps no sound; the storyboard and its VTT; ffprobe's JSON; frame times and bitrates |
Service\UploadsTest |
a token says who, until when and which film; pre-create refuses without a valid token, checks the member, the size, the quota and the limiter; post-finish takes only a file of the uploads directory; an address part is lowercase hex |
Suggest\SuggestTest |
a pair's score; only pairs that share something; the cap per channel; Gorse read and its failures fall back; a view needs thirty seconds or half the film |
Twig\VideoExtensionTest |
timecodes become links and the text is escaped; compact counts; an excerpt cut at a word |