Compare commits

...

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6752888bb0 wip 2024-10-11 16:42:13 +02:00
7 changed files with 181 additions and 58 deletions

View File

@@ -2,7 +2,7 @@ import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
@@ -45,6 +45,18 @@ export default function IndexLayout() {
name="settings" name="settings"
options={{ options={{
title: "Settings", title: "Settings",
headerRight: () => (
<View className="">
<Ionicons
name="file-tray-full-outline"
size={22}
color="white"
onPress={() => {
router.push("/logs");
}}
/>
</View>
),
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (

View File

@@ -20,12 +20,6 @@ export default function settings() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const openQuickConnectAuthCodeInput = () => { const openQuickConnectAuthCodeInput = () => {
@@ -129,30 +123,6 @@ export default function settings() {
</Button> </Button>
</View> </View>
</View> </View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text>
)}
</View>
</View>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -345,6 +345,13 @@ function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="logs"
options={{
presentation: "modal",
title: "Logs",
}}
/>
<Stack.Screen <Stack.Screen
name="(auth)/play-offline-video" name="(auth)/play-offline-video"
options={{ options={{

View File

@@ -2,11 +2,12 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api"; import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
@@ -27,6 +28,7 @@ const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
@@ -72,7 +74,17 @@ const Login: React.FC = () => {
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (result.success) { if (result.success) {
await login(credentials.username, credentials.password); try {
await login(credentials.username, credentials.password);
} catch (loginError) {
if (loginError instanceof Error) {
setError(loginError.message);
} else {
setError("An unexpected error occurred during login");
}
}
} else {
setError("Invalid credentials format");
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@@ -105,37 +117,72 @@ const Login: React.FC = () => {
async function checkUrl(url: string) { async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url; url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true); setLoadingServerCheck(true);
writeToLog("INFO", `Checking URL: ${url}`);
const protocols = ["https://", "http://"]; const timeout = 5000; // 5 seconds timeout
const timeout = 2000; // 2 seconds timeout for long 404 responses const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
for (const protocol of protocols) { // Try HTTPS first
const controller = new AbortController(); const httpsUrl = `https://${url}/System/Info/Public`;
const timeoutId = setTimeout(() => controller.abort(), timeout); try {
const response = await fetch(httpsUrl, {
try { mode: "cors",
const response = await fetch(`${protocol}${url}/System/Info/Public`, { signal: controller.signal,
mode: "cors", });
signal: controller.signal, if (response.ok) {
}); const data = (await response.json()) as PublicSystemInfo;
clearTimeout(timeoutId); setServerName(data.ServerName || "");
if (response.ok) { return `https://${url}`;
const data = (await response.json()) as PublicSystemInfo; } else {
setServerName(data.ServerName || ""); writeToLog(
return `${protocol}${url}`; "WARN",
} `HTTPS connection failed with status: ${response.status}`
} catch (e) { );
const error = e as Error;
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.log(`Error checking ${protocol}${url}:`, error);
}
} }
} catch (e) {
writeToLog("WARN", "HTTPS connection failed - trying HTTP", e);
}
// If HTTPS didn't work, try HTTP
const httpUrl = `http://${url}/System/Info/Public`;
try {
const response = await fetch(httpUrl, {
mode: "cors",
signal: controller.signal,
});
writeToLog("INFO", `HTTP response status: ${response.status}`);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `http://${url}`;
} else {
writeToLog(
"WARN",
`HTTP connection failed with status: ${response.status}`
);
}
} catch (e) {
writeToLog("ERROR", "HTTP connection failed", e);
}
// If neither worked, return undefined
writeToLog(
"ERROR",
`Failed to connect to ${url} using both HTTPS and HTTP`
);
return undefined;
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
writeToLog("ERROR", `Request to ${url} timed out`, error);
} else {
writeToLog("ERROR", `Unexpected error checking ${url}`, error);
} }
return undefined; return undefined;
} finally { } finally {
clearTimeout(timeoutId);
setLoadingServerCheck(false); setLoadingServerCheck(false);
} }
} }
@@ -197,6 +244,16 @@ const Login: React.FC = () => {
style={{ flex: 1, height: "100%" }} style={{ flex: 1, height: "100%" }}
> >
<View className="flex flex-col w-full h-full relative items-center justify-center"> <View className="flex flex-col w-full h-full relative items-center justify-center">
<View className="absolute top-4 right-4">
<Ionicons
name="file-tray-full-outline"
size={22}
color="white"
onPress={() => {
router.push("/logs");
}}
/>
</View>
<View className="px-4 -mt-20"> <View className="px-4 -mt-20">
<View className="mb-4"> <View className="mb-4">
<Text className="text-3xl font-bold mb-1"> <Text className="text-3xl font-bold mb-1">

58
app/logs.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Text } from "@/components/common/Text";
import { readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Logs: React.FC = () => {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => (await readFromLog()).reverse(),
refetchOnReconnect: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
});
const insets = useSafeAreaInsets();
return (
<ScrollView
className="flex-1 p-4"
contentContainerStyle={{ gap: 10, paddingBottom: insets.top }}
>
<View className="flex flex-col">
{logs?.map((log, index) => (
<View key={index} className="border-b-neutral-800 border py-3">
<View className="flex flex-row justify-between items-center mb-2">
<Text
className={`
text-xs
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text className="text-xs text-neutral-500">
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text uiTextView selectable className="text-xs mb-1">
{log.message}
</Text>
{log.data && (
<Text uiTextView selectable className="text-xs">
{log.data}
</Text>
)}
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text>
)}
</View>
</ScrollView>
);
};
export default Logs;

View File

@@ -1,4 +1,5 @@
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { writeToLog } from "@/utils/log";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
@@ -212,20 +213,35 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
switch (error.response?.status) { switch (error.response?.status) {
case 401: case 401:
writeToLog("ERROR", "Invalid username or password");
throw new Error("Invalid username or password"); throw new Error("Invalid username or password");
case 403: case 403:
writeToLog("ERROR", "User does not have permission to log in");
throw new Error("User does not have permission to log in"); throw new Error("User does not have permission to log in");
case 408: case 408:
writeToLog(
"WARN",
"Server is taking too long to respond, try again later"
);
throw new Error( throw new Error(
"Server is taking too long to respond, try again later" "Server is taking too long to respond, try again later"
); );
case 429: case 429:
writeToLog(
"WARN",
"Server received too many requests, try again later"
);
throw new Error( throw new Error(
"Server received too many requests, try again later" "Server received too many requests, try again later"
); );
case 500: case 500:
writeToLog("ERROR", "There is a server error");
throw new Error("There is a server error"); throw new Error("There is a server error");
default: default:
writeToLog(
"ERROR",
"An unexpected error occurred. Did you enter the server URL correctly?"
);
throw new Error( throw new Error(
"An unexpected error occurred. Did you enter the server URL correctly?" "An unexpected error occurred. Did you enter the server URL correctly?"
); );
@@ -312,6 +328,9 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
if (loading) return; if (loading) return;
const inAuthGroup = segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
const inLogs = segments[0] === "logs";
if (inLogs) return;
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
router.replace("/login"); router.replace("/login");

View File

@@ -29,7 +29,7 @@ export const writeToLog = async (
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : []; const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry); logs.push(newEntry);
const maxLogs = 100; const maxLogs = 1000;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0)); const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs)); await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));