Compare commits

...

17 Commits

Author SHA1 Message Date
Fredrik Burmester
ef851ae3f5 chore: debugging counter 2025-02-21 13:29:40 +01:00
Fredrik Burmester
349899d840 Merge branch 'develop' into feat/recently-added-notifications 2025-02-21 12:06:00 +01:00
Fredrik Burmester
e1561dbd82 fix: return NewData for initial run 2025-02-21 11:26:32 +01:00
Fredrik Burmester
e217fcff36 fix: refactor settings location for notifications 2025-02-21 10:49:26 +01:00
Fredrik Burmester
b402cf7f10 fix: add return type 2025-02-21 10:49:14 +01:00
Fredrik Burmester
f3b77b8547 fix: return correct type for notifications to work 2025-02-21 10:49:02 +01:00
Fredrik Burmester
c0643f564d fix: include count 2025-02-20 21:42:34 +01:00
Fredrik Burmester
893cedcf36 Merge branch 'develop' into feat/recently-added-notifications 2025-02-20 20:34:40 +01:00
Fredrik Burmester
69db54a66c fix: include series, increase to 30 items 2025-02-20 20:34:20 +01:00
Fredrik Burmester
268e93effb fix: time 2025-02-20 20:34:08 +01:00
Fredrik Burmester
8fe0089131 fix: task not registered properly 2025-02-20 20:34:02 +01:00
Fredrik Burmester
82ced0f101 fix: don't rerender function 2025-02-20 17:25:47 +01:00
Fredrik Burmester
5447c36bcd chore 2025-02-20 17:24:03 +01:00
Fredrik Burmester
988eede36f fix: change to 10 min as thie is the default and min value 2025-02-20 17:23:55 +01:00
Fredrik Burmester
a472d48b3b fix: 3 min not seconds interval 2025-02-20 17:21:33 +01:00
Fredrik Burmester
1fd4598ba8 chore 2025-02-20 16:31:32 +01:00
Fredrik Burmester
63ea7d128f feat: recently added notifications 2025-02-20 15:08:14 +01:00
10 changed files with 367 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
import { SettingsIndex } from "@/components/settings/SettingsIndex";
import { HomeIndex } from "@/components/settings/HomeIndex";
export default function page() {
return <SettingsIndex />;
return <HomeIndex />;
}

View File

@@ -16,11 +16,21 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useMemo } from "react";
import {
ScrollView,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as TaskManager from "expo-task-manager";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { RecentlyAddedNotificationsSettings } from "@/components/settings/RecentlyAddedNotifications";
export default function settings() {
const router = useRouter();
@@ -91,7 +101,7 @@ export default function settings() {
/>
</ListGroup>
<View className="mb-4">
<View className="">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -106,8 +116,22 @@ export default function settings() {
</ListGroup>
</View>
<RecentlyAddedNotificationsSettings />
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: "white",
overflow: "hidden",
marginVertical: 16,
opacity: 0.3,
}}
></View>
<View className="">
<StorageSettings />
</View>
</View>
</ScrollView>
);
}

View File

