Storyteller Rust API client

This commit is contained in:
Brandon Thomas committed 2025-04-22 18:55:30 -04:00
1 parent 2fee3e12d5
commit 769ee07d69
18 files changed
+595 -2

No files matched your search

Generated
+33
View File
@@ -1079,6 +1079,19 @@ dependencies = [
"tower-service",
]
[[package]]
name = "backoff"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
dependencies = [
"futures-core",
"getrandom 0.2.14",
"instant",
"pin-project-lite",
"rand 0.8.5",
]
[[package]]
name = "backtrace"
version = "0.3.69"
@@ -11232,6 +11245,26 @@ dependencies = [
"zip 0.6.6",
]
[[package]]
name = "storyteller_client"
version = "0.0.1"
dependencies = [
"anyhow",
"backoff",
"chrono",
"enums",
"enums_public",
"errors",
"log",
"reqwest 0.12.15",
"serde",
"serde_derive",
"serde_json",
"tokens",
"url",
"workspace-build-acceleration",
]
[[package]]
name = "storyteller_root"
version = "0.0.1"
+4 -1
View File
@@ -34,6 +34,7 @@ members = [
"crates/lib/api_clients/google_drive_common",
"crates/lib/api_clients/google_sign_in",
"crates/lib/api_clients/openai_sora_client",
"crates/lib/api_clients/storyteller_client",
"crates/lib/api_clients/thumbnail_generator",
"crates/lib/bootstrap",
"crates/lib/bucket_paths",
@@ -167,10 +168,12 @@ serde_yaml = "0.9.1"
sha3 = "0.10.8"
thiserror = "2"
toml = "0.7.4"
url = "2.5.4"
url = { version = "2.5.4", features = ["serde"] }
# Internal libs
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
@@ -0,0 +1,42 @@
[package]
name = "storyteller_client"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "storyteller_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
enums = { workspace = true }
enums_public = { workspace = true }
errors = { workspace = true }
tokens = { workspace = true }
# External
anyhow = { workspace = true }
chrono = { version = "0.4.22", features = ["serde"] }
log = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
url = { workspace = true }
# Serialization
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
# HTTP
backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry
#reqwest = { version = "0.11", features = ["json", "cookies"] } # HTTP Client
[dev-dependencies]
# None yet
@@ -0,0 +1,81 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use errors::AnyhowError;
#[derive(Debug)]
pub enum ApiError {
/// 400. The request was invalid.
InvalidRequest(String),
/// 401. The request was not authorized.
Unauthorized(String),
/// 403. The request was forbidden.
Forbidden(String),
/// 404. The requested resource was not found.
NotFound(String),
/// 429. Too many requests.
TooManyRequests(String),
/// 500. An internal server error occurred.
InternalServerError(String),
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
/// The request timed out.
Timeout(String),
/// A network error occurred.
NetworkError(String),
/// Uncategorized reqwest error.
OtherReqwestError(reqwest::Error),
/// Another type of error.
Other(AnyhowError),
}
impl Error for ApiError {}
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
// Server response code errors
ApiError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg),
ApiError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
// Other
ApiError::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error),
ApiError::Other(error) => write!(f, "Other error: {}", error),
}
}
}
impl From<reqwest::Error> for ApiError {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
ApiError::Timeout(error.to_string())
} else if error.is_connect() {
ApiError::NetworkError(error.to_string())
} else {
ApiError::OtherReqwestError(error)
}
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
ApiError::DeserializationError(error)
}
}
@@ -0,0 +1,4 @@
pub mod media_files;
pub mod shared_response_types;
pub mod utils;
mod api_error;
@@ -0,0 +1,216 @@
use std::num::{NonZero, NonZeroU16};
use chrono::{DateTime, Utc};
use log::{debug, error};
use reqwest::{Client, StatusCode};
use serde_derive::{Deserialize};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use crate::api_error::ApiError;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
use crate::shared_response_types::simple_entity_stats::SimpleEntityStats;
use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::status_codes::*;
/// Get details about a media file from our backend
pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result<GetMediaFileSuccessResponse, ApiError> {
let client = Client::builder()
.gzip(true)
.build()?;
let url = get_media_file_token_route(api_host, media_file_token);
debug!("Requesting {:?}", &url);
let response = client.get(url)
.header("User-Agent", "storyteller-client/1.0")
.header("Accept", "*/*")
.header("Accept-Encoding", "gzip, deflate, br")
.send()
.await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await?;
let media_file = serde_json::from_str(&response_body)?;
Ok(media_file)
}
fn get_media_file_token_route(api_host: &ApiHost, media_file_token: &MediaFileToken) -> String {
let api_hostname = api_host.to_api_hostname();
let media_file_token = media_file_token.as_str();
format!("https://{}/v1/media_files/file/{}", api_hostname, media_file_token)
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize)]
pub struct GetMediaFileSuccessResponse {
pub success: bool,
pub media_file: MediaFileInfo,
}
#[derive(Deserialize)]
pub struct MediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If this is an engine/3D asset, this is the broad category (scene,
/// animation, etc.) of that object.
/// This can also be used for filtering in list/batch endpoints.
pub maybe_engine_category: Option<MediaFileEngineCategory>,
/// If this is an engine/3D asset for an animation or a rig that can
/// be animated with either (or both) skeletal or blend shape animations,
/// this describes the animation regime used or supported.
pub maybe_animation_type: Option<MediaFileAnimationType>,
// /// If the media file has a subtype, we'll report it.
// /// This is mostly used for Bevy engine files.
// #[deprecated(note="This was for the Bevy engine. Do not use.")]
// pub maybe_media_subtype: Option<MediaFileSubtype>,
// /// Extension for the engine to load over remote:// URLs.
// #[deprecated(note="This was for the Bevy engine. Do not use.")]
// pub maybe_engine_extension: Option<String>,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// If this media file was generated by a scene from the engine, this is a
/// link to that file.
pub maybe_scene_source_media_file_token: Option<MediaFileToken>,
// /// (DEPRECATED) URL path to the media file
// #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
// pub public_bucket_path: String,
// /// (DEPRECATED) Full URL to the media file
// #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
// pub public_bucket_url: Url,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
//// Provenance Data
//pub origin_product: MediaFileOriginProductCategory,
//pub origin_category: MediaFileOriginCategory,
//pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
//pub maybe_origin_model_token: Option<String>,
//pub maybe_origin_filename: Option<String>,
/// Possible model info (if generated by inference)
pub maybe_model_weight_info: Option<GetMediaFileModelInfo>,
/// User info
pub maybe_creator_user: Option<UserDetailsLight>,
pub creator_set_visibility: Visibility,
/// The file was uploaded by the user.
/// This does not include files generated on the client side, like studio renders.
pub is_user_upload: bool,
/// The file was created by the system.
/// This includes files generated on the client side, like studio renders.
pub is_intermediate_system_file: bool,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
// /// OPTIONAL. Details on live portrait generated media files,
// /// if this is a live portrait generated file.
// pub maybe_live_portrait_details: Option<MediaFileLivePortraitDetails>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
/// For Comfy / Video Style Transfer jobs, this indicates use of face detailer.
pub used_face_detailer: bool,
/// For Comfy / Video Style Transfer jobs, this indicates use of upscaling.
pub used_upscaler: bool,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
/// We can simulate media files for "tts_results" records.
/// If this is set as true, this informs the frontend and API callers not to treat
/// the token as a media file and improperly assume it can be used with the rest of
/// the media file APIs.
pub is_emulated_media_file: bool,
/// Whether the media file is featured by staff (the featured_items API) or not.
pub is_featured: bool,
/// Statistics about the media file
pub stats: SimpleEntityStats,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub maybe_moderator_fields: Option<GetMediaFileModeratorFields>,
}
#[derive(Deserialize)]
pub struct GetMediaFileModeratorFields {
/// This may become public in the future.
pub maybe_style_transfer_source_media_file_token: Option<MediaFileToken>,
//pub creator_ip_address: String,
//pub creator_is_banned_if_user: bool,
//pub maybe_creator_deleted_at: Option<DateTime<Utc>>,
//pub maybe_mod_deleted_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize)]
pub struct GetMediaFileModelInfo {
pub weight_token: ModelWeightToken,
pub weight_type: PublicWeightsType,
pub weight_category: WeightsCategory,
pub title: String,
/// Cover images are small descriptive images that can be set for any model.
/// If a cover image is set, this is the path to the asset.
pub maybe_cover_image_public_bucket_path: Option<String>,
/// NB: Technically this shouldn't be a nullable field, but since there are so many
/// joins we'll relax that this must exist (for now).
pub maybe_weight_creator: Option<UserDetailsLight>,
}
@@ -0,0 +1 @@
pub mod get_media_file;
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use url::Url;
/// Cover image links can only be jpg, png, etc. No videos.
#[derive(Deserialize)]
pub struct CoverImageLinks {
/// Primary link to the cover image via the CDN.
pub cdn_url: Url,
/// Template to construct thumbnail URLs.
/// Replace the string `{WIDTH}` with the desired width.
/// Only relevant for image media files. (Video media files instead have
/// video previews, which, in turn, have their own thumbnail templates.)
pub thumbnail_template: String,
}
@@ -0,0 +1,40 @@
use serde_derive::{Deserialize};
use url::Url;
use crate::shared_response_types::cover_image_links::CoverImageLinks;
/// Everything we need to create a cover image.
/// Cover images are small descriptive images that can be set for any media file.
/// If a cover image is set, this is the path to the asset.
#[derive(Deserialize)]
pub struct MediaFileCoverImageDetails {
// /// (DEPRECATED) URL path to the media file
// #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
// pub maybe_cover_image_public_bucket_path: Option<String>,
// /// (DEPRECATED) Full URL to the media file
// #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
// pub maybe_cover_image_public_bucket_url: Option<Url>,
// NB(bt,2024-09-19): I accidentally rolled this field out to production.
// I don't think this field is in use, but maybe ...
// /// (DEPRECATED) Use maybe_links instead.
// #[deprecated(note="Use `maybe_links` instead.")]
// pub maybe_media_links: Option<MediaLinks>,
/// Links to the cover image (CDN direct link, thumbnail template)
/// If a cover image is set, this is the path to the asset.
/// If a cover image is not set, use the information in `default_cover` instead.
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub maybe_links: Option<CoverImageLinks>,
/// For items without a cover image, we can use one of our own.
pub default_cover: MediaFileDefaultCover,
}
/// The default cover is composed of an image and color pair that are
/// predefined by the frontend.
#[derive(Deserialize)]
pub struct MediaFileDefaultCover {
pub image_index: u8,
pub color_index: u8,
}
@@ -0,0 +1,34 @@
use serde_derive::Deserialize;
use url::Url;
/// Links to media file locations (bucket, CDN, etc.)
#[derive(Deserialize)]
pub struct MediaLinks {
/// Primary link to the asset via the CDN.
pub cdn_url: Url,
/// Template to construct thumbnail URLs.
/// Replace the string `{WIDTH}` with the desired width.
/// Only relevant for image media files. (Video media files instead have
/// video previews, which, in turn, have their own thumbnail templates.)
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
/// These are not set for "cover images" that are gif or webms, but rather video files.
pub maybe_video_previews: Option<VideoPreviews>,
}
#[derive(Deserialize)]
pub struct VideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -0,0 +1,5 @@
pub mod cover_image_links;
pub mod media_file_cover_image_details;
pub mod media_links;
pub mod simple_entity_stats;
pub mod user_details_light;
@@ -0,0 +1,10 @@
use serde_derive::Deserialize;
/// Simple stats that can be attached to any entity
#[derive(Deserialize)]
pub struct SimpleEntityStats {
/// Number of positive ratings (or "likes") for this item
pub positive_rating_count: u32,
/// Number of bookmarks for this item
pub bookmark_count: u32,
}
@@ -0,0 +1,33 @@
use serde_derive::Deserialize;
use tokens::tokens::users::UserToken;
/// Everything we need to refer to a user on the public web interface.
#[derive(Deserialize)]
pub struct UserDetailsLight {
/// The token for the user
pub user_token: UserToken,
/// The unique username someone logs in with
/// As of 2023-08-23, this is always lowercase
pub username: String,
/// As of 2023-08-23, this is the username with capitalization
/// (In the future, a display name can be customized by the user.)
pub display_name: String,
/// Email hash for Gravatar
/// Always set for now since login is email/username+password.
/// In the future this will need to become an optional *OR* be filled with a bogus hash.
pub gravatar_hash: String,
/// For users without a gravatar, we show one of our own avatars.
pub default_avatar: UserDefaultAvatarInfo,
// In the future, we'll also support user-uploaded avatars that we store on our servers.
}
#[derive(Deserialize)]
pub struct UserDefaultAvatarInfo {
pub image_index: u8,
pub color_index: u8,
}
@@ -0,0 +1,17 @@
#[derive(Clone)]
pub enum ApiHost {
Storyteller,
FakeYou,
Localhost { port: u32 },
}
impl ApiHost {
pub fn to_api_hostname(&self) -> String {
match self {
ApiHost::Storyteller => "api.storyteller.ai".to_string(),
ApiHost::FakeYou => "api.fakeyou.com".to_string(),
ApiHost::Localhost { port } => format!("localhost:{}", port),
}
}
}
@@ -0,0 +1,33 @@
use anyhow::anyhow;
use crate::api_error::ApiError;
use crate::utils::status_codes::*;
use reqwest::StatusCode;
/// Pass request errors to this method for standard error handling behavior.
/// This function needs to take temporary ownership, but will return it to the caller.
///
/// Example use:
/// let response = client.get().await?;
/// let response = filter_bad_response(response).await?;
/// ... 200 or other valid response
pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
let status = response.status();
if !status.is_success() {
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
return match status.as_u16() {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError(response_body)),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
}
Ok(response)
}
@@ -0,0 +1,3 @@
pub mod api_host;
pub mod filter_bad_response;
pub mod status_codes;
@@ -0,0 +1,23 @@
pub const STATUS_401_UNAUTHORIZED : u16 = 401u16;
pub const STATUS_403_FORBIDDEN : u16 = 403u16;
pub const STATUS_404_NOT_FOUND : u16 = 404u16;
pub const STATUS_429_TOO_MANY_REQUESTS : u16 = 429u16;
pub const STATUS_500_INTERNAL_SERVER_ERROR : u16 = 500u16;
#[cfg(test)]
mod tests {
use crate::utils::status_codes::*;
#[test]
pub fn test_codes() {
assert_eq!(STATUS_401_UNAUTHORIZED, 401);
assert_eq!(STATUS_403_FORBIDDEN, 403);
assert_eq!(STATUS_404_NOT_FOUND, 404);
assert_eq!(STATUS_429_TOO_MANY_REQUESTS, 429);
assert_eq!(STATUS_500_INTERNAL_SERVER_ERROR, 500);
}
}
@@ -98,7 +98,7 @@ strum_macros = "0.26.4"
symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
time = "0.3.30"
uuid = { version = "1.8.0", features = ["v4"] }
url = { version = "2.5.2", features = ["serde"] }
url = { workspace = true }
# Language tag parsing
# TODO: Move to language support crate