Add ability to watch an episode

- Migration for new backing table.
- CRUD server functions, new components to display and interact.
This commit is contained in:
Thomas Gideon 2024-09-14 18:33:50 -04:00
parent 75e388555b
commit b2e55008dd
11 changed files with 317 additions and 43 deletions

69
Cargo.lock generated
View file

@ -44,6 +44,21 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.15" version = "0.6.15"
@ -338,6 +353,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "ciborium" name = "ciborium"
version = "0.2.2" version = "0.2.2"
@ -445,6 +472,12 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.14" version = "0.2.14"
@ -1049,6 +1082,29 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@ -2436,6 +2492,7 @@ dependencies = [
"atoi", "atoi",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@ -2520,6 +2577,7 @@ dependencies = [
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest",
"dotenvy", "dotenvy",
@ -2562,6 +2620,7 @@ dependencies = [
"base64", "base64",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@ -2598,6 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -3218,6 +3278,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"

View file

@ -20,7 +20,7 @@ wasm-bindgen = "=0.2.93"
thiserror = "1" thiserror = "1"
tracing = { version = "0.1", optional = true } tracing = { version = "0.1", optional = true }
http = "1" http = "1"
sqlx = { version = "0.8.1", features = ["postgres", "runtime-tokio", "tls-rustls-ring", "uuid"], optional = true } sqlx = { version = "0.8.1", features = ["postgres", "runtime-tokio", "tls-rustls-ring", "uuid", "chrono"], optional = true }
anyhow = "1.0.86" anyhow = "1.0.86"
log = { version = "0.4.22", optional = true } log = { version = "0.4.22", optional = true }
env_logger = { version = "0.11.5", optional = true } env_logger = { version = "0.11.5", optional = true }

View file

@ -1,6 +1,12 @@
create table if not exists "public"."series" ( create table if not exists series (
"id" uuid default gen_random_uuid() not null primary key, id uuid default gen_random_uuid() not null primary key,
"name" text not null, created timestamptz default now() not null,
"created" timestamptz default now() not null, updated timestamptz default now() not null,
"updated" timestamptz default now() not null name text not null,
source text not null check (source in (
'Disney', 'Hulu', 'Paramount', 'Prime', 'Max', 'Netflix', 'Shudder'
)),
source_url text not null,
imdb text null,
wikipedia text null
); );

View file

@ -1,4 +0,0 @@
alter table "public"."series" add column source text not null;
alter table "public"."series" add column source_url text not null;
alter table "public"."series" add column imdb text null;
alter table "public"."series" add column wikipedia text null

View file

@ -0,0 +1,9 @@
create table if not exists viewing (
id uuid default gen_random_uuid() not null primary key,
series_id uuid not null references series(id),
created timestamptz default now() not null,
updated timestamptz default now() not null,
season smallint not null check (season > 0),
episode smallint not null check (episode > 0),
"comment" text null
);

1
sql/insert_viewing.sql Normal file
View file

@ -0,0 +1 @@
insert into viewing (series_id, season, episode, "comment") values ($1, $2, $3, $4);

3
sql/select_viewings.sql Normal file
View file

@ -0,0 +1,3 @@
select *
from viewing
where series_id = $1;

View file

@ -79,12 +79,17 @@ pub async fn add_series(
imdb: String, imdb: String,
wikipedia: String, wikipedia: String,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
use crate::db::load_statement; use std::sync::OnceLock;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use crate::db::load_statement;
let pool = expect_context::<Pool<Postgres>>(); let pool = expect_context::<Pool<Postgres>>();
let s = load_statement("insert_series.sql"); static SQL: OnceLock<String> = OnceLock::new();
sqlx::query(&s) let s = SQL.get_or_init(|| load_statement("insert_series.sql"));
sqlx::query(s)
.persistent(true) .persistent(true)
.bind(name) .bind(name)
.bind(source.to_string()) .bind(source.to_string())

162
src/app/series/history.rs Normal file
View file

@ -0,0 +1,162 @@
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::format_errors;
#[component]
pub fn ViewingList(series_id: Uuid) -> impl IntoView {
let add = create_server_action::<AddViewing>();
let viewings = create_resource(
move || (add.version().get(), series_id),
|(_, id)| async move { get_viewings(id).await },
);
view! {
<h3 class="mb-3">Viewing History</h3>
<Suspense fallback=|| view!{ <div>Loading...</div> }>
{viewings().map(format_viewings)}
</Suspense>
<ActionForm action=add>
<input type="hidden" name="series_id" value=series_id.to_string() />
<div class="row g-3">
<div class="col col-4 align-middle">
<input class="form-control btn btn-primary" type="submit" value="Watch Episode" />
</div>
<div class="col">
<div class="input-group mb-3">
<label class="input-group-text" for="season">Season</label>
<input class="form-control" name="season" type="number" value="1" />
<label class="input-group-text" for="episode">Episode</label>
<input class="form-control" name="episode" type="number" value="1" />
</div>
<div>
<label class="form-label" for="comment">Comment</label>
<textarea class="form-control" name="comment" />
</div>
</div>
</div>
</ActionForm>
}
}
fn format_viewings(
result: Result<Vec<Viewing>, ServerFnError>,
) -> Result<impl IntoView, ServerFnError> {
Ok(view! {
<ErrorBoundary fallback=format_errors>
{result.map(|viewings| {
let empty = viewings.is_empty();
view! {
<Show when=move || empty fallback=|| ()>
<div class="mb-3">No viewings found.</div>
</Show>
<ul class="list-group mb-3">
{viewings.into_iter().map(|viewing| {
view! {
<li class="list-group-item">
Season {viewing.season},
Episode {viewing.episode}
on {viewing.created}
<Suspense fallback=|| ()>
{viewing.comment.as_ref()}
</Suspense>
</li>
}
}).collect_view()}
</ul>
}
})}
</ErrorBoundary>
})
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Viewing {
pub id: Uuid,
pub created: String,
pub updated: String,
pub season: i16,
pub episode: i16,
pub comment: Option<String>,
}
#[cfg(feature = "ssr")]
mod ssr {
use super::Viewing;
use sqlx::{
postgres::PgRow,
prelude::*,
types::chrono::{DateTime, Utc},
};
impl<'r> FromRow<'r, PgRow> for Viewing {
fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
let id = row.try_get("id")?;
let created: DateTime<Utc> = row.try_get("created")?;
let created = created.format("%Y-%m-%d").to_string();
let updated: DateTime<Utc> = row.try_get("updated")?;
let updated = updated.format("%Y-%m-%d").to_string();
let season = row.try_get("season")?;
let episode = row.try_get("episode")?;
let comment = row.try_get("comment")?;
Ok(Viewing {
id,
created,
updated,
season,
episode,
comment,
})
}
}
}
#[server(AddViewing)]
pub async fn add_viewing(
series_id: Uuid,
episode: i32,
season: i32,
comment: Option<String>,
) -> Result<(), ServerFnError> {
use std::sync::OnceLock;
use sqlx::{Pool, Postgres};
use crate::db::load_statement;
static SQL: OnceLock<String> = OnceLock::new();
let pool = expect_context::<Pool<Postgres>>();
let s = SQL.get_or_init(|| load_statement("insert_viewing.sql"));
sqlx::query(s)
.persistent(true)
.bind(series_id)
.bind(season)
.bind(episode)
.bind(comment)
.execute(&pool)
.await?;
Ok(())
}
#[server(GetViewings)]
pub async fn get_viewings(series_id: Uuid) -> Result<Vec<Viewing>, ServerFnError> {
use std::sync::OnceLock;
use sqlx::{Pool, Postgres};
use crate::db::load_statement;
static SQL: OnceLock<String> = OnceLock::new();
let pool = expect_context::<Pool<Postgres>>();
let s = SQL.get_or_init(|| load_statement("select_viewings.sql"));
Ok(sqlx::query_as::<_, Viewing>(s)
.persistent(true)
.bind(series_id)
.fetch_all(&pool)
.await?)
}

View file

@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
pub mod add; pub mod add;
mod history;
pub mod view; pub mod view;
#[component] #[component]
@ -132,6 +133,8 @@ impl Display for Source {
pub struct Series { pub struct Series {
id: Uuid, id: Uuid,
name: String, name: String,
created: String,
updated: String,
source: Source, source: Source,
source_url: String, source_url: String,
imdb: Option<String>, imdb: Option<String>,
@ -141,11 +144,19 @@ pub struct Series {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod ssr { mod ssr {
use super::{Series, Source}; use super::{Series, Source};
use sqlx::{postgres::PgRow, prelude::*}; use sqlx::{
postgres::PgRow,
prelude::*,
types::chrono::{DateTime, Utc},
};
impl<'r> FromRow<'r, PgRow> for Series { impl<'r> FromRow<'r, PgRow> for Series {
fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> { fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
let id = row.try_get("id")?; let id = row.try_get("id")?;
let created: DateTime<Utc> = row.try_get("created")?;
let created = created.format("%Y-%m-%d").to_string();
let updated: DateTime<Utc> = row.try_get("updated")?;
let updated = updated.format("%Y-%m-%d").to_string();
let name = row.try_get("name")?; let name = row.try_get("name")?;
let source: String = row.try_get("source")?; let source: String = row.try_get("source")?;
@ -162,6 +173,8 @@ mod ssr {
Ok(Series { Ok(Series {
id, id,
created,
updated,
name, name,
source_url, source_url,
imdb, imdb,
@ -191,31 +204,36 @@ mod ssr {
#[server] #[server]
pub async fn get_all() -> Result<Vec<Series>, ServerFnError> { pub async fn get_all() -> Result<Vec<Series>, ServerFnError> {
use crate::db::load_statement; use std::sync::OnceLock;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use crate::db::load_statement;
static SQL: OnceLock<String> = OnceLock::new();
let pool: Pool<Postgres> = expect_context(); let pool: Pool<Postgres> = expect_context();
let s = load_statement("select_all_series.sql"); let s = SQL.get_or_init(|| load_statement("select_all_series.sql"));
let mut r = sqlx::query_as::<_, Series>(&s).fetch(&pool); Ok(sqlx::query_as::<_, Series>(&s)
.persistent(true)
let mut found = Vec::new(); .fetch_all(&pool)
while let Some(series) = r.try_next().await? { .await?)
found.push(series);
}
Ok(found)
} }
#[server(DeleteSeries)] #[server(DeleteSeries)]
pub async fn delete(id: String) -> Result<(), ServerFnError> { pub async fn delete(id: String) -> Result<(), ServerFnError> {
use crate::db::load_statement; use std::sync::OnceLock;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use crate::db::load_statement;
static SQL: OnceLock<String> = OnceLock::new();
let pool: Pool<Postgres> = expect_context(); let pool: Pool<Postgres> = expect_context();
let s = load_statement("delete_series.sql"); let s = SQL.get_or_init(|| load_statement("delete_series.sql"));
sqlx::query(&s) sqlx::query(&s)
.bind(id.parse::<Uuid>()?) .bind(id.parse::<Uuid>()?)
.execute(&pool) .execute(&pool)

View file

@ -1,4 +1,4 @@
use super::{format_errors, Series}; use super::{format_errors, history::ViewingList, Series};
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
use uuid::Uuid; use uuid::Uuid;
@ -32,20 +32,20 @@ pub fn ViewSeries() -> impl IntoView {
fn loading() -> impl IntoView { fn loading() -> impl IntoView {
view! { view! {
<div class="card-heading"> <div class="card-heading mb-3">
<A href="/" <A href="/"
attr:class="btn btn-close" attr:class="btn btn-close"
attr:style="float: right;" attr:style="float: right;"
> >
<span style="display: none;">x</span> <span style="display: none;">x</span>
</A> </A>
</div>
<h2><span class="placeholder col-4"></span></h2> <h2><span class="placeholder col-4"></span></h2>
<div class="btn-group"> <div class="btn-group mb-3">
<button class="btn btn-primary"><span class="placeholder col-4"></span></button> <button class="btn btn-primary"><span class="placeholder col-4"></span></button>
<button class="btn btn-primary">IMDB</button> <button class="btn btn-primary">IMDB</button>
<button class="btn btn-primary">Wikipedia</button> <button class="btn btn-primary">Wikipedia</button>
</div> </div>
</div>
} }
} }
@ -55,7 +55,7 @@ fn format_series_details(
Ok(view! { Ok(view! {
<ErrorBoundary fallback=format_errors> <ErrorBoundary fallback=format_errors>
{details.map(|details| view! { {details.map(|details| view! {
<div class="card-heading"> <div class="card-heading mb-3">
<A href="/" <A href="/"
attr:class="btn btn-close" attr:class="btn btn-close"
attr:style="float: right;" attr:style="float: right;"
@ -63,8 +63,9 @@ fn format_series_details(
<span style="display: none;">x</span> <span style="display: none;">x</span>
</A> </A>
<h2>{details.name}</h2> <h2>{details.name}</h2>
<small class="text-body-secondary">created {details.created}, last updated {details.updated}</small>
</div> </div>
<div class="btn-group"> <div class="btn-group mb-3">
<a href={details.source_url} <a href={details.source_url}
class="btn btn-primary" class="btn btn-primary"
target="_new" target="_new"
@ -74,6 +75,7 @@ fn format_series_details(
<OptionalUrlButton label="IMDB" opt_url=details.imdb.clone() /> <OptionalUrlButton label="IMDB" opt_url=details.imdb.clone() />
<OptionalUrlButton label="Wikipedia" opt_url=details.wikipedia.clone() /> <OptionalUrlButton label="Wikipedia" opt_url=details.wikipedia.clone() />
</div> </div>
<ViewingList series_id=details.id.clone() />
}) })
} }
</ErrorBoundary> </ErrorBoundary>
@ -98,16 +100,19 @@ fn OptionalUrlButton(label: &'static str, opt_url: Option<String>) -> impl IntoV
#[server(GetSeriesDetails)] #[server(GetSeriesDetails)]
pub async fn get_series_details(id: Uuid) -> Result<Series, ServerFnError> { pub async fn get_series_details(id: Uuid) -> Result<Series, ServerFnError> {
use crate::db::load_statement; use std::sync::OnceLock;
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use crate::db::load_statement;
static SQL: OnceLock<String> = OnceLock::new();
let pool = expect_context::<Pool<Postgres>>(); let pool = expect_context::<Pool<Postgres>>();
let s = load_statement("select_series.sql"); let s = SQL.get_or_init(|| load_statement("select_series.sql"));
let r = sqlx::query_as::<_, Series>(&s) Ok(sqlx::query_as::<_, Series>(&s)
.persistent(true) .persistent(true)
.bind(id) .bind(id)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?)
Ok(r)
} }