Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 126
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 126
0.00% covered (danger)
0.00%
0 / 21
3422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 onCanonicalNamespaces
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 register
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onImageOpenShowImageInlineBefore
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onImagePageFileHistoryLine
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onImagePageHooks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleFromTitle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleContentOnDiff
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 isTimedMediaHandlerTitle
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 onImagePageAfterImageLinks
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onFileUpload
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onTitleMove
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onPageMoveComplete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onFileDeleteComplete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onFileUndeleteComplete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onRevisionFromEditComplete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onArticlePurge
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onParserTestGlobals
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
132
 onwgQueryPages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
4
5namespace MediaWiki\TimedMediaHandler;
6
7use Article;
8use DifferenceEngine;
9use File;
10use IContextSource;
11use ImageHistoryList;
12use ImagePage;
13use LocalFile;
14use MediaWiki\Config\Config;
15use MediaWiki\Diff\Hook\ArticleContentOnDiffHook;
16use MediaWiki\Hook\CanonicalNamespacesHook;
17use MediaWiki\Hook\FileDeleteCompleteHook;
18use MediaWiki\Hook\FileUndeleteCompleteHook;
19use MediaWiki\Hook\FileUploadHook;
20use MediaWiki\Hook\PageMoveCompleteHook;
21use MediaWiki\Hook\ParserTestGlobalsHook;
22use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
23use MediaWiki\Hook\TitleMoveHook;
24use MediaWiki\Linker\LinkRenderer;
25use MediaWiki\Linker\LinkTarget;
26use MediaWiki\Output\Hook\BeforePageDisplayHook;
27use MediaWiki\Output\OutputPage;
28use MediaWiki\Page\Hook\ArticleFromTitleHook;
29use MediaWiki\Page\Hook\ArticlePurgeHook;
30use MediaWiki\Page\Hook\ImageOpenShowImageInlineBeforeHook;
31use MediaWiki\Page\Hook\ImagePageAfterImageLinksHook;
32use MediaWiki\Page\Hook\ImagePageFileHistoryLineHook;
33use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
34use MediaWiki\Revision\RevisionRecord;
35use MediaWiki\SpecialPage\Hook\WgQueryPagesHook;
36use MediaWiki\SpecialPage\SpecialPageFactory;
37use MediaWiki\Status\Status;
38use MediaWiki\TimedMediaHandler\Handlers\TextHandler\TextHandler;
39use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode;
40use MediaWiki\Title\Title;
41use MediaWiki\User\User;
42use MediaWiki\User\UserIdentity;
43use RepoGroup;
44use Skin;
45use SkinTemplate;
46use WikiFilePage;
47use WikiPage;
48
49/**
50 * Hooks for TimedMediaHandler extension
51 *
52 * @file
53 * @ingroup Extensions
54 */
55class Hooks implements
56    ArticleContentOnDiffHook,
57    ArticleFromTitleHook,
58    ArticlePurgeHook,
59    BeforePageDisplayHook,
60    CanonicalNamespacesHook,
61    FileDeleteCompleteHook,
62    FileUndeleteCompleteHook,
63    FileUploadHook,
64    ImageOpenShowImageInlineBeforeHook,
65    ImagePageAfterImageLinksHook,
66    ImagePageFileHistoryLineHook,
67    PageMoveCompleteHook,
68    ParserTestGlobalsHook,
69    RevisionFromEditCompleteHook,
70    SkinTemplateNavigation__UniversalHook,
71    TitleMoveHook,
72    WgQueryPagesHook
73{
74
75    /** @var Config */
76    private $config;
77
78    /** @var LinkRenderer */
79    private $linkRenderer;
80
81    /** @var RepoGroup */
82    private $repoGroup;
83
84    /** @var SpecialPageFactory */
85    private $specialPageFactory;
86
87    /** @var TranscodableChecker */
88    private $transcodableChecker;
89
90    /**
91     * @param Config $config
92     * @param LinkRenderer $linkRenderer
93     * @param RepoGroup $repoGroup
94     * @param SpecialPageFactory $specialPageFactory
95     */
96    public function __construct(
97        Config $config,
98        LinkRenderer $linkRenderer,
99        RepoGroup $repoGroup,
100        SpecialPageFactory $specialPageFactory
101    ) {
102        $this->config = $config;
103        $this->linkRenderer = $linkRenderer;
104        $this->repoGroup = $repoGroup;
105        $this->specialPageFactory = $specialPageFactory;
106        $this->transcodableChecker = new TranscodableChecker(
107            $config,
108            $repoGroup
109        );
110    }
111
112    /**
113     * Register TimedMediaHandler namespace IDs
114     *
115     * This way if you set a variable like $wgTimedTextNS in LocalSettings.php
116     * after you include TimedMediaHandler we can still read the variable values
117     *
118     * These are configurable due to Commons history: T123823
119     * These need to be before registerhooks due to: T123695
120     *
121     * @param array &$list
122     */
123    public function onCanonicalNamespaces( &$list ) {
124        if ( !defined( 'NS_TIMEDTEXT' ) ) {
125            $timedTextNS = $this->config->get( 'TimedTextNS' );
126            define( 'NS_TIMEDTEXT', $timedTextNS );
127            define( 'NS_TIMEDTEXT_TALK', $timedTextNS + 1 );
128        }
129
130        $list[NS_TIMEDTEXT] = 'TimedText';
131        $list[NS_TIMEDTEXT_TALK] = 'TimedText_talk';
132    }
133
134    /**
135     * Register remaining TimedMediaHandler hooks right after initial setup
136     *
137     * TODO: This function shouldn't need to exist.
138     *
139     * @return bool
140     */
141    public static function register() {
142        global $wgJobTypesExcludedFromDefaultQueue,
143        $wgExcludeFromThumbnailPurge,
144        $wgFileExtensions, $wgTmhEnableMp4Uploads,
145        $wgTmhFileExtensions;
146
147        $wgFileExtensions = array_merge( $wgFileExtensions, $wgTmhFileExtensions );
148
149        // Remove mp4 if not enabled:
150        if ( $wgTmhEnableMp4Uploads === false ) {
151            $index = array_search( 'mp4', $wgFileExtensions, true );
152            if ( $index !== false ) {
153                array_splice( $wgFileExtensions, $index, 1 );
154            }
155        }
156
157        // Transcode jobs must be explicitly requested from the job queue:
158        $wgJobTypesExcludedFromDefaultQueue[] = 'webVideoTranscode';
159
160        // Exclude transcoded assets from normal thumbnail purging
161        // ( a maintenance script could handle transcode asset purging)
162        if ( isset( $wgExcludeFromThumbnailPurge ) ) {
163            $wgExcludeFromThumbnailPurge = array_merge( $wgExcludeFromThumbnailPurge, $wgTmhFileExtensions );
164            // Also add the .log file ( used in two pass encoding )
165            // ( probably should move in-progress encodes out of web accessible directory )
166            $wgExcludeFromThumbnailPurge[] = 'log';
167        }
168
169        // validate enabled transcodeset values
170        WebVideoTranscode::validateTranscodeConfiguration();
171        return true;
172    }
173
174    /**
175     * @param ImagePage $imagePage the imagepage that is being rendered
176     * @param OutputPage $output the output for this imagepage
177     * @return bool
178     */
179    public function onImageOpenShowImageInlineBefore( $imagePage, $output ) {
180        $file = $imagePage->getDisplayedFile();
181        return self::onImagePageHooks( $file, $output );
182    }
183
184    /**
185     * @param ImageHistoryList $imageHistoryList that is being rendered
186     * @param File $file the (old) file added in this history entry
187     * @param string &$line the HTML of the history line
188     * @param string &$css the CSS class of the history line
189     * @return bool
190     */
191    public function onImagePageFileHistoryLine( $imageHistoryList, $file, &$line, &$css ) {
192        $out = $imageHistoryList->getContext()->getOutput();
193        return self::onImagePageHooks( $file, $out );
194    }
195
196    /**
197     * @param File $file the file that is being rendered
198     * @param OutputPage $out the output to which this file is being rendered
199     * @return bool
200     */
201    private static function onImagePageHooks( $file, $out ) {
202        $handler = $file->getHandler();
203        if ( $handler instanceof TimedMediaHandler ) {
204            $out->addModuleStyles( 'ext.tmh.player.styles' );
205            $out->addModules( 'ext.tmh.player' );
206        }
207        return true;
208    }
209
210    /**
211     * @param Title $title
212     * @param Article|null &$article
213     * @param IContextSource $context
214     * @return bool
215     */
216    public function onArticleFromTitle( $title, &$article, $context ) {
217        if ( $title->getNamespace() === $this->config->get( 'TimedTextNS' ) ) {
218            $article = new TimedTextPage( $title );
219        }
220        return true;
221    }
222
223    /**
224     * @param DifferenceEngine $diffEngine
225     * @param OutputPage $output
226     * @return bool
227     */
228    public function onArticleContentOnDiff( $diffEngine, $output ) {
229        if ( $output->getTitle()->getNamespace() === $this->config->get( 'TimedTextNS' ) ) {
230            $article = new TimedTextPage( $output->getTitle(), $diffEngine->getNewId() );
231            $article->renderOutput( $output );
232            return false;
233        }
234        return true;
235    }
236
237    /**
238     * @param SkinTemplate $sktemplate
239     * @param array &$links
240     */
241    public function onSkinTemplateNavigation__Universal( $sktemplate, &$links ): void {
242        if ( $this->isTimedMediaHandlerTitle( $sktemplate->getTitle() ) ) {
243            $ttTitle = Title::makeTitleSafe( NS_TIMEDTEXT, $sktemplate->getTitle()->getDBkey() );
244            if ( !$ttTitle ) {
245                return;
246            }
247            $tab = $sktemplate->tabAction( $ttTitle, 'timedtext', false, '', false );
248
249            // Lookup if we have any corresponding timed text available already
250            $file = $this->repoGroup->findFile( $sktemplate->getTitle(), [ 'ignoreRedirect' => true ] );
251            $textHandler = new TextHandler( $file, [ TimedTextPage::VTT_SUBTITLE_FORMAT ] );
252            $ttExists = count( $textHandler->getTracks() ) > 0;
253            $tab[ 'exists' ] = $ttExists;
254            $tab[ 'class' ] .= !$ttExists ? ' new' : '';
255            $links[ 'namespaces' ][ 'timedtext' ] = $tab;
256            return;
257        }
258        if ( $sktemplate->getTitle()->getNamespace() === $this->config->get( 'TimedTextNS' ) ) {
259            $page = new TimedTextPage( $sktemplate->getTitle() );
260            $links['namespaces']['file'] =
261                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
262                $sktemplate->tabAction( $page->getCorrespondingFileTitle(), 'file', false, '', true );
263        }
264    }
265
266    /**
267     * @param Title $title
268     * @return bool
269     */
270    public function isTimedMediaHandlerTitle( $title ) {
271        if ( !$title->inNamespace( NS_FILE ) ) {
272            return false;
273        }
274        $file = $this->repoGroup->findFile( $title, [ 'ignoreRedirect' => true ] );
275        // Can't find file
276        if ( !$file ) {
277            return false;
278        }
279        $handler = $file->getHandler();
280        if ( !$handler ) {
281            return false;
282        }
283        return $handler instanceof TimedMediaHandler;
284    }
285
286    /**
287     * @param Article $imagePage
288     * @param string &$html
289     * @return bool
290     */
291    public function onImagePageAfterImageLinks( $imagePage, &$html ) {
292        // load the file:
293        $file = $this->repoGroup->findFile( $imagePage->getTitle(), [ 'ignoreRedirect' => true ] );
294        if ( $this->transcodableChecker->isTranscodableFile( $file ) ) {
295            $transcodeStatusTable = new TranscodeStatusTable(
296                $imagePage->getContext(),
297                $this->linkRenderer
298            );
299            $html .= $transcodeStatusTable->getHTML( $file );
300        }
301        return true;
302    }
303
304    /**
305     * @param File $file LocalFile object
306     * @param bool $reupload
307     * @param bool $hasDescription
308     * @return bool
309     */
310    public function onFileUpload( $file, $reupload, $hasDescription ) {
311        // Check that the file is a transcodable asset:
312        if ( $this->transcodableChecker->isTranscodableFile( $file ) ) {
313            // Remove all the transcode files and db states for this asset
314            WebVideoTranscode::removeTranscodes( $file );
315            WebVideoTranscode::startJobQueue( $file );
316        }
317        return true;
318    }
319
320    /**
321     * Handle moved titles
322     *
323     * For now we just remove all the derivatives for the oldTitle. In the future we could
324     * look at moving the files, but right now thumbs are not moved, so I don't want to be
325     * inconsistent.
326     * @param Title $title
327     * @param Title $newTitle
328     * @param User $user
329     * @param string $reason
330     * @param Status &$status
331     * @return bool
332     */
333    public function onTitleMove( Title $title, Title $newTitle, User $user, $reason, Status &$status ) {
334        if ( $this->transcodableChecker->isTranscodableTitle( $title ) ) {
335            // Remove all the transcode files and db states for this asset
336            // Will be re-added after the file has moved
337            $file = $this->repoGroup->findFile( $title, [ 'ignoreRedirect' => true ] );
338            WebVideoTranscode::removeTranscodes( $file );
339        }
340        return true;
341    }
342
343    /**
344     * Hook to PageMoveComplete. Add transcode jobs for new file name
345     * @param LinkTarget $old Old title
346     * @param LinkTarget $new New title
347     * @param UserIdentity $user User who did the move
348     * @param int $pageid Database ID of the page that's been moved
349     * @param int $redirid Database ID of the created redirect
350     * @param string $reason Reason for the move
351     * @param RevisionRecord $revision RevisionRecord created by the move
352     * @return bool|void True or no return value to continue or false stop other hook handlers,
353     *     doesn't abort the move itself
354     */
355    public function onPageMoveComplete( $old, $new, $user, $pageid, $redirid, $reason, $revision ) {
356        if ( $this->transcodableChecker->isTranscodableTitle( $new ) ) {
357            $newFile = $this->repoGroup->findFile( $new, [ 'ignoreRedirect' => true, 'latest' => true ] );
358            WebVideoTranscode::startJobQueue( $newFile );
359        }
360    }
361
362    /**
363     * Hook to FileDeleteComplete. Removes transcodes on delete.
364     * @param LocalFile $file
365     * @param string|null $oldimage
366     * @param WikiFilePage|null $article
367     * @param User $user
368     * @param string $reason
369     * @return bool
370     */
371    public function onFileDeleteComplete( $file, $oldimage, $article, $user, $reason ) {
372        if ( !$oldimage && $this->transcodableChecker->isTranscodableFile( $file ) ) {
373            WebVideoTranscode::removeTranscodes( $file );
374        }
375        return true;
376    }
377
378    /**
379     * @inheritDoc
380     */
381    public function onFileUndeleteComplete( $title, $fileVersions, $user, $reason ) {
382        $file = $this->repoGroup->findFile( $title, [ 'ignoreRedirect' => true, 'latest' => true ] );
383        if ( $file && $this->transcodableChecker->isTranscodableFile( $file ) ) {
384            WebVideoTranscode::removeTranscodes( $file );
385            WebVideoTranscode::startJobQueue( $file );
386        }
387        return true;
388    }
389
390    /**
391     * If file gets reverted to a previous version, reset transcodes.
392     *
393     * @param WikiPage $wikiPage
394     * @param RevisionRecord $rev
395     * @param int $originalRevId
396     * @param UserIdentity $user
397     * @param string[] &$tags
398     *
399     * @return bool
400     */
401    public function onRevisionFromEditComplete(
402        $wikiPage, $rev, $originalRevId, $user, &$tags
403    ) {
404        // Check if the article is a file and remove transcode files:
405        if ( ( $originalRevId !== false ) && $wikiPage->getTitle()->getNamespace() === NS_FILE ) {
406            $file = $this->repoGroup->findFile( $wikiPage->getTitle() );
407            if ( $this->transcodableChecker->isTranscodableFile( $file ) ) {
408                WebVideoTranscode::removeTranscodes( $file );
409                WebVideoTranscode::startJobQueue( $file );
410            }
411        }
412        return true;
413    }
414
415    /**
416     * When a user asks for a purge, perhaps through our handy "update transcode status"
417     * link, make sure we've got the updated set of transcodes. This'll allow a user or
418     * automated process to see their status and reset them.
419     *
420     * @param WikiPage $wikiPage
421     * @return bool
422     */
423    public function onArticlePurge( $wikiPage ) {
424        if ( $wikiPage->getTitle()->getNamespace() === NS_FILE ) {
425            $file = $this->repoGroup->findFile( $wikiPage->getTitle(), [ 'ignoreRedirect' => true ] );
426            if ( $this->transcodableChecker->isTranscodableFile( $file ) ) {
427                WebVideoTranscode::cleanupTranscodes( $file );
428            }
429        }
430        return true;
431    }
432
433    /**
434     * @param array &$globals
435     */
436    public function onParserTestGlobals( &$globals ) {
437        // reset player serial so that parser tests are not order-dependent
438        TimedMediaTransformOutput::resetSerialForTest();
439
440        $globals['wgEnableTranscode'] = false;
441        $globals['wgFFmpegLocation'] = '/usr/bin/ffmpeg';
442    }
443
444    /**
445     * Add JavaScript and CSS for special pages that may include timed media
446     * but which will not fire the parser hook.
447     *
448     * FIXME: There ought to be a better interface for determining whether the
449     * page is liable to contain timed media.
450     *
451     * @param OutputPage $out
452     * @param Skin $skin
453     */
454    public function onBeforePageDisplay( $out, $skin ): void {
455        $title = $out->getTitle();
456        $namespace = $title->getNamespace();
457        $addModules = false;
458
459        if ( $namespace === NS_CATEGORY || $namespace === $this->config->get( 'TimedTextNS' ) ) {
460            $addModules = true;
461        } elseif ( $title->isSpecialPage() ) {
462            [ $name, ] = $this->specialPageFactory->resolveAlias( $title->getDBkey() );
463            if ( $name !== null && (
464                    $name === 'Search' ||
465                    $name === 'GlobalUsage' ||
466                    $name === 'Upload' ||
467                    stripos( $name, 'file' ) !== false ||
468                    stripos( $name, 'image' ) !== false
469                )
470            ) {
471                $addModules = true;
472            }
473        }
474
475        if ( $addModules ) {
476            $out->addModuleStyles( 'ext.tmh.player.styles' );
477            $out->addModules( 'ext.tmh.player' );
478        }
479    }
480
481    /**
482     * @param array &$qp
483     */
484    public function onwgQueryPages( &$qp ) {
485        $qp[] = [ SpecialOrphanedTimedText::class, 'OrphanedTimedText' ];
486    }
487}