diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..6f13e33
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,13 @@
+# Movies Diary
+
+A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
+
+## Language
+
+**Review**:
+A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
+_Avoid_: Diary entry, watch, log entry
+
+**WatchMedium**:
+The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
+_Avoid_: Source, format, venue, platform
diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs
index 63bd495..458aa3f 100644
--- a/crates/adapters/activitypub/src/review_handler.rs
+++ b/crates/adapters/activitypub/src/review_handler.rs
@@ -132,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler {
source: ReviewSource::Remote {
actor_url: actor_url_str,
},
+ watch_medium: None,
});
self.review_store
diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs
index a4854bd..01165b6 100644
--- a/crates/adapters/activitypub/src/tests/objects.rs
+++ b/crates/adapters/activitypub/src/tests/objects.rs
@@ -29,6 +29,7 @@ fn review_to_ap_object_includes_two_hashtags() {
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
+ watch_medium: None,
});
let obj = review_to_ap_object(
&review,
@@ -67,6 +68,7 @@ fn review_to_ap_object_has_public_addressing() {
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
+ watch_medium: None,
});
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object(
diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs
index 03179f1..013a5b2 100644
--- a/crates/adapters/export/src/tests/lib.rs
+++ b/crates/adapters/export/src/tests/lib.rs
@@ -61,6 +61,7 @@ fn make_entry_full(
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
+ None,
)
.unwrap();
DiaryEntry::new(movie, review)
diff --git a/crates/adapters/postgres-federation/src/ap_content.rs b/crates/adapters/postgres-federation/src/ap_content.rs
index 0138504..2170e98 100644
--- a/crates/adapters/postgres-federation/src/ap_content.rs
+++ b/crates/adapters/postgres-federation/src/ap_content.rs
@@ -106,6 +106,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
+ watch_medium: None,
}))
}
}
diff --git a/crates/adapters/postgres/migrations/0029_watch_medium.sql b/crates/adapters/postgres/migrations/0029_watch_medium.sql
new file mode 100644
index 0000000..6492370
--- /dev/null
+++ b/crates/adapters/postgres/migrations/0029_watch_medium.sql
@@ -0,0 +1 @@
+ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs
index 89f6b60..40ae8f6 100644
--- a/crates/adapters/postgres/src/diary.rs
+++ b/crates/adapters/postgres/src/diary.rs
@@ -60,7 +60,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- r.remote_actor_url
+ r.remote_actor_url,
+ r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -93,7 +94,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- r.remote_actor_url
+ r.remote_actor_url,
+ r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1
@@ -178,7 +180,8 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- r.remote_actor_url
+ r.remote_actor_url,
+ r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1{remote_clause}{search_clause}
@@ -339,6 +342,7 @@ impl DiaryRepository for PostgresDiaryRepository {
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
+ r.watch_medium,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
@@ -432,7 +436,8 @@ impl DiaryRepository for PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- r.remote_actor_url
+ r.remote_actor_url,
+ r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1
diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs
index 373e076..c8944d8 100644
--- a/crates/adapters/postgres/src/models.rs
+++ b/crates/adapters/postgres/src/models.rs
@@ -90,6 +90,7 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
}
impl ReviewRow {
@@ -105,6 +106,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
+ let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -114,6 +116,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
+ watch_medium,
}))
}
}
@@ -134,6 +137,7 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
}
impl DiaryRow {
@@ -156,6 +160,7 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
+ watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
@@ -178,6 +183,7 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
pub user_email: String,
}
@@ -198,6 +204,7 @@ impl FeedRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
+ watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))
diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs
index 1a299a6..d592898 100644
--- a/crates/adapters/postgres/src/review.rs
+++ b/crates/adapters/postgres/src/review.rs
@@ -41,8 +41,8 @@ impl ReviewRepository for PostgresReviewRepository {
};
sqlx::query(
- "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
- VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)",
+ "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
)
.bind(&id)
.bind(&movie_id)
@@ -52,6 +52,7 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
+ .bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
@@ -71,7 +72,8 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- remote_actor_url
+ remote_actor_url,
+ watch_medium
FROM reviews WHERE id = $1",
)
.bind(&id)
@@ -82,6 +84,28 @@ impl ReviewRepository for PostgresReviewRepository {
.transpose()
}
+ async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
+ let id = review.id().value().to_string();
+ let rating = review.rating().value() as i64;
+ let comment = review.comment().map(|c| c.value().to_string());
+ let watched_at = datetime_to_str(review.watched_at());
+ let watch_medium = review.watch_medium().map(|wm| wm.to_string());
+
+ sqlx::query(
+ "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
+ )
+ .bind(rating)
+ .bind(&comment)
+ .bind(&watched_at)
+ .bind(&watch_medium)
+ .bind(&id)
+ .execute(&self.pool)
+ .await
+ .map_err(Self::map_err)?;
+
+ Ok(())
+ }
+
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = $1")
@@ -98,7 +122,8 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
- remote_actor_url
+ remote_actor_url,
+ watch_medium
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
)
.bind(&uid)
diff --git a/crates/adapters/sqlite-federation/src/ap_content.rs b/crates/adapters/sqlite-federation/src/ap_content.rs
index 3a0e3db..f498b5a 100644
--- a/crates/adapters/sqlite-federation/src/ap_content.rs
+++ b/crates/adapters/sqlite-federation/src/ap_content.rs
@@ -106,6 +106,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
+ watch_medium: None,
}))
}
}
diff --git a/crates/adapters/sqlite/migrations/0029_watch_medium.sql b/crates/adapters/sqlite/migrations/0029_watch_medium.sql
new file mode 100644
index 0000000..6492370
--- /dev/null
+++ b/crates/adapters/sqlite/migrations/0029_watch_medium.sql
@@ -0,0 +1 @@
+ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs
index e9407b8..d0b272a 100644
--- a/crates/adapters/sqlite/src/diary.rs
+++ b/crates/adapters/sqlite/src/diary.rs
@@ -57,7 +57,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
- r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
+ r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -87,7 +87,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
- r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
+ r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ?
@@ -161,7 +161,7 @@ impl SqliteDiaryRepository {
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
- r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
+ r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?{remote_clause}{search_clause}
@@ -308,7 +308,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let select_sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
- r.watched_at, r.created_at, r.remote_actor_url,
+ r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
@@ -395,7 +395,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
- r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
+ r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -418,7 +418,7 @@ impl DiaryRepository for SqliteDiaryRepository {
Box::pin(async_stream::stream! {
let mut rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
- r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
+ r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -475,7 +475,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
- r.watched_at, r.created_at, r.remote_actor_url,
+ r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs
index 95df579..03c7e75 100644
--- a/crates/adapters/sqlite/src/models.rs
+++ b/crates/adapters/sqlite/src/models.rs
@@ -94,6 +94,7 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
}
impl ReviewRow {
@@ -109,6 +110,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
+ let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -118,6 +120,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
+ watch_medium,
}))
}
}
@@ -139,6 +142,7 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
}
impl DiaryRow {
@@ -162,6 +166,7 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
+ watch_medium: self.watch_medium,
}
.into_domain()?;
@@ -215,6 +220,7 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option,
+ pub watch_medium: Option,
pub user_email: String,
}
@@ -235,6 +241,7 @@ impl FeedRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
+ watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))
diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/review.rs
index d29bbd4..ccd01a5 100644
--- a/crates/adapters/sqlite/src/review.rs
+++ b/crates/adapters/sqlite/src/review.rs
@@ -41,8 +41,8 @@ impl ReviewRepository for SqliteReviewRepository {
};
sqlx::query(
- "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&movie_id)
@@ -52,6 +52,7 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
+ .bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
@@ -68,7 +69,7 @@ impl ReviewRepository for SqliteReviewRepository {
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result
-
+
))}