diff --git a/platform/api/src/api/v1/gql/models/channel.rs b/platform/api/src/api/v1/gql/models/channel.rs index 1e53f1b5..a73d1d9f 100644 --- a/platform/api/src/api/v1/gql/models/channel.rs +++ b/platform/api/src/api/v1/gql/models/channel.rs @@ -1,9 +1,11 @@ use async_graphql::{ComplexObject, Context, SimpleObject}; use chrono::Utc; use jwt_next::SignWithKey; +use ulid::Ulid; use super::category::Category; use super::date::DateRFC3339; +use super::image_upload::ImageUpload; use super::ulid::GqlUlid; use crate::api::v1::gql::error::ext::*; use crate::api::v1::gql::error::Result; @@ -22,11 +24,15 @@ pub struct Channel { pub description: Option, pub links: Vec, pub custom_thumbnail_id: Option, - pub offline_banner_id: Option, + pub pending_offline_banner_id: Option, pub category_id: Option, pub live: Option>, pub last_live_at: Option, + // Custom resolver + #[graphql(skip)] + pub offline_banner_id_: Option, + // Private fields #[graphql(skip)] stream_key_: Option, @@ -50,6 +56,23 @@ impl Channel { Ok(category.map(Into::into)) } + async fn offline_banner(&self, ctx: &Context<'_>) -> Result>> { + let Some(offline_banner_id) = self.offline_banner_id_ else { + return Ok(None); + }; + + let global = ctx.get_global::(); + + Ok(global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten()) + } + async fn stream_key(&self, ctx: &Context<'_>) -> Result> { auth_guard::<_, G>(ctx, "streamKey", self.stream_key_.as_deref(), self.id.into()).await } @@ -203,7 +226,7 @@ impl From for Channel { description: value.description, links: value.links, custom_thumbnail_id: value.custom_thumbnail_id.map(Into::into), - offline_banner_id: value.offline_banner_id.map(Into::into), + pending_offline_banner_id: value.pending_offline_banner_id.map(Into::into), category_id: value.category_id.map(Into::into), live: value.active_connection_id.map(|_| ChannelLive { room_id: value.room_id.into(), @@ -212,6 +235,7 @@ impl From for Channel { channel_id: value.id, _phantom: std::marker::PhantomData, }), + offline_banner_id_: value.offline_banner_id, last_live_at: value.last_live_at.map(DateRFC3339), stream_key_, } diff --git a/platform/api/src/api/v1/gql/mutations/channel.rs b/platform/api/src/api/v1/gql/mutations/channel.rs index 8acacf78..c07872b6 100644 --- a/platform/api/src/api/v1/gql/mutations/channel.rs +++ b/platform/api/src/api/v1/gql/mutations/channel.rs @@ -62,4 +62,47 @@ impl ChannelMutation { Ok(user.into()) } + + async fn remove_offline_banner(&self, ctx: &Context<'_>) -> Result> { + let global = ctx.get_global::(); + let request_context = ctx.get_req_context(); + + let auth = request_context + .auth(global) + .await? + .map_err_gql(GqlError::Auth(AuthError::NotLoggedIn))?; + + let user: database::User = common::database::query( + r#" + UPDATE users + SET + channel_offline_banner_id = NULL, + channel_pending_offline_banner_id = NULL, + updated_at = NOW() + WHERE + id = $1 + RETURNING * + "#, + ) + .bind(auth.session.user_id) + .build_query_as() + .fetch_one(global.db()) + .await?; + + global + .nats() + .publish( + SubscriptionTopic::ChannelOfflineBanner(user.id), + pb::scuffle::platform::internal::events::ChannelOfflineBanner { + channel_id: Some(user.id.into()), + offline_banner_id: None, + } + .encode_to_vec() + .into(), + ) + .await + .map_err_gql("failed to publish offline banner event")?; + + Ok(user.into()) + } } diff --git a/platform/api/src/api/v1/gql/mutations/user.rs b/platform/api/src/api/v1/gql/mutations/user.rs index 2cc0501a..a8d6ec41 100644 --- a/platform/api/src/api/v1/gql/mutations/user.rs +++ b/platform/api/src/api/v1/gql/mutations/user.rs @@ -216,7 +216,7 @@ impl UserMutation { .into(), ) .await - .map_err_gql("failed to publish message")?; + .map_err_gql("failed to publish profile picture event")?; Ok(user.into()) } diff --git a/platform/api/src/api/v1/gql/subscription/channel.rs b/platform/api/src/api/v1/gql/subscription/channel.rs index 61516e8f..735dc147 100644 --- a/platform/api/src/api/v1/gql/subscription/channel.rs +++ b/platform/api/src/api/v1/gql/subscription/channel.rs @@ -9,6 +9,7 @@ use crate::api::auth::AuthError; use crate::api::v1::gql::error::ext::*; use crate::api::v1::gql::error::{GqlError, Result}; use crate::api::v1::gql::ext::ContextExt; +use crate::api::v1::gql::models::image_upload::ImageUpload; use crate::api::v1::gql::models::ulid::GqlUlid; use crate::global::ApiGlobal; use crate::subscription::SubscriptionTopic; @@ -33,8 +34,88 @@ struct ChannelLiveStream { pub live: bool, } +#[derive(SimpleObject)] +struct ChannelOfflineBannerStream { + pub channel_id: GqlUlid, + pub offline_banner: Option>, +} + #[Subscription] impl ChannelSubscription { + async fn channel_offline_banner<'ctx>( + &self, + ctx: &'ctx Context<'ctx>, + channel_id: GqlUlid, + ) -> Result>> + 'ctx> { + let global = ctx.get_global::(); + + let Some(offline_banner_id) = global + .user_by_id_loader() + .load(channel_id.to_ulid()) + .await + .map_err_ignored_gql("failed to fetch channel")? + .map(|u| u.channel.offline_banner_id) + else { + return Err(GqlError::InvalidInput { + fields: vec!["channelId"], + message: "channel not found", + } + .into()); + }; + + let mut subscription = global + .subscription_manager() + .subscribe(SubscriptionTopic::ChannelOfflineBanner(channel_id.to_ulid())) + .await + .map_err_gql("failed to subscribe to channel offline banner")?; + + let offline_banner = if let Some(offline_banner_id) = offline_banner_id { + global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten() + } else { + None + }; + + Ok(async_stream::stream!({ + yield Ok(ChannelOfflineBannerStream { + channel_id, + offline_banner, + }); + + while let Ok(message) = subscription.recv().await { + let event = pb::scuffle::platform::internal::events::ChannelOfflineBanner::decode(message.payload) + .map_err_ignored_gql("failed to decode channel offline banner event")?; + + let channel_id = event.channel_id.into_ulid(); + let offline_banner_id = event.offline_banner_id.map(|u| u.into_ulid()); + + let offline_banner = if let Some(offline_banner_id) = offline_banner_id { + global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten() + } else { + None + }; + + yield Ok(ChannelOfflineBannerStream { + channel_id: channel_id.into(), + offline_banner, + }); + } + })) + } + async fn channel_follows<'ctx>( &self, ctx: &'ctx Context<'ctx>, diff --git a/platform/api/src/api/v1/gql/subscription/user.rs b/platform/api/src/api/v1/gql/subscription/user.rs index c987fd27..8384d9d1 100644 --- a/platform/api/src/api/v1/gql/subscription/user.rs +++ b/platform/api/src/api/v1/gql/subscription/user.rs @@ -75,7 +75,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserDisplayName::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user display name event")?; let user_id = event.user_id.into_ulid(); @@ -112,7 +112,7 @@ impl UserSubscription { .subscription_manager() .subscribe(SubscriptionTopic::UserDisplayColor(user_id.to_ulid())) .await - .map_err_gql("failed to subscribe to user display name")?; + .map_err_gql("failed to subscribe to user display color")?; Ok(async_stream::stream!({ yield Ok(UserDisplayColorStream { @@ -122,7 +122,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserDisplayColor::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user display color event")?; let user_id = event.user_id.into_ulid(); @@ -159,7 +159,7 @@ impl UserSubscription { .subscription_manager() .subscribe(SubscriptionTopic::UserProfilePicture(user_id.to_ulid())) .await - .map_err_gql("failed to subscribe to user display name")?; + .map_err_gql("failed to subscribe to user profile picture")?; let profile_picture = if let Some(profile_picture_id) = profile_picture_id { global @@ -182,7 +182,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserProfilePicture::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user profile picture event")?; let user_id = event.user_id.into_ulid(); let profile_picture_id = event.profile_picture_id.map(|u| u.into_ulid()); @@ -259,7 +259,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserFollowChannel::decode(message.payload) - .map_err_ignored_gql("failed to decode user follow")?; + .map_err_ignored_gql("failed to decode user follow event")?; let user_id = event.user_id.into_ulid(); diff --git a/platform/api/src/api/v1/upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload.rs similarity index 68% rename from platform/api/src/api/v1/upload/profile_picture.rs rename to platform/api/src/api/v1/upload/image_upload.rs index e0e3dd7d..e395eed6 100644 --- a/platform/api/src/api/v1/upload/profile_picture.rs +++ b/platform/api/src/api/v1/upload/image_upload.rs @@ -2,13 +2,14 @@ use std::sync::Arc; use aws_sdk_s3::types::ObjectCannedAcl; use bytes::Bytes; +use common::database::deadpool_postgres::Transaction; use common::http::ext::ResultExt; use common::http::RouteError; use common::make_response; use common::s3::PutObjectOptions; use hyper::{Response, StatusCode}; use pb::scuffle::platform::internal::image_processor; -use pb::scuffle::platform::internal::types::{uploaded_file_metadata, ImageFormat, UploadedFileMetadata}; +use pb::scuffle::platform::internal::types::{uploaded_file_metadata, UploadedFileMetadata}; use serde_json::json; use ulid::Ulid; @@ -16,41 +17,14 @@ use super::UploadType; use crate::api::auth::AuthData; use crate::api::error::ApiError; use crate::api::Body; -use crate::config::{ApiConfig, ImageUploaderConfig}; -use crate::database::{FileType, RolePermission, UploadedFileStatus}; +use crate::database::{FileType, UploadedFileStatus}; use crate::global::ApiGlobal; -fn create_task(file_id: Ulid, input_path: &str, config: &ImageUploaderConfig, owner_id: Ulid) -> image_processor::Task { - image_processor::Task { - input_path: input_path.to_string(), - base_height: 128, // 128, 256, 384, 512 - base_width: 128, // 128, 256, 384, 512 - formats: vec![ - ImageFormat::PngStatic as i32, - ImageFormat::AvifStatic as i32, - ImageFormat::WebpStatic as i32, - ImageFormat::Gif as i32, - ImageFormat::Webp as i32, - ImageFormat::Avif as i32, - ], - callback_subject: config.callback_subject.clone(), - limits: Some(image_processor::task::Limits { - max_input_duration_ms: 10 * 1000, // 10 seconds - max_input_frame_count: 300, - max_input_height: 1000, - max_input_width: 1000, - max_processing_time_ms: 60 * 1000, // 60 seconds - }), - resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, - upscale: true, // For profile pictures we want to have a consistent size - scales: vec![1, 2, 3, 4], - resize_method: image_processor::task::ResizeMethod::PadCenter as i32, - output_prefix: format!("{owner_id}/{file_id}"), - } -} +pub(crate) mod offline_banner; +pub(crate) mod profile_picture; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -enum AcceptedFormats { +pub(super) enum AcceptedFormats { Webp, Avif, Avifs, @@ -132,23 +106,37 @@ impl AcceptedFormats { } } -#[derive(Default, serde::Deserialize)] -#[serde(default)] -pub(super) struct ProfilePicture { - set_active: bool, +pub(super) trait ImageUploadRequest { + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: Ulid, + owner_id: Ulid, + ) -> image_processor::Task; + + fn task_priority(global: &Arc) -> i64; + + fn get_max_size(global: &Arc) -> usize; + + fn validate_permissions(auth: &AuthData) -> bool; + + fn file_type(global: &Arc) -> FileType; + + async fn process(&self, auth: &AuthData, tx: &Transaction, file_id: Ulid) -> Result<(), RouteError>; } -impl UploadType for ProfilePicture { - fn validate_format(_: &Arc, _: &AuthData, content_type: &str) -> bool { +impl UploadType for T { + fn validate_format(_global: &Arc, _auth: &AuthData, content_type: &str) -> bool { AcceptedFormats::from_content_type(content_type).is_some() } fn validate_permissions(&self, auth: &AuthData) -> bool { - auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) + T::validate_permissions(auth) } fn get_max_size(global: &Arc) -> usize { - global.config::().max_profile_picture_size + T::get_max_size(global) } async fn handle( @@ -164,14 +152,9 @@ impl UploadType for ProfilePicture { let file_id = Ulid::new(); - let config = global.config::(); + let task = T::create_task(global, &auth, image_format, file_id, auth.session.user_id); - let input_path = format!( - "{}/profile_pictures/{}/source.{}", - auth.session.user_id, - file_id, - image_format.ext() - ); + let input_path = task.input_path.clone(); let mut client = global .db() @@ -185,13 +168,8 @@ impl UploadType for ProfilePicture { common::database::query("INSERT INTO image_jobs (id, priority, task) VALUES ($1, $2, $3)") .bind(file_id) - .bind(config.profile_picture_task_priority) - .bind(common::database::Protobuf(create_task( - file_id, - &input_path, - config, - auth.session.user_id, - ))) + .bind(T::task_priority(global)) + .bind(common::database::Protobuf(task)) .build() .execute(&tx) .await @@ -202,7 +180,7 @@ impl UploadType for ProfilePicture { .bind(auth.session.user_id) // owner_id .bind(auth.session.user_id) // uploader_id .bind(name.unwrap_or_else(|| format!("untitled.{}", image_format.ext()))) // name - .bind(FileType::ProfilePicture) // type + .bind(T::file_type(global)) // type .bind(common::database::Protobuf(UploadedFileMetadata { metadata: Some(uploaded_file_metadata::Metadata::Image(uploaded_file_metadata::Image { versions: Vec::new(), @@ -216,15 +194,7 @@ impl UploadType for ProfilePicture { .await .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to insert uploaded file"))?; - if self.set_active { - common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") - .bind(file_id) - .bind(auth.session.user_id) - .build() - .execute(&tx) - .await - .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; - } + T::process(&self, &auth, &tx, file_id).await?; global .image_uploader_s3() diff --git a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs new file mode 100644 index 00000000..8ef978ad --- /dev/null +++ b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use common::database::deadpool_postgres::Transaction; +use common::http::ext::ResultExt; +use common::http::RouteError; +use hyper::StatusCode; +use pb::scuffle::platform::internal::image_processor; +use pb::scuffle::platform::internal::types::ImageFormat; + +use super::{AcceptedFormats, ImageUploadRequest}; +use crate::api::auth::AuthData; +use crate::api::error::ApiError; +use crate::config::{ApiConfig, ImageUploaderConfig}; +use crate::database::{FileType, RolePermission}; +use crate::global::ApiGlobal; + +#[derive(Default, serde::Deserialize)] +#[serde(default)] +pub(crate) struct OfflineBanner { + set_active: bool, +} + +impl ImageUploadRequest for OfflineBanner { + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: ulid::Ulid, + owner_id: ulid::Ulid, + ) -> image_processor::Task { + let config = global.config::(); + + image_processor::Task { + input_path: format!( + "{}/offliner_banners/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 192, // 192, 384, 576, 768 + base_width: 960, // 960, 1920, 2880, 3840 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + // Disable animated offline banners for now + // ImageFormat::Gif as i32, + // ImageFormat::Webp as i32, + // ImageFormat::Avif as i32, + ], + callback_subject: config.callback_subject.clone(), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 2000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::Fit as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } + + fn task_priority(global: &Arc) -> i64 { + global.config::().offline_banner_task_priority + } + + fn get_max_size(global: &Arc) -> usize { + global.config::().max_offline_banner_size + } + + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadOfflineBanner) + } + + fn file_type(_global: &Arc) -> FileType { + FileType::OfflineBanner + } + + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: ulid::Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET channel_pending_offline_banner_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } +} diff --git a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs new file mode 100644 index 00000000..8324d7a4 --- /dev/null +++ b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use common::database::deadpool_postgres::Transaction; +use common::http::ext::ResultExt; +use common::http::RouteError; +use hyper::StatusCode; +use pb::scuffle::platform::internal::image_processor; +use pb::scuffle::platform::internal::types::ImageFormat; +use ulid::Ulid; + +use super::{AcceptedFormats, ImageUploadRequest}; +use crate::api::auth::AuthData; +use crate::api::error::ApiError; +use crate::config::{ApiConfig, ImageUploaderConfig}; +use crate::database::{FileType, RolePermission}; +use crate::global::ApiGlobal; + +#[derive(Default, serde::Deserialize)] +#[serde(default)] +pub struct ProfilePicture { + set_active: bool, +} + +impl ImageUploadRequest for ProfilePicture { + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: Ulid, + owner_id: Ulid, + ) -> image_processor::Task { + let config = global.config::(); + + image_processor::Task { + input_path: format!( + "{}/profile_pictures/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 128, // 128, 256, 384, 512 + base_width: 128, // 128, 256, 384, 512 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + ImageFormat::Gif as i32, + ImageFormat::Webp as i32, + ImageFormat::Avif as i32, + ], + callback_subject: config.callback_subject.clone(), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 1000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::PadCenter as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } + + fn task_priority(global: &std::sync::Arc) -> i64 { + global.config::().profile_picture_task_priority + } + + fn get_max_size(global: &Arc) -> usize { + global.config::().max_profile_picture_size + } + + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) + } + + fn file_type(_global: &std::sync::Arc) -> FileType { + FileType::ProfilePicture + } + + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } +} diff --git a/platform/api/src/api/v1/upload/mod.rs b/platform/api/src/api/v1/upload/mod.rs index 2d9410f0..6476c956 100644 --- a/platform/api/src/api/v1/upload/mod.rs +++ b/platform/api/src/api/v1/upload/mod.rs @@ -11,7 +11,8 @@ use hyper::body::Incoming; use hyper::{Request, Response, StatusCode}; use multer::{Constraints, SizeLimit}; -use self::profile_picture::ProfilePicture; +use self::image_upload::offline_banner::OfflineBanner; +use self::image_upload::profile_picture::ProfilePicture; use crate::api::auth::AuthData; use crate::api::error::ApiError; use crate::api::request_context::RequestContext; @@ -19,7 +20,7 @@ use crate::api::Body; use crate::global::ApiGlobal; use crate::turnstile::validate_turnstile_token; -pub(crate) mod profile_picture; +pub(crate) mod image_upload; trait UploadType: serde::de::DeserializeOwned + Default { fn validate_format(global: &Arc, auth: &AuthData, content_type: &str) -> bool; @@ -39,7 +40,9 @@ trait UploadType: serde::de::DeserializeOwned + Default { } pub fn routes(_: &Arc) -> RouterBuilder> { - Router::builder().post("/profile-picture", handler::) + Router::builder() + .post("/profile-picture", handler::) + .post("/offline-banner", handler::) } async fn handler(req: Request) -> Result, RouteError> { diff --git a/platform/api/src/config.rs b/platform/api/src/config.rs index bdeb65a7..63e75706 100644 --- a/platform/api/src/config.rs +++ b/platform/api/src/config.rs @@ -15,6 +15,9 @@ pub struct ApiConfig { /// Max profile picture upload size pub max_profile_picture_size: usize, + + /// Max offline banner upload size + pub max_offline_banner_size: usize, } impl Default for ApiConfig { @@ -23,6 +26,7 @@ impl Default for ApiConfig { bind_address: "[::]:4000".parse().expect("failed to parse bind address"), tls: None, max_profile_picture_size: 5 * 1024 * 1024, // 5 MB + max_offline_banner_size: 10 * 1024 * 1024, // 10 MB } } } @@ -82,6 +86,9 @@ pub struct ImageUploaderConfig { /// Igdb image task priority, higher number means higher priority pub igdb_image_task_priority: i32, + + /// Offline banner task priority, higher number means higher priority + pub offline_banner_task_priority: i64, } impl Default for ImageUploaderConfig { @@ -89,8 +96,9 @@ impl Default for ImageUploaderConfig { Self { bucket: S3BucketConfig::default(), callback_subject: "scuffle-platform-image_processor-callback".to_string(), - public_endpoint: "https://images.scuffle.tv/scuffle-image-processor-public".to_string(), profile_picture_task_priority: 2, + offline_banner_task_priority: 2, + public_endpoint: "https://images.scuffle.tv/scuffle-image-processor-public".to_string(), igdb_image_task_priority: 1, } } diff --git a/platform/api/src/database/channel.rs b/platform/api/src/database/channel.rs index 597a46fe..d92c6ccf 100644 --- a/platform/api/src/database/channel.rs +++ b/platform/api/src/database/channel.rs @@ -34,6 +34,9 @@ pub struct Channel { /// The offline banner of the channel #[from_row(rename = "channel_offline_banner_id")] pub offline_banner_id: Option, + /// The offline banner of the channel + #[from_row(rename = "channel_pending_offline_banner_id")] + pub pending_offline_banner_id: Option, /// The current stream's category #[from_row(rename = "channel_category_id")] pub category_id: Option, diff --git a/platform/api/src/database/file_type.rs b/platform/api/src/database/file_type.rs index 8daf1dc9..877dd56d 100644 --- a/platform/api/src/database/file_type.rs +++ b/platform/api/src/database/file_type.rs @@ -5,6 +5,8 @@ use postgres_types::{FromSql, ToSql}; pub enum FileType { #[postgres(name = "profile_picture")] ProfilePicture, + #[postgres(name = "offline_banner")] + OfflineBanner, #[postgres(name = "category_cover")] CategoryCover, #[postgres(name = "category_artwork")] diff --git a/platform/api/src/database/role.rs b/platform/api/src/database/role.rs index e93fc253..1c0e2bc9 100644 --- a/platform/api/src/database/role.rs +++ b/platform/api/src/database/role.rs @@ -31,6 +31,8 @@ pub enum RolePermission { StreamRecording, /// Upload Profile Pictures UploadProfilePicture, + /// Upload Offline Banners + UploadOfflineBanner, } impl<'a> postgres_types::FromSql<'a> for RolePermission { diff --git a/platform/api/src/image_processor_callback.rs b/platform/api/src/image_processor_callback.rs index 762e4a45..46297ea3 100644 --- a/platform/api/src/image_processor_callback.rs +++ b/platform/api/src/image_processor_callback.rs @@ -182,6 +182,32 @@ async fn handle_message( None } } + FileType::OfflineBanner => { + let owner_id = uploaded_file + .owner_id + .ok_or_else(|| anyhow::anyhow!("uploaded file owner id is null"))?; + + if common::database::query("UPDATE users SET channel_offline_banner_id = $1, channel_pending_offline_banner_id = NULL, updated_at = NOW() WHERE id = $2 AND channel_pending_offline_banner_id = $3") + .bind(uploaded_file.id) + .bind(owner_id) + .bind(uploaded_file.id) + .build() + .execute(&tx) + .await + .context("failed to update user")? == 1 { + Some(( + SubscriptionTopic::ChannelOfflineBanner(uploaded_file.owner_id.unwrap()), + pb::scuffle::platform::internal::events::ChannelOfflineBanner { + channel_id: Some(uploaded_file.owner_id.unwrap().into()), + offline_banner_id: Some(uploaded_file.id.into()), + } + .encode_to_vec() + .into(), + )) + } else { + None + } + } FileType::CategoryCover => None, FileType::CategoryArtwork => None, }; @@ -235,6 +261,21 @@ async fn handle_message( .await .context("failed to update user")?; } + FileType::OfflineBanner => { + let owner_id = uploaded_file + .owner_id + .ok_or_else(|| anyhow::anyhow!("uploaded file owner id is null"))?; + + common::database::query( + "UPDATE users SET channel_pending_offline_banner_id = NULL, updated_at = NOW() WHERE id = $1 AND channel_pending_offline_banner_id = $2", + ) + .bind(owner_id) + .bind(uploaded_file.id) + .build() + .execute(&tx) + .await + .context("failed to update user")?; + } FileType::CategoryCover => {} FileType::CategoryArtwork => {} } diff --git a/platform/api/src/subscription.rs b/platform/api/src/subscription.rs index 6172d36b..ca2e1324 100644 --- a/platform/api/src/subscription.rs +++ b/platform/api/src/subscription.rs @@ -76,6 +76,7 @@ pub enum SubscriptionTopic { ChannelChatMessages(Ulid), ChannelTitle(Ulid), ChannelLive(Ulid), + ChannelOfflineBanner(Ulid), UserDisplayName(Ulid), UserDisplayColor(Ulid), UserFollows(Ulid), @@ -90,6 +91,7 @@ impl std::fmt::Display for SubscriptionTopic { Self::ChannelChatMessages(channel_id) => write!(f, "channel.{channel_id}.chat_messages"), Self::ChannelTitle(channel_id) => write!(f, "channel.{channel_id}.title"), Self::ChannelLive(channel_id) => write!(f, "channel.{channel_id}.live"), + Self::ChannelOfflineBanner(channel_id) => write!(f, "channel.{channel_id}.offline_banner"), Self::UserDisplayName(user_id) => write!(f, "user.{user_id}.display_name"), Self::UserDisplayColor(user_id) => write!(f, "user.{user_id}.display_color"), Self::UserFollows(user_id) => write!(f, "user.{user_id}.follows"), diff --git a/platform/api/src/video_event_handler.rs b/platform/api/src/video_event_handler.rs index 0b9a729e..9e7c2d1f 100644 --- a/platform/api/src/video_event_handler.rs +++ b/platform/api/src/video_event_handler.rs @@ -62,10 +62,14 @@ async fn handle_room_event(global: &Arc, event: event::Room, ti .await .context("failed to fetch playback session count")?; + let t = chrono::NaiveDateTime::from_timestamp_millis(timestamp) + .expect("timestamp is not valid") + .and_utc(); + let channel_id = common::database::query("UPDATE users SET channel_active_connection_id = $1, channel_live_viewer_count = $2, channel_live_viewer_count_updated_at = NOW(), channel_last_live_at = $3 WHERE channel_room_id = $4 RETURNING id") .bind(connection_id.into_ulid()) .bind(live_viewer_count) - .bind(chrono::NaiveDateTime::from_timestamp_millis(timestamp)) + .bind(t) .bind(room_id.into_ulid()) .build_query_single_scalar() .fetch_one(global.db()) diff --git a/platform/migrations/20230825170300_init.up.sql b/platform/migrations/20230825170300_init.up.sql index 7e30564b..32dbd9ab 100644 --- a/platform/migrations/20230825170300_init.up.sql +++ b/platform/migrations/20230825170300_init.up.sql @@ -1,6 +1,6 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE TYPE file_type AS ENUM ('profile_picture', 'category_cover', 'category_artwork'); +CREATE TYPE file_type AS ENUM ('profile_picture', 'offline_banner', 'category_cover', 'category_artwork'); CREATE TYPE uploaded_file_status AS ENUM ('unqueued', 'queued', 'failed', 'completed'); @@ -41,6 +41,7 @@ CREATE TABLE users ( channel_links JSONB NOT NULL DEFAULT '[]'::JSONB, channel_custom_thumbnail_id UUID, channel_offline_banner_id UUID, + channel_pending_offline_banner_id UUID, channel_category_id UUID, channel_stream_key VARCHAR(256), channel_role_order UUID[] NOT NULL DEFAULT '{}'::UUID[], diff --git a/platform/website/src/components/channel/offline-banner.svelte b/platform/website/src/components/channel/offline-banner.svelte new file mode 100644 index 00000000..c8508400 --- /dev/null +++ b/platform/website/src/components/channel/offline-banner.svelte @@ -0,0 +1,97 @@ + + +
+ {#if offlineBanner} + + {/if} + +
+ + diff --git a/platform/website/src/components/responsive-image.svelte b/platform/website/src/components/responsive-image.svelte new file mode 100644 index 00000000..cf6d6f67 --- /dev/null +++ b/platform/website/src/components/responsive-image.svelte @@ -0,0 +1,175 @@ + + +{#if preparedVariants && preparedVariants.bestSupported} + + {#each preparedVariants.variants as variant} + + {/each} + + +{/if} + + diff --git a/platform/website/src/components/settings/file-upload-button.svelte b/platform/website/src/components/settings/file-upload-button.svelte new file mode 100644 index 00000000..e9bc05d1 --- /dev/null +++ b/platform/website/src/components/settings/file-upload-button.svelte @@ -0,0 +1,128 @@ + + +{#if fileError} + (fileError = null)} /> +{/if} + + + + + + + + + diff --git a/platform/website/src/components/side-nav.svelte b/platform/website/src/components/side-nav.svelte index c3420ef7..8056fddd 100644 --- a/platform/website/src/components/side-nav.svelte +++ b/platform/website/src/components/side-nav.svelte @@ -2,7 +2,6 @@ import Fa from "svelte-fa"; import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons"; import { faHouse, faUser } from "@fortawesome/free-solid-svg-icons"; - import { sideNavCollapsed, sideNavHidden } from "$store/layout"; import SideNavStreamerCard from "$components/side-nav/streamer-card.svelte"; import { page } from "$app/stores"; @@ -168,7 +167,8 @@ --> - + + What is Scuffle? diff --git a/platform/website/src/components/user/profile-picture.svelte b/platform/website/src/components/user/profile-picture.svelte index 0bec86ef..22c996c9 100644 --- a/platform/website/src/components/user/profile-picture.svelte +++ b/platform/website/src/components/user/profile-picture.svelte @@ -1,122 +1,18 @@ -{#if profilePicture && preparedVariants && preparedVariants.bestSupported} - - {#each preparedVariants.variants as variant} - - {/each} - avatar - +{#if profilePicture} + {:else} {/if} - - diff --git a/platform/website/src/lib/auth.ts b/platform/website/src/lib/auth.ts index b488a333..ea897f16 100644 --- a/platform/website/src/lib/auth.ts +++ b/platform/website/src/lib/auth.ts @@ -38,6 +38,19 @@ export function getUser(client: Client) { lastLoginAt channel { id + pendingOfflineBannerId + offlineBanner { + id + variants { + width + height + scale + url + format + byteSize + } + endpoint + } live { roomId } diff --git a/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte b/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte index 40b439e0..387097a6 100644 --- a/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte +++ b/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte @@ -9,6 +9,7 @@ import BrandIcon from "$/components/icons/brand-icon.svelte"; import { userId } from "$/store/auth"; import ProfilePicture from "$/components/user/profile-picture.svelte"; + import OfflineBanner from "$/components/channel/offline-banner.svelte"; export let data: LayoutData; $: channelId = data.user.id; @@ -37,7 +38,12 @@
-
+ +
-
+
@@ -104,85 +110,73 @@ } } - .offline-banner { - background: url("/xqc-offline-banner.jpeg"); - background-size: cover; - background-position: center; + .user-card { + background-color: $bgColor; + padding: 1rem; + border-radius: 0.5rem; + border: 2px solid $borderColor; + margin: 1rem; - aspect-ratio: 5 / 1; + max-width: 20rem; display: flex; - align-items: center; + flex-direction: column; + gap: 1rem; + + & > .user-info { + display: grid; + grid-template-areas: "avatar name" "avatar followers"; + justify-content: start; + column-gap: 1rem; + row-gap: 0.25rem; + grid-template-rows: 1fr 1fr; + + & > .avatar { + grid-area: avatar; + } - padding: 1rem; + & > .name { + grid-area: name; + align-self: end; - & > .user-card { - background-color: $bgColor; - padding: 1rem; - border-radius: 0.5rem; - border: 2px solid $borderColor; + font-size: 1.5rem; + font-weight: 600; + line-height: 0.9em; - max-width: 20rem; + color: $textColor; + } - display: flex; - flex-direction: column; - gap: 1rem; + & > .followers { + grid-area: followers; + align-self: start; - & > .user-info { - display: grid; - grid-template-areas: "avatar name" "avatar followers"; - justify-content: start; - column-gap: 0.5rem; - row-gap: 0.25rem; - grid-template-rows: 1fr 1fr; + font-weight: 500; + color: $textColorLight; + } + } - & > .avatar { - grid-area: avatar; - } + & > .description { + color: $textColorLighter; + text-wrap: wrap; + } - & > .name { - grid-area: name; - align-self: end; + & > .socials { + list-style: none; + margin: 0; + padding: 0; - font-size: 1.25rem; - font-weight: 600; - line-height: 0.9em; + & > li { + padding: 0.15rem 0; + & > a { color: $textColor; - } - - & > .followers { - grid-area: followers; - align-self: start; - + text-decoration: none; font-weight: 500; - color: $textColorLight; - } - } - - & > .description { - color: $textColorLighter; - text-wrap: wrap; - } - - & > .socials { - list-style: none; - margin: 0; - padding: 0; - - & > li { - padding: 0.15rem 0; - - & > a { - color: $textColor; - text-decoration: none; - font-weight: 500; - &:hover, - &:focus-visible { - & > span { - text-decoration: underline; - } + &:hover, + &:focus-visible { + & > span { + text-decoration: underline; } } } diff --git a/platform/website/src/routes/(app)/[username]/+layout.ts b/platform/website/src/routes/(app)/[username]/+layout.ts index a33c96c4..deabf165 100644 --- a/platform/website/src/routes/(app)/[username]/+layout.ts +++ b/platform/website/src/routes/(app)/[username]/+layout.ts @@ -47,6 +47,18 @@ export async function load({ params, parent }: LayoutLoadEvent) { liveViewerCount } description + offlineBanner { + id + variants { + width + height + scale + url + format + byteSize + } + endpoint + } followersCount links { name diff --git a/platform/website/src/routes/(app)/[username]/+page.svelte b/platform/website/src/routes/(app)/[username]/+page.svelte index 1a03cd3d..0e829582 100644 --- a/platform/website/src/routes/(app)/[username]/+page.svelte +++ b/platform/website/src/routes/(app)/[username]/+page.svelte @@ -78,12 +78,6 @@
{#if data.user.channel.live} - {/if}
diff --git a/platform/website/src/routes/(app)/settings/profile/+page.svelte b/platform/website/src/routes/(app)/settings/profile/+page.svelte index 24e6a25c..39e8a951 100644 --- a/platform/website/src/routes/(app)/settings/profile/+page.svelte +++ b/platform/website/src/routes/(app)/settings/profile/+page.svelte @@ -1,7 +1,7 @@ -{#if $user} - {#if fileError} - (fileError = null)} - /> - {/if} + +{#if $user && $userId}
- -
{#if $user.pendingProfilePictureId}
@@ -258,23 +224,14 @@ /> {/if}
- - -
+
+
+ {#if $user.channel.pendingOfflineBannerId} +
+ +
+ {:else} + + {#if !$user.channel.offlineBanner} +

No offline banner

+ {/if} +
+ {/if} +
+ (status = Status.Saving)}>Upload Picture + +
+
+
.display-name { text-align: center; diff --git a/platform/website/static/xqc-offline-banner.jpeg b/platform/website/static/xqc-offline-banner.jpeg deleted file mode 100644 index 73e5ff9b..00000000 Binary files a/platform/website/static/xqc-offline-banner.jpeg and /dev/null differ diff --git a/proto/scuffle/platform/internal/events/api.proto b/proto/scuffle/platform/internal/events/api.proto index 8ca58be0..e49b7d2e 100644 --- a/proto/scuffle/platform/internal/events/api.proto +++ b/proto/scuffle/platform/internal/events/api.proto @@ -49,6 +49,12 @@ message UserProfilePicture { scuffle.types.Ulid profile_picture_id = 2; } +// For channel.{id}.offline_banner event +message ChannelOfflineBanner { + scuffle.types.Ulid channel_id = 1; + scuffle.types.Ulid offline_banner_id = 2; +} + // For file.{id}.status event message UploadedFileStatus { message Success {} diff --git a/schema.graphql b/schema.graphql index c0253123..0cd4e2d0 100644 --- a/schema.graphql +++ b/schema.graphql @@ -149,7 +149,8 @@ type Channel { lastLiveAt: DateRFC3339 links: [ChannelLink!]! live: ChannelLive - offlineBannerId: ULID + offlineBanner: ImageUpload + pendingOfflineBannerId: ULID streamKey: String title: String } @@ -173,6 +174,7 @@ type ChannelLiveStream { } type ChannelMutation { + removeOfflineBanner: User! title( """ The new title. @@ -181,6 +183,11 @@ type ChannelMutation { ): User! } +type ChannelOfflineBannerStream { + channelId: ULID! + offlineBanner: ImageUpload +} + type ChannelTitleStream { channelId: ULID! title: String @@ -370,6 +377,7 @@ type Subscription { channelFollowersCount(channelId: ULID!): Int! channelFollows(channelId: ULID!): FollowStream! channelLive(channelId: ULID!): ChannelLiveStream! + channelOfflineBanner(channelId: ULID!): ChannelOfflineBannerStream! channelTitle(channelId: ULID!): ChannelTitleStream! chatMessages( """ diff --git a/video/cli/src/invoker/direct.rs b/video/cli/src/invoker/direct.rs index 4be98070..288e4c75 100644 --- a/video/cli/src/invoker/direct.rs +++ b/video/cli/src/invoker/direct.rs @@ -165,7 +165,7 @@ impl DirectBackend { } qb.push(" LIMIT "); - qb.push_bind(search_options.limit.max(100).min(1000)); + qb.push_bind(search_options.limit.max(100).min(1000) as i64); let orgs: Vec = qb .build_query_as() diff --git a/video/ingest/src/ingest/connection.rs b/video/ingest/src/ingest/connection.rs index ee8e0ed7..bdd5b2fc 100644 --- a/video/ingest/src/ingest/connection.rs +++ b/video/ingest/src/ingest/connection.rs @@ -99,17 +99,17 @@ pub async fn handle(global: Arc, socket: let fut = pin!(session.run()); let mut session = RtmpSession::new(fut, publish, data); - let Ok(Some(event)) = select! { - _ = global.ctx().done() => { - tracing::debug!("Global context closed, closing connection"); - return; - }, - d = session.publish() => d, - _ = tokio::time::sleep(Duration::from_secs(5)) => { - tracing::debug!("session timed out before publish request"); - return; - }, - } else { + let Ok(Some(event)) = select! ( + _ = global.ctx().done() => { + tracing::debug!("Global context closed, closing connection"); + return; + }, + d = session.publish() => d, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + tracing::debug!("session timed out before publish request"); + return; + }, + ) else { tracing::debug!("connection disconnected before publish"); return; };