diff --git a/backend/src/api/id.rs b/backend/src/api/id.rs index fd69112b2..e886f06ad 100644 --- a/backend/src/api/id.rs +++ b/backend/src/api/id.rs @@ -83,6 +83,7 @@ define_kinds![ block = b"bl", series = b"sr", event = b"ev", + playlist = b"pl", search_realm = b"rs", search_event = b"es", search_series = b"ss", diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 3ccb8950b..cff31a219 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -1,5 +1,3 @@ -use std::fmt; - use chrono::{DateTime, Utc}; use postgres_types::ToSql; use serde::{Serialize, Deserialize}; @@ -18,27 +16,26 @@ use crate::{ util::{impl_from_db, select}, }, prelude::*, - util::lazy_format, }; #[derive(Debug)] pub(crate) struct AuthorizedEvent { - key: Key, - series: Option, - opencast_id: String, - is_live: bool, + pub(crate) key: Key, + pub(crate) series: Option, + pub(crate) opencast_id: String, + pub(crate) is_live: bool, - title: String, - description: Option, - created: DateTime, - creators: Vec, + pub(crate) title: String, + pub(crate) description: Option, + pub(crate) created: DateTime, + pub(crate) creators: Vec, - metadata: ExtraMetadata, - read_roles: Vec, - write_roles: Vec, + pub(crate) metadata: ExtraMetadata, + pub(crate) read_roles: Vec, + pub(crate) write_roles: Vec, - synced_data: Option, + pub(crate) synced_data: Option, } #[derive(Debug)] @@ -300,14 +297,12 @@ impl AuthorizedEvent { pub(crate) async fn load_for_series( series_key: Key, - order: EventSortOrder, context: &Context, ) -> ApiResult> { let selection = Self::select(); let query = format!( "select {selection} from events \ - where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1 {}", - order.to_sql(), + where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1", ); context.db .query_mapped( @@ -557,14 +552,6 @@ impl Default for EventSortOrder { } } -impl EventSortOrder { - /// Returns an SQL query fragment like `order by foo asc`. - fn to_sql(&self) -> impl fmt::Display { - let Self { column, direction } = *self; - lazy_format!("order by {} {}", column.to_sql(), direction.to_sql()) - } -} - impl EventSortColumn { fn to_sql(self) -> &'static str { match self { diff --git a/backend/src/api/model/mod.rs b/backend/src/api/model/mod.rs index 589068aef..38941aefb 100644 --- a/backend/src/api/model/mod.rs +++ b/backend/src/api/model/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod acl; pub(crate) mod block; pub(crate) mod event; pub(crate) mod known_roles; +pub(crate) mod playlist; pub(crate) mod realm; pub(crate) mod search; pub(crate) mod series; diff --git a/backend/src/api/model/playlist/mod.rs b/backend/src/api/model/playlist/mod.rs new file mode 100644 index 000000000..6a36691b6 --- /dev/null +++ b/backend/src/api/model/playlist/mod.rs @@ -0,0 +1,163 @@ +use juniper::graphql_object; +use postgres_types::ToSql; + +use crate::{ + api::{ + common::NotAllowed, err::ApiResult, Context, Id, Node + }, + db::{types::Key, util::{impl_from_db, select}}, + prelude::*, +}; + +use super::event::AuthorizedEvent; + + +#[derive(juniper::GraphQLUnion)] +#[graphql(Context = Context)] +pub(crate) enum Playlist { + Playlist(AuthorizedPlaylist), + NotAllowed(NotAllowed), +} + +pub(crate) struct AuthorizedPlaylist { + pub(crate) key: Key, + opencast_id: String, + title: String, + description: Option, + + read_roles: Vec, + #[allow(dead_code)] // TODO + write_roles: Vec, +} + + +#[derive(juniper::GraphQLUnion)] +#[graphql(Context = Context)] +pub(crate) enum PlaylistEntry { + Event(AuthorizedEvent), + NotAllowed(NotAllowed), + Missing(Missing), +} + +/// The data referred to by a playlist entry was not found. +pub(crate) struct Missing; +crate::api::util::impl_object_with_dummy_field!(Missing); + + +impl_from_db!( + AuthorizedPlaylist, + select: { + playlists.{ id, opencast_id, title, description, read_roles, write_roles }, + }, + |row| { + Self { + key: row.id(), + opencast_id: row.opencast_id(), + title: row.title(), + description: row.description(), + read_roles: row.read_roles(), + write_roles: row.write_roles(), + } + }, +); + +impl Playlist { + pub(crate) async fn load_by_id(id: Id, context: &Context) -> ApiResult> { + if let Some(key) = id.key_for(Id::SERIES_KIND) { + Self::load_by_key(key, context).await + } else { + Ok(None) + } + } + + pub(crate) async fn load_by_key(key: Key, context: &Context) -> ApiResult> { + Self::load_by_any_id("id", &key, context).await + } + + pub(crate) async fn load_by_opencast_id(id: String, context: &Context) -> ApiResult> { + Self::load_by_any_id("opencast_id", &id, context).await + } + + async fn load_by_any_id( + col: &str, + id: &(dyn ToSql + Sync), + context: &Context, + ) -> ApiResult> { + let selection = AuthorizedPlaylist::select(); + let query = format!("select {selection} from playlists where {col} = $1"); + context.db + .query_opt(&query, &[id]) + .await? + .map(|row| { + let playlist = AuthorizedPlaylist::from_row_start(&row); + if context.auth.overlaps_roles(&playlist.read_roles) { + Playlist::Playlist(playlist) + } else { + Playlist::NotAllowed(NotAllowed) + } + }) + .pipe(Ok) + } +} + +/// Represents an Opencast series. +#[graphql_object(Context = Context)] +impl AuthorizedPlaylist { + fn id(&self) -> Id { + Node::id(self) + } + + fn opencast_id(&self) -> &str { + &self.opencast_id + } + + fn title(&self) -> &str { + &self.title + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + async fn entries(&self, context: &Context) -> ApiResult> { + let (selection, mapping) = select!( + found: "events.id is not null", + event: AuthorizedEvent, + ); + let query = format!("\ + with entries as (\ + select unnest(entries) as entry \ + from playlists \ + where id = $1\ + ), + event_ids as (\ + select (entry).content_id as id \ + from entries \ + where (entry).type = 'event'\ + ) + select {selection} from event_ids \ + left join events on events.opencast_id = event_ids.id\ + "); + context.db + .query_mapped(&query, dbargs![&self.key], |row| { + if !mapping.found.of::(&row) { + return PlaylistEntry::Missing(Missing); + } + + let event = AuthorizedEvent::from_row(&row, mapping.event); + if !context.auth.overlaps_roles(&event.read_roles) { + return PlaylistEntry::NotAllowed(NotAllowed); + } + + PlaylistEntry::Event(event) + }) + .await? + .pipe(Ok) + } +} + +impl Node for AuthorizedPlaylist { + fn id(&self) -> Id { + Id::playlist(self.key) + } +} diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs index e1dc01281..4a358158e 100644 --- a/backend/src/api/model/series.rs +++ b/backend/src/api/model/series.rs @@ -9,7 +9,7 @@ use crate::{ Id, model::{ realm::Realm, - event::{AuthorizedEvent, EventSortOrder} + event::AuthorizedEvent, }, Node, }, @@ -145,9 +145,8 @@ impl Series { .pipe(Ok) } - #[graphql(arguments(order(default = Default::default())))] - async fn events(&self, order: EventSortOrder, context: &Context) -> ApiResult> { - AuthorizedEvent::load_for_series(self.key, order, context).await + async fn events(&self, context: &Context) -> ApiResult> { + AuthorizedEvent::load_for_series(self.key, context).await } } diff --git a/backend/src/api/query.rs b/backend/src/api/query.rs index c5324c423..eb1c5787e 100644 --- a/backend/src/api/query.rs +++ b/backend/src/api/query.rs @@ -9,6 +9,7 @@ use super::{ model::{ event::{AuthorizedEvent, Event}, known_roles::{self, KnownGroup, KnownUsersSearchOutcome}, + playlist::Playlist, realm::Realm, search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome}, series::Series, @@ -66,6 +67,16 @@ impl Query { Series::load_by_id(id, context).await } + /// Returns a playlist by its Opencast ID. + async fn playlist_by_opencast_id(id: String, context: &Context) -> ApiResult> { + Playlist::load_by_opencast_id(id, context).await + } + + /// Returns a playlist by its ID. + async fn playlist_by_id(id: Id, context: &Context) -> ApiResult> { + Playlist::load_by_id(id, context).await + } + /// Returns the current user. fn current_user(context: &Context) -> Option<&User> { match &context.auth { diff --git a/backend/src/db/cmd.rs b/backend/src/db/cmd.rs index 2fdf9f535..c5ac7831d 100644 --- a/backend/src/db/cmd.rs +++ b/backend/src/db/cmd.rs @@ -245,7 +245,7 @@ async fn clear(db: &mut Db, config: &Config, yes: bool) -> Result<()> { // Next we drop all types. for ty in types { - tx.execute(&format!("drop type if exists {ty}"), &[]).await?; + tx.execute(&format!("drop type if exists {ty} cascade"), &[]).await?; trace!("Dropped type {ty}"); } diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index dd08e7df2..369efcf6d 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -366,4 +366,5 @@ static MIGRATIONS: Lazy> = include_migrations![ 31: "series-metadata", 32: "custom-actions", 33: "event-slide-text-and-segments", + 34: "playlists", ]; diff --git a/backend/src/db/migrations/34-playlists.sql b/backend/src/db/migrations/34-playlists.sql new file mode 100644 index 000000000..ed7384377 --- /dev/null +++ b/backend/src/db/migrations/34-playlists.sql @@ -0,0 +1,45 @@ +select prepare_randomized_ids('playlist'); + + +create type playlist_entry_type as enum ('event'); + +-- All fields should never be null. +create type playlist_entry as ( + -- The Opencast ID of this entry. Not a UUID. + opencast_id bigint, + + type playlist_entry_type, + + -- The Opencast ID of the referenced content. + content_id text +); + +create table playlists ( + id bigint primary key default randomized_id('playlist'), + opencast_id text not null unique, + + title text not null, + description text, + creator text, + + entries playlist_entry[] not null, + + read_roles text[] not null, + write_roles text[] not null, + + updated timestamp with time zone not null, + + constraint read_roles_no_null_value check (array_position(read_roles, null) is null), + constraint write_roles_no_null_value check (array_position(write_roles, null) is null), + constraint entries_no_null_value check (array_position(entries, null) is null) +); + + +-- To perform queries like `write_roles && $1` on the whole table. Probably just +-- to list all playlists that a user has write access to. +create index idx_playlists_write_roles on events using gin (write_roles); + + +-- Search index --------------------------------------------------------------- + +-- TODO diff --git a/backend/src/db/types.rs b/backend/src/db/types.rs index 6312fdf80..a5586ded8 100644 --- a/backend/src/db/types.rs +++ b/backend/src/db/types.rs @@ -97,6 +97,23 @@ pub enum SeriesState { Waiting, } +/// Represents the `playlist_entry_type` type defined in `31-playlists.sql`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSql, ToSql)] +#[postgres(name = "playlist_entry_type")] +pub enum PlaylistEntryType { + #[postgres(name = "event")] + Event, +} + +/// Represents the `playlist_entry` type defined in `31-playlists.sql`. +#[derive(Debug, FromSql, ToSql, Clone)] +#[postgres(name = "playlist_entry")] +pub struct PlaylistEntry { + pub opencast_id: i64, + #[postgres(name = "type")] + pub ty: PlaylistEntryType, + pub content_id: String, +} /// Represents extra metadata in the DB. Is a map from "namespace" to a /// `string -> string array` map. diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs index 2389ccdfb..56ebcff10 100644 --- a/backend/src/sync/harvest/mod.rs +++ b/backend/src/sync/harvest/mod.rs @@ -8,7 +8,11 @@ use tokio_postgres::types::ToSql; use crate::{ auth::ROLE_ADMIN, config::Config, - db::{types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState}, DbConnection}, + db::{ + self, + DbConnection, + types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState}, + }, prelude::*, }; use super::{status::SyncStatus, OcClient}; @@ -134,6 +138,8 @@ async fn store_in_db( let mut removed_events = 0; let mut upserted_series = 0; let mut removed_series = 0; + let mut upserted_playlists = 0; + let mut removed_playlists = 0; for item in items { // Make sure we haven't received this update yet. The code below can @@ -277,23 +283,85 @@ async fn store_in_db( removed_series += 1; } - HarvestItem::Unknown { kind, .. } => { - warn!("Unknown item of kind '{kind}' in harvest response. \ - You might need to update Tobira."); + HarvestItem::Playlist { + id: opencast_id, + title, + description, + creator, + acl, + entries, + updated, + } => { + let entries = entries.into_iter().filter_map(|e| { + // We do not store entries that we don't know, meaning that + // a resync is required as soon as Tobira learns about + // these new entries. But that's fine as that's likely + // required anyway, given that more changes have to be done. + let ty = match e.ty.as_str() { + "E" => db::types::PlaylistEntryType::Event, + _ => return None, + }; + + Some(db::types::PlaylistEntry { + opencast_id: e.id, + ty, + content_id: e.content_id, + }) + }).collect::>(); + + upsert(db, "playlists", "opencast_id", &[ + ("opencast_id", &opencast_id), + ("title", &title), + ("description", &description), + ("creator", &creator), + ("read_roles", &acl.read), + ("write_roles", &acl.write), + ("entries", &entries), + ("updated", &updated), + ]).await?; + + trace!(opencast_id, title, "Inserted or updated playlist"); + upserted_playlists += 1; + } + + HarvestItem::PlaylistDeleted { id: opencast_id, .. } => { + let rows_affected = db + .execute("delete from playlists where opencast_id = $1", &[&opencast_id]) + .await?; + check_affected_rows_removed(rows_affected, "playlist", &opencast_id); + removed_playlists += 1; + } + + HarvestItem::Unknown { kind, updated } => { + let known = [ + "event", + "event-deleted", + "series", + "series-deleted", + "playlist", + "playlist-deleted", + ]; + + if known.contains(&&*kind) { + warn!("Could not deserialize item in harvest response for \ + kind '{kind}' (updated {updated})"); + } else { + warn!("Unknown item of kind '{kind}' in harvest response. \ + You might need to update Tobira."); + } } } } - if upserted_events == 0 && upserted_series == 0 && removed_events == 0 && removed_series == 0 { + if upserted_events == 0 && upserted_series == 0 && upserted_playlists == 0 + && removed_events == 0 && removed_series == 0 && removed_playlists == 0 + { trace!("Harvest outcome: nothing changed!"); } else { info!( - "Harvest outcome: upserted {} events, upserted {} series, \ - removed {} events, removed {} series (in {:.2?})", - upserted_events, - upserted_series, - removed_events, - removed_series, + upserted_events, upserted_series, upserted_playlists, + removed_events, removed_series, removed_playlists, + "Harvest done in {:.2?}", before.elapsed(), ); } diff --git a/backend/src/sync/harvest/response.rs b/backend/src/sync/harvest/response.rs index 1ddfd6aff..788350edc 100644 --- a/backend/src/sync/harvest/response.rs +++ b/backend/src/sync/harvest/response.rs @@ -72,6 +72,25 @@ pub(crate) enum HarvestItem { updated: DateTime, }, + #[serde(rename_all = "camelCase")] + Playlist { + id: String, + title: String, + description: Option, + creator: Option, + acl: Acl, + entries: Vec, + #[serde(with = "chrono::serde::ts_milliseconds")] + updated: DateTime, + }, + + #[serde(rename_all = "camelCase")] + PlaylistDeleted { + id: String, + #[serde(with = "chrono::serde::ts_milliseconds")] + updated: DateTime, + }, + #[serde(untagged)] Unknown { kind: String, @@ -87,6 +106,8 @@ impl HarvestItem { Self::EventDeleted { updated, .. } => updated, Self::Series { updated, .. } => updated, Self::SeriesDeleted { updated, .. } => updated, + Self::Playlist { updated, .. } => updated, + Self::PlaylistDeleted { updated, .. } => updated, Self::Unknown { updated, .. } => updated, } } @@ -155,3 +176,12 @@ pub(crate) struct Acl { #[serde(flatten)] pub(crate) custom_actions: CustomActions, } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlaylistEntry { + pub id: i64, + #[serde(rename = "type")] + pub ty: String, + pub content_id: String, +} diff --git a/backend/src/util.rs b/backend/src/util.rs index 49e754685..093046aca 100644 --- a/backend/src/util.rs +++ b/backend/src/util.rs @@ -1,4 +1,3 @@ -use std::fmt; use bytes::Bytes; use http_body_util::BodyExt; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; @@ -10,35 +9,6 @@ use secrecy::Secret; use crate::{http::Response, prelude::*}; -/// A lazy `fmt` formatter, specified by a callable. Usually created via -/// `lazy_format!`. -/// -/// This is particularly useful in situations where you want a method to return -/// a formatted value, but don't want to return an allocated `String`. For -/// example, if the returned value is formatted into yet another value anyway, -/// allocating a string is useless. Instead of returning `String`, you then -/// return `impl fmt::Display + '_`. -pub(crate) struct LazyFormat fmt::Result>(pub F); - -impl fmt::Display for LazyFormat -where - F: Fn(&mut fmt::Formatter) -> fmt::Result, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - (self.0)(f) - } -} - -macro_rules! lazy_format { - ($fmt:literal $($t:tt)*) => { - crate::util::LazyFormat(move |f| write!(f, $fmt $($t)*)) - }; -} - -pub(crate) use lazy_format; - - - /// An empty `enum` for signaling the fact that a function (potentially) never returns. /// Note that you can't construct a value of this type, so a function returning it /// can never return. A function returning `Result` never returns diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index f38ec9b0b..6891c5167 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -56,6 +56,7 @@ not-found: page-not-found: Seite nicht gefunden video-not-found: Video nicht gefunden series-not-found: Serie nicht gefunden + playlist-not-found: Playlist nicht gefunden page-explanation: > Die von Ihnen gewünschte Seite existiert nicht. Sie wurde möglicherweise gelöscht oder umbenannt. @@ -65,6 +66,9 @@ not-found: series-explanation: > Die von Ihnen gewünschte Serie existiert nicht. Sie wurde möglicherweise gelöscht oder verschoben. + playlist-explanation: > + Die von Ihnen gewünschte Playlist existiert nicht. Sie wurde möglicherweise + gelöscht oder verschoben. url-typo: > Falls Sie die Adresse/URL manuell eingegeben haben, prüfen Sie diese auf Fehler. @@ -176,8 +180,6 @@ series: series: Serie deleted: Gelöschte Serie deleted-series-block: Die hier referenzierte Serie wurde gelöscht. - no-events: Diese Serie enthält keine Videos oder Sie sind nicht berechtigt, diese zu sehen. - upcoming-live-streams: "Anstehende Livestreams ({{count}})" entry-of-series-thumbnail: "Vorschlaubild für Teil von „{{series}}“" videos: heading: Videos @@ -186,9 +188,17 @@ series: text: > Die Daten der gewünschten Serie wurden leider noch nicht vollständig übertragen. Dies sollte in Kürze automatisch passieren. Versuchen Sie es in wenigen Minuten noch einmal! + +videolist-block: + upcoming-live-streams: "Anstehende Livestreams ({{count}})" + missing-video: Video nicht gefunden + unauthorized: Fehlende Berechtigung + hidden-items_one: 'Ein Video wurde nicht gefunden oder Sie haben keinen Zugriff darauf.' + hidden-items_other: '{{count}} Videos wurden nicht gefunden oder Sie haben keinen Zugriff darauf.' settings: order: Reihenfolge order-label: Video Reihenfolge auswählen + original: Wie Playlist new-to-old: Neueste zuerst old-to-new: Älteste zuerst a-z: A bis Z @@ -580,6 +590,7 @@ manage: api-remote-errors: view: event: Sie sind nicht autorisiert, dieses Video zu sehen. + playlist: Sie sind nicht autorisiert, diese Playlist zu sehen. upload: not-logged-in: Sie müssen angemeldet sein, um Videos hochzuladen. not-authorized: $t(upload.not-authorized) diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 1385cf14b..497c55849 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -55,6 +55,7 @@ not-found: page-not-found: Page not found video-not-found: Video not found series-not-found: Series not found + playlist-not-found: Playlist not found page-explanation: > The page you want to visit does not exist. It might have been removed or renamed. @@ -64,6 +65,9 @@ not-found: series-explanation: > The series you want to view does not exist. It might have been removed or moved. + playlist-explanation: > + The playlist you want to view does not exist. It might have been removed or + moved. url-typo: > If you entered the address/URL manually, please double check it for spelling mistakes. @@ -173,8 +177,6 @@ series: series: Series deleted: Deleted series deleted-series-block: The series referenced here was deleted. - no-events: This series does not contain any events, or you might not be authorized to see them. - upcoming-live-streams: "Upcoming live streams ({{count}})" entry-of-series-thumbnail: "Thumbnail for entry of “{{series}}”" videos: heading: Videos @@ -183,9 +185,18 @@ series: text: > The data of the requested series has not been fully transferred yet. This should happen automatically soon. Try again in a few minutes. + +videolist-block: + upcoming-live-streams: "Upcoming live streams ({{count}})" + missing-video: Video not found + unauthorized: Missing permissions + hidden-items_one: 'One video is missing or requires additional permissions to view.' + hidden-items_other: '{{count}} videos are missing or require additional permissions to view.' + no-videos: No videos settings: order: Order order-label: Choose video order + original: Playlist order new-to-old: Newest first old-to-new: Oldest first a-z: A to Z @@ -554,6 +565,7 @@ manage: api-remote-errors: view: event: You are not authorized to view this video. + playlist: You are not authorized to view this playlist. upload: not-logged-in: You have to be logged in to upload videos. not-authorized: $t(upload.not-authorized) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 28dd2e609..bb469d993 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -18,6 +18,7 @@ import { ManageVideoDetailsRoute } from "./routes/manage/Video/Details"; import { ManageVideoTechnicalDetailsRoute } from "./routes/manage/Video/TechnicalDetails"; import React from "react"; import { ManageVideoAccessRoute } from "./routes/manage/Video/Access"; +import { DirectPlaylistOCRoute, DirectPlaylistRoute } from "./routes/Playlist"; @@ -42,6 +43,8 @@ const { DirectOpencastVideoRoute, DirectSeriesRoute, DirectSeriesOCRoute, + DirectPlaylistRoute, + DirectPlaylistOCRoute, ManageRoute, ManageVideosRoute, ManageVideoAccessRoute, diff --git a/frontend/src/routes/NotFound.tsx b/frontend/src/routes/NotFound.tsx index 0711554f9..b1d199523 100644 --- a/frontend/src/routes/NotFound.tsx +++ b/frontend/src/routes/NotFound.tsx @@ -32,7 +32,7 @@ const query = graphql` `; type Props = { - kind: "page" | "video" | "series"; + kind: "page" | "video" | "series" | "playlist"; }; export const NotFound: React.FC = ({ kind }) => { @@ -41,6 +41,7 @@ export const NotFound: React.FC = ({ kind }) => { "page": () => t("not-found.page-not-found"), "video": () => t("not-found.video-not-found"), "series": () => t("not-found.series-not-found"), + "playlist": () => t("not-found.playlist-not-found"), }); // Ideally our backend would respond with 404 here, but that's not @@ -64,6 +65,7 @@ export const NotFound: React.FC = ({ kind }) => { "page": () => t("not-found.page-explanation"), "video": () => t("not-found.video-explanation"), "series": () => t("not-found.series-explanation"), + "playlist": () => t("not-found.playlist-explanation"), })} {t("not-found.url-typo")}

