Repository: IgnisDa/ryot (language: rust)

Issue:
Cache search responses



Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index db0b0e1fda..86649e2615 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1133,8 +1133,11 @@ dependencies = [
  "common-utils",
  "config",
  "database-models",
+ "dependent-models",
  "sea-orm",
  "sea-query",
+ "serde",
+ "serde_json",
  "tracing",
  "uuid",
 ]
@@ -1723,6 +1726,7 @@ name = "dependent-models"
 version = "0.1.0"
 dependencies = [
  "async-graphql",
+ "chrono",
  "common-models",
  "config",
  "database-models",
@@ -1732,7 +1736,9 @@ dependencies = [
  "media-models",
  "rust_decimal",
  "schematic",
+ "sea-orm",
  "serde",
+ "serde_json",
  "serde_with 3.11.0",
  "strum",
 ]
@@ -6135,6 +6141,7 @@ version = "0.1.0"
 dependencies = [
  "async-graphql",
  "common-models",
+ "dependent-models",
  "statistics-service",
  "traits",
 ]
@@ -6147,6 +6154,7 @@ dependencies = [
  "common-models",
  "database-models",
  "database-utils",
+ "dependent-models",
  "enums",
  "hashbag",
  "itertools 0.13.0",
@@ -6250,6 +6258,7 @@ dependencies = [
  "chrono-tz",
  "common-models",
  "config",
+ "dependent-models",
  "file-storage-service",
  "openidconnect",
  "sea-orm",
diff --git a/apps/frontend/app/lib/state/fitness.ts b/apps/frontend/app/lib/state/fitness.ts
index 74e88e9acd..54b7f91889 100644
--- a/apps/frontend/app/lib/state/fitness.ts
+++ b/apps/frontend/app/lib/state/fitness.ts
@@ -78,7 +78,7 @@ export type InProgressWorkout = {
 	videos: Array<string>;
 	repeatedFrom?: string;
 	supersets: Superset[];
-	images: Array<string>;
+	images: Array<Media>;
 	updateWorkoutId?: string;
 	exercises: Array<Exercise>;
 	replacingExerciseIdx?: number;
@@ -209,8 +209,8 @@ export const currentWorkoutToCreateWorkoutInput = (
 			updateWorkoutTemplateId: currentWorkout.updateWorkoutTemplateId,
 			startTime: new Date(currentWorkout.startTime).toISOString(),
 			assets: {
-				images: [...currentWorkout.images],
 				videos: [...currentWorkout.videos],
+				images: currentWorkout.images.map((m) => m.key),
 			},
 		},
 	};
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
index 5fb964bbc3..d4627ca9bc 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
@@ -217,6 +217,9 @@ export default function Page() {
 	] = useDisclosure(false);
 	const [_, setMeasurementsDrawerOpen] = useMeasurementsDrawerOpen();
 	const [currentTimer, setCurrentTimer] = useTimerAtom();
+	const [assetsModalOpened, setAssetsModalOpened] = useState<
+		string | null | undefined
+	>(undefined);
 
 	useInterval(() => {
 		const timeRemaining = dayjsLib(currentTimer?.endAt).diff(
@@ -266,6 +269,10 @@ export default function Page() {
 				<ClientOnly fallback={<Text>Loading workout...</Text>}>
 					{() => (
 						<>
+							<UploadAssetsModal
+								modalOpenedBy={assetsModalOpened}
+								closeModal={() => setAssetsModalOpened(undefined)}
+							/>
 							<TimerDrawer
 								opened={timerDrawerOpened}
 								onClose={timerDrawerClose}
@@ -282,7 +289,9 @@ export default function Page() {
 								onClose={() => setSupersetModalOpened(null)}
 							/>
 							<Stack ref={parent}>
-								<NameAndCommentInputs />
+								<NameAndCommentInputs
+									openAssetsModal={() => setAssetsModalOpened(null)}
+								/>
 								<Group>
 									<WorkoutDurationTimer />
 									<StatDisplay
@@ -497,6 +506,9 @@ export default function Page() {
 										openTimerDrawer={timerDrawerOpen}
 										reorderDrawerToggle={reorderDrawerToggle}
 										openSupersetModal={(s) => setSupersetModalOpened(s)}
+										setOpenAssetsModal={() =>
+											setAssetsModalOpened(ex.identifier)
+										}
 									/>
 								))}
 								<Group justify="center">
@@ -535,12 +547,15 @@ export default function Page() {
 	);
 }
 
-const NameAndCommentInputs = () => {
+const NameAndCommentInputs = (props: {
+	openAssetsModal: () => void;
+}) => {
 	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
 	invariant(currentWorkout);
 
 	const [name, setName] = useDebouncedState(currentWorkout.name, 500);
 	const [comment, setComment] = useDebouncedState(currentWorkout.comment, 500);
+	const workoutHasImages = currentWorkout.images.length > 0;
 
 	useDidUpdate(() => {
 		if (name)
@@ -563,12 +578,20 @@ const NameAndCommentInputs = () => {
 	return (
 		<>
 			<TextInput
-				size="sm"
 				required
+				size="sm"
 				label="Name"
 				defaultValue={name}
 				placeholder="A name for your workout"
 				onChange={(e) => setName(e.currentTarget.value)}
+				rightSection={
+					<ActionIcon
+						onClick={props.openAssetsModal}
+						variant={workoutHasImages ? "outline" : undefined}
+					>
+						<IconCamera size={30} />
+					</ActionIcon>
+				}
 			/>
 			<Textarea
 				size="sm"
@@ -937,6 +960,163 @@ const exerciseHasDetailsToShow = (
 	(details?.attributes.images.length || 0) > 0 ||
 	(userDetails?.history?.length || 0) > 0;
 
+const UploadAssetsModal = (props: {
+	closeModal: () => void;
+	modalOpenedBy: string | null | undefined;
+}) => {
+	const coreDetails = useCoreDetails();
+	const fileUploadAllowed = coreDetails.fileStorageEnabled;
+	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
+	const [cameraFacing, toggleCameraFacing] = useToggle([
+		"environment",
+		"user",
+	] as const);
+	const webcamRef = useRef<Webcam>(null);
+
+	if (!currentWorkout) return null;
+
+	const exerciseIdx = currentWorkout.exercises.findIndex(
+		(e) => e.identifier === props.modalOpenedBy,
+	);
+	const exercise =
+		exerciseIdx !== -1 ? currentWorkout.exercises[exerciseIdx] : null;
+
+	return (
+		<Modal
+			withCloseButton={false}
+			onClose={() => props.closeModal()}
+			opened={props.modalOpenedBy !== undefined}
+		>
+			<Stack>
+				<Text size="lg">
+					Images for {exercise ? exercise.name : "the workout"}
+				</Text>
+				{fileUploadAllowed ? (
+					<>
+						{isString(props.modalOpenedBy) ? (
+							exercise && exercise.images.length > 0 ? (
+								<Avatar.Group spacing="xs">
+									{exercise.images.map((i, imgIdx) => (
+										<ImageDisplay
+											key={i.key}
+											imageSrc={i.imageSrc}
+											removeImage={() => {
+												deleteUploadedAsset(i.key);
+												setCurrentWorkout(
+													produce(currentWorkout, (draft) => {
+														const images = draft.exercises[exerciseIdx].images;
+														images.splice(imgIdx, 1);
+														draft.exercises[exerciseIdx].images = images;
+													}),
+												);
+											}}
+										/>
+									))}
+								</Avatar.Group>
+							) : null
+						) : currentWorkout.images.length > 0 ? (
+							<Avatar.Group spacing="xs">
+								{currentWorkout.images.map((i, imgIdx) => (
+									<ImageDisplay
+										key={i.key}
+										imageSrc={i.imageSrc}
+										removeImage={() => {
+											deleteUploadedAsset(i.key);
+											setCurrentWorkout(
+												produce(currentWorkout, (draft) => {
+													const images = draft.images;
+													images.splice(imgIdx, 1);
+													draft.images = images;
+												}),
+											);
+										}}
+									/>
+								))}
+							</Avatar.Group>
+						) : null}
+						<Group justify="center" gap={4}>
+							<Paper radius="md" style={{ overflow: "hidden" }}>
+								<Webcam
+									ref={webcamRef}
+									height={180}
+									width={240}
+									videoConstraints={{ facingMode: cameraFacing }}
+									screenshotFormat={fileType}
+								/>
+							</Paper>
+							<Stack>
+								<ActionIcon size="xl" onClick={() => toggleCameraFacing()}>
+									<IconCameraRotate size={32} />
+								</ActionIcon>
+								<ActionIcon
+									size="xl"
+									onClick={async () => {
+										if (
+											props.modalOpenedBy === null &&
+											!coreDetails.isServerKeyValidated
+										) {
+											notifications.show({
+												color: "red",
+												message: PRO_REQUIRED_MESSAGE,
+											});
+											return;
+										}
+										const imageSrc = webcamRef.current?.getScreenshot();
+										if (imageSrc) {
+											const buffer = Buffer.from(
+												imageSrc.replace(/^data:image\/\w+;base64,/, ""),
+												"base64",
+											);
+											const fileObj = new File([buffer], "image.jpg", {
+												type: fileType,
+											});
+											const toSubmitForm = new FormData();
+											toSubmitForm.append("file", fileObj, "image.jpg");
+											try {
+												const resp = await fetch(
+													$path("/actions", { intent: "uploadWorkoutAsset" }),
+													{ method: "POST", body: toSubmitForm },
+												);
+												const data = await resp.json();
+												setCurrentWorkout(
+													produce(currentWorkout, (draft) => {
+														const media = { imageSrc, key: data.key };
+														if (exercise)
+															draft.exercises[exerciseIdx].images.push(media);
+														else draft.images.push(media);
+													}),
+												);
+											} catch {
+												notifications.show({
+													color: "red",
+													message: "Error while uploading image",
+												});
+											}
+										}
+									}}
+								>
+									<IconCamera size={32} />
+								</ActionIcon>
+							</Stack>
+						</Group>
+						<Button
+							fullWidth
+							variant="outline"
+							onClick={() => props.closeModal()}
+						>
+							Done
+						</Button>
+					</>
+				) : (
+					<Text c="red" size="sm">
+						Please set the S3 variables required to enable file uploading
+					</Text>
+				)}
+			</Stack>
+		</Modal>
+	);
+};
+
 const ExerciseDisplay = (props: {
 	exerciseIdx: number;
 	stopTimer: () => void;
@@ -944,6 +1124,7 @@ const ExerciseDisplay = (props: {
 	openTimerDrawer: () => void;
 	reorderDrawerToggle: () => void;
 	openSupersetModal: (s: string) => void;
+	setOpenAssetsModal: (identifier: string) => void;
 }) => {
 	const { isCreatingTemplate } = useLoaderData<typeof loader>();
 	const theme = useMantineTheme();
@@ -956,7 +1137,6 @@ const ExerciseDisplay = (props: {
 	const exercise = useGetExerciseAtIndex(props.exerciseIdx);
 	invariant(exercise);
 	const coreDetails = useCoreDetails();
-	const fileUploadAllowed = coreDetails.fileStorageEnabled;
 	const [detailsParent] = useAutoAnimate();
 	const { data: exerciseDetails } = useQuery(
 		getExerciseDetailsQuery(exercise.exerciseId),
@@ -970,15 +1150,6 @@ const ExerciseDisplay = (props: {
 		const sound = new Howl({ src: ["/add-set.mp3"] });
 		if (!userPreferences.fitness.logging.muteSounds) sound.play();
 	};
-	const [cameraFacing, toggleCameraFacing] = useToggle([
-		"environment",
-		"user",
-	] as const);
-	const webcamRef = useRef<Webcam>(null);
-	const [
-		assetsModalOpened,
-		{ close: assetsModalClose, toggle: assetsModalToggle },
-	] = useDisclosure(false);
 
 	const exerciseHistory = userExerciseDetails?.history;
 	const [durationCol, distanceCol, weightCol, repsCol] = match(exercise.lot)
@@ -1013,95 +1184,6 @@ const ExerciseDisplay = (props: {
 
 	return (
 		<>
-			<Modal
-				opened={assetsModalOpened}
-				onClose={assetsModalClose}
-				withCloseButton={false}
-			>
-				<Stack>
-					<Text size="lg">Images for {exercise.name}</Text>
-					{fileUploadAllowed ? (
-						<>
-							{exercise.images.length > 0 ? (
-								<Avatar.Group spacing="xs">
-									{exercise.images.map((i, imgIdx) => (
-										<ImageDisplay
-											key={i.key}
-											imageSrc={i.imageSrc}
-											removeImage={() => {
-												deleteUploadedAsset(i.key);
-												setCurrentWorkout(
-													produce(currentWorkout, (draft) => {
-														const images =
-															draft.exercises[props.exerciseIdx].images;
-														images.splice(imgIdx, 1);
-														draft.exercises[props.exerciseIdx].images = images;
-													}),
-												);
-											}}
-										/>
-									))}
-								</Avatar.Group>
-							) : null}
-							<Group justify="center" gap={4}>
-								<Paper radius="md" style={{ overflow: "hidden" }}>
-									<Webcam
-										ref={webcamRef}
-										height={180}
-										width={240}
-										videoConstraints={{ facingMode: cameraFacing }}
-										screenshotFormat={fileType}
-									/>
-								</Paper>
-								<Stack>
-									<ActionIcon size="xl" onClick={() => toggleCameraFacing()}>
-										<IconCameraRotate size={32} />
-									</ActionIcon>
-									<ActionIcon
-										size="xl"
-										onClick={async () => {
-											const imageSrc = webcamRef.current?.getScreenshot();
-											if (imageSrc) {
-												const buffer = Buffer.from(
-													imageSrc.replace(/^data:image\/\w+;base64,/, ""),
-													"base64",
-												);
-												const fileObj = new File([buffer], "image.jpg", {
-													type: fileType,
-												});
-												const toSubmitForm = new FormData();
-												toSubmitForm.append("file", fileObj, "image.jpg");
-												const resp = await fetch(
-													$path("/actions", { intent: "uploadWorkoutAsset" }),
-													{ method: "POST", body: toSubmitForm },
-												);
-												const data = await resp.json();
-												setCurrentWorkout(
-													produce(currentWorkout, (draft) => {
-														draft.exercises[props.exerciseIdx].images.push({
-															imageSrc,
-															key: data.key,
-														});
-													}),
-												);
-											}
-										}}
-									>
-										<IconCamera size={32} />
-									</ActionIcon>
-								</Stack>
-							</Group>
-							<Button fullWidth variant="outline" onClick={assetsModalClose}>
-								Done
-							</Button>
-						</>
-					) : (
-						<Text c="red" size="sm">
-							Please set the S3 variables required to enable file uploading
-						</Text>
-					)}
-				</Stack>
-			</Modal>
 			<Paper
 				radius={0}
 				style={{
@@ -1175,11 +1257,11 @@ const ExerciseDisplay = (props: {
 							</Menu.Item>
 							<Menu.Item
 								leftSection={<IconPhoto size={14} />}
-								onClick={assetsModalToggle}
+								style={isCreatingTemplate ? { display: "none" } : undefined}
+								onClick={() => props.setOpenAssetsModal(exercise.identifier)}
 								rightSection={
 									exercise.images.length > 0 ? exercise.images.length : null
 								}
-								style={isCreatingTemplate ? { display: "none" } : undefined}
 							>
 								Images
 							</Menu.Item>
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx b/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
index b1c3d04f10..a3dbf48045 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
@@ -1,6 +1,7 @@
 import {
 	ActionIcon,
 	Anchor,
+	Avatar,
 	Box,
 	Button,
 	Container,
@@ -557,6 +558,16 @@ export default function Page() {
 							<Text span>{loaderData.information.comment}</Text>
 						</Box>
 					) : null}
+					{loaderData.information.assets &&
+					loaderData.information.assets.images.length > 0 ? (
+						<Avatar.Group>
+							{loaderData.information.assets.images.map((i) => (
+								<Anchor key={i} href={i} target="_blank">
+									<Avatar src={i} />
+								</Anchor>
+							))}
+						</Avatar.Group>
+					) : null}
 					{loaderData.information.exercises.map((exercise, idx) => (
 						<ExerciseHistory
 							exerciseIdx={idx}
diff --git a/crates/enums/src/lib.rs b/crates/enums/src/lib.rs
index 0c9108f032..c4ef6e6a31 100644
--- a/crates/enums/src/lib.rs
+++ b/crates/enums/src/lib.rs
@@ -91,6 +91,18 @@ pub enum UserLot {
     Normal,
 }
 
+#[derive(
+    Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, Enum,
+)]
+#[sea_orm(
+    rs_type = "String",
+    db_type = "String(StringLen::None)",
+    rename_all = "snake_case"
+)]
+pub enum UserNotificationLot {
+    Queued,
+}
+
 #[derive(
     Debug,
     Clone,
diff --git a/crates/migrations/src/lib.rs b/crates/migrations/src/lib.rs
index bb7e56261d..764923cc0f 100644
--- a/crates/migrations/src/lib.rs
+++ b/crates/migrations/src/lib.rs
@@ -52,6 +52,8 @@ mod m20241121_changes_for_issue_445;
 mod m20241124_changes_for_issue_1118;
 mod m20241126_changes_for_issue_1113;
 mod m20241129_changes_for_issue_1114;
+mod m20241214_create_user_notification;
+mod m20241215_changes_for_issue_1131;
 
 pub use m20230410_create_metadata::Metadata as AliasedMetadata;
 pub use m20230413_create_person::Person as AliasedPerson;
@@ -125,6 +127,8 @@ impl MigratorTrait for Migrator {
             Box::new(m20241124_changes_for_issue_1118::Migration),
             Box::new(m20241129_changes_for_issue_1114::Migration),
             Box::new(m20241126_changes_for_issue_1113::Migration),
+            Box::new(m20241214_create_user_notification::Migration),
+            Box::new(m20241215_changes_for_issue_1131::Migration),
         ]
     }
 }
diff --git a/crates/migrations/src/m20240531_create_queued_notification.rs b/crates/migrations/src/m20240531_create_queued_notification.rs
index 894f3531e6..9a4a9577a7 100644
--- a/crates/migrations/src/m20240531_create_queued_notification.rs
+++ b/crates/migrations/src/m20240531_create_queued_notification.rs
@@ -1,3 +1,4 @@
+// FIXME: Remove this migration completely
 use sea_orm_migration::prelude::*;
 
 use super::m20230417_create_user::User;
diff --git a/crates/migrations/src/m20241214_create_user_notification.rs b/crates/migrations/src/m20241214_create_user_notification.rs
new file mode 100644
index 0000000000..94825d0e5f
--- /dev/null
+++ b/crates/migrations/src/m20241214_create_user_notification.rs
@@ -0,0 +1,60 @@
+use sea_orm_migration::prelude::*;
+
+use super::m20230417_create_user::User;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[derive(Iden)]
+pub enum UserNotification {
+    Id,
+    Lot,
+    Table,
+    UserId,
+    Message,
+}
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .create_table(
+                Table::create()
+                    .table(UserNotification::Table)
+                    .col(
+                        ColumnDef::new(UserNotification::Id)
+                            .uuid()
+                            .not_null()
+                            .default(PgFunc::gen_random_uuid())
+                            .primary_key(),
+                    )
+                    .col(ColumnDef::new(UserNotification::Lot).text().not_null())
+                    .col(ColumnDef::new(UserNotification::Message).text().not_null())
+                    .col(ColumnDef::new(UserNotification::UserId).text().not_null())
+                    .foreign_key(
+                        ForeignKey::create()
+                            .name("notification_to_user_foreign_key")
+                            .from(UserNotification::Table, UserNotification::UserId)
+                            .to(User::Table, User::Id)
+                            .on_delete(ForeignKeyAction::Cascade)
+                            .on_update(ForeignKeyAction::Cascade),
+                    )
+                    .to_owned(),
+            )
+            .await?;
+        manager
+            .create_index(
+                Index::create()
+                    .name("notification__user_id__index")
+                    .table(UserNotification::Table)
+                    .col(UserNotification::UserId)
+                    .to_owned(),
+            )
+            .await?;
+        Ok(())
+    }
+
+    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
+        Ok(())
+    }
+}
diff --git a/crates/migrations/src/m20241215_changes_for_issue_1131.rs b/crates/migrations/src/m20241215_changes_for_issue_1131.rs
new file mode 100644
index 0000000000..cb06552ba4
--- /dev/null
+++ b/crates/migrations/src/m20241215_changes_for_issue_1131.rs
@@ -0,0 +1,20 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        let db = manager.get_connection();
+        if manager.has_table("queued_notification").await? {
+            db.execute_unprepared(r#"DROP TABLE "queued_notification""#)
+                .await?;
+        }
+        Ok(())
+    }
+
+    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
+        Ok(())
+    }
+}
diff --git a/crates/models/common/src/lib.rs b/crates/models/common/src/lib.rs
index 6306a167c6..db06d89960 100644
--- a/crates/models/common/src/lib.rs
+++ b/crates/models/common/src/lib.rs
@@ -1,13 +1,11 @@
-use std::collections::HashMap;
-
 use async_graphql::{Enum, InputObject, SimpleObject};
 use chrono::NaiveDate;
 use educe::Educe;
 use enum_meta::{meta, Meta};
-use enums::{EntityLot, ExerciseEquipment, ExerciseMuscle, MediaLot};
+use enums::{EntityLot, MediaLot, MediaSource};
 use rust_decimal::Decimal;
 use schematic::{ConfigEnum, Schematic};
-use sea_orm::{prelude::DateTimeUtc, FromJsonQueryResult, FromQueryResult};
+use sea_orm::{prelude::DateTimeUtc, FromJsonQueryResult};
 use serde::{Deserialize, Serialize};
 use serde_with::skip_serializing_none;
 use strum::{Display, EnumIter};
@@ -161,13 +159,15 @@ pub struct NamedObject {
     pub name: String,
 }
 
-#[derive(Serialize, Deserialize, Debug, InputObject, Clone, Default)]
+#[derive(
+    Clone, Debug, Default, PartialEq, InputObject, FromJsonQueryResult, Eq, Serialize, Deserialize,
+)]
 pub struct SearchInput {
-    pub query: Option<String>,
     pub page: Option<i32>,
+    pub query: Option<String>,
 }
 
-#[derive(Serialize, Deserialize, Debug, SimpleObject, Clone, Default)]
+#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, SimpleObject, Clone, Default)]
 pub struct SearchDetails {
     pub total: i32,
     pub next_page: Option<i32>,
@@ -175,10 +175,10 @@ pub struct SearchDetails {
 
 #[derive(Debug, InputObject, Default)]
 pub struct ChangeCollectionToEntityInput {
-    pub creator_user_id: String,
-    pub collection_name: String,
     pub entity_id: String,
     pub entity_lot: EntityLot,
+    pub creator_user_id: String,
+    pub collection_name: String,
     pub information: Option<serde_json::Value>,
 }
 
@@ -251,121 +251,70 @@ pub struct UserAnalyticsInput {
     pub group_by: Option<DailyUserActivitiesResponseGroupedBy>,
 }
 
-#[skip_serializing_none]
-#[derive(
-    Debug, Default, SimpleObject, Serialize, Deserialize, Clone, FromQueryResult, PartialEq, Eq,
-)]
-pub struct DailyUserActivityItem {
-    pub day: NaiveDate,
-    pub total_metadata_review_count: i64,
-    pub total_collection_review_count: i64,
-    pub total_metadata_group_review_count: i64,
-    pub total_person_review_count: i64,
-    pub user_measurement_count: i64,
-    pub workout_count: i64,
-    pub total_workout_duration: i64,
-    pub audio_book_count: i64,
-    pub total_audio_book_duration: i64,
-    pub anime_count: i64,
-    pub book_count: i64,
-    pub total_book_pages: i64,
-    pub podcast_count: i64,
-    pub total_podcast_duration: i64,
-    pub manga_count: i64,
-    pub movie_count: i64,
-    pub total_movie_duration: i64,
-    pub show_count: i64,
-    pub total_show_duration: i64,
-    pub video_game_count: i64,
-    pub total_video_game_duration: i64,
-    pub visual_novel_count: i64,
-    pub total_visual_novel_duration: i64,
-    pub total_workout_personal_bests: i64,
-    pub total_workout_weight: i64,
-    pub total_workout_reps: i64,
-    pub total_workout_distance: i64,
-    pub total_workout_rest_time: i64,
-    pub total_metadata_count: i64,
-    pub total_review_count: i64,
-    pub total_count: i64,
-    pub total_duration: i64,
-}
-
-#[skip_serializing_none]
-#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone, PartialEq, Eq)]
-pub struct DailyUserActivitiesResponse {
-    pub total_count: i64,
-    pub item_count: usize,
-    pub total_duration: i64,
-    pub items: Vec<DailyUserActivityItem>,
-    pub grouped_by: DailyUserActivitiesResponseGroupedBy,
-}
-
-#[skip_serializing_none]
-#[derive(
-    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
-)]
-pub struct FitnessAnalyticsExercise {
-    pub count: u32,
-    pub exercise: String,
+#[derive(Clone, Debug, PartialEq, InputObject, FromJsonQueryResult, Eq, Serialize, Deserialize)]
+pub struct MetadataGroupSearchInput {
+    pub lot: MediaLot,
+    pub source: MediaSource,
+    pub search: SearchInput,
 }
 
 #[skip_serializing_none]
 #[derive(
-    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+    Debug,
+    Serialize,
+    Deserialize,
+    InputObject,
+    Clone,
+    SimpleObject,
+    FromJsonQueryResult,
+    Eq,
+    PartialEq,
+    Hash,
+    Default,
+    Schematic,
 )]
-pub struct FitnessAnalyticsMuscle {
-    pub count: u32,
-    pub muscle: ExerciseMuscle,
+#[graphql(input_name = "PersonSourceSpecificsInput")]
+#[serde(rename_all = "snake_case")]
+pub struct PersonSourceSpecifics {
+    pub is_tmdb_company: Option<bool>,
+    pub is_anilist_studio: Option<bool>,
 }
 
-#[skip_serializing_none]
-#[derive(
-    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
-)]
-pub struct FitnessAnalyticsEquipment {
-    pub count: u32,
-    pub equipment: ExerciseEquipment,
+#[derive(Clone, Debug, PartialEq, InputObject, FromJsonQueryResult, Eq, Serialize, Deserialize)]
+pub struct PeopleSearchInput {
+    pub search: SearchInput,
+    pub source: MediaSource,
+    pub source_specifics: Option<PersonSourceSpecifics>,
 }
 
-#[skip_serializing_none]
-#[derive(
-    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
-)]
-pub struct UserFitnessAnalytics {
-    pub workout_reps: i32,
-    pub workout_weight: i32,
-    pub workout_count: i32,
-    pub workout_distance: i32,
-    pub workout_duration: i32,
-    pub workout_rest_time: i32,
-    pub measurement_count: i32,
-    pub workout_personal_bests: i32,
-    pub workout_muscles: Vec<FitnessAnalyticsMuscle>,
-    pub workout_exercises: Vec<FitnessAnalyticsExercise>,
-    pub workout_equipments: Vec<FitnessAnalyticsEquipment>,
+#[derive(Clone, Debug, PartialEq, InputObject, FromJsonQueryResult, Eq, Serialize, Deserialize)]
+pub struct MetadataSearchInput {
+    pub search: SearchInput,
+    pub lot: MediaLot,
+    pub source: MediaSource,
 }
 
-#[skip_serializing_none]
-#[derive(
-    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
-)]
-pub struct UserAnalytics {
-    pub fitness: UserFitnessAnalytics,
-    pub activities: DailyUserActivitiesResponse,
-    pub hours: Vec<DailyUserActivityHourRecord>,
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct UserLevelCacheKey<T> {
+    pub input: T,
+    pub user_id: String,
 }
 
-#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
-pub struct TmdbLanguage {
-    pub iso_639_1: String,
-    pub english_name: String,
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MetadataRecentlyConsumedCacheInput {
+    pub entity_id: String,
+    pub entity_lot: EntityLot,
 }
 
-#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
-pub struct TmdbSettings {
-    pub image_url: String,
-    pub languages: Vec<TmdbLanguage>,
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ProgressUpdateCacheInput {
+    pub metadata_id: String,
+    pub show_season_number: Option<i32>,
+    pub manga_volume_number: Option<i32>,
+    pub show_episode_number: Option<i32>,
+    pub anime_episode_number: Option<i32>,
+    pub podcast_episode_number: Option<i32>,
+    pub manga_chapter_number: Option<Decimal>,
 }
 
 #[skip_serializing_none]
@@ -373,39 +322,13 @@ pub struct TmdbSettings {
 pub enum ApplicationCacheKey {
     IgdbSettings,
     TmdbSettings,
-    ListennotesSettings,
     ServerKeyValidated,
-    UserAnalyticsParameters {
-        user_id: String,
-    },
-    UserAnalytics {
-        user_id: String,
-        input: UserAnalyticsInput,
-    },
-    MetadataRecentlyConsumed {
-        user_id: String,
-        entity_id: String,
-        entity_lot: EntityLot,
-    },
-    ProgressUpdateCache {
-        user_id: String,
-        metadata_id: String,
-        show_season_number: Option<i32>,
-        show_episode_number: Option<i32>,
-        podcast_episode_number: Option<i32>,
-        anime_episode_number: Option<i32>,
-        manga_chapter_number: Option<Decimal>,
-        manga_volume_number: Option<i32>,
-    },
-}
-
-#[skip_serializing_none]
-#[derive(Clone, Debug, PartialEq, FromJsonQueryResult, Serialize, Deserialize, Eq)]
-pub enum ApplicationCacheValue {
-    Empty,
-    TmdbSettings(TmdbSettings),
-    UserAnalytics(UserAnalytics),
-    IgdbSettings { access_token: String },
-    UserAnalyticsParameters(ApplicationDateRange),
-    ListennotesSettings { genres: HashMap<i32, String> },
+    ListennotesSettings,
+    UserAnalyticsParameters(UserLevelCacheKey<()>),
+    PeopleSearch(UserLevelCacheKey<PeopleSearchInput>),
+    UserAnalytics(UserLevelCacheKey<UserAnalyticsInput>),
+    MetadataSearch(UserLevelCacheKey<MetadataSearchInput>),
+    MetadataGroupSearch(UserLevelCacheKey<MetadataGroupSearchInput>),
+    ProgressUpdateCache(UserLevelCacheKey<ProgressUpdateCacheInput>),
+    MetadataRecentlyConsumed(UserLevelCacheKey<MetadataRecentlyConsumedCacheInput>),
 }
diff --git a/crates/models/database/src/application_cache.rs b/crates/models/database/src/application_cache.rs
index f6cea0b5b1..aa66c5fb5f 100644
--- a/crates/models/database/src/application_cache.rs
+++ b/crates/models/database/src/application_cache.rs
@@ -1,6 +1,6 @@
 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1
 
-use common_models::{ApplicationCacheKey, ApplicationCacheValue};
+use common_models::ApplicationCacheKey;
 use sea_orm::entity::prelude::*;
 
 #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
@@ -12,7 +12,7 @@ pub struct Model {
     #[sea_orm(column_type = "Json")]
     pub key: ApplicationCacheKey,
     #[sea_orm(column_type = "Json")]
-    pub value: ApplicationCacheValue,
+    pub value: serde_json::Value,
     pub expires_at: Option<DateTimeUtc>,
 }
 
diff --git a/crates/models/database/src/daily_user_activity.rs b/crates/models/database/src/daily_user_activity.rs
index ca16d3abe7..513c21881a 100644
--- a/crates/models/database/src/daily_user_activity.rs
+++ b/crates/models/database/src/daily_user_activity.rs
@@ -54,6 +54,21 @@ pub struct Model {
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id",
+        on_update = "Cascade",
+        on_delete = "Cascade"
+    )]
+    User,
+}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}
 
 impl ActiveModelBehavior for ActiveModel {}
diff --git a/crates/models/database/src/lib.rs b/crates/models/database/src/lib.rs
index 127aefdca2..badc0fb457 100644
--- a/crates/models/database/src/lib.rs
+++ b/crates/models/database/src/lib.rs
@@ -22,11 +22,11 @@ pub mod metadata_to_person;
 pub mod monitored_entity;
 pub mod notification_platform;
 pub mod person;
-pub mod queued_notification;
 pub mod review;
 pub mod seen;
 pub mod user;
 pub mod user_measurement;
+pub mod user_notification;
 pub mod user_to_entity;
 pub mod workout;
 pub mod workout_template;
diff --git a/crates/models/database/src/person.rs b/crates/models/database/src/person.rs
index 5c123e1186..4df7a9f7fa 100644
--- a/crates/models/database/src/person.rs
+++ b/crates/models/database/src/person.rs
@@ -3,8 +3,9 @@
 use async_graphql::SimpleObject;
 use async_trait::async_trait;
 use chrono::NaiveDate;
+use common_models::PersonSourceSpecifics;
 use enums::MediaSource;
-use media_models::{MetadataImage, PersonSourceSpecifics, PersonStateChanges};
+use media_models::{MetadataImage, PersonStateChanges};
 use nanoid::nanoid;
 use sea_orm::{entity::prelude::*, ActiveValue};
 use serde::{Deserialize, Serialize};
diff --git a/crates/models/database/src/prelude.rs b/crates/models/database/src/prelude.rs
index 6a216d9f56..78836630f1 100644
--- a/crates/models/database/src/prelude.rs
+++ b/crates/models/database/src/prelude.rs
@@ -19,11 +19,11 @@ pub use super::metadata_to_person::Entity as MetadataToPerson;
 pub use super::monitored_entity::Entity as MonitoredEntity;
 pub use super::notification_platform::Entity as NotificationPlatform;
 pub use super::person::Entity as Person;
-pub use super::queued_notification::Entity as QueuedNotification;
 pub use super::review::Entity as Review;
 pub use super::seen::Entity as Seen;
 pub use super::user::Entity as User;
 pub use super::user_measurement::Entity as UserMeasurement;
+pub use super::user_notification::Entity as UserNotification;
 pub use super::user_to_entity::Entity as UserToEntity;
 pub use super::workout::Entity as Workout;
 pub use super::workout_template::Entity as WorkoutTemplate;
diff --git a/crates/models/database/src/queued_notification.rs b/crates/models/database/src/queued_notification.rs
deleted file mode 100644
index 2fda4c4dd0..0000000000
--- a/crates/models/database/src/queued_notification.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
-
-use async_trait::async_trait;
-use nanoid::nanoid;
-use sea_orm::{entity::prelude::*, ActiveValue};
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
-#[sea_orm(table_name = "queued_notification")]
-pub struct Model {
-    #[sea_orm(primary_key, auto_increment = false)]
-    pub id: String,
-    pub message: String,
-    pub user_id: String,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {
-    #[sea_orm(
-        belongs_to = "super::user::Entity",
-        from = "Column::UserId",
-        to = "super::user::Column::Id",
-        on_update = "Cascade",
-        on_delete = "Cascade"
-    )]
-    User,
-}
-
-impl Related<super::user::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::User.def()
-    }
-}
-
-#[async_trait]
-impl ActiveModelBehavior for ActiveModel {
-    async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
-    where
-        C: ConnectionTrait,
-    {
-        if insert {
-            self.id = ActiveValue::Set(format!("qun_{}", nanoid!(12)));
-        }
-        Ok(self)
-    }
-}
diff --git a/crates/models/database/src/user.rs b/crates/models/database/src/user.rs
index 5fdb2def6a..d02e7e6559 100644
--- a/crates/models/database/src/user.rs
+++ b/crates/models/database/src/user.rs
@@ -37,6 +37,8 @@ pub enum Relation {
     AccessLink,
     #[sea_orm(has_many = "super::collection::Entity")]
     Collection,
+    #[sea_orm(has_many = "super::daily_user_activity::Entity")]
+    DailyUserActivity,
     #[sea_orm(has_many = "super::exercise::Entity")]
     Exercise,
     #[sea_orm(has_many = "super::import_report::Entity")]
@@ -45,14 +47,14 @@ pub enum Relation {
     Integration,
     #[sea_orm(has_many = "super::notification_platform::Entity")]
     NotificationPlatform,
-    #[sea_orm(has_many = "super::queued_notification::Entity")]
-    QueuedNotification,
     #[sea_orm(has_many = "super::review::Entity")]
     Review,
     #[sea_orm(has_many = "super::seen::Entity")]
     Seen,
     #[sea_orm(has_many = "super::user_measurement::Entity")]
     UserMeasurement,
+    #[sea_orm(has_many = "super::user_notification::Entity")]
+    UserNotification,
     #[sea_orm(has_many = "super::user_to_entity::Entity")]
     UserToEntity,
     #[sea_orm(has_many = "super::workout::Entity")]
@@ -67,6 +69,18 @@ impl Related<super::access_link::Entity> for Entity {
     }
 }
 
+impl Related<super::collection::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Collection.def()
+    }
+}
+
+impl Related<super::daily_user_activity::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::DailyUserActivity.def()
+    }
+}
+
 impl Related<super::exercise::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::Exercise.def()
@@ -91,12 +105,6 @@ impl Related<super::notification_platform::Entity> for Entity {
     }
 }
 
-impl Related<super::queued_notification::Entity> for Entity {
-    fn to() -> RelationDef {
-        Relation::QueuedNotification.def()
-    }
-}
-
 impl Related<super::review::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::Review.def()
@@ -115,6 +123,12 @@ impl Related<super::user_measurement::Entity> for Entity {
     }
 }
 
+impl Related<super::user_notification::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::UserNotification.def()
+    }
+}
+
 impl Related<super::user_to_entity::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::UserToEntity.def()
diff --git a/crates/models/database/src/user_notification.rs b/crates/models/database/src/user_notification.rs
new file mode 100644
index 0000000000..dc46f661f5
--- /dev/null
+++ b/crates/models/database/src/user_notification.rs
@@ -0,0 +1,35 @@
+//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1
+
+use enums::UserNotificationLot;
+use sea_orm::entity::prelude::*;
+use uuid::Uuid;
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
+#[sea_orm(table_name = "user_notification")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: Uuid,
+    pub message: String,
+    pub user_id: String,
+    pub lot: UserNotificationLot,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id",
+        on_update = "Cascade",
+        on_delete = "Cascade"
+    )]
+    User,
+}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/crates/models/dependent/Cargo.toml b/crates/models/dependent/Cargo.toml
index ae2e1a3944..100de3ff35 100644
--- a/crates/models/dependent/Cargo.toml
+++ b/crates/models/dependent/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2021"
 
 [dependencies]
 async-graphql = { workspace = true }
