diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index 89d03d0c..dc04e43b 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -1,5 +1,5 @@
-import { SettingsIndex } from "@/components/settings/SettingsIndex";
+import { HomeIndex } from "@/components/settings/HomeIndex";
export default function page() {
- return ;
+ return ;
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 0d061be5..59a75dfd 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -4,14 +4,21 @@ import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
+ getServerUrlFromStorage,
getTokenFromStorage,
+ getUserFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
-import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
+import {
+ BACKGROUND_FETCH_TASK,
+ BACKGROUND_FETCH_TASK_RECENTLY_ADDED,
+ registerBackgroundFetchAsyncRecentlyAdded,
+ unregisterBackgroundFetchAsyncRecentlyAdded,
+} from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
@@ -41,6 +48,8 @@ import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
+import { Jellyfin } from "@jellyfin/sdk";
+import { fetchAndStoreRecentlyAdded } from "@/utils/recently-added-notifications";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -97,6 +106,23 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
+ TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
+ const token = getTokenFromStorage();
+ const url = getServerUrlFromStorage();
+ const user = getUserFromStorage();
+
+ console.log(
+ "TaskManager ~ trigger ~ recently added notifications:",
+ token,
+ url,
+ user?.Id
+ );
+
+ if (!token || !url || !user?.Id) return;
+
+ fetchAndStoreRecentlyAdded(user.Id, url, token);
+ });
+
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -278,6 +304,12 @@ function Layout() {
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
+
+ if (settings.recentlyAddedNotifications === true) {
+ registerBackgroundFetchAsyncRecentlyAdded();
+ } else {
+ unregisterBackgroundFetchAsyncRecentlyAdded();
+ }
}, [settings]);
useEffect(() => {
diff --git a/components/settings/SettingsIndex.tsx b/components/settings/HomeIndex.tsx
similarity index 99%
rename from components/settings/SettingsIndex.tsx
rename to components/settings/HomeIndex.tsx
index 964bee31..44eebc27 100644
--- a/components/settings/SettingsIndex.tsx
+++ b/components/settings/HomeIndex.tsx
@@ -53,7 +53,7 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
-export const SettingsIndex = () => {
+export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index e14c00cd..c408d333 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -164,6 +164,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
+
{
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
- selected={settings.defaultBitrate}
title={
@@ -202,6 +202,14 @@ export const OtherSettings: React.FC = () => {
}
/>
+
+
+ updateSettings({ recentlyAddedNotifications })
+ }
+ />
+
);
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
index 8525afd0..5ca3b229 100644
--- a/components/settings/StorageSettings.tsx
+++ b/components/settings/StorageSettings.tsx
@@ -8,6 +8,8 @@ import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
+import { storage } from "@/utils/mmkv";
+import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -109,6 +111,15 @@ export const StorageSettings = () => {
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
+
+ {
+ storage.set(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY, "[]");
+ }}
+ title={"Reset recently added notifications"}
+ />
+
);
};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 52ccf719..8fe359fd 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -145,6 +145,7 @@ export type Settings = {
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
+ recentlyAddedNotifications: boolean;
};
export interface Lockable {
@@ -198,6 +199,7 @@ const defaultValues: Settings = {
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
+ recentlyAddedNotifications: true,
};
const loadSettings = (): Partial => {
diff --git a/utils/background-tasks.ts b/utils/background-tasks.ts
index 0e9a408a..9b0c9e72 100644
--- a/utils/background-tasks.ts
+++ b/utils/background-tasks.ts
@@ -8,7 +8,7 @@ export const BACKGROUND_FETCH_TASK = "background-fetch";
export async function registerBackgroundFetchAsync() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
- minimumInterval: 60 * 1, // 1 minutes
+ minimumInterval: 3 * 1, // 1 minutes
stopOnTerminate: false, // android only,
startOnBoot: false, // android only
});
@@ -24,3 +24,26 @@ export async function unregisterBackgroundFetchAsync() {
console.log("Error unregistering background fetch task", error);
}
}
+
+export const BACKGROUND_FETCH_TASK_RECENTLY_ADDED =
+ "background-fetch-recently-added";
+
+export async function registerBackgroundFetchAsyncRecentlyAdded() {
+ try {
+ BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, {
+ minimumInterval: 60 * 60, // 60 minutes
+ stopOnTerminate: false, // android only,
+ startOnBoot: true, // android only
+ });
+ } catch (error) {
+ console.log("Error registering background fetch task", error);
+ }
+}
+
+export async function unregisterBackgroundFetchAsyncRecentlyAdded() {
+ try {
+ BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED);
+ } catch (error) {
+ console.log("Error unregistering background fetch task", error);
+ }
+}
diff --git a/utils/recently-added-notifications.ts b/utils/recently-added-notifications.ts
new file mode 100644
index 00000000..0da66604
--- /dev/null
+++ b/utils/recently-added-notifications.ts
@@ -0,0 +1,168 @@
+import { Api, Jellyfin } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
+import { storage } from "@/utils/mmkv";
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+import { getAuthHeaders } from "./jellyfin/jellyfin";
+import { getOrSetDeviceId } from "./device";
+import { getDeviceName } from "react-native-device-info";
+
+export const RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY =
+ "notification_send_for_item_ids";
+
+const acceptedIemTypes = ["Movie", "Episode", "Series"];
+
+async function sendNewItemNotification(item: BaseItemDto) {
+ if (Platform.isTV) return;
+ if (!item.Type) return;
+
+ if (!acceptedIemTypes.includes(item.Type)) return;
+
+ if (item.Type === "Movie")
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: `New Movie Added`,
+ body: `${item.Name} (${item.ProductionYear})`,
+ },
+ trigger: null,
+ });
+ else if (item.Type === "Episode")
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: `New Episode Added`,
+ body: `${item.SeriesName} - ${item.Name}`,
+ },
+ trigger: null,
+ });
+ else if (item.Type === "Series")
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: `New Series Added`,
+ body: `${item.Name} (${item.ProductionYear})`,
+ },
+ trigger: null,
+ });
+}
+
+/**
+ * Fetches recently added items from Jellyfin and sends notifications for new content.
+ *
+ * This function performs the following operations:
+ * 1. Retrieves previously notified item IDs from storage
+ * 2. Connects to Jellyfin server using provided credentials
+ * 3. Fetches 5 most recent episodes and 5 most recent movies
+ * 4. Checks for new items that haven't been notified before
+ * 5. Sends notifications for new items
+ * 6. Updates storage with new item IDs
+ *
+ * Note: On first run (when no previous notifications exist), it will store all
+ * current items without sending notifications to avoid mass-notifications.
+ *
+ * @param userId - The Jellyfin user ID to fetch items for
+ * @param basePath - The base URL of the Jellyfin server
+ * @param token - The authentication token for the Jellyfin server
+ */
+export async function fetchAndStoreRecentlyAdded(
+ userId: string,
+ basePath: string,
+ token: string
+) {
+ try {
+ const deviceName = await getDeviceName();
+ const id = getOrSetDeviceId();
+
+ // Get stored items
+ const _alreadySentItemIds = storage.getString(
+ RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY
+ );
+ const alreadySentItemIds: string[] = _alreadySentItemIds
+ ? JSON.parse(_alreadySentItemIds)
+ : [];
+
+ console.log(
+ "fetchAndStoreRecentlyAdded ~ notifications stored:",
+ alreadySentItemIds.length
+ );
+
+ const jellyfin = new Jellyfin({
+ clientInfo: { name: "Streamyfin", version: "0.26.1" },
+ deviceInfo: {
+ name: deviceName,
+ id,
+ },
+ });
+
+ const api = jellyfin?.createApi(basePath, token);
+
+ const response1 = await getItemsApi(api).getItems({
+ userId: userId,
+ limit: 5,
+ recursive: true,
+ includeItemTypes: ["Episode"],
+ sortOrder: ["Descending"],
+ sortBy: ["DateCreated"],
+ });
+ const response2 = await getItemsApi(api).getItems({
+ userId: userId,
+ limit: 5,
+ recursive: true,
+ includeItemTypes: ["Movie"],
+ sortOrder: ["Descending"],
+ sortBy: ["DateCreated"],
+ });
+
+ const newEpisodes =
+ response1.data.Items?.map((item) => ({
+ Id: item.Id,
+ Name: item.Name,
+ DateCreated: item.DateCreated,
+ Type: item.Type,
+ })) ?? [];
+
+ const newMovies =
+ response2.data.Items?.map((item) => ({
+ Id: item.Id,
+ Name: item.Name,
+ DateCreated: item.DateCreated,
+ Type: item.Type,
+ })) ?? [];
+
+ const newIds: string[] = [];
+ const items = [...newEpisodes, ...newMovies];
+
+ // Don't send initial mass-notifications if there are no previously sent notifications
+ if (alreadySentItemIds.length === 0) {
+ // Store all items as sent (since these items are already in the users library)
+ storage.set(
+ RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
+ JSON.stringify(items.map((item) => item.Id))
+ );
+ return;
+ } else {
+ // Only send notifications for items that haven't been sent yet
+ for (const newItem of items) {
+ const alreadySentNotificationFor = alreadySentItemIds.some(
+ (id) => id === newItem.Id
+ );
+
+ if (!alreadySentNotificationFor) {
+ const fullItem = await getUserLibraryApi(api).getItem({
+ itemId: newItem.Id!,
+ userId: userId,
+ });
+
+ await sendNewItemNotification(fullItem.data);
+ newIds.push(newItem.Id!);
+ }
+ }
+ // Store all new items as sent, so that we don't send notifications for them again
+ storage.set(
+ RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
+ JSON.stringify([...new Set([...alreadySentItemIds, ...newIds])])
+ );
+ }
+ } catch (error) {
+ console.error("Error fetching recently added items:", error);
+ }
+}