diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6d36f734..a38e5f84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,11 +46,8 @@ body: - 0.25.0 - 0.24.0 - 0.23.0 -<<<<<<< Updated upstream -======= - 0.22.0 - 0.21.0 ->>>>>>> Stashed changes - older validations: required: true diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 4ed55a24..8ee52292 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -85,6 +85,12 @@ export default function IndexLayout() { title: "", }} /> + { - if (!api || !user?.Id) return []; - - const response = await getItemsApi(api).getItems({ - userId: user.Id, - tags: ["sf_promoted"], - recursive: true, - fields: ["Tags"], - includeItemTypes: ["BoxSet"], - }); - - return response.data.Items || []; - }, - enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 60 * 1000, - }); - const collections = useMemo(() => { const allow = ["movies", "tvshows"]; return ( @@ -214,107 +191,139 @@ export default function index() { [api, user?.Id] ); - const sections = useMemo(() => { - if (!api || !user?.Id) return []; + let sections: Section[] = []; + if (settings?.home === null || settings?.home?.sections === null) { + sections = useMemo(() => { + if (!api || !user?.Id) return []; - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; - const title = t("home.recently_added_in", {libraryName: c.Name}); - const queryKey = [ - "home", - "recentlyAddedIn" + c.CollectionType, - user?.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id - ); - }); + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("home.recently_added_in", {libraryName: c.Name}); + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); - const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async () => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - includeItemTypes: ["Movie", "Series", "Episode"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async () => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - enableResumable: false, - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - ...latestMediaViews, - ...(mediaListCollections?.map( - (ml) => - ({ - title: ml.Name, - queryKey: ["home", "mediaList", ml.Id!], - queryFn: async () => ml, - type: "MediaListSection", - orientation: "vertical", - } as Section) - ) || []), - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async () => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "vertical", - }, - { - title: t("home.suggested_episodes"), - queryKey: ["home", "suggestedEpisodes", user?.Id], - queryFn: async () => { - try { - const suggestions = await getSuggestions(api, user.Id); - const nextUpPromises = suggestions.map((series) => - getNextUp(api, user.Id, series.Id) - ); - const nextUpResults = await Promise.all(nextUpPromises); - - return nextUpResults.filter((item) => item !== null) || []; - } catch (error) { - console.error("Error fetching data:", error); - return []; - } + const ss: Section[] = [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", }, - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - ]; - return ss; - }, [api, user?.Id, collections, mediaListCollections]); + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async () => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...latestMediaViews, + // ...(mediaListCollections?.map( + // (ml) => + // ({ + // title: ml.Name, + // queryKey: ["home", "mediaList", ml.Id!], + // queryFn: async () => ml, + // type: "MediaListSection", + // orientation: "vertical", + // } as Section) + // ) || []), + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "vertical", + }, + { + title: t("home.suggested_episodes"), + queryKey: ["home", "suggestedEpisodes", user?.Id], + queryFn: async () => { + try { + const suggestions = await getSuggestions(api, user.Id); + const nextUpPromises = suggestions.map((series) => + getNextUp(api, user.Id, series.Id) + ); + const nextUpResults = await Promise.all(nextUpPromises); + + return nextUpResults.filter((item) => item !== null) || []; + } catch (error) { + console.error("Error fetching data:", error); + return []; + } + }, + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ]; + return ss; + }, [api, user?.Id, collections]); + } else { + sections = useMemo(() => { + if (!api || !user?.Id) return []; + const ss: Section[] = []; + + for (const key in settings.home?.sections) { + const section = settings.home?.sections[key]; + ss.push({ + title: key, + queryKey: ["home", key, user?.Id], + queryFn: async () => + ( + await getItemsApi(api).getItems({ + userId: user?.Id, + limit: section.items?.limit || 20, + recursive: true, + includeItemTypes: section.items?.includeItemTypes, + sortBy: section.items?.sortBy, + sortOrder: section.items?.sortOrder, + filters: section.items?.filters, + parentId: section.items?.parentId, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + } + return ss; + }, [api, user?.Id, settings.home?.sections]); + } if (isConnected === false) { return ( @@ -358,7 +367,7 @@ export default function index() { ); } - if (e1 || e2) + if (e1) return ( {t("home.oops")} @@ -366,7 +375,7 @@ export default function index() { ); - if (l1 || l2) + if (l1) return ( diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx deleted file mode 100644 index c44146db..00000000 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { ListGroup } from "@/components/list/ListGroup"; -import { ListItem } from "@/components/list/ListItem"; -import { Loader } from "@/components/Loader"; -import DisabledSetting from "@/components/settings/DisabledSetting"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Linking, Switch } from "react-native"; - -export default function page() { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { t } = useTranslation(); - - const [settings, updateSettings, pluginSettings] = useSettings(); - - const handleOpenLink = () => { - Linking.openURL( - "https://github.com/lostb1t/jellyfin-plugin-collection-import" - ); - }; - - const queryClient = useQueryClient(); - - const { - data: mediaListCollections, - isLoading: isLoadingMediaListCollections, - } = useQuery({ - queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], - queryFn: async () => { - if (!api || !user?.Id) return []; - - const response = await getItemsApi(api).getItems({ - userId: user.Id, - tags: ["sf_promoted"], - recursive: true, - fields: ["Tags"], - includeItemTypes: ["BoxSet"], - }); - - return response.data.Items ?? []; - }, - enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 0, - }); - - const disabled = useMemo( - () => - pluginSettings?.usePopularPlugin?.locked === true && - pluginSettings?.mediaListCollectionIds?.locked === true, - [pluginSettings] - ); - - if (!settings) return null; - - return ( - - - { - updateSettings({ usePopularPlugin: true }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - updateSettings({ usePopularPlugin }) - } - /> - - - - {t("home.settings.plugins.popular_lists.enable_popular_hint")}{" "} - - {t("home.settings.plugins.popular_lists.read_more_about_popular_lists")} - - - - {settings.usePopularPlugin && ( - <> - {!isLoadingMediaListCollections ? ( - <> - {mediaListCollections?.length === 0 ? ( - - {t("home.settings.plugins.popular_lists.no_collections_found")} - - ) : ( - <> - - {mediaListCollections?.map((mlc) => ( - - { - if (!settings.mediaListCollectionIds) { - updateSettings({ - mediaListCollectionIds: [mlc.Id!], - }); - return; - } - - updateSettings({ - mediaListCollectionIds: - settings.mediaListCollectionIds.includes( - mlc.Id! - ) - ? settings.mediaListCollectionIds.filter( - (id) => id !== mlc.Id - ) - : [ - ...settings.mediaListCollectionIds, - mlc.Id!, - ], - }); - }} - /> - - ))} - - - {t("home.settings.plugins.popular_lists.select_the_lists_you_want_to_display")} - - - )} - - ) : ( - - )} - - )} - - ); -} diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx index deb30cf4..caac1e33 100644 --- a/components/settings/PluginSettings.tsx +++ b/components/settings/PluginSettings.tsx @@ -27,11 +27,6 @@ export const PluginSettings = () => { title="Marlin Search" showArrow /> - router.push("/settings/popular-lists/page")} - title="Popular Lists" - showArrow - /> ); diff --git a/translations/en.json b/translations/en.json index e35b55b6..c73ebe23 100644 --- a/translations/en.json +++ b/translations/en.json @@ -159,14 +159,6 @@ "toasts": { "saved": "Saved" } - }, - "popular_lists": { - "enable_plugin": "Enable plugin", - "enable_popular_lists": "Enable Popular Lists", - "enable_popular_hint": "Popular Lists is a plugin that enables you to show custom Jellyfin lists on the Streamyfin home page.", - "read_more_about_popular_lists": "Read more about Popular Lists.", - "no_collections_found": "No collections found. Add some in Jellyfin.", - "select_the_lists_you_want_to_display": "Select the lists you want displayed on the home screen." } }, "storage": { diff --git a/translations/fr.json b/translations/fr.json index c3b4001b..aa0ae115 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -159,14 +159,6 @@ "toasts": { "saved": "Enregistré" } - }, - "popular_lists": { - "enable_plugin": "Activer le plugiciel", - "enable_popular_lists": "Activer Popular Lists", - "enable_popular_hint": "Popular Lists est un plugiciel qui affiche des listes populaires sur l'écran d'accueil.", - "read_more_about_popular_lists": "Lisez-en plus sur Popular Lists.", - "no_collections_found": "Aucune collection trouvée. Ajoutez-en dans Jellyfin.", - "select_the_lists_you_want_to_display": "Sélectionnez les listes que vous voulez afficher sur l'écran d'accueil." } }, "storage": { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 7f9bba1e..527b3d63 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,19 +1,21 @@ import { atom, useAtom } from "jotai"; -import {useCallback, useEffect, useMemo} from "react"; +import { useCallback, useEffect, useMemo } from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; import { CultureDto, - PluginStatus, SubtitlePlaybackMode, + ItemSortBy, + SortOrder, + BaseItemKind, + ItemFilter, } from "@jellyfin/sdk/lib/generated-client"; -import {apiAtom} from "@/providers/JellyfinProvider"; -import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; -import {writeErrorLog} from "@/utils/log"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { writeInfoLog } from "@/utils/log"; -const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004" -const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" +const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"; +const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"; export type DownloadQuality = "original" | "high" | "low"; @@ -68,13 +70,32 @@ export type DefaultLanguageOption = { export enum DownloadMethod { Remux = "remux", - Optimized = "optimized" + Optimized = "optimized", } +export type Home = { + sections: [Object]; +}; + +export type HomeSection = { + orientation?: "horizontal" | "vertical"; + items?: HomeSectionItemResolver; +}; + +export type HomeSectionItemResolver = { + sortBy?: Array; + sortOrder?: Array; + includeItemTypes?: Array; + genres?: Array; + parentId?: string; + limit?: number; + filters?: Array; +}; + export type Settings = { + home?: Home | null; autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; - usePopularPlugin?: boolean; deviceProfile?: "Expo" | "Native" | "Old"; mediaListCollectionIds?: string[]; preferedLanguage?: string; @@ -107,19 +128,21 @@ export type Settings = { export interface Lockable { locked: boolean; - value: T + value: T; } -export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; +export type PluginLockableSettings = { + [K in keyof Settings]: Lockable; +}; export type StreamyfinPluginConfig = { - settings: PluginLockableSettings -} + settings: PluginLockableSettings; +}; const loadSettings = (): Settings => { const defaultValues: Settings = { + home: null, autoRotate: true, forceLandscapeInVideoPlayer: false, - usePopularPlugin: false, deviceProfile: "Expo", mediaListCollectionIds: [], preferedLanguage: undefined, @@ -174,7 +197,9 @@ const saveSettings = (settings: Settings) => { }; export const settingsAtom = atom(null); -export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS)); +export const pluginSettingsAtom = atom( + storage.get(STREAMYFIN_PLUGIN_SETTINGS) +); export const useSettings = () => { const [api] = useAtom(apiAtom); @@ -188,62 +213,25 @@ export const useSettings = () => { } }, [_settings, setSettings]); - const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { - storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) - _setPluginSettings(settings) + const setPluginSettings = useCallback( + (settings: PluginLockableSettings | undefined) => { + storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings); + _setPluginSettings(settings); }, [_setPluginSettings] - ) + ); - const refreshStreamyfinPluginSettings = useCallback( - async () => { - if (!api) - return + const refreshStreamyfinPluginSettings = useCallback(async () => { + if (!api) return; + const settings = await api + .getStreamyfinPluginConfig() + .then(({ data }) => data?.settings); - const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); + writeInfoLog(`Got remote settings: ${JSON.stringify(settings)}`); - if (plugins && plugins.length > 0) { - const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID); - - if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) { - writeErrorLog( - "Streamyfin plugin is currently not active.\n" + - `Current status is: ${streamyfinPlugin?.Status}` - ); - setPluginSettings(undefined); - return; - } - - const settings = await api.getStreamyfinPluginConfig() - .then(({data}) => data.settings) - - setPluginSettings(settings); - return settings; - } - }, - [api] - ) - - // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. - // If admin sets locked to false but provides a value, - // use user settings first and fallback on admin setting if required. - const settings: Settings = useMemo(() => { - const overrideSettings = Object.entries(pluginSettings || {}) - .reduce((acc, [key, setting]) => { - if (setting) { - const {value, locked} = setting - acc = Object.assign(acc, { - [key]: locked ? value : _settings?.[key as keyof Settings] ?? value - }) - } - return acc - }, {} as Settings) - - return { - ..._settings, - ...overrideSettings - } - }, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings]) + setPluginSettings(settings); + return settings; + }, [api]); const updateSettings = (update: Partial) => { if (settings) { @@ -254,5 +242,52 @@ export const useSettings = () => { } }; - return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const; + // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. + // If admin sets locked to false but provides a value, + // use user settings first and fallback on admin setting if required. + const settings: Settings = useMemo(() => { + let unlockedPluginDefaults = {} as Settings; + const overrideSettings = Object.entries(pluginSettings || {}).reduce( + (acc, [key, setting]) => { + if (setting) { + const { value, locked } = setting; + + // Make sure we override default settings with plugin settings when they are not locked. + // Admin decided what users defaults should be and grants them the ability to change them too. + if ( + locked === false && + value && + _settings?.[key as keyof Settings] !== value + ) { + unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, { + [key as keyof Settings]: value, + }); + } + + acc = Object.assign(acc, { + [key]: locked ? value : _settings?.[key as keyof Settings] ?? value, + }); + } + return acc; + }, + {} as Settings + ); + + // Update settings with plugin defined defaults + if (Object.keys(unlockedPluginDefaults).length > 0) { + updateSettings(unlockedPluginDefaults); + } + return { + ..._settings, + ...overrideSettings, + }; + }, [_settings, pluginSettings]); + + return [ + settings, + updateSettings, + pluginSettings, + setPluginSettings, + refreshStreamyfinPluginSettings, + ] as const; }; diff --git a/utils/log.tsx b/utils/log.tsx index 7c432406..45999062 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -1,7 +1,7 @@ import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { storage } from "./mmkv"; -import {useQuery} from "@tanstack/react-query"; -import React, {createContext, useContext} from "react"; +import { useQuery } from "@tanstack/react-query"; +import React, { createContext, useContext } from "react"; type LogLevel = "INFO" | "WARN" | "ERROR"; @@ -19,10 +19,12 @@ const mmkvStorage = createJSONStorage(() => ({ })); const logsAtom = atomWithStorage("logs", [], mmkvStorage); -const LogContext = createContext | null>(null); -const DownloadContext = createContext | null>(null); +const LogContext = createContext | null>( + null +); +const DownloadContext = createContext | null>( + null +); function useLogProvider() { const { data: logs } = useQuery({ @@ -32,11 +34,10 @@ function useLogProvider() { }); return { - logs - } + logs, + }; } - export const writeToLog = (level: LogLevel, message: string, data?: any) => { const newEntry: LogEntry = { timestamp: new Date().toISOString(), @@ -53,10 +54,13 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => { const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0)); storage.set("logs", JSON.stringify(recentLogs)); + console.log(message); }; -export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data); -export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data); +export const writeInfoLog = (message: string, data?: any) => + writeToLog("INFO", message, data); +export const writeErrorLog = (message: string, data?: any) => + writeToLog("ERROR", message, data); export const readFromLog = (): LogEntry[] => { const logs = storage.getString("logs"); @@ -75,14 +79,10 @@ export function useLog() { return context; } -export function LogProvider({children}: { children: React.ReactNode }) { +export function LogProvider({ children }: { children: React.ReactNode }) { const provider = useLogProvider(); - return ( - - {children} - - ) + return {children}; } export default logsAtom;