+chrono = { workspace = true }
 common-models = { path = "../common" }
 config = { path = "../../config" }
 database-models = { path = "../database" }
@@ -14,6 +15,11 @@ media-models = { path = "../media" }
 fitness-models = { path = "../fitness" }
 rust_decimal = { workspace = true }
 schematic = { workspace = true }
+sea-orm = { workspace = true }
 serde = { workspace = true }
+serde_json = { workspace = true }
 serde_with = { workspace = true }
 strum = { workspace = true }
+
+[package.metadata.cargo-machete]
+ignored = ["serde_json"]
diff --git a/crates/models/dependent/src/lib.rs b/crates/models/dependent/src/lib.rs
index 063f5e135c..d0a655ecde 100644
--- a/crates/models/dependent/src/lib.rs
+++ b/crates/models/dependent/src/lib.rs
@@ -1,5 +1,11 @@
+use std::collections::HashMap;
+
 use async_graphql::{InputObject, OutputType, SimpleObject, Union};
-use common_models::{BackendError, SearchDetails};
+use chrono::NaiveDate;
+use common_models::{
+    ApplicationDateRange, BackendError, DailyUserActivitiesResponseGroupedBy,
+    DailyUserActivityHourRecord, SearchDetails,
+};
 use config::FrontendConfig;
 use database_models::{
     collection, exercise, metadata, metadata_group, person, seen, user, user_measurement,
@@ -14,17 +20,19 @@ use importer_models::ImportFailedItem;
 use media_models::{
     CreateOrUpdateCollectionInput, EntityWithLot, GenreListItem, GraphqlMediaAssets,
     ImportOrExportExerciseItem, ImportOrExportMetadataGroupItem, ImportOrExportMetadataItem,
-    ImportOrExportPersonItem, MetadataCreatorGroupedByRole, PersonDetailsGroupedByRole, ReviewItem,
+    ImportOrExportPersonItem, MetadataCreatorGroupedByRole, MetadataGroupSearchItem,
+    MetadataSearchItemResponse, PeopleSearchItem, PersonDetailsGroupedByRole, ReviewItem,
     UserDetailsError, UserMediaNextEntry, UserMetadataDetailsEpisodeProgress,
     UserMetadataDetailsShowSeasonProgress,
 };
 use rust_decimal::Decimal;
 use schematic::Schematic;
+use sea_orm::{FromJsonQueryResult, FromQueryResult};
 use serde::{Deserialize, Serialize};
 use serde_with::skip_serializing_none;
 use strum::Display;
 
-#[derive(Serialize, Deserialize, Debug, SimpleObject, Clone)]
+#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, SimpleObject, Clone)]
 #[graphql(concrete(name = "ExerciseListResults", params(fitness_models::ExerciseListItem)))]
 #[graphql(concrete(
     name = "MediaCollectionContentsResults",
@@ -281,3 +289,153 @@ pub struct UserWorkoutTemplateDetails {
     pub details: workout_template::Model,
     pub collections: Vec<collection::Model>,
 }
+
+#[skip_serializing_none]
+#[derive(
+    Debug, Default, SimpleObject, Serialize, Deserialize, Clone, FromQueryResult, PartialEq, Eq,
+)]
+pub struct DailyUserActivityItem {
+    pub day: NaiveDate,
+    pub total_metadata_review_count: i64,
+    pub total_collection_review_count: i64,
+    pub total_metadata_group_review_count: i64,
+    pub total_person_review_count: i64,
+    pub user_measurement_count: i64,
+    pub workout_count: i64,
+    pub total_workout_duration: i64,
+    pub audio_book_count: i64,
+    pub total_audio_book_duration: i64,
+    pub anime_count: i64,
+    pub book_count: i64,
+    pub total_book_pages: i64,
+    pub podcast_count: i64,
+    pub total_podcast_duration: i64,
+    pub manga_count: i64,
+    pub movie_count: i64,
+    pub total_movie_duration: i64,
+    pub show_count: i64,
+    pub total_show_duration: i64,
+    pub video_game_count: i64,
+    pub total_video_game_duration: i64,
+    pub visual_novel_count: i64,
+    pub total_visual_novel_duration: i64,
+    pub total_workout_personal_bests: i64,
+    pub total_workout_weight: i64,
+    pub total_workout_reps: i64,
+    pub total_workout_distance: i64,
+    pub total_workout_rest_time: i64,
+    pub total_metadata_count: i64,
+    pub total_review_count: i64,
+    pub total_count: i64,
+    pub total_duration: i64,
+}
+
+#[skip_serializing_none]
+#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone, PartialEq, Eq)]
+pub struct DailyUserActivitiesResponse {
+    pub total_count: i64,
+    pub item_count: usize,
+    pub total_duration: i64,
+    pub items: Vec<DailyUserActivityItem>,
+    pub grouped_by: DailyUserActivitiesResponseGroupedBy,
+}
+
+#[skip_serializing_none]
+#[derive(
+    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+)]
+pub struct FitnessAnalyticsExercise {
+    pub count: u32,
+    pub exercise: String,
+}
+
+#[skip_serializing_none]
+#[derive(
+    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+)]
+pub struct FitnessAnalyticsMuscle {
+    pub count: u32,
+    pub muscle: ExerciseMuscle,
+}
+
+#[skip_serializing_none]
+#[derive(
+    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+)]
+pub struct FitnessAnalyticsEquipment {
+    pub count: u32,
+    pub equipment: ExerciseEquipment,
+}
+
+#[skip_serializing_none]
+#[derive(
+    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+)]
+pub struct UserFitnessAnalytics {
+    pub workout_reps: i32,
+    pub workout_weight: i32,
+    pub workout_count: i32,
+    pub workout_distance: i32,
+    pub workout_duration: i32,
+    pub workout_rest_time: i32,
+    pub measurement_count: i32,
+    pub workout_personal_bests: i32,
+    pub workout_muscles: Vec<FitnessAnalyticsMuscle>,
+    pub workout_exercises: Vec<FitnessAnalyticsExercise>,
+    pub workout_equipments: Vec<FitnessAnalyticsEquipment>,
+}
+
+#[skip_serializing_none]
+#[derive(
+    Debug, SimpleObject, Serialize, Deserialize, FromJsonQueryResult, Clone, Eq, PartialEq,
+)]
+pub struct UserAnalytics {
+    pub fitness: UserFitnessAnalytics,
+    pub activities: DailyUserActivitiesResponse,
+    pub hours: Vec<DailyUserActivityHourRecord>,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
+pub struct TmdbLanguage {
+    pub iso_639_1: String,
+    pub english_name: String,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
+pub struct TmdbSettings {
+    pub image_url: String,
+    pub languages: Vec<TmdbLanguage>,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
+pub struct ListennotesSettings {
+    pub genres: HashMap<i32, String>,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
+pub struct IgdbSettings {
+    pub access_token: String,
+}
+
+#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize, Clone)]
+pub struct EmptyCacheValue {
+    pub _empty: (),
+}
+
+pub type MetadataSearchResponse = SearchResults<MetadataSearchItemResponse>;
+pub type PeopleSearchResponse = SearchResults<PeopleSearchItem>;
+pub type MetadataGroupSearchResponse = SearchResults<MetadataGroupSearchItem>;
+
+#[skip_serializing_none]
+#[derive(Clone, Debug, PartialEq, FromJsonQueryResult, Serialize, Deserialize, Eq)]
+pub enum ApplicationCacheValue {
+    Empty(EmptyCacheValue),
+    TmdbSettings(TmdbSettings),
+    IgdbSettings(IgdbSettings),
+    UserAnalytics(UserAnalytics),
+    PeopleSearch(PeopleSearchResponse),
+    MetadataSearch(MetadataSearchResponse),
+    ListennotesSettings(ListennotesSettings),
+    UserAnalyticsParameters(ApplicationDateRange),
+    MetadataGroupSearch(MetadataGroupSearchResponse),
+}
diff --git a/crates/models/media/src/lib.rs b/crates/models/media/src/lib.rs
index 0a30fa432f..6aa3b5c40f 100644
--- a/crates/models/media/src/lib.rs
+++ b/crates/models/media/src/lib.rs
@@ -4,7 +4,8 @@ use async_graphql::{Enum, InputObject, InputType, OneofObject, SimpleObject, Uni
 use boilermates::boilermates;
 use chrono::{NaiveDate, NaiveDateTime};
 use common_models::{
-    CollectionExtraInformation, IdAndNamedObject, SearchInput, StoredUrl, StringIdObject,
+    CollectionExtraInformation, IdAndNamedObject, PersonSourceSpecifics, SearchInput, StoredUrl,
+    StringIdObject,
 };
 use common_utils::deserialize_date;
 use enums::{
@@ -23,11 +24,11 @@ pub struct EntityWithLot {
     pub entity_lot: EntityLot,
 }
 
-#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)]
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, SimpleObject, Clone)]
 pub struct MetadataSearchItemResponse {
-    pub item: MetadataSearchItem,
     /// Whether the user has interacted with this media item.
     pub has_interacted: bool,
+    pub item: MetadataSearchItem,
     pub database_id: Option<String>,
 }
 
@@ -301,18 +302,18 @@ pub struct MangaSpecifics {
     pub url: Option<String>,
 }
 
-#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)]
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, SimpleObject, Clone)]
 pub struct MetadataSearchItem {
-    pub identifier: String,
     pub title: String,
+    pub identifier: String,
     pub image: Option<String>,
     pub publish_year: Option<i32>,
 }
 
-#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)]
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, SimpleObject, Clone)]
 pub struct PeopleSearchItem {
-    pub identifier: String,
     pub name: String,
+    pub identifier: String,
     pub image: Option<String>,
     pub birth_year: Option<i32>,
 }
