Compare commits

...

19 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
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +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
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Fredrik Burmester
63ea7d128f feat: recently added notifications 2025-02-20 15:08:14 +01:00
16 changed files with 837 additions and 19 deletions

View File

@@ -43,6 +43,7 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.26.1",
"version": "0.27.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",

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,7 +116,21 @@ export default function settings() {
</ListGroup>
</View>
<StorageSettings />
<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

@@ -32,20 +32,20 @@
}
},
"production": {
"channel": "0.26.1",
"channel": "0.27.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.26.1",
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"channel": "0.26.1",
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -5,9 +5,10 @@ import de from "./translations/de.json";
import en from "./translations/en.json";
import es from "./translations/es.json";
import fr from "./translations/fr.json";
import it from "./translations/it.json";
import ja from "./translations/ja.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json";
import it from "./translations/it.json";
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization";
@@ -16,9 +17,10 @@ export const APP_LANGUAGES = [
{ label: "English", value: "en" },
{ label: "Español", value: "es" },
{ label: "Français", value: "fr" },
{ label: "Italiano", value: "it" },
{ label: "日本語", value: "ja" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" },
{ label: "Italiano", value: "it" },
{ label: "繁體中文", value: "zh-TW" },
];
@@ -29,9 +31,10 @@ i18n.use(initReactI18next).init({
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
it: { translation: it },
ja: { translation: ja },
nl: { translation: nl },
sv: { translation: sv },
it: { translation: it },
"zh-TW": { translation: zhTW },
},

View File

@@ -61,7 +61,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.26.1" },
clientInfo: { name: "Streamyfin", version: "0.27.0" },
deviceInfo: {
name: deviceName,
id,
@@ -90,7 +90,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.26.1"`,
}, DeviceId="${deviceId}", Version="0.27.0"`,
};
}, [deviceId]);

457
translations/ja.json Normal file
View File

@@ -0,0 +1,457 @@
{
"login": {
"username_required": "ユーザー名は必須です",
"error_title": "エラー",
"login_title": "ログイン",
"login_to_title": "ログイン先",
"username_placeholder": "ユーザー名",
"password_placeholder": "パスワード",
"login_button": "ログイン",
"quick_connect": "クイックコネクト",
"enter_code_to_login": "ログインするにはコード {{code}} を入力してください",
"failed_to_initiate_quick_connect": "クイックコネクトを開始できませんでした",
"got_it": "了解",
"connection_failed": "接続に失敗しました",
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
"an_unexpected_error_occured": "予期しないエラーが発生しました",
"change_server": "サーバーの変更",
"invalid_username_or_password": "ユーザー名またはパスワードが無効です",
"user_does_not_have_permission_to_log_in": "ユーザーにログイン権限がありません",
"server_is_taking_too_long_to_respond_try_again_later": "サーバーの応答に時間がかかりすぎています。しばらくしてからもう一度お試しください。",
"server_received_too_many_requests_try_again_later": "サーバーにリクエストが多すぎます。後でもう一度お試しください。",
"there_is_a_server_error": "サーバーエラーが発生しました",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか"
},
"server": {
"enter_url_to_jellyfin_server": "JellyfinサーバーのURLを入力してください",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "接続",
"previous_servers": "前のサーバー",
"clear_button": "クリア",
"search_for_local_servers": "ローカルサーバーを検索",
"searching": "検索中...",
"servers": "サーバー"
},
"home": {
"no_internet": "インターネット接続がありません",
"no_items": "アイテムはありません",
"no_internet_message": "心配しないでください。\nダウンロードしたコンテンツは引き続き視聴できます。",
"go_to_downloads": "ダウンロードに移動",
"oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る",
"next_up": "次の動画",
"recently_added_in": "{{libraryName}}に最近追加された",
"suggested_movies": "おすすめ映画",
"suggested_episodes": "おすすめエピソード",
"intro": {
"welcome_to_streamyfin": "Streamyfinへようこそ",
"a_free_and_open_source_client_for_jellyfin": "Jellyfinのためのフリーでオープンソースのクライアント。",
"features_title": "特長",
"features_description": "Streamyfinには多くの機能があり、設定メニューで見つけることができるさまざまなソフトウェアと統合されています。これには以下が含まれます。",
"jellyseerr_feature_description": "Jellyseerrインスタンスに接続し、アプリ内で直接映画をリクエストします。",
"downloads_feature_title": "ダウンロード",
"downloads_feature_description": "映画やテレビ番組をダウンロードしてオフラインで視聴します。デフォルトの方法を使用するか、バックグラウンドでファイルをダウンロードするために最適化されたサーバーをインストールしてください。",
"chromecast_feature_description": "映画とテレビ番組をChromecastデバイスにキャストします。",
"centralised_settings_plugin_title": "集中設定プラグイン",
"centralised_settings_plugin_description": "Jellyfinサーバーから設定を構成します。すべてのユーザーのすべてのクライアント設定は自動的に同期されます。",
"done_button": "完了",
"go_to_settings_button": "設定に移動",
"read_more": "続きを読む"
},
"settings": {
"settings_title": "設定",
"log_out_button": "ログアウト",
"user_info": {
"user_info_title": "ユーザー情報",
"user": "ユーザー",
"server": "サーバー",
"token": "トークン",
"app_version": "アプリバージョン"
},
"quick_connect": {
"quick_connect_title": "クイックコネクト",
"authorize_button": "クイックコネクトを承認する",
"enter_the_quick_connect_code": "クイックコネクトコードを入力...",
"success": "成功しました",
"quick_connect_autorized": "クイックコネクトが承認されました",
"error": "エラー",
"invalid_code": "無効なコードです",
"authorize": "承認"
},
"media_controls": {
"media_controls_title": "メディアコントロール",
"forward_skip_length": "スキップの長さ",
"rewind_length": "巻き戻しの長さ",
"seconds_unit": "s"
},
"audio": {
"audio_title": "オーディオ",
"set_audio_track": "前のアイテムからオーディオトラックを設定",
"audio_language": "オーディオ言語",
"audio_hint": "デフォルトのオーディオ言語を選択します。",
"none": "なし",
"language": "言語"
},
"subtitles": {
"subtitle_title": "字幕",
"subtitle_language": "字幕の言語",
"subtitle_mode": "字幕モード",
"set_subtitle_track": "前のアイテムから字幕トラックを設定",
"subtitle_size": "字幕サイズ",
"subtitle_hint": "字幕設定を構成します。",
"none": "なし",
"language": "言語",
"loading": "ロード中",
"modes": {
"Default": "デフォルト",
"Smart": "スマート",
"Always": "常に",
"None": "なし",
"OnlyForced": "強制のみ"
}
},
"other": {
"other_title": "その他",
"auto_rotate": "画面の自動回転",
"video_orientation": "動画の向き",
"orientation": "向き",
"orientations": {
"DEFAULT": "デフォルト",
"ALL": "すべて",
"PORTRAIT": "縦",
"PORTRAIT_UP": "縦向き(上)",
"PORTRAIT_DOWN": "縦方向",
"LANDSCAPE": "横方向",
"LANDSCAPE_LEFT": "横方向 左",
"LANDSCAPE_RIGHT": "横方向 右",
"OTHER": "その他",
"UNKNOWN": "不明"
},
"safe_area_in_controls": "コントロールの安全エリア",
"show_custom_menu_links": "カスタムメニューのリンクを表示",
"hide_libraries": "ライブラリを非表示",
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
"disable_haptic_feedback": "触覚フィードバックを無効にする"
},
"downloads": {
"downloads_title": "ダウンロード",
"download_method": "ダウンロード方法",
"remux_max_download": "Remux最大ダウンロード数",
"auto_download": "自動ダウンロード",
"optimized_versions_server": "Optimized versionsサーバー",
"save_button": "保存",
"optimized_server": "Optimizedサーバー",
"optimized": "最適化",
"default": "デフォルト",
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート"
},
"plugins": {
"plugins_title": "プラグイン",
"jellyseerr": {
"jellyseerr_warning": "この統合はまだ初期段階です。状況が変化する可能性があります。",
"server_url": "サーバーURL",
"server_url_hint": "例: http(s)://your-host.url\n(必要に応じてポートを追加)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "パスワード",
"password_placeholder": "Jellyfinユーザー {{username}} のパスワードを入力してください",
"save_button": "保存",
"clear_button": "クリア",
"login_button": "ログイン",
"total_media_requests": "メディアリクエストの合計",
"movie_quota_limit": "映画のクオータ制限",
"movie_quota_days": "映画のクオータ日数",
"tv_quota_limit": "テレビのクオータ制限",
"tv_quota_days": "テレビのクオータ日数",
"reset_jellyseerr_config_button": "Jellyseerrの設定をリセット",
"unlimited": "無制限"
},
"marlin_search": {
"enable_marlin_search": "マーリン検索を有効にする ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート",
"marlin_search_hint": "MarlinサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_marlin": "Marlinについて詳しく読む。",
"save_button": "保存",
"toasts": {
"saved": "保存しました"
}
}
},
"storage": {
"storage_title": "ストレージ",
"app_usage": "アプリ {{usedSpace}}%",
"phone_usage": "電話 {{availableSpace}}%",
"size_used": "{{used}} / {{total}} 使用済み",
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除"
},
"intro": {
"show_intro": "イントロを表示",
"reset_intro": "イントロをリセット"
},
"logs": {
"logs_title": "ログ",
"no_logs_available": "ログがありません",
"delete_all_logs": "すべてのログを削除"
},
"languages": {
"title": "言語",
"app_language": "アプリの言語",
"app_language_description": "アプリの言語を選択。",
"system": "システム"
},
"toasts": {
"error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です",
"connected": "接続済み",
"could_not_connect": "接続できません",
"invalid_url": "無効なURL"
}
},
"downloads": {
"downloads_title": "ダウンロード",
"tvseries": "TVシリーズ",
"movies": "映画",
"queue": "キュー",
"queue_hint": "アプリを再起動するとキューとダウンロードは失われます",
"no_items_in_queue": "キューにアイテムがありません",
"no_downloaded_items": "ダウンロードしたアイテムはありません",
"delete_all_movies_button": "すべての映画を削除",
"delete_all_tvseries_button": "すべてのシリーズを削除",
"delete_all_button": "すべて削除",
"active_download": "アクティブなダウンロード",
"no_active_downloads": "アクティブなダウンロードはありません",
"active_downloads": "アクティブなダウンロード",
"new_app_version_requires_re_download": "新しいアプリバージョンでは再ダウンロードが必要です",
"new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。",
"back": "戻る",
"delete": "削除",
"something_went_wrong": "問題が発生しました",
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
"eta": "ETA {{eta}}",
"methods": "方法",
"toasts": {
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
"download_cancelled": "ダウンロードをキャンセルしました",
"could_not_cancel_download": "ダウンロードをキャンセルできませんでした",
"download_completed": "ダウンロードが完了しました",
"download_started_for": "{{item}}のダウンロードが開始されました",
"item_is_ready_to_be_downloaded": "{{item}}をダウンロードする準備ができました",
"download_stated_for_item": "{{item}}のダウンロードが開始されました",
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
"download_completed_for_item": "{{item}}のダウンロードが完了しました",
"queued_item_for_optimization": "{{item}}をoptimizeのキューに追加しました",
"failed_to_start_download_for_item": "{{item}}のダウンロードを開始できませんでした: {{message}}",
"server_responded_with_status_code": "サーバーはステータス{{statusCode}}で応答しました",
"no_response_received_from_server": "サーバーからの応答がありません",
"error_setting_up_the_request": "リクエストの設定中にエラーが発生しました",
"failed_to_start_download_for_item_unexpected_error": "{{item}}のダウンロードを開始できませんでした: 予期しないエラーが発生しました",
"all_files_folders_and_jobs_deleted_successfully": "すべてのファイル、フォルダ、ジョブが正常に削除されました",
"an_error_occured_while_deleting_files_and_jobs": "ファイルとジョブの削除中にエラーが発生しました",
"go_to_downloads": "ダウンロードに移動"
}
}
},
"search": {
"search_here": "ここを検索...",
"search": "検索...",
"x_items": "{{count}}のアイテム",
"library": "ライブラリ",
"discover": "見つける",
"no_results": "結果はありません",
"no_results_found_for": "結果が見つかりませんでした:",
"movies": "映画",
"series": "シリーズ",
"episodes": "エピソード",
"collections": "コレクション",
"actors": "俳優",
"request_movies": "映画をリクエスト",
"request_series": "シリーズをリクエスト",
"recently_added": "最近の追加",
"recent_requests": "最近のリクエスト",
"plex_watchlist": "Plexウォッチリスト",
"trending": "トレンド",
"popular_movies": "人気の映画",
"movie_genres": "映画のジャンル",
"upcoming_movies": "今後リリースされる映画",
"studios": "制作会社",
"popular_tv": "人気のテレビ番組",
"tv_genres": "シリーズのジャンル",
"upcoming_tv": "今後リリースされるシリーズ",
"networks": "ネットワーク",
"tmdb_movie_keyword": "TMDB映画キーワード",
"tmdb_movie_genre": "TMDB映画ジャンル",
"tmdb_tv_keyword": "TMDBシリーズキーワード",
"tmdb_tv_genre": "TMDBシリーズジャンル",
"tmdb_search": "TMDB検索",
"tmdb_studio": "TMDB 制作会社",
"tmdb_network": "TMDB ネットワーク",
"tmdb_movie_streaming_services": "TMDB映画ストリーミングサービス",
"tmdb_tv_streaming_services": "TMDBシリーズストリーミングサービス"
},
"library": {
"no_items_found": "アイテムが見つかりません",
"no_results": "検索結果はありません",
"no_libraries_found": "ライブラリが見つかりません",
"item_types": {
"movies": "映画",
"series": "シリーズ",
"boxsets": "ボックスセット",
"items": "アイテム"
},
"options": {
"display": "表示",
"row": "行",
"list": "リスト",
"image_style": "画像のスタイル",
"poster": "ポスター",
"cover": "カバー",
"show_titles": "タイトルの表示",
"show_stats": "統計を表示"
},
"filters": {
"genres": "ジャンル",
"years": "年",
"sort_by": "ソート",
"sort_order": "ソート順",
"tags": "タグ"
}
},
"favorites": {
"series": "シリーズ",
"movies": "映画",
"episodes": "エピソード",
"videos": "ビデオ",
"boxsets": "ボックスセット",
"playlists": "プレイリスト"
},
"custom_links": {
"no_links": "リンクがありません"
},
"player": {
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
"client_error": "クライアントエラー",
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
"message_from_server": "サーバーからのメッセージ: {{message}}",
"video_has_finished_playing": "ビデオの再生が終了しました!",
"no_video_source": "動画ソースがありません...",
"next_episode": "次のエピソード",
"refresh_tracks": "トラックを更新",
"subtitle_tracks": "字幕トラック:",
"audio_tracks": "音声トラック:",
"playback_state": "再生状態:",
"no_data_available": "データなし",
"index": "インデックス:"
},
"item_card": {
"next_up": "次",
"no_items_to_display": "表示するアイテムがありません",
"cast_and_crew": "キャスト&クルー",
"series": "シリーズ",
"seasons": "シーズン",
"season": "シーズン",
"no_episodes_for_this_season": "このシーズンのエピソードはありません",
"overview": "ストーリー",
"more_with": "{{name}}の詳細",
"similar_items": "類似アイテム",
"no_similar_items_found": "類似のアイテムは見つかりませんでした",
"video": "映像",
"more_details": "さらに詳細を表示",
"quality": "画質",
"audio": "音声",
"subtitles": "字幕",
"show_more": "もっと見る",
"show_less": "少なく表示",
"appeared_in": "出演作品",
"could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし",
"download": {
"download_season": "シーズンをダウンロード",
"download_series": "シリーズをダウンロード",
"download_episode": "エピソードをダウンロード",
"download_movie": "映画をダウンロード",
"download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_button": "ダウンロード",
"using_optimized_server": "Optimizeサーバーを使用する",
"using_default_method": "デフォルトの方法を使用"
}
},
"live_tv": {
"next": "次",
"previous": "前",
"live_tv": "ライブTV",
"coming_soon": "近日公開",
"on_now": "現在",
"shows": "表示",
"movies": "映画",
"sports": "スポーツ",
"for_kids": "子供向け",
"news": "ニュース"
},
"jellyseerr": {
"confirm": "確認",
"cancel": "キャンセル",
"yes": "はい",
"whats_wrong": "どうしましたか?",
"issue_type": "問題の種類",
"select_an_issue": "問題を選択",
"types": "種類",
"describe_the_issue": "(オプション) 問題を説明してください...",
"submit_button": "送信",
"report_issue_button": "チケットを報告",
"request_button": "リクエスト",
"are_you_sure_you_want_to_request_all_seasons": "すべてのシーズンをリクエストしてもよろしいですか?",
"failed_to_login": "ログインに失敗しました",
"cast": "出演者",
"details": "詳細",
"status": "状態",
"original_title": "原題",
"series_type": "シリーズタイプ",
"release_dates": "公開日",
"first_air_date": "初放送日",
"next_air_date": "次回放送日",
"revenue": "収益",
"budget": "予算",
"original_language": "オリジナルの言語",
"production_country": "制作国",
"studios": "制作会社",
"network": "ネットワーク",
"currently_streaming_on": "ストリーミング中",
"advanced": "詳細",
"request_as": "別ユーザーとしてリクエスト",
"tags": "タグ",
"quality_profile": "画質プロファイル",
"root_folder": "ルートフォルダ",
"season_x": "シーズン{{seasons}}",
"season_number": "シーズン{{season_number}}",
"number_episodes": "エピソード{{episode_number}}",
"born": "生まれ",
"appearances": "出演",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
"failed_to_test_jellyseerr_server_url": "JellyseerrサーバーのURLをテストに失敗しました",
"issue_submitted": "チケットを送信しました!",
"requested_item": "{{item}}をリクエスト!",
"you_dont_have_permission_to_request": "リクエストする権限がありません!",
"something_went_wrong_requesting_media": "メディアのリクエスト中に問題が発生しました。"
}
},
"tabs": {
"home": "ホーム",
"search": "検索",
"library": "ライブラリ",
"custom_links": "カスタムリンク",
"favorites": "お気に入り"
}
}

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;
}