Skip to content

Commit

Permalink
Add playlists to sync code, DB & API and add playlist direct links (#…
Browse files Browse the repository at this point in the history
…1159)

This is the first PR about playlists for Tobira. It requires
opencast/opencast#5734 to be useful.

This does mainly two things:
- Tobira now understands the playlist data that Opencast is sending via
Harvest API and stores it in Tobira's DB.
- Adds Playlists to Tobira's GraphQL API (without any mutations yet)
- Add the two direct link routes so that playlists can be viewed given
their ID. This required making the core "series block" code more generic
to also work with playlists.

Two main features are still missing:
- Playlist blocks (that can be placed on pages)
- "My Playlists": management area to create, delete and edit playlists

For these two, I will create separate issues. Therefore:

Closes #937 

## Screenshots

Unfortunately, there are no playlists on the test deployment yet. So
here are some screenshots:


![image](https://github.com/elan-ev/tobira/assets/7419664/b64db947-c384-498c-b71f-bb216cbfe5cf)


![image](https://github.com/elan-ev/tobira/assets/7419664/39d0fe04-f077-4938-b9e5-b19c36223ca9)


![image](https://github.com/elan-ev/tobira/assets/7419664/fd50b965-3d93-44c7-b410-86c42813f134)

As you can see:
- Currently this looks very very similar to how series are displayed.
- For playlists, there is a new order mode "Playlist order" which shows
the videos in the order that the playlist specifies. This is the default
of course. But users can still sort by date and title, if they so wish.
- In the "Playlist order" mode, video that are missing or cannot be
accessed are shown with a placeholder.
- In any other ordering mode, only a small note is shown at the bottom.
(Since we have no information about the missing items, we cannot
properly sort them. They would be sorted all at the very end anyway.)

## Open questions

- Should the "missing items" note also be shown for series? So far we
don't: we simply do not show the videos that the user has no read access
to.
- What about the specific formulations for the note and the missing
video placeholders? Specifically:
  - German:
    ```yaml
    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.'
    ```
  - English:
    ```yaml
    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.'
    ```
- How should we call the "order as specified by the playlist" ordering
mode? Currently I called it "Playlist order" (en) / "Wie Playlist" (de).
Better ideas appreciated.

---

Can be reviewed commit by commit.
  • Loading branch information
owi92 authored Jul 1, 2024
2 parents 6f06073 + ece4812 commit 7a1f954
Show file tree
Hide file tree
Showing 24 changed files with 1,618 additions and 962 deletions.
1 change: 1 addition & 0 deletions backend/src/api/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 13 additions & 26 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::fmt;

use chrono::{DateTime, Utc};
use hyper::StatusCode;
use postgres_types::ToSql;
Expand All @@ -19,28 +17,27 @@ use crate::{
util::{impl_from_db, select},
},
prelude::*,
util::lazy_format,
};


#[derive(Debug)]
pub(crate) struct AuthorizedEvent {
pub(crate) key: Key,
series: Option<Key>,
opencast_id: String,
is_live: bool,
pub(crate) series: Option<Key>,
pub(crate) opencast_id: String,
pub(crate) is_live: bool,

title: String,
description: Option<String>,
created: DateTime<Utc>,
creators: Vec<String>,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) created: DateTime<Utc>,
pub(crate) creators: Vec<String>,

metadata: ExtraMetadata,
read_roles: Vec<String>,
write_roles: Vec<String>,
pub(crate) metadata: ExtraMetadata,
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,

synced_data: Option<SyncedEventData>,
tobira_deletion_timestamp: Option<DateTime<Utc>>,
pub(crate) synced_data: Option<SyncedEventData>,
pub(crate) tobira_deletion_timestamp: Option<DateTime<Utc>>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -329,14 +326,12 @@ impl AuthorizedEvent {

pub(crate) async fn load_for_series(
series_key: Key,
order: EventSortOrder,
context: &Context,
) -> ApiResult<Vec<Self>> {
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(
Expand Down Expand Up @@ -630,14 +625,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 {
Expand Down
1 change: 1 addition & 0 deletions backend/src/api/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
163 changes: 163 additions & 0 deletions backend/src/api/model/playlist/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

read_roles: Vec<String>,
#[allow(dead_code)] // TODO
write_roles: Vec<String>,
}


#[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<Option<Self>> {
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<Option<Self>> {
Self::load_by_any_id("id", &key, context).await
}

pub(crate) async fn load_by_opencast_id(id: String, context: &Context) -> ApiResult<Option<Self>> {
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<Option<Self>> {
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<Vec<PlaylistEntry>> {
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::<bool>(&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)
}
}
7 changes: 3 additions & 4 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
Id,
model::{
realm::Realm,
event::{AuthorizedEvent, EventSortOrder}
event::AuthorizedEvent,
},
Node,
},
Expand Down Expand Up @@ -145,9 +145,8 @@ impl Series {
.pipe(Ok)
}

#[graphql(arguments(order(default = Default::default())))]
async fn events(&self, order: EventSortOrder, context: &Context) -> ApiResult<Vec<AuthorizedEvent>> {
AuthorizedEvent::load_for_series(self.key, order, context).await
async fn events(&self, context: &Context) -> ApiResult<Vec<AuthorizedEvent>> {
AuthorizedEvent::load_for_series(self.key, context).await
}

/// Returns `true` if the realm has a series block with this series.
Expand Down
11 changes: 11 additions & 0 deletions backend/src/api/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Option<Playlist>> {
Playlist::load_by_opencast_id(id, context).await
}

/// Returns a playlist by its ID.
async fn playlist_by_id(id: Id, context: &Context) -> ApiResult<Option<Playlist>> {
Playlist::load_by_id(id, context).await
}

/// Returns the current user.
fn current_user(context: &Context) -> Option<&User> {
match &context.auth {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/db/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,5 @@ static MIGRATIONS: Lazy<BTreeMap<u64, Migration>> = include_migrations![
32: "custom-actions",
33: "event-slide-text-and-segments",
34: "event-view-and-deletion-timestamp",
35: "playlists",
];
45 changes: 45 additions & 0 deletions backend/src/db/migrations/35-playlists.sql
Original file line number Diff line number Diff line change
@@ -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 playlists using gin (write_roles);


-- Search index ---------------------------------------------------------------

-- TODO
17 changes: 17 additions & 0 deletions backend/src/db/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 7a1f954

Please sign in to comment.