@@ -371,34 +372,12 @@ pub enum ProgressUpdateResultUnion {
     Error(ProgressUpdateError),
 }
 
-#[skip_serializing_none]
-#[derive(
-    Debug,
-    Serialize,
-    Deserialize,
-    InputObject,
-    Clone,
-    SimpleObject,
-    FromJsonQueryResult,
-    Eq,
-    PartialEq,
-    Hash,
-    Default,
-    Schematic,
-)]
-#[graphql(input_name = "PersonSourceSpecificsInput")]
-#[serde(rename_all = "snake_case")]
-pub struct PersonSourceSpecifics {
-    pub is_tmdb_company: Option<bool>,
-    pub is_anilist_studio: Option<bool>,
-}
-
 #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, SimpleObject, Hash)]
 pub struct PartialMetadataPerson {
     pub name: String,
+    pub role: String,
     pub identifier: String,
     pub source: MediaSource,
-    pub role: String,
     pub character: Option<String>,
     #[graphql(skip)]
     pub source_specifics: Option<PersonSourceSpecifics>,
@@ -790,12 +769,12 @@ pub struct CommitPersonInput {
     pub source_specifics: Option<PersonSourceSpecifics>,
 }
 
-#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)]
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, SimpleObject, Clone)]
 pub struct MetadataGroupSearchItem {
     pub name: String,
     pub identifier: String,
-    pub image: Option<String>,
     pub parts: Option<usize>,
+    pub image: Option<String>,
 }
 
 #[skip_serializing_none]