@@ -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,30 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
const token = getTokenFromStorage();
const url = getServerUrlFromStorage();
const user = getUserFromStorage();
const c = storage.getNumber("notification_send_for_item_ids.count");
storage.set("notification_send_for_item_ids.count", (c || 0) + 1);
console.log(
"TaskManager ~ trigger ~ recently added notifications:",
token,
url,
user?.Id
);
if (!token || !url || !user?.Id) return;
const result = await fetchAndStoreRecentlyAdded(user.Id, url, token);
if (!result) return BackgroundFetch.BackgroundFetchResult.NoData;
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -268,6 +301,32 @@ function Layout() {
checkAndRequestPermissions();
}, []);
const checkStatusAsync = async () => {
if (Platform.isTV) return;
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(
BACKGROUND_FETCH_TASK_RECENTLY_ADDED
);
};
useEffect(() => {
(async () => {
const isRegistered = await checkStatusAsync();
if (settings.recentlyAddedNotifications === false && isRegistered) {
console.log("unregisterBackgroundFetchAsyncRecentlyAdded");
unregisterBackgroundFetchAsyncRecentlyAdded();
} else if (
settings.recentlyAddedNotifications === true &&
!isRegistered
) {
console.log("registerBackgroundFetchAsyncRecentlyAdded");
registerBackgroundFetchAsyncRecentlyAdded();
}
})();
}, [settings.recentlyAddedNotifications]);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
if (settings.autoRotate === true) {

View File

@@ -53,7 +53,7 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
export const SettingsIndex = () => {
export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();

View File

@@ -22,6 +22,7 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
export const OtherSettings: React.FC = () => {
const router = useRouter();
@@ -164,6 +165,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
@@ -173,7 +175,6 @@ export const OtherSettings: React.FC = () => {
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">

View File

@@ -0,0 +1,56 @@
import settings from "@/app/(auth)/(tabs)/(home)/settings";
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import React, { useCallback, useEffect, useMemo } from "react";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import * as TaskManager from "expo-task-manager";
import * as BackgroundFetch from "expo-background-fetch";
import { useMMKVNumber } from "react-native-mmkv";
export const RecentlyAddedNotificationsSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const clearRecentlyAddedNotifications = useCallback(() => {
storage.delete(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
}, []);
const recentlyAddedNotificationsItemIds = useMemo(() => {
const s = storage.getString(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
if (!s) return [] as string[];
try {
const t: string[] = JSON.parse(s);
return t;
} catch (e) {
throw new Error("Failed to parse recently added notifications item ids");
}
}, []);
const [triggerCount, setTriggerCount] = useMMKVNumber(
"notification_send_for_item_ids.count"
);
return (
<View className="mb-4" {...props}>
<ListGroup title={"Recently Added Notifications"}>
<ListItem title={"Recently added notifications"}>
<Switch
value={settings.recentlyAddedNotifications}
onValueChange={(recentlyAddedNotifications) =>
updateSettings({ recentlyAddedNotifications })
}
/>
</ListItem>
<ListItem title={`Trigger count (${triggerCount || 0})`} />
<ListItem
textColor="red"
onPress={clearRecentlyAddedNotifications}
title={`Reset recently added notifications (${recentlyAddedNotificationsItemIds.length})`}
/>
</ListGroup>
</View>
);
};

View File

@@ -8,6 +8,9 @@ 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";
import { useCallback, useMemo } from "react";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -41,6 +44,7 @@ export const StorageSettings = () => {
return ((value / total) * 100).toFixed(2);
};
return (
<View>
<View className="flex flex-col gap-y-1">
@@ -109,6 +113,7 @@ export const StorageSettings = () => {
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>
);
};

View File

@@ -145,6 +145,7 @@ export type Settings = {
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
recentlyAddedNotifications: boolean;
};
export interface Lockable<T> {
@@ -198,6 +199,7 @@ const defaultValues: Settings = {
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
recentlyAddedNotifications: true,
};
const loadSettings = (): Partial<Settings> => {

View File

@@ -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: 10 * 60, // 10 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: 10 * 60, // 10 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);
}
}

View File

@@ -0,0 +1,187 @@
import { storage } from "@/utils/mmkv";
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import { getOrSetDeviceId } from "./device";
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
): Promise<number> {
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.27.0" },
deviceInfo: {
name: deviceName,
id,
},
});
const api = jellyfin?.createApi(basePath, token);
const response1 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Episode"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response2 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Movie"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response3 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Series"],
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 newSeries =
response3.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newIds: string[] = [];
const items = [...newEpisodes, ...newMovies, ...newSeries];
// 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 items.length;
} 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])])
);
return newIds.length;
}
} catch (error) {
console.error("Error fetching recently added items:", error);
}
return 0;
}