From 63ea7d128fe50c9f5ede8c150c12554f68343ff8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 20 Feb 2025 15:08:14 +0100 Subject: [PATCH] feat: recently added notifications --- app/(auth)/(tabs)/(home)/index.tsx | 4 +- app/_layout.tsx | 34 +++- .../{SettingsIndex.tsx => HomeIndex.tsx} | 2 +- components/settings/OtherSettings.tsx | 10 +- components/settings/StorageSettings.tsx | 11 ++ utils/atoms/settings.ts | 2 + utils/background-tasks.ts | 25 ++- utils/recently-added-notifications.ts | 168 ++++++++++++++++++ 8 files changed, 250 insertions(+), 6 deletions(-) rename components/settings/{SettingsIndex.tsx => HomeIndex.tsx} (99%) create mode 100644 utils/recently-added-notifications.ts 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); + } +}