@@ -1415,27 +1394,6 @@ pub struct PresignedPutUrlInput {
     pub prefix: String,
 }
 
-#[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
-pub struct PeopleSearchInput {
-    pub search: SearchInput,
-    pub source: MediaSource,
-    pub source_specifics: Option<PersonSourceSpecifics>,
-}
-
-#[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
-pub struct MetadataGroupSearchInput {
-    pub search: SearchInput,
-    pub lot: MediaLot,
-    pub source: MediaSource,
-}
-
-#[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
-pub struct MetadataSearchInput {
-    pub search: SearchInput,
-    pub lot: MediaLot,
-    pub source: MediaSource,
-}
-
 #[derive(Debug, Serialize, Deserialize, SimpleObject, Clone, Default)]
 pub struct GroupedCalendarEvent {
     pub events: Vec<GraphqlCalendarEvent>,
diff --git a/crates/providers/src/anilist/mod.rs b/crates/providers/src/anilist/mod.rs
index 84f747f8fb..298d920fdf 100644
--- a/crates/providers/src/anilist/mod.rs
+++ b/crates/providers/src/anilist/mod.rs
@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
 use chrono::NaiveDate;
-use common_models::{SearchDetails, StoredUrl};
+use common_models::{PersonSourceSpecifics, SearchDetails, StoredUrl};
 use common_utils::PAGE_SIZE;
 use config::AnilistPreferredLanguage;
 use dependent_models::SearchResults;
@@ -13,7 +13,7 @@ use media_models::{
     AnimeAiringScheduleSpecifics, AnimeSpecifics, MangaSpecifics, MetadataDetails,
     MetadataImageForMediaDetails, MetadataPerson, MetadataPersonRelated, MetadataSearchItem,
     MetadataVideo, MetadataVideoSource, PartialMetadataPerson, PartialMetadataWithoutId,
-    PeopleSearchItem, PersonSourceSpecifics,
+    PeopleSearchItem,
 };
 use reqwest::Client;
 use rust_decimal::Decimal;
diff --git a/crates/providers/src/audible.rs b/crates/providers/src/audible.rs
index 06c27c1aeb..660c0ceecd 100644
--- a/crates/providers/src/audible.rs
+++ b/crates/providers/src/audible.rs
@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
-use common_models::{NamedObject, SearchDetails};
+use common_models::{NamedObject, PersonSourceSpecifics, SearchDetails};
 use common_utils::{convert_date_to_year, convert_string_to_date, PAGE_SIZE};
 use convert_case::{Case, Casing};
 use database_models::metadata_group::MetadataGroupWithoutId;
@@ -12,7 +12,7 @@ use itertools::Itertools;
 use media_models::{
     AudioBookSpecifics, MetadataDetails, MetadataFreeCreator, MetadataImageForMediaDetails,
     MetadataPerson, MetadataSearchItem, PartialMetadataPerson, PartialMetadataWithoutId,
-    PeopleSearchItem, PersonSourceSpecifics,
+    PeopleSearchItem,
 };
 use paginate::Pages;
 use reqwest::Client;
diff --git a/crates/providers/src/igdb.rs b/crates/providers/src/igdb.rs
index 6e909712c4..8f9a20400f 100644
--- a/crates/providers/src/igdb.rs
+++ b/crates/providers/src/igdb.rs
@@ -5,18 +5,17 @@ use application_utils::get_base_http_client;
 use async_trait::async_trait;
 use chrono::Datelike;
 use common_models::{
-    ApplicationCacheKey, ApplicationCacheValue, IdObject, NamedObject, SearchDetails, StoredUrl,
+    ApplicationCacheKey, IdObject, NamedObject, PersonSourceSpecifics, SearchDetails, StoredUrl,
 };
 use common_utils::{ryot_log, PAGE_SIZE};
 use database_models::metadata_group::MetadataGroupWithoutId;
-use dependent_models::SearchResults;
+use dependent_models::{ApplicationCacheValue, IgdbSettings, SearchResults};
 use enums::{MediaLot, MediaSource};
 use itertools::Itertools;
 use media_models::{
     MetadataDetails, MetadataGroupSearchItem, MetadataImageForMediaDetails, MetadataPerson,
     MetadataPersonRelated, MetadataSearchItem, MetadataVideo, MetadataVideoSource,
-    PartialMetadataPerson, PartialMetadataWithoutId, PeopleSearchItem, PersonSourceSpecifics,
-    VideoGameSpecifics,
+    PartialMetadataPerson, PartialMetadataWithoutId, PeopleSearchItem, VideoGameSpecifics,
 };
 use reqwest::{
     header::{HeaderName, HeaderValue, AUTHORIZATION},
@@ -538,18 +537,19 @@ impl IgdbService {
 
     async fn get_client_config(&self) -> Result<Client> {
         let cc = &self.supporting_service.cache_service;
-        let maybe_settings = cc.get_key(ApplicationCacheKey::IgdbSettings).await.ok();
-        let access_token = if let Some(Some(ApplicationCacheValue::IgdbSettings { access_token })) =
-            maybe_settings
-        {
-            access_token
+        let maybe_settings = cc
+            .get_value::<IgdbSettings>(ApplicationCacheKey::IgdbSettings)
+            .await
+            .ok();
+        let access_token = if let Some(value) = maybe_settings.flatten() {
+            value.access_token
         } else {
             let access_token = self.get_access_token().await;
-            cc.set_with_expiry(
+            cc.set_key(
                 ApplicationCacheKey::IgdbSettings,
-                ApplicationCacheValue::IgdbSettings {
+                ApplicationCacheValue::IgdbSettings(IgdbSettings {
                     access_token: access_token.clone(),
-                },
+                }),
             )
             .await
             .ok();
diff --git a/crates/providers/src/listennotes.rs b/crates/providers/src/listennotes.rs
index 0b1ff82f0e..3ea99486fe 100644
--- a/crates/providers/src/listennotes.rs
+++ b/crates/providers/src/listennotes.rs
@@ -4,9 +4,9 @@ use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
 use chrono::Datelike;
-use common_models::{ApplicationCacheKey, ApplicationCacheValue, SearchDetails};
+use common_models::{ApplicationCacheKey, SearchDetails};
 use common_utils::{convert_naive_to_utc, PAGE_SIZE};
-use dependent_models::SearchResults;
+use dependent_models::{ApplicationCacheValue, ListennotesSettings, SearchResults};
 use enums::{MediaLot, MediaSource};
 use itertools::Itertools;
 use media_models::{
@@ -187,13 +187,11 @@ impl ListennotesService {
     async fn get_genres(&self) -> Result<HashMap<i32, String>> {
         let cc = &self.supporting_service.cache_service;
         let maybe_settings = cc
-            .get_key(ApplicationCacheKey::ListennotesSettings)
+            .get_value::<ListennotesSettings>(ApplicationCacheKey::ListennotesSettings)
             .await
             .ok();
-        let genres = if let Some(Some(ApplicationCacheValue::ListennotesSettings { genres })) =
-            maybe_settings
-        {
-            genres
+        let genres = if let Some(value) = maybe_settings.flatten() {
+            value.genres
         } else {
             #[derive(Debug, Serialize, Deserialize, Default)]
             #[serde(rename_all = "snake_case")]
@@ -216,11 +214,11 @@ impl ListennotesService {
             for genre in data.genres {
                 genres.insert(genre.id, genre.name);
             }
-            cc.set_with_expiry(
+            cc.set_key(
                 ApplicationCacheKey::ListennotesSettings,
-                ApplicationCacheValue::ListennotesSettings {
+                ApplicationCacheValue::ListennotesSettings(ListennotesSettings {
                     genres: genres.clone(),
-                },
+                }),
             )
             .await
             .ok();
diff --git a/crates/providers/src/manga_updates.rs b/crates/providers/src/manga_updates.rs
index 83761b6864..7b037a8e44 100644
--- a/crates/providers/src/manga_updates.rs
+++ b/crates/providers/src/manga_updates.rs
@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
 use chrono::NaiveDate;
-use common_models::SearchDetails;
+use common_models::{PersonSourceSpecifics, SearchDetails};
 use common_utils::PAGE_SIZE;
 use dependent_models::SearchResults;
 use enums::{MediaLot, MediaSource};
@@ -10,7 +10,7 @@ use itertools::Itertools;
 use media_models::{
     MangaSpecifics, MetadataDetails, MetadataImageForMediaDetails, MetadataPerson,
     MetadataPersonRelated, MetadataSearchItem, PartialMetadataPerson, PartialMetadataWithoutId,
-    PeopleSearchItem, PersonSourceSpecifics,
+    PeopleSearchItem,
 };
 use reqwest::Client;
 use rust_decimal::Decimal;
diff --git a/crates/providers/src/openlibrary.rs b/crates/providers/src/openlibrary.rs
index d671272919..51de37df31 100644
--- a/crates/providers/src/openlibrary.rs
+++ b/crates/providers/src/openlibrary.rs
@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
 use chrono::{Datelike, NaiveDate};
-use common_models::SearchDetails;
+use common_models::{PersonSourceSpecifics, SearchDetails};
 use common_utils::{ryot_log, PAGE_SIZE};
 use convert_case::{Case, Casing};
 use dependent_models::SearchResults;
@@ -11,7 +11,7 @@ use itertools::Itertools;
 use media_models::{
     BookSpecifics, MetadataDetails, MetadataImageForMediaDetails, MetadataPerson,
     MetadataPersonRelated, MetadataSearchItem, PartialMetadataPerson, PartialMetadataWithoutId,
-    PeopleSearchItem, PersonSourceSpecifics,
+    PeopleSearchItem,
 };
 use reqwest::Client;
 use scraper::{Html, Selector};
diff --git a/crates/providers/src/tmdb.rs b/crates/providers/src/tmdb.rs
index eda378604a..20d7d42ffb 100644
--- a/crates/providers/src/tmdb.rs
+++ b/crates/providers/src/tmdb.rs
@@ -8,12 +8,11 @@ use application_utils::{get_base_http_client, get_current_date};
 use async_trait::async_trait;
 use chrono::NaiveDate;
 use common_models::{
-    ApplicationCacheKey, ApplicationCacheValue, IdObject, NamedObject, SearchDetails, StoredUrl,
-    TmdbLanguage, TmdbSettings,
+    ApplicationCacheKey, IdObject, NamedObject, PersonSourceSpecifics, SearchDetails, StoredUrl,
 };
 use common_utils::{convert_date_to_year, convert_string_to_date, SHOW_SPECIAL_SEASON_NAMES};
 use database_models::metadata_group::MetadataGroupWithoutId;
-use dependent_models::SearchResults;
+use dependent_models::{ApplicationCacheValue, SearchResults, TmdbLanguage, TmdbSettings};
 use enums::{MediaLot, MediaSource};
 use hashbag::HashBag;
 use itertools::Itertools;
@@ -21,8 +20,8 @@ use media_models::{
     ExternalIdentifiers, MetadataDetails, MetadataGroupSearchItem, MetadataImage,
     MetadataImageForMediaDetails, MetadataPerson, MetadataPersonRelated, MetadataSearchItem,
     MetadataVideo, MetadataVideoSource, MovieSpecifics, PartialMetadataPerson,
-    PartialMetadataWithoutId, PeopleSearchItem, PersonSourceSpecifics, ShowEpisode, ShowSeason,
-    ShowSpecifics, WatchProvider,
+    PartialMetadataWithoutId, PeopleSearchItem, ShowEpisode, ShowSeason, ShowSpecifics,
+    WatchProvider,
 };
 use reqwest::{
     header::{HeaderValue, AUTHORIZATION},
@@ -1319,37 +1318,39 @@ async fn get_settings(
     supporting_service: &Arc<SupportingService>,
 ) -> Result<TmdbSettings> {
     let cc = &supporting_service.cache_service;
-    let maybe_settings = cc.get_key(ApplicationCacheKey::TmdbSettings).await.ok();
-    let tmdb_settings =
-        if let Some(Some(ApplicationCacheValue::TmdbSettings(setting))) = maybe_settings {
-            setting
-        } else {
-            #[derive(Debug, Serialize, Deserialize, Clone)]
-            struct TmdbImageConfiguration {
-                secure_base_url: String,
-            }
-            #[derive(Debug, Serialize, Deserialize, Clone)]
-            struct TmdbConfiguration {
-                images: TmdbImageConfiguration,
-            }
-            let rsp = client.get(format!("{}/configuration", URL)).send().await?;
-            let data_1: TmdbConfiguration = rsp.json().await?;
-            let rsp = client
-                .get(format!("{}/configuration/languages", URL))
-                .send()
-                .await?;
-            let data_2: Vec<TmdbLanguage> = rsp.json().await?;
-            let settings = TmdbSettings {
-                image_url: data_1.images.secure_base_url,
-                languages: data_2,
-            };
-            cc.set_with_expiry(
-                ApplicationCacheKey::TmdbSettings,
-                ApplicationCacheValue::TmdbSettings(settings.clone()),
-            )
-            .await
-            .ok();
-            settings
+    let maybe_settings = cc
+        .get_value::<TmdbSettings>(ApplicationCacheKey::TmdbSettings)
+        .await
+        .ok();
+    let tmdb_settings = if let Some(setting) = maybe_settings.flatten() {
+        setting
+    } else {
+        #[derive(Debug, Serialize, Deserialize, Clone)]
+        struct TmdbImageConfiguration {
+            secure_base_url: String,
+        }
+        #[derive(Debug, Serialize, Deserialize, Clone)]
+        struct TmdbConfiguration {
+            images: TmdbImageConfiguration,
+        }
+        let rsp = client.get(format!("{}/configuration", URL)).send().await?;
+        let data_1: TmdbConfiguration = rsp.json().await?;
+        let rsp = client
+            .get(format!("{}/configuration/languages", URL))
+            .send()
+            .await?;
+        let data_2: Vec<TmdbLanguage> = rsp.json().await?;
+        let settings = TmdbSettings {
+            image_url: data_1.images.secure_base_url,
+            languages: data_2,
         };
+        cc.set_key(
+            ApplicationCacheKey::TmdbSettings,
+            ApplicationCacheValue::TmdbSettings(settings.clone()),
+        )
+        .await
+        .ok();
+        settings
+    };
     Ok(tmdb_settings)
 }
diff --git a/crates/providers/src/vndb.rs b/crates/providers/src/vndb.rs
index 104a899ab8..42dce07244 100644
--- a/crates/providers/src/vndb.rs
+++ b/crates/providers/src/vndb.rs
@@ -1,14 +1,14 @@
 use anyhow::{anyhow, Result};
 use application_utils::get_base_http_client;
 use async_trait::async_trait;
-use common_models::{NamedObject, SearchDetails};
+use common_models::{NamedObject, PersonSourceSpecifics, SearchDetails};
 use common_utils::{convert_date_to_year, convert_string_to_date, PAGE_SIZE};
 use dependent_models::SearchResults;
 use enums::{MediaLot, MediaSource};
 use itertools::Itertools;
 use media_models::{
     MetadataDetails, MetadataImageForMediaDetails, MetadataPerson, MetadataSearchItem,
-    PartialMetadataPerson, PeopleSearchItem, PersonSourceSpecifics, VisualNovelSpecifics,
+    PartialMetadataPerson, PeopleSearchItem, VisualNovelSpecifics,
 };
 use reqwest::Client;
 use rust_decimal::Decimal;
diff --git a/crates/resolvers/miscellaneous/src/lib.rs b/crates/resolvers/miscellaneous/src/lib.rs
index 014f722188..d3d32da469 100644
--- a/crates/resolvers/miscellaneous/src/lib.rs
+++ b/crates/resolvers/miscellaneous/src/lib.rs
@@ -1,19 +1,21 @@
 use std::sync::Arc;
 
 use async_graphql::{Context, Object, Result};
-use common_models::{BackgroundJob, SearchInput, StringIdObject};
+use common_models::{
+    BackgroundJob, MetadataGroupSearchInput, MetadataSearchInput, PeopleSearchInput, SearchInput,
+    StringIdObject,
+};
 use dependent_models::{
-    CoreDetails, GenreDetails, MetadataGroupDetails, PersonDetails, SearchResults,
+    CoreDetails, GenreDetails, MetadataGroupDetails, MetadataGroupSearchResponse,
+    MetadataSearchResponse, PeopleSearchResponse, PersonDetails, SearchResults,
     UserMetadataDetails, UserMetadataGroupDetails, UserPersonDetails,
 };
 use media_models::{
     CommitMediaInput, CommitPersonInput, CreateCustomMetadataInput, CreateOrUpdateReviewInput,
     CreateReviewCommentInput, GenreDetailsInput, GenreListItem, GraphqlCalendarEvent,
-    GraphqlMetadataDetails, GroupedCalendarEvent, MetadataGroupSearchInput,
-    MetadataGroupSearchItem, MetadataGroupsListInput, MetadataListInput, MetadataPartialDetails,
-    MetadataSearchInput, MetadataSearchItemResponse, PeopleListInput, PeopleSearchInput,
-    PeopleSearchItem, ProgressUpdateInput, UpdateSeenItemInput, UserCalendarEventInput,
-    UserUpcomingCalendarEventInput,
+    GraphqlMetadataDetails, GroupedCalendarEvent, MetadataGroupsListInput, MetadataListInput,
+    MetadataPartialDetails, PeopleListInput, ProgressUpdateInput, UpdateSeenItemInput,
+    UserCalendarEventInput, UserUpcomingCalendarEventInput,
 };
 use miscellaneous_service::MiscellaneousService;
 use traits::AuthProvider;
@@ -97,7 +99,7 @@ impl MiscellaneousQuery {
         &self,
         gql_ctx: &Context<'_>,
         input: MetadataSearchInput,
-    ) -> Result<SearchResults<MetadataSearchItemResponse>> {
+    ) -> Result<MetadataSearchResponse> {
         let service = gql_ctx.data_unchecked::<Arc<MiscellaneousService>>();
         let user_id = self.user_id_from_ctx(gql_ctx).await?;
         service.metadata_search(&user_id, input).await
@@ -197,7 +199,7 @@ impl MiscellaneousQuery {
         &self,
         gql_ctx: &Context<'_>,
         input: PeopleSearchInput,
-    ) -> Result<SearchResults<PeopleSearchItem>> {
+    ) -> Result<PeopleSearchResponse> {
         let service = gql_ctx.data_unchecked::<Arc<MiscellaneousService>>();
         let user_id = self.user_id_from_ctx(gql_ctx).await?;
         service.people_search(&user_id, input).await
@@ -208,7 +210,7 @@ impl MiscellaneousQuery {
         &self,
         gql_ctx: &Context<'_>,
         input: MetadataGroupSearchInput,
-    ) -> Result<SearchResults<MetadataGroupSearchItem>> {
+    ) -> Result<MetadataGroupSearchResponse> {
         let service = gql_ctx.data_unchecked::<Arc<MiscellaneousService>>();
         let user_id = self.user_id_from_ctx(gql_ctx).await?;
         service.metadata_group_search(&user_id, input).await
diff --git a/crates/resolvers/statistics/Cargo.toml b/crates/resolvers/statistics/Cargo.toml
index 96a442d9c6..c978a482ec 100644
--- a/crates/resolvers/statistics/Cargo.toml
+++ b/crates/resolvers/statistics/Cargo.toml
@@ -6,5 +6,6 @@ edition = "2021"
 [dependencies]
 async-graphql = { workspace = true }
 common-models = { path = "../../models/common" }
+dependent-models = { path = "../../models/dependent" }
 statistics-service = { path = "../../services/statistics" }
 traits = { path = "../../traits" }
diff --git a/crates/resolvers/statistics/src/lib.rs b/crates/resolvers/statistics/src/lib.rs
index 7505faf856..ba253ddf6f 100644
--- a/crates/resolvers/statistics/src/lib.rs
+++ b/crates/resolvers/statistics/src/lib.rs
@@ -1,7 +1,8 @@
 use std::sync::Arc;
 
 use async_graphql::{Context, Object, Result};
-use common_models::{ApplicationDateRange, UserAnalytics, UserAnalyticsInput};
+use common_models::{ApplicationDateRange, UserAnalyticsInput};
+use dependent_models::UserAnalytics;
 use statistics_service::StatisticsService;
 use traits::AuthProvider;
 
diff --git a/crates/services/cache/Cargo.toml b/crates/services/cache/Cargo.toml
index 94dbc0c0a7..34aa620ac3 100644
--- a/crates/services/cache/Cargo.toml
+++ b/crates/services/cache/Cargo.toml
@@ -10,10 +10,13 @@ common-models = { path = "../../models/common" }
 common-utils = { path = "../../utils/common" }
 config = { path = "../../config" }
 database-models = { path = "../../models/database" }
+dependent-models = { path = "../../models/dependent" }
 sea-orm = { workspace = true }
 sea-query = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
 tracing = { workspace = true }
 uuid = { workspace = true }
 
 [package.metadata.cargo-machete]
-ignored = ["tracing"]
+ignored = ["serde", "tracing"]
diff --git a/crates/services/cache/src/lib.rs b/crates/services/cache/src/lib.rs
index 478896301e..de6492d171 100644
--- a/crates/services/cache/src/lib.rs
+++ b/crates/services/cache/src/lib.rs
@@ -2,11 +2,13 @@ use std::sync::Arc;
 
 use async_graphql::Result;
 use chrono::{Duration, Utc};
-use common_models::{ApplicationCacheKey, ApplicationCacheValue};
+use common_models::ApplicationCacheKey;
 use common_utils::ryot_log;
 use database_models::{application_cache, prelude::ApplicationCache};
+use dependent_models::ApplicationCacheValue;
 use sea_orm::{ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
 use sea_query::OnConflict;
+use serde::de::DeserializeOwned;
 use uuid::Uuid;
 
 pub struct CacheService {
@@ -26,20 +28,27 @@ impl CacheService {
 impl CacheService {
     fn get_expiry_for_key(&self, key: &ApplicationCacheKey) -> Option<i64> {
         match key {
-            ApplicationCacheKey::UserAnalyticsParameters { .. } => Some(8),
-            ApplicationCacheKey::UserAnalytics { .. } => Some(2),
             ApplicationCacheKey::IgdbSettings
             | ApplicationCacheKey::ListennotesSettings
             | ApplicationCacheKey::ServerKeyValidated
             | ApplicationCacheKey::TmdbSettings => None,
-            ApplicationCacheKey::MetadataRecentlyConsumed { .. } => Some(1),
+
+            ApplicationCacheKey::MetadataRecentlyConsumed { .. }
+            | ApplicationCacheKey::MetadataSearch { .. }
+            | ApplicationCacheKey::PeopleSearch { .. }
+            | ApplicationCacheKey::MetadataGroupSearch { .. } => Some(1),
+
+            ApplicationCacheKey::UserAnalytics { .. } => Some(2),
+
+            ApplicationCacheKey::UserAnalyticsParameters { .. } => Some(8),
+
             ApplicationCacheKey::ProgressUpdateCache { .. } => {
                 Some(self.config.server.progress_update_threshold)
             }
         }
     }
 
-    pub async fn set_with_expiry(
+    pub async fn set_key(
         &self,
         key: ApplicationCacheKey,
         value: ApplicationCacheValue,
@@ -48,8 +57,8 @@ impl CacheService {
         let expiry_hours = self.get_expiry_for_key(&key);
         let to_insert = application_cache::ActiveModel {
             key: ActiveValue::Set(key),
-            value: ActiveValue::Set(value),
             created_at: ActiveValue::Set(now),
+            value: ActiveValue::Set(serde_json::to_value(value)?),
             expires_at: ActiveValue::Set(expiry_hours.map(|hours| now + Duration::hours(hours))),
             ..Default::default()
         };
@@ -70,18 +79,26 @@ impl CacheService {
         Ok(insert_id)
     }
 
-    pub async fn get_key(&self, key: ApplicationCacheKey) -> Result<Option<ApplicationCacheValue>> {
+    pub async fn get_value<T: DeserializeOwned>(
+        &self,
+        key: ApplicationCacheKey,
+    ) -> Result<Option<T>> {
         let cache = ApplicationCache::find()
-            .filter(application_cache::Column::Key.eq(key))
+            .filter(application_cache::Column::Key.eq(key.clone()))
             .one(&self.db)
             .await?;
-        Ok(cache
+        let Some(db_value) = cache
             .filter(|cache| {
                 cache
                     .expires_at
                     .map_or(true, |expires_at| expires_at > Utc::now())
             })
-            .map(|m| m.value))
+            .map(|m| m.value)
+        else {
+            return Ok(None);
+        };
+        let parsed = serde_json::from_value::<T>(db_value);
+        Ok(parsed.ok())
     }
 
     pub async fn expire_key(&self, key: ApplicationCacheKey) -> Result<bool> {
diff --git a/crates/services/fitness/src/lib.rs b/crates/services/fitness/src/lib.rs
index 668ba20149..4fcdf32869 100644
--- a/crates/services/fitness/src/lib.rs
+++ b/crates/services/fitness/src/lib.rs
@@ -22,7 +22,7 @@ use dependent_models::{
     UserWorkoutTemplateDetails,
 };
 use dependent_utils::{
-    create_custom_exercise, create_or_update_workout, create_user_measurement,
+    create_custom_exercise, create_or_update_user_workout, create_user_measurement,
     db_workout_to_workout_input, get_focused_workout_summary,
 };
 use enums::{EntityLot, ExerciseLot, ExerciseSource, Visibility};
@@ -527,7 +527,7 @@ impl FitnessService {
         user_id: &String,
         input: UserWorkoutInput,
     ) -> Result<String> {
-        let identifier = create_or_update_workout(input, user_id, &self.0).await?;
+        let identifier = create_or_update_user_workout(input, user_id, &self.0).await?;
         Ok(identifier)
     }
 
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index eb69c734fb..94aec50d72 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -12,9 +12,10 @@ use async_graphql::{Error, Result};
 use background::{ApplicationJob, CoreApplicationJob};
 use chrono::{Days, Duration, NaiveDate, TimeZone, Utc};
 use common_models::{
-    ApplicationCacheKey, ApplicationCacheValue, BackendError, BackgroundJob,
-    ChangeCollectionToEntityInput, DefaultCollection, IdAndNamedObject, MediaStateChanged,
-    SearchDetails, SearchInput, StoredUrl, StringIdObject,
+    ApplicationCacheKey, BackendError, BackgroundJob, ChangeCollectionToEntityInput,
+    DefaultCollection, IdAndNamedObject, MediaStateChanged, MetadataGroupSearchInput,
+    MetadataSearchInput, PeopleSearchInput, ProgressUpdateCacheInput, SearchDetails, SearchInput,
+    StoredUrl, StringIdObject, UserLevelCacheKey,
 };
 use common_utils::{
     convert_naive_to_utc, get_first_and_last_day_of_month, ryot_log, IsFeatureEnabled,
@@ -31,9 +32,9 @@ use database_models::{
         AccessLink, ApplicationCache, CalendarEvent, Collection, CollectionToEntity, Exercise,
         Genre, ImportReport, Metadata, MetadataGroup, MetadataToGenre, MetadataToMetadata,
         MetadataToMetadataGroup, MetadataToPerson, MonitoredEntity, NotificationPlatform, Person,
-        QueuedNotification, Review, Seen, User, UserToEntity,
+        Review, Seen, User, UserNotification, UserToEntity,
     },
-    queued_notification, review, seen, user, user_to_entity,
+    review, seen, user, user_notification, user_to_entity,
 };
 use database_utils::{
     apply_collection_filter, calculate_user_activities_and_summary, entity_in_collections,
@@ -41,26 +42,27 @@ use database_utils::{
     remove_entity_from_collection, revoke_access_link, user_by_id,
 };
 use dependent_models::{
-    CoreDetails, ExerciseFilters, ExerciseParameters, ExerciseParametersLotMapping, GenreDetails,
-    MetadataBaseData, MetadataGroupDetails, MetadataLotSourceMappings, PersonDetails,
-    ProviderLanguageInformation, SearchResults, UserMetadataDetails, UserMetadataGroupDetails,
-    UserPersonDetails,
+    ApplicationCacheValue, CoreDetails, EmptyCacheValue, ExerciseFilters, ExerciseParameters,
+    ExerciseParametersLotMapping, GenreDetails, MetadataBaseData, MetadataGroupDetails,
+    MetadataGroupSearchResponse, MetadataLotSourceMappings, MetadataSearchResponse,
+    PeopleSearchResponse, PersonDetails, ProviderLanguageInformation, SearchResults,
+    UserMetadataDetails, UserMetadataGroupDetails, UserPersonDetails,
 };
 use dependent_utils::{
     add_entity_to_collection, commit_metadata, commit_metadata_group_internal,
-    commit_metadata_internal, commit_person, create_partial_metadata,
+    commit_metadata_internal, commit_person, create_partial_metadata, create_user_notification,
     deploy_after_handle_media_seen_tasks, deploy_background_job, deploy_update_metadata_job,
     first_metadata_image_as_url, get_entity_recently_consumed, get_metadata_provider,
     get_openlibrary_service, get_tmdb_non_media_service, get_users_and_cte_monitoring_entity,
     get_users_monitoring_entity, handle_after_media_seen_tasks, is_metadata_finished_by_user,
     metadata_images_as_urls, post_review, progress_update,
-    queue_media_state_changed_notification_for_user, queue_notifications_to_user_platforms,
-    refresh_collection_to_entity_association, update_metadata_and_notify_users,
+    queue_media_state_changed_notification_for_user, refresh_collection_to_entity_association,
+    update_metadata_and_notify_users,
 };
 use enums::{
     EntityLot, ExerciseEquipment, ExerciseForce, ExerciseLevel, ExerciseLot, ExerciseMechanic,
     ExerciseMuscle, MediaLot, MediaSource, MetadataToMetadataRelation, SeenState,
-    UserToMediaReason,
+    UserNotificationLot, UserToMediaReason,
 };
 use env_utils::{APP_VERSION, UNKEY_API_ID};
 use futures::TryStreamExt;
@@ -72,15 +74,14 @@ use media_models::{
     GraphqlMediaAssets, GraphqlMetadataDetails, GraphqlMetadataGroup, GraphqlVideoAsset,
     GroupedCalendarEvent, ImportOrExportItemReviewComment, MediaAssociatedPersonStateChanges,
     MediaGeneralFilter, MediaSortBy, MetadataCreator, MetadataCreatorGroupedByRole,
-    MetadataDetails, MetadataFreeCreator, MetadataGroupSearchInput, MetadataGroupSearchItem,
-    MetadataGroupsListInput, MetadataImage, MetadataImageForMediaDetails, MetadataListInput,
-    MetadataPartialDetails, MetadataSearchInput, MetadataSearchItemResponse, MetadataVideo,
-    MetadataVideoSource, PartialMetadata, PartialMetadataWithoutId, PeopleListInput,
-    PeopleSearchInput, PeopleSearchItem, PersonAndMetadataGroupsSortBy, PersonDetailsGroupedByRole,
-    PersonDetailsItemWithCharacter, PodcastSpecifics, ProgressUpdateInput, ReviewPostedEvent,
-    SeenAnimeExtraInformation, SeenPodcastExtraInformation, SeenShowExtraInformation,
-    ShowSpecifics, UpdateSeenItemInput, UserCalendarEventInput, UserMediaNextEntry,
-    UserMetadataDetailsEpisodeProgress, UserMetadataDetailsShowSeasonProgress,
+    MetadataDetails, MetadataFreeCreator, MetadataGroupsListInput, MetadataImage,
+    MetadataImageForMediaDetails, MetadataListInput, MetadataPartialDetails,
+    MetadataSearchItemResponse, MetadataVideo, MetadataVideoSource, PartialMetadata,
+    PartialMetadataWithoutId, PeopleListInput, PersonAndMetadataGroupsSortBy,
+    PersonDetailsGroupedByRole, PersonDetailsItemWithCharacter, PodcastSpecifics,
+    ProgressUpdateInput, ReviewPostedEvent, SeenAnimeExtraInformation, SeenPodcastExtraInformation,
+    SeenShowExtraInformation, ShowSpecifics, UpdateSeenItemInput, UserCalendarEventInput,
+    UserMediaNextEntry, UserMetadataDetailsEpisodeProgress, UserMetadataDetailsShowSeasonProgress,
     UserUpcomingCalendarEventInput,
 };
 use migrations::{
@@ -1659,7 +1660,14 @@ ORDER BY RANDOM() LIMIT 10;
         &self,
         user_id: &String,
         input: MetadataSearchInput,
-    ) -> Result<SearchResults<MetadataSearchItemResponse>> {
+    ) -> Result<MetadataSearchResponse> {
+        let cache_key = ApplicationCacheKey::MetadataSearch(UserLevelCacheKey {
+            input: input.clone(),
+            user_id: user_id.to_owned(),
+        });
+        if let Some(cached) = self.0.cache_service.get_value(cache_key.clone()).await? {
+            return Ok(cached);
+        }
         let query = input.search.query.unwrap_or_default();
         if query.is_empty() {
             return Ok(SearchResults {
@@ -1728,6 +1736,13 @@ ORDER BY RANDOM() LIMIT 10;
             details: results.details,
             items: data,
         };
+        self.0
+            .cache_service
+            .set_key(
+                cache_key,
+                ApplicationCacheValue::MetadataSearch(results.clone()),
+            )
+            .await?;
         Ok(results)
     }
 
@@ -1735,7 +1750,14 @@ ORDER BY RANDOM() LIMIT 10;
         &self,
         user_id: &String,
         input: PeopleSearchInput,
-    ) -> Result<SearchResults<PeopleSearchItem>> {
+    ) -> Result<PeopleSearchResponse> {
+        let cache_key = ApplicationCacheKey::PeopleSearch(UserLevelCacheKey {
+            input: input.clone(),
+            user_id: user_id.clone(),
+        });
+        if let Some(results) = self.0.cache_service.get_value(cache_key.clone()).await? {
+            return Ok(results);
+        }
         let query = input.search.query.unwrap_or_default();
         if query.is_empty() {
             return Ok(SearchResults {
@@ -1756,6 +1778,13 @@ ORDER BY RANDOM() LIMIT 10;
                 preferences.general.display_nsfw,
             )
             .await?;
+        self.0
+            .cache_service
+            .set_key(
+                cache_key,
+                ApplicationCacheValue::PeopleSearch(results.clone()),
+            )
+            .await?;
         Ok(results)
     }
 
@@ -1763,7 +1792,14 @@ ORDER BY RANDOM() LIMIT 10;
         &self,
         user_id: &String,
         input: MetadataGroupSearchInput,
-    ) -> Result<SearchResults<MetadataGroupSearchItem>> {
+    ) -> Result<MetadataGroupSearchResponse> {
+        let cache_key = ApplicationCacheKey::MetadataGroupSearch(UserLevelCacheKey {
+            input: input.clone(),
+            user_id: user_id.clone(),
+        });
+        if let Some(results) = self.0.cache_service.get_value(cache_key.clone()).await? {
+            return Ok(results);
+        }
         let query = input.search.query.unwrap_or_default();
         if query.is_empty() {
             return Ok(SearchResults {
@@ -1779,6 +1815,13 @@ ORDER BY RANDOM() LIMIT 10;
         let results = provider
             .metadata_group_search(&query, input.search.page, preferences.general.display_nsfw)
             .await?;
+        self.0
+            .cache_service
+            .set_key(
+                cache_key,
+                ApplicationCacheValue::MetadataGroupSearch(results.clone()),
+            )
+            .await?;
         Ok(results)
     }
 
@@ -1884,16 +1927,18 @@ ORDER BY RANDOM() LIMIT 10;
         let aen = si.anime_extra_information.as_ref().and_then(|d| d.episode);
         let mcn = si.manga_extra_information.as_ref().and_then(|d| d.chapter);
         let mvn = si.manga_extra_information.as_ref().and_then(|d| d.volume);
-        let cache = ApplicationCacheKey::ProgressUpdateCache {
-            show_season_number: ssn,
-            manga_volume_number: mvn,
-            show_episode_number: sen,
-            anime_episode_number: aen,
-            manga_chapter_number: mcn,
-            podcast_episode_number: pen,
+        let cache = ApplicationCacheKey::ProgressUpdateCache(UserLevelCacheKey {
             user_id: user_id.to_owned(),
-            metadata_id: si.metadata_id.clone(),
-        };
+            input: ProgressUpdateCacheInput {
+                show_season_number: ssn,
+                manga_volume_number: mvn,
+                show_episode_number: sen,
+                anime_episode_number: aen,
+                manga_chapter_number: mcn,
+                podcast_episode_number: pen,
+                metadata_id: si.metadata_id.clone(),
+            },
+        });
         self.0.cache_service.expire_key(cache).await?;
         let seen_id = si.id.clone();
         let metadata_id = si.metadata_id.clone();
@@ -2430,10 +2475,11 @@ ORDER BY RANDOM() LIMIT 10;
                 let related_users = col.find_related(UserToEntity).all(&self.0.db).await?;
                 if get_current_date(&self.0.timezone) == reminder.reminder {
                     for user in related_users {
-                        queue_notifications_to_user_platforms(
-                            &user.user_id,
+                        create_user_notification(
                             &reminder.text,
+                            &user.user_id,
                             &self.0.db,
+                            UserNotificationLot::Queued,
                         )
                         .await?;
                         remove_entity_from_collection(
@@ -2838,13 +2884,14 @@ ORDER BY RANDOM() LIMIT 10;
                 event.entity_lot,
                 Some("reviews"),
             );
-            queue_notifications_to_user_platforms(
-                &user_id,
+            create_user_notification(
                 &format!(
                     "New review posted for {} ({}, {}) by {}.",
                     event.obj_title, event.entity_lot, url, event.username
                 ),
+                &user_id,
                 &self.0.db,
+                UserNotificationLot::Queued,
             )
             .await?;
         }
@@ -2978,7 +3025,10 @@ ORDER BY RANDOM() LIMIT 10;
             Genre::delete_by_id(genre).exec(&self.0.db).await?;
         }
         ryot_log!(debug, "Deleting all queued notifications");
-        QueuedNotification::delete_many().exec(&self.0.db).await?;
+        UserNotification::delete_many()
+            .filter(user_notification::Column::Lot.eq(UserNotificationLot::Queued))
+            .exec(&self.0.db)
+            .await?;
         ryot_log!(debug, "Deleting revoked access tokens");
         AccessLink::delete_many()
             .filter(access_link::Column::IsRevoked.eq(true))
@@ -3055,8 +3105,9 @@ ORDER BY RANDOM() LIMIT 10;
         let users = User::find().all(&self.0.db).await?;
         for user_details in users {
             ryot_log!(debug, "Sending notification to user: {:?}", user_details.id);
-            let notifications = QueuedNotification::find()
-                .filter(queued_notification::Column::UserId.eq(&user_details.id))
+            let notifications = UserNotification::find()
+                .filter(user_notification::Column::UserId.eq(&user_details.id))
+                .filter(user_notification::Column::Lot.eq(UserNotificationLot::Queued))
                 .all(&self.0.db)
                 .await?;
             if notifications.is_empty() {
@@ -3138,9 +3189,9 @@ ORDER BY RANDOM() LIMIT 10;
         let is_server_key_validated = self.get_is_server_key_validated().await;
         let cs = &self.0.cache_service;
         if is_server_key_validated {
-            cs.set_with_expiry(
+            cs.set_key(
                 ApplicationCacheKey::ServerKeyValidated,
-                ApplicationCacheValue::Empty,
+                ApplicationCacheValue::Empty(EmptyCacheValue::default()),
             )
             .await?;
         } else {
diff --git a/crates/services/statistics/Cargo.toml b/crates/services/statistics/Cargo.toml
index 0cfcc27f21..ec9c1a6c1d 100644
--- a/crates/services/statistics/Cargo.toml
+++ b/crates/services/statistics/Cargo.toml
@@ -8,6 +8,7 @@ async-graphql = { workspace = true }
 common-models = { path = "../../models/common" }
 database-models = { path = "../../models/database" }
 database-utils = { path = "../../utils/database" }
+dependent-models = { path = "../../models/dependent" }
 enums = { path = "../../enums" }
 hashbag = { workspace = true }
 itertools = { workspace = true }
diff --git a/crates/services/statistics/src/lib.rs b/crates/services/statistics/src/lib.rs
index 845f66a9f5..007588debf 100644
--- a/crates/services/statistics/src/lib.rs
+++ b/crates/services/statistics/src/lib.rs
@@ -2,13 +2,16 @@ use std::{cmp::Reverse, fmt::Write, sync::Arc};
 
 use async_graphql::Result;
 use common_models::{
-    ApplicationCacheKey, ApplicationCacheValue, ApplicationDateRange, DailyUserActivitiesResponse,
-    DailyUserActivitiesResponseGroupedBy, DailyUserActivityHourRecord, DailyUserActivityItem,
-    FitnessAnalyticsEquipment, FitnessAnalyticsExercise, FitnessAnalyticsMuscle, UserAnalytics,
-    UserAnalyticsInput, UserFitnessAnalytics,
+    ApplicationCacheKey, ApplicationDateRange, DailyUserActivitiesResponseGroupedBy,
+    DailyUserActivityHourRecord, UserAnalyticsInput, UserLevelCacheKey,
 };
 use database_models::{daily_user_activity, prelude::DailyUserActivity};
 use database_utils::calculate_user_activities_and_summary;
+use dependent_models::{
+    ApplicationCacheValue, DailyUserActivitiesResponse, DailyUserActivityItem,
+    FitnessAnalyticsEquipment, FitnessAnalyticsExercise, FitnessAnalyticsMuscle, UserAnalytics,
+    UserFitnessAnalytics,
+};
 use enums::{ExerciseEquipment, ExerciseMuscle};
 use hashbag::HashBag;
 use itertools::Itertools;
@@ -35,12 +38,11 @@ impl StatisticsService {
         &self,
         user_id: &String,
     ) -> Result<ApplicationDateRange> {
-        let cache_key = ApplicationCacheKey::UserAnalyticsParameters {
+        let cache_key = ApplicationCacheKey::UserAnalyticsParameters(UserLevelCacheKey {
+            input: (),
             user_id: user_id.to_owned(),
-        };
-        if let Some(ApplicationCacheValue::UserAnalyticsParameters(cached)) =
-            self.0.cache_service.get_key(cache_key.clone()).await?
-        {
+        });
+        if let Some(cached) = self.0.cache_service.get_value(cache_key.clone()).await? {
             return Ok(cached);
         }
         let get_date = |ordering: Order| {
@@ -64,7 +66,7 @@ impl StatisticsService {
         };
         self.0
             .cache_service
-            .set_with_expiry(
+            .set_key(
                 cache_key,
                 ApplicationCacheValue::UserAnalyticsParameters(response.clone()),
             )
@@ -268,12 +270,15 @@ impl StatisticsService {
         user_id: &String,
         input: UserAnalyticsInput,
     ) -> Result<UserAnalytics> {
-        let cache_key = ApplicationCacheKey::UserAnalytics {
+        let cache_key = ApplicationCacheKey::UserAnalytics(UserLevelCacheKey {
             input: input.clone(),
             user_id: user_id.to_owned(),
-        };
-        if let Some(ApplicationCacheValue::UserAnalytics(cached)) =
-            self.0.cache_service.get_key(cache_key.clone()).await?
+        });
+        if let Some(cached) = self
+            .0
+            .cache_service
+            .get_value::<UserAnalytics>(cache_key.clone())
+            .await?
         {
             return Ok(cached);
         }
@@ -376,7 +381,7 @@ impl StatisticsService {
         };
         self.0
             .cache_service
-            .set_with_expiry(
+            .set_key(
                 cache_key,
                 ApplicationCacheValue::UserAnalytics(response.clone()),
             )
diff --git a/crates/services/supporting/Cargo.toml b/crates/services/supporting/Cargo.toml
index 8ed1112008..b6b6fa185c 100644
--- a/crates/services/supporting/Cargo.toml
+++ b/crates/services/supporting/Cargo.toml
@@ -11,6 +11,7 @@ cache-service = { path = "../../services/cache" }
 common-models = { path = "../../models/common" }
 chrono-tz = { workspace = true }
 config = { path = "../../config" }
+dependent-models = { path = "../../models/dependent" }
 file-storage-service = { path = "../../services/file-storage" }
 openidconnect = { workspace = true }
 sea-orm = { workspace = true }
diff --git a/crates/services/supporting/src/lib.rs b/crates/services/supporting/src/lib.rs
index 9a2c47f72c..5d176e5038 100644
--- a/crates/services/supporting/src/lib.rs
+++ b/crates/services/supporting/src/lib.rs
@@ -5,6 +5,7 @@ use async_graphql::Result;
 use background::{ApplicationJob, CoreApplicationJob};
 use cache_service::CacheService;
 use common_models::ApplicationCacheKey;
+use dependent_models::EmptyCacheValue;
 use file_storage_service::FileStorageService;
 use openidconnect::core::CoreClient;
 use sea_orm::DatabaseConnection;
@@ -65,7 +66,7 @@ impl SupportingService {
 
     pub async fn is_server_key_validated(&self) -> Result<bool> {
         self.cache_service
-            .get_key(ApplicationCacheKey::ServerKeyValidated)
+            .get_value::<EmptyCacheValue>(ApplicationCacheKey::ServerKeyValidated)
             .await
             .map(|v| v.is_some())
     }
diff --git a/crates/traits/src/lib.rs b/crates/traits/src/lib.rs
index 15c057fbe4..7862e1dd15 100644
--- a/crates/traits/src/lib.rs
+++ b/crates/traits/src/lib.rs
@@ -4,14 +4,14 @@ use anyhow::{bail, Result};
 use application_utils::AuthContext;
 use async_graphql::{Context, Error, Result as GraphqlResult};
 use async_trait::async_trait;
-use common_models::BackendError;
+use common_models::{BackendError, PersonSourceSpecifics};
 use common_utils::ryot_log;
 use database_models::metadata_group::MetadataGroupWithoutId;
 use database_utils::check_token;
 use dependent_models::SearchResults;
 use media_models::{
     MetadataDetails, MetadataGroupSearchItem, MetadataPerson, MetadataSearchItem,
-    PartialMetadataWithoutId, PeopleSearchItem, PersonSourceSpecifics,
+    PartialMetadataWithoutId, PeopleSearchItem,
 };
 use sea_orm::{prelude::DateTimeUtc, DatabaseConnection};
 
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 575bab68b9..02f89f8676 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -5,8 +5,9 @@ use async_graphql::{Enum, Error, Result};
 use background::{ApplicationJob, CoreApplicationJob};
 use chrono::Utc;
 use common_models::{
-    ApplicationCacheKey, ApplicationCacheValue, BackgroundJob, ChangeCollectionToEntityInput,
-    DefaultCollection, MediaStateChanged, StoredUrl, StringIdObject,
+    ApplicationCacheKey, BackgroundJob, ChangeCollectionToEntityInput, DefaultCollection,
+    MediaStateChanged, MetadataRecentlyConsumedCacheInput, ProgressUpdateCacheInput, StoredUrl,
+    StringIdObject, UserLevelCacheKey,
 };
 use common_utils::{ryot_log, EXERCISE_LOT_MAPPINGS, SHOW_SPECIAL_SEASON_NAMES};
 use database_models::{
@@ -18,16 +19,16 @@ use database_models::{
         Collection, CollectionToEntity, Exercise, Genre, Metadata, MetadataGroup, MetadataToGenre,
         MetadataToMetadata, MetadataToPerson, MonitoredEntity, Person, Seen, UserToEntity, Workout,
     },
-    queued_notification, review, seen, user_measurement, user_to_entity, workout,
+    review, seen, user_measurement, user_notification, user_to_entity, workout,
 };
 use database_utils::{
     admin_account_guard, create_or_update_collection, get_cte_column_from_lot,
     remove_entity_from_collection, schedule_user_for_workout_revision, user_by_id,
 };
-use dependent_models::{ImportCompletedItem, ImportResult};
+use dependent_models::{ApplicationCacheValue, EmptyCacheValue, ImportCompletedItem, ImportResult};
 use enums::{
     EntityLot, ExerciseLot, ExerciseSource, MediaLot, MediaSource, MetadataToMetadataRelation,
-    SeenState, Visibility, WorkoutSetPersonalBest,
+    SeenState, UserNotificationLot, Visibility, WorkoutSetPersonalBest,
 };
 use file_storage_service::FileStorageService;
 use fitness_models::{
@@ -673,13 +674,15 @@ pub async fn get_users_monitoring_entity(
     )
 }
 
-pub async fn queue_notifications_to_user_platforms(
+pub async fn create_user_notification(
+    message: &str,
     user_id: &String,
-    msg: &str,
     db: &DatabaseConnection,
+    lot: UserNotificationLot,
 ) -> Result<bool> {
-    let insert_data = queued_notification::ActiveModel {
-        message: ActiveValue::Set(msg.to_owned()),
+    let insert_data = user_notification::ActiveModel {
+        lot: ActiveValue::Set(lot),
+        message: ActiveValue::Set(message.to_owned()),
         user_id: ActiveValue::Set(user_id.to_owned()),
         ..Default::default()
     };
@@ -696,7 +699,7 @@ pub async fn queue_media_state_changed_notification_for_user(
     let (msg, change) = notification;
     let notification_preferences = user_by_id(user_id, ss).await?.preferences.notifications;
     if notification_preferences.enabled && notification_preferences.to_send.contains(change) {
-        queue_notifications_to_user_platforms(user_id, msg, &ss.db)
+        create_user_notification(msg, user_id, &ss.db, UserNotificationLot::Queued)
             .await
             .trace_ok();
     } else {
@@ -1258,13 +1261,15 @@ pub async fn mark_entity_as_recently_consumed(
     ss: &Arc<SupportingService>,
 ) -> Result<()> {
     ss.cache_service
-        .set_with_expiry(
-            ApplicationCacheKey::MetadataRecentlyConsumed {
-                entity_lot,
+        .set_key(
+            ApplicationCacheKey::MetadataRecentlyConsumed(UserLevelCacheKey {
                 user_id: user_id.to_owned(),
-                entity_id: entity_id.to_owned(),
-            },
-            ApplicationCacheValue::Empty,
+                input: MetadataRecentlyConsumedCacheInput {
+                    entity_lot,
+                    entity_id: entity_id.to_owned(),
+                },
+            }),
+            ApplicationCacheValue::Empty(EmptyCacheValue::default()),
         )
         .await?;
     Ok(())
@@ -1278,11 +1283,15 @@ pub async fn get_entity_recently_consumed(
 ) -> Result<bool> {
     Ok(ss
         .cache_service
-        .get_key(ApplicationCacheKey::MetadataRecentlyConsumed {
-            entity_lot,
-            user_id: user_id.to_owned(),
-            entity_id: entity_id.to_owned(),
-        })
+        .get_value::<EmptyCacheValue>(ApplicationCacheKey::MetadataRecentlyConsumed(
+            UserLevelCacheKey {
+                user_id: user_id.to_owned(),
+                input: MetadataRecentlyConsumedCacheInput {
+                    entity_lot,
+                    entity_id: entity_id.to_owned(),
+                },
+            },
+        ))
         .await?
         .is_some())
 }
@@ -1294,18 +1303,23 @@ pub async fn progress_update(
     input: ProgressUpdateInput,
     ss: &Arc<SupportingService>,
 ) -> Result<ProgressUpdateResultUnion> {
-    let cache = ApplicationCacheKey::ProgressUpdateCache {
+    let cache = ApplicationCacheKey::ProgressUpdateCache(UserLevelCacheKey {
         user_id: user_id.to_owned(),
-        metadata_id: input.metadata_id.clone(),
-        show_season_number: input.show_season_number,
-        show_episode_number: input.show_episode_number,
-        manga_volume_number: input.manga_volume_number,
-        manga_chapter_number: input.manga_chapter_number,
-        anime_episode_number: input.anime_episode_number,
-        podcast_episode_number: input.podcast_episode_number,
-    };
+        input: ProgressUpdateCacheInput {
+            metadata_id: input.metadata_id.clone(),
+            show_season_number: input.show_season_number,
+            show_episode_number: input.show_episode_number,
+            manga_volume_number: input.manga_volume_number,
+            manga_chapter_number: input.manga_chapter_number,
+            anime_episode_number: input.anime_episode_number,
+            podcast_episode_number: input.podcast_episode_number,
+        },
+    });
     if respect_cache {
-        let in_cache = ss.cache_service.get_key(cache.clone()).await?;
+        let in_cache = ss
+            .cache_service
+            .get_value::<EmptyCacheValue>(cache.clone())
+            .await?;
         if in_cache.is_some() {
             ryot_log!(debug, "Seen is already in cache");
             return Ok(ProgressUpdateResultUnion::Error(ProgressUpdateError {
@@ -1526,7 +1540,10 @@ pub async fn progress_update(
     let id = seen.id.clone();
     if seen.state == SeenState::Completed && respect_cache {
         ss.cache_service
-            .set_with_expiry(cache, ApplicationCacheValue::Empty)
+            .set_key(
+                cache,
+                ApplicationCacheValue::Empty(EmptyCacheValue::default()),
+            )
             .await?;
     }
     if seen.state == SeenState::Completed {
@@ -1724,7 +1741,7 @@ pub async fn get_focused_workout_summary(
 }
 
 /// Create a workout in the database and also update user and exercise associations.
-pub async fn create_or_update_workout(
+pub async fn create_or_update_user_workout(
     input: UserWorkoutInput,
     user_id: &String,
     ss: &Arc<SupportingService>,
@@ -2313,7 +2330,7 @@ where
             }
             ImportCompletedItem::Workout(workout) => {
                 need_to_schedule_user_for_workout_revision = true;
-                if let Err(err) = create_or_update_workout(workout, user_id, ss).await {
+                if let Err(err) = create_or_update_user_workout(workout, user_id, ss).await {
                     import.failed.push(ImportFailedItem {
                         lot: None,
                         error: Some(err.message),
@@ -2324,7 +2341,7 @@ where
             }
             ImportCompletedItem::ApplicationWorkout(workout) => {
                 let workout_input = db_workout_to_workout_input(workout.details);
-                match create_or_update_workout(workout_input, user_id, ss).await {
+                match create_or_update_user_workout(workout_input, user_id, ss).await {
                     Err(err) => {
                         import.failed.push(ImportFailedItem {
                             lot: None,


Test patch: