Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.06% covered (success)
90.06%
607 / 674
56.10% covered (warning)
56.10%
23 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playlist_Manager
90.06% covered (success)
90.06%
607 / 674
56.10% covered (warning)
56.10%
23 / 41
209.12
0.00% covered (danger)
0.00%
0 / 1
 register_rest_routes
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
1 / 1
1
 register_add_matching_route
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 rest_add_matching
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
4.02
 playlist_args
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
2
 edit_permissions_callback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 update_permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rest_list_playlists
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 rest_get_playlist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rest_create_playlist
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 rest_update_playlist
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 rest_delete_playlist
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 changes_from_request
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 query
89.36% covered (warning)
89.36%
42 / 47
0.00% covered (danger)
0.00%
0 / 1
11.15
 create
82.50% covered (warning)
82.50%
33 / 40
0.00% covered (danger)
0.00%
0 / 1
12.77
 update
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
15.15
 delete
70.73% covered (warning)
70.73%
29 / 41
0.00% covered (danger)
0.00%
0 / 1
9.60
 locate
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
8.69
 change_block_at
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 remove_block_at
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 is_whitespace_block
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 without_inner_slot
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 apply_changes
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
13
 sanitize_settings
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 settings_from_attrs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sync_heading
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 heading_text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 with_heading_text
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 find_block_path
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 block_at
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 block_key
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 playlist_block_paths
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 records
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 unpublished_posts
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 post_types
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 modified_time
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 find_record
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 record_for_block
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 block_attrs
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 prepare_item
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
14
 cannot_publish
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 not_found
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Playlist management for the VideoPress dashboard.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Error;
11use WP_Post;
12use WP_Query;
13use WP_REST_Request;
14use WP_REST_Response;
15use WP_REST_Server;
16
17/**
18 * Lists, creates, edits and deletes the site's playlists.
19 *
20 * A playlist is a Video Playlist block in a post, so every change rewrites
21 * that block in its post: Playlist_Index picks published changes up from the
22 * post save. Playlists in unpublished posts ( drafts, pending, scheduled,
23 * private ) aren't indexed; they are read straight from their posts so the
24 * dashboard can list the ones it creates before they go live.
25 */
26class Playlist_Manager {
27
28    /**
29     * Route base under Rest_Controller::REST_NAMESPACE.
30     *
31     * @var string
32     */
33    const ROUTE = '/playlists';
34
35    /**
36     * Accepted playlist keys, as sanitize_key() leaves them.
37     *
38     * @var string
39     */
40    const KEY_PATTERN = '[a-z0-9_\-]+';
41
42    /**
43     * Fields the list can be sorted by.
44     *
45     * @var string[]
46     */
47    const ORDERBY = array( 'updated', 'title', 'videos' );
48
49    /**
50     * Post statuses a listed playlist can have, `publish` first.
51     *
52     * @var string[]
53     */
54    const STATUSES = array( 'publish', 'future', 'draft', 'pending', 'private' );
55
56    /**
57     * Statuses a playlist can be switched to from the dashboard.
58     *
59     * @var string[]
60     */
61    const SETTABLE_STATUSES = array( 'publish', 'draft' );
62
63    /**
64     * Post types a new playlist can be created in.
65     *
66     * @var string[]
67     */
68    const CREATABLE_POST_TYPES = array( 'page', 'post' );
69
70    /**
71     * Most unpublished posts read per request.
72     *
73     * @var int
74     */
75    const UNPUBLISHED_LIMIT = 200;
76
77    /**
78     * The block's display and playback attributes the dashboard edits, as
79     * block.json declares them ( Playlist_Manager_Test keeps the two in step ).
80     *
81     * @var array[]
82     */
83    const SETTINGS = array(
84        'showPlaylistTitle'    => array(
85            'type'    => 'boolean',
86            'default' => true,
87        ),
88        'layout'               => array(
89            'type'    => 'string',
90            'enum'    => array( 'side-rail', 'grid', 'strip' ),
91            'default' => 'side-rail',
92        ),
93        'darkPlayer'           => array(
94            'type'    => 'boolean',
95            'default' => false,
96        ),
97        'showPlayer'           => array(
98            'type'    => 'boolean',
99            'default' => true,
100        ),
101        'entryClickAction'     => array(
102            'type'    => 'string',
103            'enum'    => array( 'new-tab', 'show-player' ),
104            'default' => 'new-tab',
105        ),
106        'autoplayNext'         => array(
107            'type'    => 'boolean',
108            'default' => false,
109        ),
110        'muteByDefault'        => array(
111            'type'    => 'boolean',
112            'default' => false,
113        ),
114        'loopPlaylist'         => array(
115            'type'    => 'boolean',
116            'default' => false,
117        ),
118        'showThumbnail'        => array(
119            'type'    => 'boolean',
120            'default' => true,
121        ),
122        'showTitle'            => array(
123            'type'    => 'boolean',
124            'default' => true,
125        ),
126        'showResolution'       => array(
127            'type'    => 'boolean',
128            'default' => true,
129        ),
130        'showDuration'         => array(
131            'type'    => 'boolean',
132            'default' => true,
133        ),
134        'showPositionNumber'   => array(
135            'type'    => 'boolean',
136            'default' => false,
137        ),
138        'showTotalRuntime'     => array(
139            'type'    => 'boolean',
140            'default' => true,
141        ),
142        'entryTitleFontFamily' => array(
143            'type'    => 'string',
144            'default' => '',
145        ),
146    );
147
148    /**
149     * Register the playlist routes.
150     *
151     * @return void
152     */
153    public static function register_rest_routes() {
154        register_rest_route(
155            Rest_Controller::REST_NAMESPACE,
156            self::ROUTE,
157            array(
158                array(
159                    'methods'             => WP_REST_Server::READABLE,
160                    'callback'            => array( __CLASS__, 'rest_list_playlists' ),
161                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
162                    'args'                => array(
163                        'page'     => array(
164                            'type'    => 'integer',
165                            'default' => 1,
166                            'minimum' => 1,
167                        ),
168                        'per_page' => array(
169                            'type'    => 'integer',
170                            'default' => 12,
171                            'minimum' => 1,
172                            'maximum' => 100,
173                        ),
174                        'search'   => array(
175                            'type'    => 'string',
176                            'default' => '',
177                        ),
178                        'orderby'  => array(
179                            'type'    => 'string',
180                            'default' => 'updated',
181                            'enum'    => self::ORDERBY,
182                        ),
183                        'order'    => array(
184                            'type'    => 'string',
185                            'default' => 'desc',
186                            'enum'    => array( 'asc', 'desc' ),
187                        ),
188                        'status'   => array(
189                            'type'    => 'array',
190                            'items'   => array(
191                                'type' => 'string',
192                                'enum' => self::STATUSES,
193                            ),
194                            'default' => array(),
195                        ),
196                    ),
197                ),
198                array(
199                    'methods'             => WP_REST_Server::CREATABLE,
200                    'callback'            => array( __CLASS__, 'rest_create_playlist' ),
201                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
202                    'args'                => array_merge(
203                        self::playlist_args(),
204                        array(
205                            'postType' => array(
206                                'type'    => 'string',
207                                'enum'    => self::CREATABLE_POST_TYPES,
208                                'default' => 'page',
209                            ),
210                            'status'   => array(
211                                'type'    => 'string',
212                                'enum'    => self::SETTABLE_STATUSES,
213                                'default' => 'draft',
214                            ),
215                        )
216                    ),
217                ),
218            )
219        );
220
221        register_rest_route(
222            Rest_Controller::REST_NAMESPACE,
223            self::ROUTE . '/(?P<key>' . self::KEY_PATTERN . ')',
224            array(
225                array(
226                    'methods'             => WP_REST_Server::READABLE,
227                    'callback'            => array( __CLASS__, 'rest_get_playlist' ),
228                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
229                ),
230                array(
231                    'methods'             => WP_REST_Server::EDITABLE,
232                    'callback'            => array( __CLASS__, 'rest_update_playlist' ),
233                    'permission_callback' => array( __CLASS__, 'edit_permissions_callback' ),
234                    'args'                => array_merge(
235                        self::playlist_args(),
236                        array(
237                            'status' => array(
238                                'type' => 'string',
239                                'enum' => self::SETTABLE_STATUSES,
240                            ),
241                        )
242                    ),
243                ),
244                array(
245                    'methods'             => WP_REST_Server::DELETABLE,
246                    'callback'            => array( __CLASS__, 'rest_delete_playlist' ),
247                    'permission_callback' => array( __CLASS__, 'edit_permissions_callback' ),
248                ),
249            )
250        );
251    }
252
253    /**
254     * Register the add-matching route: a one-time snapshot of a query appended to a playlist.
255     *
256     * @return void
257     */
258    public static function register_add_matching_route() {
259        register_rest_route(
260            Rest_Controller::REST_NAMESPACE,
261            self::ROUTE . '/(?P<key>' . self::KEY_PATTERN . ')/add-matching',
262            array(
263                'methods'             => WP_REST_Server::CREATABLE,
264                'callback'            => array( __CLASS__, 'rest_add_matching' ),
265                'permission_callback' => array( __CLASS__, 'edit_permissions_callback' ),
266                'args'                => array_merge(
267                    Video_Query::args(),
268                    array(
269                        'limit' => array(
270                            'type'    => 'integer',
271                            'minimum' => 1,
272                            'maximum' => Video_Query::MAX_SNAPSHOT,
273                            'default' => Video_Query::DEFAULT_LIMIT,
274                        ),
275                    )
276                ),
277            )
278        );
279    }
280
281    /**
282     * Append the videos matching a search and filters that the playlist does not have yet.
283     * The playlist stores the GUIDs, so later uploads or reclassification never change it.
284     *
285     * @param WP_REST_Request $request Incoming request.
286     * @return WP_REST_Response|WP_Error
287     */
288    public static function rest_add_matching( WP_REST_Request $request ) {
289        $record = self::find_record( (string) $request['key'] );
290        if ( ! $record ) {
291            return self::not_found();
292        }
293        $current = array_map(
294            function ( $entry ) {
295                return array(
296                    'guid'       => $entry['guid'],
297                    'durationMs' => $entry['durationMs'],
298                    'height'     => $entry['height'],
299                );
300            },
301            $record['videos']
302        );
303        $matches = Video_Query::matches( Video_Query::state( $request ), array_column( $current, 'guid' ), (int) $request['limit'] );
304        if ( ! $matches['entries'] ) {
305            return Video_Query::nothing_to_add();
306        }
307
308        $saved = self::update( $record['key'], array( 'videos' => array_merge( $current, $matches['entries'] ) ) );
309        if ( is_wp_error( $saved ) ) {
310            return $saved;
311        }
312        return rest_ensure_response(
313            array(
314                'playlist'  => self::prepare_item( $saved ),
315                'added'     => count( $matches['entries'] ),
316                'remaining' => max( 0, $matches['total'] - count( $matches['entries'] ) ),
317            )
318        );
319    }
320
321    /**
322     * Arguments shared by the create and update routes.
323     *
324     * @return array
325     */
326    private static function playlist_args() {
327        $settings = array();
328        foreach ( self::SETTINGS as $name => $schema ) {
329            unset( $schema['default'] );
330            $settings[ $name ] = $schema;
331        }
332
333        return array(
334            'title'       => array(
335                'type'              => 'string',
336                'sanitize_callback' => 'sanitize_text_field',
337            ),
338            'description' => array(
339                'type'              => 'string',
340                'sanitize_callback' => 'sanitize_textarea_field',
341            ),
342            'videos'      => array(
343                'type'  => 'array',
344                'items' => array(
345                    'type'       => 'object',
346                    'properties' => array(
347                        'guid'       => array(
348                            'type'     => 'string',
349                            'pattern'  => '^[a-zA-Z0-9]{8}$',
350                            'required' => true,
351                        ),
352                        'durationMs' => array(
353                            'type'    => 'integer',
354                            'minimum' => 0,
355                        ),
356                        'height'     => array(
357                            'type'    => 'integer',
358                            'minimum' => 0,
359                        ),
360                    ),
361                ),
362            ),
363            'settings'    => array(
364                'type'                 => 'object',
365                'properties'           => $settings,
366                'additionalProperties' => false,
367            ),
368        );
369    }
370
371    /**
372     * Changing a playlist needs the dashboard capability and the right to edit its post.
373     *
374     * @param WP_REST_Request $request Incoming request.
375     * @return bool
376     */
377    public static function edit_permissions_callback( WP_REST_Request $request ) {
378        if ( ! Rest_Controller::permissions_callback() ) {
379            return false;
380        }
381
382        $record = self::find_record( (string) $request['key'] );
383
384        // Unknown keys fall through to the callback, which answers 404.
385        return null === $record || current_user_can( 'edit_post', $record['post_id'] );
386    }
387
388    /**
389     * Kept for callers of the first version of the routes.
390     *
391     * @param WP_REST_Request $request Incoming request.
392     * @return bool
393     */
394    public static function update_permissions_callback( WP_REST_Request $request ) {
395        return self::edit_permissions_callback( $request );
396    }
397
398    /**
399     * List the site's playlists, a page at a time.
400     *
401     * @param WP_REST_Request $request Incoming request.
402     * @return WP_REST_Response
403     */
404    public static function rest_list_playlists( WP_REST_Request $request ) {
405        $result = self::query(
406            array(
407                'page'     => (int) $request['page'],
408                'per_page' => (int) $request['per_page'],
409                'search'   => (string) $request['search'],
410                'orderby'  => (string) $request['orderby'],
411                'order'    => (string) $request['order'],
412                'status'   => (array) $request['status'],
413            )
414        );
415
416        $response = rest_ensure_response( array_map( array( __CLASS__, 'prepare_item' ), $result['items'] ) );
417        $response->header( 'X-WP-Total', (string) $result['total'] );
418        $response->header( 'X-WP-TotalPages', (string) $result['total_pages'] );
419
420        return $response;
421    }
422
423    /**
424     * Get one playlist.
425     *
426     * @param WP_REST_Request $request Incoming request.
427     * @return WP_REST_Response|WP_Error
428     */
429    public static function rest_get_playlist( WP_REST_Request $request ) {
430        $record = self::find_record( (string) $request['key'] );
431        if ( ! $record ) {
432            return self::not_found();
433        }
434
435        return rest_ensure_response( self::prepare_item( $record ) );
436    }
437
438    /**
439     * Create a playlist in a new post.
440     *
441     * @param WP_REST_Request $request Incoming request.
442     * @return WP_REST_Response|WP_Error
443     */
444    public static function rest_create_playlist( WP_REST_Request $request ) {
445        $record = self::create(
446            array_merge(
447                self::changes_from_request( $request ),
448                array(
449                    'post_type' => (string) $request['postType'],
450                    'status'    => (string) $request['status'],
451                )
452            )
453        );
454        if ( is_wp_error( $record ) ) {
455            return $record;
456        }
457
458        $response = rest_ensure_response( self::prepare_item( $record ) );
459        $response->set_status( 201 );
460
461        return $response;
462    }
463
464    /**
465     * Update a playlist's details, videos, settings or post status.
466     *
467     * @param WP_REST_Request $request Incoming request.
468     * @return WP_REST_Response|WP_Error
469     */
470    public static function rest_update_playlist( WP_REST_Request $request ) {
471        $changes = self::changes_from_request( $request );
472        if ( $request->has_param( 'status' ) ) {
473            $changes['status'] = (string) $request->get_param( 'status' );
474        }
475
476        $record = self::update( (string) $request['key'], $changes );
477        if ( is_wp_error( $record ) ) {
478            return $record;
479        }
480
481        return rest_ensure_response( self::prepare_item( $record ) );
482    }
483
484    /**
485     * Delete a playlist.
486     *
487     * @param WP_REST_Request $request Incoming request.
488     * @return WP_REST_Response|WP_Error
489     */
490    public static function rest_delete_playlist( WP_REST_Request $request ) {
491        $result = self::delete( (string) $request['key'] );
492        if ( is_wp_error( $result ) ) {
493            return $result;
494        }
495
496        return rest_ensure_response( $result );
497    }
498
499    /**
500     * The playlist changes a request carries.
501     *
502     * @param WP_REST_Request $request Incoming request.
503     * @return array Any of `title`, `description`, `videos` and `settings`.
504     */
505    private static function changes_from_request( WP_REST_Request $request ) {
506        $changes = array();
507        foreach ( array( 'title', 'description', 'videos', 'settings' ) as $field ) {
508            if ( $request->has_param( $field ) ) {
509                $changes[ $field ] = $request->get_param( $field );
510            }
511        }
512
513        return $changes;
514    }
515
516    /**
517     * Filter, sort and paginate the playlists.
518     *
519     * @param array $args {
520     *     Query arguments.
521     *
522     *     @type int      $page     1-based page.
523     *     @type int      $per_page Playlists per page.
524     *     @type string   $search   Text matched against titles and descriptions.
525     *     @type string   $orderby  One of ORDERBY.
526     *     @type string   $order    `asc` or `desc`.
527     *     @type string[] $status   Post statuses to keep; empty keeps all.
528     * }
529     * @return array{items: array[], total: int, total_pages: int}
530     */
531    public static function query( array $args ) {
532        $page     = max( 1, (int) ( $args['page'] ?? 1 ) );
533        $per_page = max( 1, (int) ( $args['per_page'] ?? 12 ) );
534        $search   = trim( (string) ( $args['search'] ?? '' ) );
535        $orderby  = in_array( $args['orderby'] ?? '', self::ORDERBY, true ) ? $args['orderby'] : 'updated';
536        $order    = 'asc' === ( $args['order'] ?? 'desc' ) ? 'asc' : 'desc';
537        $statuses = array_values( array_intersect( (array) ( $args['status'] ?? array() ), self::STATUSES ) );
538
539        $playlists = array_values( self::records() );
540
541        if ( $statuses ) {
542            $playlists = array_values(
543                array_filter(
544                    $playlists,
545                    function ( $playlist ) use ( $statuses ) {
546                        return in_array( $playlist['status'], $statuses, true );
547                    }
548                )
549            );
550        }
551
552        if ( '' !== $search ) {
553            $playlists = array_values(
554                array_filter(
555                    $playlists,
556                    function ( $playlist ) use ( $search ) {
557                        return false !== stripos( $playlist['title'], $search )
558                            || false !== stripos( $playlist['description'], $search );
559                    }
560                )
561            );
562        }
563
564        usort(
565            $playlists,
566            function ( $a, $b ) use ( $orderby, $order ) {
567                switch ( $orderby ) {
568                    case 'title':
569                        $cmp = strcasecmp( $a['title'], $b['title'] );
570                        break;
571                    case 'videos':
572                        $cmp = count( $a['videos'] ) - count( $b['videos'] );
573                        break;
574                    default:
575                        $cmp = $a['updated'] - $b['updated'];
576                }
577                if ( 0 === $cmp ) {
578                    return strcmp( $a['key'], $b['key'] );
579                }
580                return 'asc' === $order ? $cmp : -$cmp;
581            }
582        );
583
584        $total = count( $playlists );
585
586        return array(
587            'items'       => array_slice( $playlists, ( $page - 1 ) * $per_page, $per_page ),
588            'total'       => $total,
589            'total_pages' => (int) ceil( $total / $per_page ),
590        );
591    }
592
593    /**
594     * Create a playlist: a new post holding one Video Playlist block.
595     *
596     * @param array $args Any of `title`, `description`, `videos` and `settings`,
597     *                    plus the new post's `post_type` and `status`.
598     * @return array|WP_Error The new record.
599     */
600    public static function create( array $args ) {
601        $post_type = in_array( $args['post_type'] ?? '', self::CREATABLE_POST_TYPES, true ) ? $args['post_type'] : 'page';
602        $status    = in_array( $args['status'] ?? '', self::SETTABLE_STATUSES, true ) ? $args['status'] : 'draft';
603        $type      = get_post_type_object( $post_type );
604
605        if ( ! $type || ! current_user_can( $type->cap->create_posts ) ) {
606            return new WP_Error(
607                'videopress_playlist_cannot_create',
608                __( 'You aren’t allowed to create playlists.', 'jetpack-videopress-pkg' ),
609                array( 'status' => rest_authorization_required_code() )
610            );
611        }
612        if ( 'publish' === $status && ! current_user_can( $type->cap->publish_posts ) ) {
613            return self::cannot_publish();
614        }
615
616        $title = isset( $args['title'] ) && is_string( $args['title'] ) ? $args['title'] : '';
617        $block = self::apply_changes(
618            array(
619                'blockName'    => Playlist_Index::BLOCK_NAME,
620                'attrs'        => array( 'playlistId' => wp_generate_uuid4() ),
621                'innerBlocks'  => array(),
622                'innerHTML'    => '',
623                'innerContent' => array(),
624            ),
625            array_merge(
626                array( 'title' => $title ),
627                array_intersect_key( $args, array_flip( array( 'description', 'videos', 'settings' ) ) )
628            )
629        );
630
631        $post_id = wp_insert_post(
632            wp_slash(
633                array(
634                    'post_type'    => $post_type,
635                    'post_status'  => $status,
636                    'post_title'   => '' !== $title ? $title : __( 'Untitled playlist', 'jetpack-videopress-pkg' ),
637                    'post_content' => serialize_block( $block ),
638                )
639            ),
640            true
641        );
642        if ( is_wp_error( $post_id ) ) {
643            return $post_id;
644        }
645
646        $record = self::record_for_block( $post_id, array( 0 ) );
647
648        return $record ? $record : self::not_found();
649    }
650
651    /**
652     * Rewrite a playlist's block in its post, and optionally its post's status.
653     *
654     * @param string $key     Playlist key.
655     * @param array  $changes Any of `title`, `description`, `videos`, `settings` and `status`.
656     * @return array|WP_Error The updated record.
657     */
658    public static function update( $key, array $changes ) {
659        $target = self::locate( $key );
660        if ( is_wp_error( $target ) ) {
661            return $target;
662        }
663        list( $post, $blocks, $path ) = $target;
664
665        $postarr = array(
666            'ID'           => $post->ID,
667            'post_content' => serialize_blocks( self::change_block_at( $blocks, $path, $changes ) ),
668        );
669
670        // A post named after its playlist, as the ones the dashboard creates are, follows a rename.
671        $old_title = self::block_at( $blocks, $path )['attrs']['playlistTitle'] ?? '';
672        if ( isset( $changes['title'] ) && is_string( $changes['title'] ) && '' !== trim( $changes['title'] )
673            && $changes['title'] !== $old_title && $post->post_title === $old_title ) {
674            $postarr['post_title'] = $changes['title'];
675        }
676
677        $status = $changes['status'] ?? null;
678        if ( is_string( $status ) && in_array( $status, self::SETTABLE_STATUSES, true ) && $status !== $post->post_status ) {
679            $type = get_post_type_object( $post->post_type );
680            if ( 'publish' === $status && ( ! $type || ! current_user_can( $type->cap->publish_posts ) ) ) {
681                return self::cannot_publish();
682            }
683            $postarr['post_status'] = $status;
684        }
685
686        $updated = wp_update_post( wp_slash( $postarr ), true );
687        if ( is_wp_error( $updated ) ) {
688            return $updated;
689        }
690
691        $saved = self::record_for_block( $post->ID, $path );
692
693        return $saved ? $saved : self::not_found();
694    }
695
696    /**
697     * Delete a playlist: remove its block, and trash the post when nothing else is left in it.
698     *
699     * @param string $key Playlist key.
700     * @return array|WP_Error `deleted`, whether the post went to the trash, and the playlist as it was.
701     */
702    public static function delete( $key ) {
703        $record = self::find_record( $key );
704        $target = self::locate( $key );
705        if ( ! $record || is_wp_error( $target ) ) {
706            return $record ? $target : self::not_found();
707        }
708        list( $post, $blocks, $path ) = $target;
709
710        $previous = self::prepare_item( $record );
711        $content  = serialize_blocks( self::remove_block_at( $blocks, $path ) );
712
713        if ( '' === trim( $content ) ) {
714            if ( ! current_user_can( 'delete_post', $post->ID ) ) {
715                return new WP_Error(
716                    'videopress_playlist_cannot_delete',
717                    __( 'You aren’t allowed to delete the post this playlist is in.', 'jetpack-videopress-pkg' ),
718                    array( 'status' => rest_authorization_required_code() )
719                );
720            }
721            if ( ! wp_trash_post( $post->ID ) ) {
722                return new WP_Error(
723                    'videopress_playlist_delete_failed',
724                    __( 'The playlist could not be deleted.', 'jetpack-videopress-pkg' ),
725                    array( 'status' => 500 )
726                );
727            }
728
729            return array(
730                'deleted'     => true,
731                'postTrashed' => true,
732                'previous'    => $previous,
733            );
734        }
735
736        $updated = wp_update_post(
737            wp_slash(
738                array(
739                    'ID'           => $post->ID,
740                    'post_content' => $content,
741                )
742            ),
743            true
744        );
745        if ( is_wp_error( $updated ) ) {
746            return $updated;
747        }
748
749        return array(
750            'deleted'     => true,
751            'postTrashed' => false,
752            'previous'    => $previous,
753        );
754    }
755
756    /**
757     * Find a playlist's post, its parsed blocks and the path to its block.
758     *
759     * @param string $key Playlist key.
760     * @return array|WP_Error [ WP_Post, blocks, path ].
761     */
762    private static function locate( $key ) {
763        $record = self::find_record( $key );
764        if ( ! $record ) {
765            return self::not_found();
766        }
767
768        $post = get_post( $record['post_id'] );
769        if ( ! $post instanceof WP_Post ) {
770            return self::not_found();
771        }
772
773        if ( ! function_exists( 'wp_check_post_lock' ) ) {
774            require_once ABSPATH . 'wp-admin/includes/post.php';
775        }
776        if ( wp_check_post_lock( $post->ID ) ) {
777            return new WP_Error(
778                'videopress_playlist_post_locked',
779                __( 'Someone is editing the post this playlist is in. Try again once they are done.', 'jetpack-videopress-pkg' ),
780                array( 'status' => 409 )
781            );
782        }
783
784        $blocks = parse_blocks( $post->post_content );
785        $path   = self::find_block_path( $blocks, $record['key'], $post->ID );
786        if ( null === $path ) {
787            return self::not_found();
788        }
789
790        return array( $post, $blocks, $path );
791    }
792
793    /**
794     * Apply playlist changes to the block a path leads to.
795     *
796     * @param array $blocks  Parsed blocks.
797     * @param int[] $path    Indexes leading to the block through `innerBlocks`.
798     * @param array $changes Any of `title`, `description`, `videos` and `settings`.
799     * @return array The blocks, with that one changed.
800     */
801    private static function change_block_at( array $blocks, array $path, array $changes ) {
802        $index = array_shift( $path );
803
804        if ( $path ) {
805            $blocks[ $index ]['innerBlocks'] = self::change_block_at( $blocks[ $index ]['innerBlocks'], $path, $changes );
806        } else {
807            $blocks[ $index ] = self::apply_changes( $blocks[ $index ], $changes );
808        }
809
810        return $blocks;
811    }
812
813    /**
814     * Remove the block a path leads to, with its slot in the parent's inner content.
815     *
816     * @param array $blocks Parsed blocks.
817     * @param int[] $path   Indexes leading to the block through `innerBlocks`.
818     * @return array The blocks without that one.
819     */
820    private static function remove_block_at( array $blocks, array $path ) {
821        $index = array_shift( $path );
822
823        if ( $path ) {
824            $parent      = $blocks[ $index ];
825            $child_index = $path[0];
826            if ( 1 === count( $path ) ) {
827                $parent['innerContent'] = self::without_inner_slot( $parent['innerContent'] ?? array(), $child_index );
828            }
829            $parent['innerBlocks'] = self::remove_block_at( $parent['innerBlocks'], $path );
830            $blocks[ $index ]      = $parent;
831
832            return $blocks;
833        }
834
835        array_splice( $blocks, $index, 1 );
836
837        // Drop the whitespace that separated the block from its neighbour, so no blank gap is left.
838        foreach ( array( $index - 1, $index ) as $neighbour ) {
839            if ( isset( $blocks[ $neighbour ] ) && self::is_whitespace_block( $blocks[ $neighbour ] ) ) {
840                array_splice( $blocks, $neighbour, 1 );
841                break;
842            }
843        }
844
845        return array_values( $blocks );
846    }
847
848    /**
849     * Whether a parsed block is the whitespace parse_blocks() keeps between blocks.
850     *
851     * @param array $block Parsed block.
852     * @return bool
853     */
854    private static function is_whitespace_block( array $block ) {
855        return null === ( $block['blockName'] ?? null ) && '' === trim( (string) ( $block['innerHTML'] ?? '' ) );
856    }
857
858    /**
859     * Drop the n-th inner block placeholder from a block's inner content.
860     *
861     * @param array $inner_content Inner content: strings and null placeholders.
862     * @param int   $n             Index of the inner block.
863     * @return array
864     */
865    private static function without_inner_slot( array $inner_content, $n ) {
866        $seen = -1;
867        foreach ( $inner_content as $i => $chunk ) {
868            if ( null === $chunk && ++$seen === $n ) {
869                array_splice( $inner_content, $i, 1 );
870                break;
871            }
872        }
873
874        return $inner_content;
875    }
876
877    /**
878     * Apply playlist changes to a parsed Video Playlist block.
879     *
880     * The heading inner block mirrors the title, so it is kept in step with
881     * `playlistTitle` and `showPlaylistTitle` the way the block editor does.
882     *
883     * @param array $block   Parsed block.
884     * @param array $changes Any of `title`, `description`, `videos` and `settings`.
885     * @return array The block with the changes applied.
886     */
887    public static function apply_changes( array $block, array $changes ) {
888        $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
889
890        if ( isset( $changes['title'] ) && is_string( $changes['title'] ) ) {
891            $attrs['playlistTitle'] = $changes['title'];
892        }
893
894        if ( isset( $changes['description'] ) && is_string( $changes['description'] ) ) {
895            $attrs['playlistDescription'] = $changes['description'];
896        }
897
898        if ( isset( $changes['videos'] ) && is_array( $changes['videos'] ) ) {
899            // Stored the way the block editor stores them: unknown metadata is left out.
900            $attrs['videos'] = array_map(
901                function ( $entry ) {
902                    return array_filter(
903                        array(
904                            'guid'       => $entry['guid'],
905                            'durationMs' => $entry['durationMs'],
906                            'height'     => $entry['height'],
907                        )
908                    );
909                },
910                Initializer::sanitize_playlist_entries( $changes['videos'] )
911            );
912        }
913
914        if ( isset( $changes['settings'] ) && is_array( $changes['settings'] ) ) {
915            foreach ( self::sanitize_settings( $changes['settings'] ) as $name => $value ) {
916                // The block editor leaves defaults out of the markup; so does this.
917                if ( self::SETTINGS[ $name ]['default'] === $value ) {
918                    unset( $attrs[ $name ] );
919                } else {
920                    $attrs[ $name ] = $value;
921                }
922            }
923        }
924
925        $block['attrs'] = $attrs;
926
927        return self::sync_heading( $block );
928    }
929
930    /**
931     * Keep only known settings with valid values.
932     *
933     * @param array $settings Raw settings.
934     * @return array
935     */
936    public static function sanitize_settings( array $settings ) {
937        $clean = array();
938
939        foreach ( self::SETTINGS as $name => $schema ) {
940            if ( ! array_key_exists( $name, $settings ) ) {
941                continue;
942            }
943            $value = $settings[ $name ];
944
945            if ( 'boolean' === $schema['type'] ) {
946                if ( is_bool( $value ) ) {
947                    $clean[ $name ] = $value;
948                }
949                continue;
950            }
951
952            if ( ! is_string( $value ) ) {
953                continue;
954            }
955            if ( isset( $schema['enum'] ) ) {
956                if ( in_array( $value, $schema['enum'], true ) ) {
957                    $clean[ $name ] = $value;
958                }
959                continue;
960            }
961            // The only free-form setting is a font-family preset slug.
962            $clean[ $name ] = sanitize_key( $value );
963        }
964
965        return $clean;
966    }
967
968    /**
969     * A playlist block's settings, defaults filled in.
970     *
971     * @param array $attrs Block attributes.
972     * @return array
973     */
974    public static function settings_from_attrs( array $attrs ) {
975        $settings = array();
976        foreach ( self::SETTINGS as $name => $schema ) {
977            $settings[ $name ] = $schema['default'];
978        }
979
980        return array_merge( $settings, self::sanitize_settings( $attrs ) );
981    }
982
983    /**
984     * Keep the heading inner block in step with the title, as the block editor's template does:
985     * a level-2 heading holding the title while `showPlaylistTitle` is on, none otherwise.
986     *
987     * @param array $block Parsed Video Playlist block.
988     * @return array
989     */
990    private static function sync_heading( array $block ) {
991        $attrs    = $block['attrs'];
992        $show     = ! isset( $attrs['showPlaylistTitle'] ) || false !== $attrs['showPlaylistTitle'];
993        $title    = isset( $attrs['playlistTitle'] ) && is_string( $attrs['playlistTitle'] ) ? $attrs['playlistTitle'] : '';
994        $existing = $block['innerBlocks'][0] ?? null;
995
996        if ( ! $show ) {
997            $block['innerBlocks']  = array();
998            $block['innerHTML']    = '';
999            $block['innerContent'] = array();
1000
1001            return $block;
1002        }
1003
1004        if ( is_array( $existing ) && 'core/heading' === ( $existing['blockName'] ?? null ) ) {
1005            if ( self::heading_text( $existing ) !== $title ) {
1006                $block['innerBlocks'][0] = self::with_heading_text( $existing, $title );
1007            }
1008
1009            return $block;
1010        }
1011
1012        $html                  = '<h2 class="wp-block-heading">' . esc_html( $title ) . '</h2>';
1013        $block['innerBlocks']  = array(
1014            array(
1015                'blockName'    => 'core/heading',
1016                'attrs'        => array(),
1017                'innerBlocks'  => array(),
1018                'innerHTML'    => $html,
1019                'innerContent' => array( $html ),
1020            ),
1021        );
1022        $block['innerHTML']    = "\n\n";
1023        $block['innerContent'] = array( "\n", null, "\n" );
1024
1025        return $block;
1026    }
1027
1028    /**
1029     * The plain text of a parsed core/heading block.
1030     *
1031     * @param array $heading Parsed heading block.
1032     * @return string
1033     */
1034    private static function heading_text( array $heading ) {
1035        return html_entity_decode( wp_strip_all_tags( (string) ( $heading['innerHTML'] ?? '' ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1036    }
1037
1038    /**
1039     * Replace the text of a parsed core/heading block.
1040     *
1041     * @param array  $heading Parsed heading block.
1042     * @param string $text    New plain-text heading.
1043     * @return array
1044     */
1045    private static function with_heading_text( array $heading, $text ) {
1046        $replace = function ( $html ) use ( $text ) {
1047            return is_string( $html )
1048                ? preg_replace_callback(
1049                    '/^(\s*<h[1-6]\b[^>]*>).*(<\/h[1-6]>\s*)$/s',
1050                    function ( $matches ) use ( $text ) {
1051                        return $matches[1] . esc_html( $text ) . $matches[2];
1052                    },
1053                    $html
1054                )
1055                : $html;
1056        };
1057
1058        $heading['innerHTML']    = $replace( $heading['innerHTML'] ?? '' );
1059        $heading['innerContent'] = array_map( $replace, $heading['innerContent'] ?? array() );
1060
1061        return $heading;
1062    }
1063
1064    /**
1065     * Locate the Video Playlist block a key names, mirroring how Playlist_Index keys them.
1066     *
1067     * @param array  $blocks  Parsed blocks of the post.
1068     * @param string $key     Playlist key.
1069     * @param int    $post_id Post the blocks belong to.
1070     * @return int[]|null Indexes leading to the block through `innerBlocks`, or null.
1071     */
1072    public static function find_block_path( array $blocks, $key, $post_id ) {
1073        $found = null;
1074
1075        foreach ( self::playlist_block_paths( $blocks ) as $ordinal => $path ) {
1076            $own_key = self::block_key( self::block_at( $blocks, $path ), $post_id, $ordinal );
1077
1078            // The index suffixes a key another post already owns with this post's id.
1079            // When a key repeats within the post, the index keeps the last block.
1080            if ( $own_key === $key || $own_key . '-' . $post_id === $key ) {
1081                $found = $path;
1082            }
1083        }
1084
1085        return $found;
1086    }
1087
1088    /**
1089     * The block a path leads to.
1090     *
1091     * @param array $blocks Parsed blocks.
1092     * @param int[] $path   Indexes leading to the block through `innerBlocks`.
1093     * @return array
1094     */
1095    private static function block_at( array $blocks, array $path ) {
1096        $block = $blocks[ $path[0] ];
1097        foreach ( array_slice( $path, 1 ) as $index ) {
1098            $block = $block['innerBlocks'][ $index ];
1099        }
1100
1101        return $block;
1102    }
1103
1104    /**
1105     * A playlist block's key before collisions with other posts are resolved.
1106     *
1107     * @param array $block   Parsed block.
1108     * @param int   $post_id Post the block belongs to.
1109     * @param int   $ordinal 0-based position among the post's playlist blocks.
1110     * @return string
1111     */
1112    private static function block_key( array $block, $post_id, $ordinal ) {
1113        $key = isset( $block['attrs']['playlistId'] ) && is_string( $block['attrs']['playlistId'] )
1114            ? sanitize_key( $block['attrs']['playlistId'] )
1115            : '';
1116
1117        return '' !== $key ? $key : $post_id . '-' . ( $ordinal + 1 );
1118    }
1119
1120    /**
1121     * Paths to every standalone Video Playlist block, in document order.
1122     *
1123     * @param array $blocks Parsed blocks.
1124     * @param int[] $prefix Path of the parent block.
1125     * @return int[][]
1126     */
1127    private static function playlist_block_paths( array $blocks, array $prefix = array() ) {
1128        $paths = array();
1129
1130        foreach ( $blocks as $index => $block ) {
1131            $name = $block['blockName'] ?? null;
1132            if ( in_array( $name, Playlist_Index::DYNAMIC_PLAYLIST_BLOCKS, true ) ) {
1133                continue;
1134            }
1135
1136            $path = array_merge( $prefix, array( $index ) );
1137            if ( Playlist_Index::BLOCK_NAME === $name ) {
1138                $source = $block['attrs']['source'] ?? 'own';
1139                if ( 'own' === $source ) {
1140                    $paths[] = $path;
1141                }
1142            }
1143
1144            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1145                $paths = array_merge( $paths, self::playlist_block_paths( $block['innerBlocks'], $path ) );
1146            }
1147        }
1148
1149        return $paths;
1150    }
1151
1152    /**
1153     * Every playlist the dashboard lists, keyed by playlist key: the published
1154     * ones from the index, then those in unpublished posts the user can edit.
1155     *
1156     * @return array[] Records as Channel::playlists() lists them, plus `status`.
1157     */
1158    public static function records() {
1159        $records = array();
1160        foreach ( Channel::playlists() as $playlist ) {
1161            $playlist['status']          = 'publish';
1162            $records[ $playlist['key'] ] = $playlist;
1163        }
1164
1165        foreach ( self::unpublished_posts() as $post ) {
1166            if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1167                continue;
1168            }
1169            foreach ( Playlist_Index::extract_records( $post ) as $key => $record ) {
1170                $key = (string) $key;
1171                if ( isset( $records[ $key ] ) ) {
1172                    $key .= '-' . $post->ID;
1173                }
1174                $records[ $key ] = array_merge(
1175                    $record,
1176                    array(
1177                        'key'     => $key,
1178                        'status'  => $post->post_status,
1179                        'updated' => self::modified_time( $post ),
1180                    )
1181                );
1182            }
1183        }
1184
1185        return $records;
1186    }
1187
1188    /**
1189     * Unpublished posts that hold a Video Playlist block, most recently modified first.
1190     *
1191     * @return WP_Post[]
1192     */
1193    private static function unpublished_posts() {
1194        $add_content_clause = function ( $where ) {
1195            global $wpdb;
1196
1197            return $where . $wpdb->prepare(
1198                " AND {$wpdb->posts}.post_content LIKE %s",
1199                '%' . $wpdb->esc_like( '<!-- wp:' . Playlist_Index::BLOCK_NAME ) . '%'
1200            );
1201        };
1202
1203        add_filter( 'posts_where', $add_content_clause );
1204        $query = new WP_Query(
1205            array(
1206                'post_type'              => self::post_types(),
1207                'post_status'            => array_values( array_diff( self::STATUSES, array( 'publish' ) ) ),
1208                'posts_per_page'         => self::UNPUBLISHED_LIMIT,
1209                'orderby'                => 'modified',
1210                'order'                  => 'DESC',
1211                'no_found_rows'          => true,
1212                'ignore_sticky_posts'    => true,
1213                'suppress_filters'       => false,
1214                'update_post_meta_cache' => false,
1215                'update_post_term_cache' => false,
1216            )
1217        );
1218        remove_filter( 'posts_where', $add_content_clause );
1219
1220        return array_values(
1221            array_filter(
1222                $query->posts,
1223                function ( $post ) {
1224                    return $post instanceof WP_Post;
1225                }
1226            )
1227        );
1228    }
1229
1230    /**
1231     * Post types that hold playlists, as Playlist_Index indexes them.
1232     *
1233     * @return string[]
1234     */
1235    private static function post_types() {
1236        return array_values(
1237            array_filter(
1238                get_post_types( array( 'public' => true ) ),
1239                function ( $post_type ) {
1240                    return 'attachment' !== $post_type;
1241                }
1242            )
1243        );
1244    }
1245
1246    /**
1247     * When a post was last modified, as a Unix timestamp.
1248     *
1249     * A draft that was never updated has no GMT modified time, so fall back to the local one.
1250     *
1251     * @param WP_Post $post Post.
1252     * @return int
1253     */
1254    private static function modified_time( WP_Post $post ) {
1255        if ( '0000-00-00 00:00:00' !== $post->post_modified_gmt ) {
1256            return (int) strtotime( $post->post_modified_gmt . ' UTC' );
1257        }
1258
1259        return (int) strtotime( get_gmt_from_date( $post->post_modified ) . ' UTC' );
1260    }
1261
1262    /**
1263     * Look a playlist up by key.
1264     *
1265     * @param string $key Playlist key.
1266     * @return array|null
1267     */
1268    public static function find_record( $key ) {
1269        $records = self::records();
1270
1271        return $records[ $key ] ?? null;
1272    }
1273
1274    /**
1275     * The record of the playlist block at a path in a post, whatever its key turned out to be.
1276     *
1277     * @param int   $post_id Post id.
1278     * @param int[] $path    Indexes leading to the block through `innerBlocks`.
1279     * @return array|null
1280     */
1281    private static function record_for_block( $post_id, array $path ) {
1282        $post = get_post( $post_id );
1283        if ( ! $post instanceof WP_Post ) {
1284            return null;
1285        }
1286        $blocks = parse_blocks( $post->post_content );
1287
1288        foreach ( self::records() as $record ) {
1289            if ( (int) $record['post_id'] === (int) $post_id && self::find_block_path( $blocks, $record['key'], $post_id ) === $path ) {
1290                return $record;
1291            }
1292        }
1293
1294        return null;
1295    }
1296
1297    /**
1298     * The attributes of a record's block, read from its post.
1299     *
1300     * @param array $record Playlist record.
1301     * @return array
1302     */
1303    private static function block_attrs( array $record ) {
1304        $post = get_post( $record['post_id'] );
1305        if ( ! $post instanceof WP_Post ) {
1306            return array();
1307        }
1308
1309        $blocks = parse_blocks( $post->post_content );
1310        $path   = self::find_block_path( $blocks, $record['key'], $post->ID );
1311        if ( null === $path ) {
1312            return array();
1313        }
1314        $attrs = self::block_at( $blocks, $path )['attrs'] ?? array();
1315
1316        return is_array( $attrs ) ? $attrs : array();
1317    }
1318
1319    /**
1320     * Shape a playlist record for the REST response.
1321     *
1322     * @param array $record Record as records() lists it.
1323     * @return array
1324     */
1325    public static function prepare_item( array $record ) {
1326        $post    = get_post( $record['post_id'] );
1327        $videos  = array_map(
1328            function ( $entry ) {
1329                return array(
1330                    'guid'       => $entry['guid'],
1331                    'durationMs' => $entry['durationMs'],
1332                    'height'     => $entry['height'],
1333                );
1334            },
1335            $record['videos']
1336        );
1337        $is_post = $post instanceof WP_Post;
1338        $status  = $record['status'] ?? ( $is_post ? $post->post_status : 'publish' );
1339        $type    = $is_post ? get_post_type_object( $post->post_type ) : null;
1340
1341        if ( 'publish' === $status ) {
1342            $url = Channel::is_enabled() ? Channel::playlist_url( $record['key'] ) : ( $is_post ? (string) get_permalink( $post ) : '' );
1343        } else {
1344            $url = $is_post ? (string) get_preview_post_link( $post ) : '';
1345        }
1346
1347        return array(
1348            'key'         => $record['key'],
1349            'title'       => $record['title'],
1350            'description' => $record['description'],
1351            'videos'      => $videos,
1352            'videoCount'  => count( $videos ),
1353            'durationMs'  => array_sum( array_column( $videos, 'durationMs' ) ),
1354            'updated'     => gmdate( 'c', $record['updated'] ?? ( $is_post ? self::modified_time( $post ) : 0 ) ),
1355            'status'      => $status,
1356            'settings'    => self::settings_from_attrs( self::block_attrs( $record ) ),
1357            'post'        => array(
1358                'id'        => (int) $record['post_id'],
1359                'title'     => $is_post ? html_entity_decode( get_the_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) ) : '',
1360                'type'      => $is_post ? $post->post_type : '',
1361                'typeLabel' => $type ? $type->labels->singular_name : '',
1362                'editUrl'   => $is_post ? (string) get_edit_post_link( $post, 'raw' ) : '',
1363            ),
1364            'url'         => $url,
1365            'canEdit'     => $is_post && current_user_can( 'edit_post', $post->ID ),
1366            'canPublish'  => $type && current_user_can( $type->cap->publish_posts ),
1367        );
1368    }
1369
1370    /**
1371     * The error for publishing without the capability.
1372     *
1373     * @return WP_Error
1374     */
1375    private static function cannot_publish() {
1376        return new WP_Error(
1377            'videopress_playlist_cannot_publish',
1378            __( 'You aren’t allowed to publish this playlist.', 'jetpack-videopress-pkg' ),
1379            array( 'status' => rest_authorization_required_code() )
1380        );
1381    }
1382
1383    /**
1384     * The not-found error shared by every playlist route.
1385     *
1386     * @return WP_Error
1387     */
1388    private static function not_found() {
1389        return new WP_Error(
1390            'videopress_playlist_not_found',
1391            __( 'Playlist not found.', 'jetpack-videopress-pkg' ),
1392            array( 'status' => 404 )
1393        );
1394    }
1395}