diff --git a/frontend/src/routes/Playlist.tsx b/frontend/src/routes/Playlist.tsx new file mode 100644 index 000000000..0b4cba496 --- /dev/null +++ b/frontend/src/routes/Playlist.tsx @@ -0,0 +1,153 @@ +import { graphql, readInlineData, useFragment } from "react-relay"; +import { useTranslation } from "react-i18next"; +import { unreachable } from "@opencast/appkit"; + +import { loadQuery } from "../relay"; +import { makeRoute } from "../rauta"; +import { RootLoader } from "../layout/Root"; +import { Nav } from "../layout/Navigation"; +import { PageTitle } from "../layout/header/ui"; +import { keyOfId, playlistId } from "../util"; +import { NotFound } from "./NotFound"; +import { b64regex } from "./util"; +import { Breadcrumbs } from "../ui/Breadcrumbs"; +import { PlaylistByOpencastIdQuery } from "./__generated__/PlaylistByOpencastIdQuery.graphql"; +import { PlaylistRouteData$key } from "./__generated__/PlaylistRouteData.graphql"; +import { PlaylistByIdQuery } from "./__generated__/PlaylistByIdQuery.graphql"; +import { ErrorPage } from "../ui/error"; +import { VideoListBlock, videoListEventFragment } from "../ui/Blocks/VideoList"; +import { VideoListEventData$key } from "../ui/Blocks/__generated__/VideoListEventData.graphql"; + + +export const DirectPlaylistOCRoute = makeRoute({ + url: ({ ocID }: { ocID: string }) => `/!p/:${ocID}`, + match: url => { + const regex = new RegExp("^/!p/:([^/]+)$", "u"); + const matches = regex.exec(url.pathname); + + if (!matches) { + return null; + } + + + const opencastId = decodeURIComponent(matches[1]); + const query = graphql` + query PlaylistByOpencastIdQuery($id: String!) { + ... UserData + playlist: playlistByOpencastId(id: $id) { ...PlaylistRouteData } + rootRealm { ... NavigationData } + } + `; + const queryRef = loadQuery(query, { id: opencastId }); + + + return { + render: () =>