mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-11 12:30:46 +00:00
added video support to sdui server
This commit is contained in:
parent
fd6e29a8ec
commit
44abb9f635
6 changed files with 275 additions and 1598 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native';
|
import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native';
|
||||||
|
import Video from 'react-native-video';
|
||||||
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
@ -148,18 +149,34 @@ const BottomSheetCampaign: React.FC<BottomSheetProps> = ({ campaign, onDismiss,
|
||||||
<Ionicons name="close" size={isTablet ? 26 : 22} color={content.closeButtonColor || '#fff'} />
|
<Ionicons name="close" size={isTablet ? 26 : 22} color={content.closeButtonColor || '#fff'} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{content.imageUrl && (
|
{/* Media - Image or Video */}
|
||||||
<Image
|
{(content.imageUrl || content.videoUrl) && (
|
||||||
source={{ uri: content.imageUrl }}
|
<View style={[
|
||||||
style={[
|
styles.bottomSheetImage,
|
||||||
styles.bottomSheetImage,
|
{
|
||||||
{
|
aspectRatio: content.aspectRatio || 1.5,
|
||||||
aspectRatio: content.aspectRatio || 1.5,
|
maxHeight: imageMaxHeight,
|
||||||
maxHeight: imageMaxHeight,
|
}
|
||||||
}
|
]}>
|
||||||
]}
|
{content.mediaType === 'video' && content.videoUrl ? (
|
||||||
resizeMode="cover"
|
<Video
|
||||||
/>
|
source={{ uri: content.videoUrl }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
resizeMode="cover"
|
||||||
|
repeat={true}
|
||||||
|
muted={true}
|
||||||
|
paused={false}
|
||||||
|
playInBackground={false}
|
||||||
|
playWhenInactive={false}
|
||||||
|
/>
|
||||||
|
) : content.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: content.imageUrl }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.bottomSheetContent}>
|
<View style={styles.bottomSheetContent}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -13,6 +13,7 @@ import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Campaign } from '../../services/campaignService';
|
import { Campaign } from '../../services/campaignService';
|
||||||
|
import Video from 'react-native-video';
|
||||||
|
|
||||||
interface PosterModalProps {
|
interface PosterModalProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
|
|
@ -94,7 +95,8 @@ export const PosterModal: React.FC<PosterModalProps> = ({
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{content.imageUrl && (
|
{/* Media Container - Image or Video */}
|
||||||
|
{(content.imageUrl || content.videoUrl) && (
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.imageContainer,
|
styles.imageContainer,
|
||||||
{
|
{
|
||||||
|
|
@ -102,11 +104,24 @@ export const PosterModal: React.FC<PosterModalProps> = ({
|
||||||
maxHeight: maxImageHeight,
|
maxHeight: maxImageHeight,
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
<Image
|
{content.mediaType === 'video' && content.videoUrl ? (
|
||||||
source={{ uri: content.imageUrl }}
|
<Video
|
||||||
style={styles.image}
|
source={{ uri: content.videoUrl }}
|
||||||
resizeMode="cover"
|
style={styles.image}
|
||||||
/>
|
resizeMode="cover"
|
||||||
|
repeat={true}
|
||||||
|
muted={true}
|
||||||
|
paused={false}
|
||||||
|
playInBackground={false}
|
||||||
|
playWhenInactive={false}
|
||||||
|
/>
|
||||||
|
) : content.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: content.imageUrl }}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,19 +29,19 @@ const BackupScreen: React.FC = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
|
const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
|
||||||
|
|
||||||
// Collapsible sections state
|
// Collapsible sections state
|
||||||
const [expandedSections, setExpandedSections] = useState({
|
const [expandedSections, setExpandedSections] = useState({
|
||||||
coreData: false,
|
coreData: false,
|
||||||
addonsIntegrations: false,
|
addonsIntegrations: false,
|
||||||
settingsPreferences: false,
|
settingsPreferences: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animated values for each section
|
// Animated values for each section
|
||||||
const coreDataAnim = useRef(new Animated.Value(0)).current;
|
const coreDataAnim = useRef(new Animated.Value(0)).current;
|
||||||
const addonsAnim = useRef(new Animated.Value(0)).current;
|
const addonsAnim = useRef(new Animated.Value(0)).current;
|
||||||
const settingsAnim = useRef(new Animated.Value(0)).current;
|
const settingsAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
// Chevron rotation animated values
|
// Chevron rotation animated values
|
||||||
const coreDataChevron = useRef(new Animated.Value(0)).current;
|
const coreDataChevron = useRef(new Animated.Value(0)).current;
|
||||||
const addonsChevron = useRef(new Animated.Value(0)).current;
|
const addonsChevron = useRef(new Animated.Value(0)).current;
|
||||||
|
|
@ -60,7 +60,7 @@ const BackupScreen: React.FC = () => {
|
||||||
) => {
|
) => {
|
||||||
setAlertTitle(title);
|
setAlertTitle(title);
|
||||||
setAlertMessage(message);
|
setAlertMessage(message);
|
||||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
|
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ const BackupScreen: React.FC = () => {
|
||||||
openAlert(
|
openAlert(
|
||||||
'Restart Failed',
|
'Restart Failed',
|
||||||
'Failed to restart the app. Please manually close and reopen the app to see your restored data.',
|
'Failed to restart the app. Please manually close and reopen the app to see your restored data.',
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -81,10 +81,10 @@ const BackupScreen: React.FC = () => {
|
||||||
// Toggle section collapse/expand
|
// Toggle section collapse/expand
|
||||||
const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => {
|
const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => {
|
||||||
const isExpanded = expandedSections[section];
|
const isExpanded = expandedSections[section];
|
||||||
|
|
||||||
let heightAnim: Animated.Value;
|
let heightAnim: Animated.Value;
|
||||||
let chevronAnim: Animated.Value;
|
let chevronAnim: Animated.Value;
|
||||||
|
|
||||||
if (section === 'coreData') {
|
if (section === 'coreData') {
|
||||||
heightAnim = coreDataAnim;
|
heightAnim = coreDataAnim;
|
||||||
chevronAnim = coreDataChevron;
|
chevronAnim = coreDataChevron;
|
||||||
|
|
@ -95,7 +95,7 @@ const BackupScreen: React.FC = () => {
|
||||||
heightAnim = settingsAnim;
|
heightAnim = settingsAnim;
|
||||||
chevronAnim = settingsChevron;
|
chevronAnim = settingsChevron;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate height and chevron rotation
|
// Animate height and chevron rotation
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(heightAnim, {
|
Animated.timing(heightAnim, {
|
||||||
|
|
@ -111,8 +111,8 @@ const BackupScreen: React.FC = () => {
|
||||||
easing: Easing.inOut(Easing.ease),
|
easing: Easing.inOut(Easing.ease),
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
setExpandedSections(prev => ({...prev, [section]: !isExpanded}));
|
setExpandedSections(prev => ({ ...prev, [section]: !isExpanded }));
|
||||||
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
|
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
|
||||||
|
|
||||||
// Create backup
|
// Create backup
|
||||||
|
|
@ -135,6 +135,9 @@ const BackupScreen: React.FC = () => {
|
||||||
if (preferences.includeWatchProgress) {
|
if (preferences.includeWatchProgress) {
|
||||||
items.push(`Watch Progress: ${preview.watchProgress} entries`);
|
items.push(`Watch Progress: ${preview.watchProgress} entries`);
|
||||||
total += preview.watchProgress;
|
total += preview.watchProgress;
|
||||||
|
// Include watched status with watch progress
|
||||||
|
items.push(`Watched Status: ${preview.watchedStatus} items`);
|
||||||
|
total += preview.watchedStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferences.includeAddons) {
|
if (preferences.includeAddons) {
|
||||||
|
|
@ -149,7 +152,7 @@ const BackupScreen: React.FC = () => {
|
||||||
|
|
||||||
// Check if no items are selected
|
// Check if no items are selected
|
||||||
const message = items.length > 0
|
const message = items.length > 0
|
||||||
? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, and integration data.`
|
? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, watched markers, and integration data.`
|
||||||
: `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`;
|
: `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`;
|
||||||
|
|
||||||
openAlert(
|
openAlert(
|
||||||
|
|
@ -157,51 +160,51 @@ const BackupScreen: React.FC = () => {
|
||||||
message,
|
message,
|
||||||
items.length > 0
|
items.length > 0
|
||||||
? [
|
? [
|
||||||
{ label: 'Cancel', onPress: () => {} },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
label: 'Create Backup',
|
label: 'Create Backup',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const backupOptions = getBackupOptions();
|
const backupOptions = getBackupOptions();
|
||||||
|
|
||||||
const fileUri = await backupService.createBackup(backupOptions);
|
const fileUri = await backupService.createBackup(backupOptions);
|
||||||
|
|
||||||
// Share the backup file
|
// Share the backup file
|
||||||
if (await Sharing.isAvailableAsync()) {
|
if (await Sharing.isAvailableAsync()) {
|
||||||
await Sharing.shareAsync(fileUri, {
|
await Sharing.shareAsync(fileUri, {
|
||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
dialogTitle: 'Share Nuvio Backup',
|
dialogTitle: 'Share Nuvio Backup',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
openAlert(
|
|
||||||
'Backup Created',
|
|
||||||
'Your backup has been created and is ready to share.',
|
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[BackupScreen] Failed to create backup:', error);
|
|
||||||
openAlert(
|
|
||||||
'Backup Failed',
|
|
||||||
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openAlert(
|
||||||
|
'Backup Created',
|
||||||
|
'Your backup has been created and is ready to share.',
|
||||||
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[BackupScreen] Failed to create backup:', error);
|
||||||
|
openAlert(
|
||||||
|
'Backup Failed',
|
||||||
|
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
: [{ label: 'OK', onPress: () => {} }]
|
]
|
||||||
|
: [{ label: 'OK', onPress: () => { } }]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupScreen] Failed to get backup preview:', error);
|
logger.error('[BackupScreen] Failed to get backup preview:', error);
|
||||||
openAlert(
|
openAlert(
|
||||||
'Error',
|
'Error',
|
||||||
'Failed to prepare backup information. Please try again.',
|
'Failed to prepare backup information. Please try again.',
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +231,7 @@ const BackupScreen: React.FC = () => {
|
||||||
'Confirm Restore',
|
'Confirm Restore',
|
||||||
`This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`,
|
`This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`,
|
||||||
[
|
[
|
||||||
{ label: 'Cancel', onPress: () => {} },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
label: 'Restore',
|
label: 'Restore',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
|
|
@ -243,9 +246,9 @@ const BackupScreen: React.FC = () => {
|
||||||
'Restore Complete',
|
'Restore Complete',
|
||||||
'Your data has been successfully restored. Please restart the app to see all changes.',
|
'Your data has been successfully restored. Please restart the app to see all changes.',
|
||||||
[
|
[
|
||||||
{ label: 'Cancel', onPress: () => {} },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
label: 'Restart App',
|
label: 'Restart App',
|
||||||
onPress: restartApp,
|
onPress: restartApp,
|
||||||
style: { fontWeight: 'bold' }
|
style: { fontWeight: 'bold' }
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +259,7 @@ const BackupScreen: React.FC = () => {
|
||||||
openAlert(
|
openAlert(
|
||||||
'Restore Failed',
|
'Restore Failed',
|
||||||
`Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -270,7 +273,7 @@ const BackupScreen: React.FC = () => {
|
||||||
openAlert(
|
openAlert(
|
||||||
'File Selection Failed',
|
'File Selection Failed',
|
||||||
`Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
[{ label: 'OK', onPress: () => {} }]
|
[{ label: 'OK', onPress: () => { } }]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [openAlert]);
|
}, [openAlert]);
|
||||||
|
|
@ -281,26 +284,26 @@ const BackupScreen: React.FC = () => {
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
|
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
|
||||||
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
|
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
{/* Empty for now, but keeping structure consistent */}
|
{/* Empty for now, but keeping structure consistent */}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
|
||||||
Backup & Restore
|
Backup & Restore
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
>
|
>
|
||||||
|
|
@ -321,7 +324,7 @@ const BackupScreen: React.FC = () => {
|
||||||
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
Choose what to include in your backups
|
Choose what to include in your backups
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Core Data Group */}
|
{/* Core Data Group */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.sectionHeader}
|
style={styles.sectionHeader}
|
||||||
|
|
@ -369,7 +372,7 @@ const BackupScreen: React.FC = () => {
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Addons & Integrations Group */}
|
{/* Addons & Integrations Group */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.sectionHeader}
|
style={styles.sectionHeader}
|
||||||
|
|
@ -424,7 +427,7 @@ const BackupScreen: React.FC = () => {
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Settings & Preferences Group */}
|
{/* Settings & Preferences Group */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.sectionHeader}
|
style={styles.sectionHeader}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,13 @@ export interface BackupData {
|
||||||
// Onboarding/flags
|
// Onboarding/flags
|
||||||
hasCompletedOnboarding?: boolean;
|
hasCompletedOnboarding?: boolean;
|
||||||
showLoginHintToastOnce?: boolean;
|
showLoginHintToastOnce?: boolean;
|
||||||
|
// Watched status markers
|
||||||
|
watchedStatus?: Record<string, boolean>;
|
||||||
|
// Catalog UI preferences
|
||||||
|
catalogUiPreferences?: {
|
||||||
|
mobileColumns?: string;
|
||||||
|
showTitles?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
|
|
@ -89,7 +96,7 @@ export class BackupService {
|
||||||
private readonly BACKUP_VERSION = '1.0.0';
|
private readonly BACKUP_VERSION = '1.0.0';
|
||||||
private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_';
|
private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_';
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() { }
|
||||||
|
|
||||||
public static getInstance(): BackupService {
|
public static getInstance(): BackupService {
|
||||||
if (!BackupService.instance) {
|
if (!BackupService.instance) {
|
||||||
|
|
@ -104,11 +111,11 @@ export class BackupService {
|
||||||
public async createBackup(options: BackupOptions = {}): Promise<string> {
|
public async createBackup(options: BackupOptions = {}): Promise<string> {
|
||||||
try {
|
try {
|
||||||
logger.info('[BackupService] Starting backup creation...');
|
logger.info('[BackupService] Starting backup creation...');
|
||||||
|
|
||||||
const userScope = await this.getUserScope();
|
const userScope = await this.getUserScope();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const filename = `${this.BACKUP_FILENAME_PREFIX}${timestamp}.json`;
|
const filename = `${this.BACKUP_FILENAME_PREFIX}${timestamp}.json`;
|
||||||
|
|
||||||
// Collect all data
|
// Collect all data
|
||||||
const backupData: BackupData = {
|
const backupData: BackupData = {
|
||||||
version: this.BACKUP_VERSION,
|
version: this.BACKUP_VERSION,
|
||||||
|
|
@ -136,6 +143,8 @@ export class BackupService {
|
||||||
globalSeasonViewMode: options.includeUserPreferences !== false ? await this.getGlobalSeasonViewMode() : undefined,
|
globalSeasonViewMode: options.includeUserPreferences !== false ? await this.getGlobalSeasonViewMode() : undefined,
|
||||||
hasCompletedOnboarding: options.includeUserPreferences !== false ? await this.getHasCompletedOnboarding() : undefined,
|
hasCompletedOnboarding: options.includeUserPreferences !== false ? await this.getHasCompletedOnboarding() : undefined,
|
||||||
showLoginHintToastOnce: options.includeUserPreferences !== false ? await this.getShowLoginHintToastOnce() : undefined,
|
showLoginHintToastOnce: options.includeUserPreferences !== false ? await this.getShowLoginHintToastOnce() : undefined,
|
||||||
|
watchedStatus: options.includeWatchProgress !== false ? await this.getWatchedStatus() : undefined,
|
||||||
|
catalogUiPreferences: options.includeSettings !== false ? await this.getCatalogUiPreferences() : undefined,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
|
|
@ -170,7 +179,7 @@ export class BackupService {
|
||||||
|
|
||||||
logger.info(`[BackupService] Backup created successfully: ${filename}`);
|
logger.info(`[BackupService] Backup created successfully: ${filename}`);
|
||||||
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
|
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
|
||||||
|
|
||||||
return fileUri;
|
return fileUri;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupService] Failed to create backup:', error);
|
logger.error('[BackupService] Failed to create backup:', error);
|
||||||
|
|
@ -187,6 +196,7 @@ export class BackupService {
|
||||||
addons: number;
|
addons: number;
|
||||||
downloads: number;
|
downloads: number;
|
||||||
scrapers: number;
|
scrapers: number;
|
||||||
|
watchedStatus: number;
|
||||||
total: number;
|
total: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -195,13 +205,15 @@ export class BackupService {
|
||||||
watchProgressData,
|
watchProgressData,
|
||||||
addonsData,
|
addonsData,
|
||||||
downloadsData,
|
downloadsData,
|
||||||
scrapersData
|
scrapersData,
|
||||||
|
watchedStatusData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.getLibrary(),
|
this.getLibrary(),
|
||||||
this.getWatchProgress(),
|
this.getWatchProgress(),
|
||||||
this.getAddons(),
|
this.getAddons(),
|
||||||
this.getDownloads(),
|
this.getDownloads(),
|
||||||
this.getLocalScrapers()
|
this.getLocalScrapers(),
|
||||||
|
this.getWatchedStatus()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0;
|
const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0;
|
||||||
|
|
@ -209,6 +221,7 @@ export class BackupService {
|
||||||
const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0;
|
const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0;
|
||||||
const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0;
|
const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0;
|
||||||
const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0;
|
const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0;
|
||||||
|
const watchedStatusCount = Object.keys(watchedStatusData).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
library: libraryCount,
|
library: libraryCount,
|
||||||
|
|
@ -216,11 +229,12 @@ export class BackupService {
|
||||||
addons: addonsCount,
|
addons: addonsCount,
|
||||||
downloads: downloadsCount,
|
downloads: downloadsCount,
|
||||||
scrapers: scrapersCount,
|
scrapers: scrapersCount,
|
||||||
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount
|
watchedStatus: watchedStatusCount,
|
||||||
|
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount + watchedStatusCount
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupService] Failed to get backup preview:', error);
|
logger.error('[BackupService] Failed to get backup preview:', error);
|
||||||
return { library: 0, watchProgress: 0, addons: 0, downloads: 0, scrapers: 0, total: 0 };
|
return { library: 0, watchProgress: 0, addons: 0, downloads: 0, scrapers: 0, watchedStatus: 0, total: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,14 +244,14 @@ export class BackupService {
|
||||||
public async restoreBackup(fileUri: string, options: BackupOptions = {}): Promise<void> {
|
public async restoreBackup(fileUri: string, options: BackupOptions = {}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info('[BackupService] Starting backup restore...');
|
logger.info('[BackupService] Starting backup restore...');
|
||||||
|
|
||||||
// Read and validate backup file
|
// Read and validate backup file
|
||||||
const backupContent = await FileSystem.readAsStringAsync(fileUri);
|
const backupContent = await FileSystem.readAsStringAsync(fileUri);
|
||||||
const backupData: BackupData = JSON.parse(backupContent);
|
const backupData: BackupData = JSON.parse(backupContent);
|
||||||
|
|
||||||
// Validate backup format
|
// Validate backup format
|
||||||
this.validateBackupData(backupData);
|
this.validateBackupData(backupData);
|
||||||
|
|
||||||
logger.info(`[BackupService] Restoring backup from ${backupData.timestamp}`);
|
logger.info(`[BackupService] Restoring backup from ${backupData.timestamp}`);
|
||||||
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
|
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
|
||||||
|
|
||||||
|
|
@ -245,27 +259,27 @@ export class BackupService {
|
||||||
if (options.includeSettings !== false && backupData.data.settings) {
|
if (options.includeSettings !== false && backupData.data.settings) {
|
||||||
await this.restoreSettings(backupData.data.settings);
|
await this.restoreSettings(backupData.data.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeLibrary !== false && backupData.data.library) {
|
if (options.includeLibrary !== false && backupData.data.library) {
|
||||||
await this.restoreLibrary(backupData.data.library);
|
await this.restoreLibrary(backupData.data.library);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeWatchProgress !== false && backupData.data.watchProgress) {
|
if (options.includeWatchProgress !== false && backupData.data.watchProgress) {
|
||||||
await this.restoreWatchProgress(backupData.data.watchProgress);
|
await this.restoreWatchProgress(backupData.data.watchProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeAddons !== false && backupData.data.addons) {
|
if (options.includeAddons !== false && backupData.data.addons) {
|
||||||
await this.restoreAddons(backupData.data.addons);
|
await this.restoreAddons(backupData.data.addons);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeDownloads !== false && backupData.data.downloads) {
|
if (options.includeDownloads !== false && backupData.data.downloads) {
|
||||||
await this.restoreDownloads(backupData.data.downloads);
|
await this.restoreDownloads(backupData.data.downloads);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeTraktData !== false && backupData.data.traktSettings) {
|
if (options.includeTraktData !== false && backupData.data.traktSettings) {
|
||||||
await this.restoreTraktSettings(backupData.data.traktSettings);
|
await this.restoreTraktSettings(backupData.data.traktSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeLocalScrapers !== false && backupData.data.localScrapers) {
|
if (options.includeLocalScrapers !== false && backupData.data.localScrapers) {
|
||||||
await this.restoreLocalScrapers(backupData.data.localScrapers);
|
await this.restoreLocalScrapers(backupData.data.localScrapers);
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +326,12 @@ export class BackupService {
|
||||||
if (backupData.data.syncQueue) {
|
if (backupData.data.syncQueue) {
|
||||||
await this.restoreSyncQueue(backupData.data.syncQueue);
|
await this.restoreSyncQueue(backupData.data.syncQueue);
|
||||||
}
|
}
|
||||||
|
if (backupData.data.watchedStatus) {
|
||||||
|
await this.restoreWatchedStatus(backupData.data.watchedStatus);
|
||||||
|
}
|
||||||
|
if (backupData.data.catalogUiPreferences) {
|
||||||
|
await this.restoreCatalogUiPreferences(backupData.data.catalogUiPreferences);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('[BackupService] Backup restore completed successfully');
|
logger.info('[BackupService] Backup restore completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -327,7 +347,7 @@ export class BackupService {
|
||||||
try {
|
try {
|
||||||
const backupContent = await FileSystem.readAsStringAsync(fileUri);
|
const backupContent = await FileSystem.readAsStringAsync(fileUri);
|
||||||
const backupData: BackupData = JSON.parse(backupContent);
|
const backupData: BackupData = JSON.parse(backupContent);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: backupData.version,
|
version: backupData.version,
|
||||||
timestamp: backupData.timestamp,
|
timestamp: backupData.timestamp,
|
||||||
|
|
@ -412,10 +432,10 @@ export class BackupService {
|
||||||
try {
|
try {
|
||||||
const scope = await this.getUserScope();
|
const scope = await this.getUserScope();
|
||||||
const allKeys = await mmkvStorage.getAllKeys();
|
const allKeys = await mmkvStorage.getAllKeys();
|
||||||
const watchProgressKeys = allKeys.filter(key =>
|
const watchProgressKeys = allKeys.filter(key =>
|
||||||
key.startsWith(`@user:${scope}:@watch_progress:`)
|
key.startsWith(`@user:${scope}:@watch_progress:`)
|
||||||
);
|
);
|
||||||
|
|
||||||
const watchProgress: Record<string, any> = {};
|
const watchProgress: Record<string, any> = {};
|
||||||
if (watchProgressKeys.length > 0) {
|
if (watchProgressKeys.length > 0) {
|
||||||
const pairs = await mmkvStorage.multiGet(watchProgressKeys);
|
const pairs = await mmkvStorage.multiGet(watchProgressKeys);
|
||||||
|
|
@ -460,7 +480,7 @@ export class BackupService {
|
||||||
const scopedKey = `@user:${scope}:@subtitle_settings`;
|
const scopedKey = `@user:${scope}:@subtitle_settings`;
|
||||||
const subtitlesJson = await mmkvStorage.getItem(scopedKey);
|
const subtitlesJson = await mmkvStorage.getItem(scopedKey);
|
||||||
let subtitleSettings = subtitlesJson ? JSON.parse(subtitlesJson) : {};
|
let subtitleSettings = subtitlesJson ? JSON.parse(subtitlesJson) : {};
|
||||||
|
|
||||||
// Also check for legacy subtitle size preference
|
// Also check for legacy subtitle size preference
|
||||||
const legacySubtitleSize = await mmkvStorage.getItem('@subtitle_size_preference');
|
const legacySubtitleSize = await mmkvStorage.getItem('@subtitle_size_preference');
|
||||||
if (legacySubtitleSize && !subtitleSettings.subtitleSize) {
|
if (legacySubtitleSize && !subtitleSettings.subtitleSize) {
|
||||||
|
|
@ -469,7 +489,7 @@ export class BackupService {
|
||||||
subtitleSettings.subtitleSize = legacySize;
|
subtitleSettings.subtitleSize = legacySize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return subtitleSettings;
|
return subtitleSettings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupService] Failed to get subtitle settings:', error);
|
logger.error('[BackupService] Failed to get subtitle settings:', error);
|
||||||
|
|
@ -505,10 +525,10 @@ export class BackupService {
|
||||||
try {
|
try {
|
||||||
const scope = await this.getUserScope();
|
const scope = await this.getUserScope();
|
||||||
const allKeys = await mmkvStorage.getAllKeys();
|
const allKeys = await mmkvStorage.getAllKeys();
|
||||||
const durationKeys = allKeys.filter(key =>
|
const durationKeys = allKeys.filter(key =>
|
||||||
key.startsWith(`@user:${scope}:@content_duration:`)
|
key.startsWith(`@user:${scope}:@content_duration:`)
|
||||||
);
|
);
|
||||||
|
|
||||||
const contentDuration: Record<string, number> = {};
|
const contentDuration: Record<string, number> = {};
|
||||||
if (durationKeys.length > 0) {
|
if (durationKeys.length > 0) {
|
||||||
const pairs = await mmkvStorage.multiGet(durationKeys);
|
const pairs = await mmkvStorage.multiGet(durationKeys);
|
||||||
|
|
@ -540,7 +560,7 @@ export class BackupService {
|
||||||
// Get general Trakt settings
|
// Get general Trakt settings
|
||||||
const traktSettingsJson = await mmkvStorage.getItem('trakt_settings');
|
const traktSettingsJson = await mmkvStorage.getItem('trakt_settings');
|
||||||
const traktSettings = traktSettingsJson ? JSON.parse(traktSettingsJson) : {};
|
const traktSettings = traktSettingsJson ? JSON.parse(traktSettingsJson) : {};
|
||||||
|
|
||||||
// Get authentication tokens
|
// Get authentication tokens
|
||||||
const [
|
const [
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|
@ -557,7 +577,7 @@ export class BackupService {
|
||||||
mmkvStorage.getItem('trakt_sync_frequency'),
|
mmkvStorage.getItem('trakt_sync_frequency'),
|
||||||
mmkvStorage.getItem('trakt_completion_threshold')
|
mmkvStorage.getItem('trakt_completion_threshold')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...traktSettings,
|
...traktSettings,
|
||||||
authentication: {
|
authentication: {
|
||||||
|
|
@ -567,7 +587,7 @@ export class BackupService {
|
||||||
},
|
},
|
||||||
autosync: {
|
autosync: {
|
||||||
enabled: autosyncEnabled ? (() => {
|
enabled: autosyncEnabled ? (() => {
|
||||||
try { return JSON.parse(autosyncEnabled); }
|
try { return JSON.parse(autosyncEnabled); }
|
||||||
catch { return true; }
|
catch { return true; }
|
||||||
})() : true,
|
})() : true,
|
||||||
frequency: syncFrequency ? parseInt(syncFrequency, 10) : 60000,
|
frequency: syncFrequency ? parseInt(syncFrequency, 10) : 60000,
|
||||||
|
|
@ -625,7 +645,7 @@ export class BackupService {
|
||||||
mmkvStorage.getItem('mdblist_api_key'),
|
mmkvStorage.getItem('mdblist_api_key'),
|
||||||
mmkvStorage.getItem('openrouter_api_key')
|
mmkvStorage.getItem('openrouter_api_key')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mdblistApiKey: mdblistKey || undefined,
|
mdblistApiKey: mdblistKey || undefined,
|
||||||
openRouterApiKey: openRouterKey || undefined
|
openRouterApiKey: openRouterKey || undefined
|
||||||
|
|
@ -650,14 +670,14 @@ export class BackupService {
|
||||||
try {
|
try {
|
||||||
const scope = await this.getUserScope();
|
const scope = await this.getUserScope();
|
||||||
const scopedKey = `@user:${scope}:stremio-addon-order`;
|
const scopedKey = `@user:${scope}:stremio-addon-order`;
|
||||||
|
|
||||||
// Try scoped key first, then legacy keys
|
// Try scoped key first, then legacy keys
|
||||||
const [scopedOrder, legacyOrder, localOrder] = await Promise.all([
|
const [scopedOrder, legacyOrder, localOrder] = await Promise.all([
|
||||||
mmkvStorage.getItem(scopedKey),
|
mmkvStorage.getItem(scopedKey),
|
||||||
mmkvStorage.getItem('stremio-addon-order'),
|
mmkvStorage.getItem('stremio-addon-order'),
|
||||||
mmkvStorage.getItem('@user:local:stremio-addon-order')
|
mmkvStorage.getItem('@user:local:stremio-addon-order')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const orderJson = scopedOrder || legacyOrder || localOrder;
|
const orderJson = scopedOrder || legacyOrder || localOrder;
|
||||||
return orderJson ? JSON.parse(orderJson) : [];
|
return orderJson ? JSON.parse(orderJson) : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -764,12 +784,12 @@ export class BackupService {
|
||||||
const scope = await this.getUserScope();
|
const scope = await this.getUserScope();
|
||||||
const scopedKey = `@user:${scope}:@subtitle_settings`;
|
const scopedKey = `@user:${scope}:@subtitle_settings`;
|
||||||
await mmkvStorage.setItem(scopedKey, JSON.stringify(subtitles));
|
await mmkvStorage.setItem(scopedKey, JSON.stringify(subtitles));
|
||||||
|
|
||||||
// Also restore legacy subtitle size preference for backward compatibility
|
// Also restore legacy subtitle size preference for backward compatibility
|
||||||
if (subtitles && typeof subtitles.subtitleSize === 'number') {
|
if (subtitles && typeof subtitles.subtitleSize === 'number') {
|
||||||
await mmkvStorage.setItem('@subtitle_size_preference', subtitles.subtitleSize.toString());
|
await mmkvStorage.setItem('@subtitle_size_preference', subtitles.subtitleSize.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[BackupService] Subtitle settings restored');
|
logger.info('[BackupService] Subtitle settings restored');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupService] Failed to restore subtitle settings:', error);
|
logger.error('[BackupService] Failed to restore subtitle settings:', error);
|
||||||
|
|
@ -822,48 +842,48 @@ export class BackupService {
|
||||||
// Restore general Trakt settings
|
// Restore general Trakt settings
|
||||||
if (traktSettings && typeof traktSettings === 'object') {
|
if (traktSettings && typeof traktSettings === 'object') {
|
||||||
const { authentication, autosync, ...generalSettings } = traktSettings;
|
const { authentication, autosync, ...generalSettings } = traktSettings;
|
||||||
|
|
||||||
// Restore general settings
|
// Restore general settings
|
||||||
await mmkvStorage.setItem('trakt_settings', JSON.stringify(generalSettings));
|
await mmkvStorage.setItem('trakt_settings', JSON.stringify(generalSettings));
|
||||||
|
|
||||||
// Restore authentication tokens if available
|
// Restore authentication tokens if available
|
||||||
if (authentication) {
|
if (authentication) {
|
||||||
const tokenPromises = [];
|
const tokenPromises = [];
|
||||||
|
|
||||||
if (authentication.accessToken) {
|
if (authentication.accessToken) {
|
||||||
tokenPromises.push(mmkvStorage.setItem('trakt_access_token', authentication.accessToken));
|
tokenPromises.push(mmkvStorage.setItem('trakt_access_token', authentication.accessToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authentication.refreshToken) {
|
if (authentication.refreshToken) {
|
||||||
tokenPromises.push(mmkvStorage.setItem('trakt_refresh_token', authentication.refreshToken));
|
tokenPromises.push(mmkvStorage.setItem('trakt_refresh_token', authentication.refreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authentication.tokenExpiry) {
|
if (authentication.tokenExpiry) {
|
||||||
tokenPromises.push(mmkvStorage.setItem('trakt_token_expiry', authentication.tokenExpiry.toString()));
|
tokenPromises.push(mmkvStorage.setItem('trakt_token_expiry', authentication.tokenExpiry.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(tokenPromises);
|
await Promise.all(tokenPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore autosync settings if available
|
// Restore autosync settings if available
|
||||||
if (autosync) {
|
if (autosync) {
|
||||||
const autosyncPromises = [];
|
const autosyncPromises = [];
|
||||||
|
|
||||||
if (autosync.enabled !== undefined) {
|
if (autosync.enabled !== undefined) {
|
||||||
autosyncPromises.push(mmkvStorage.setItem('trakt_autosync_enabled', JSON.stringify(autosync.enabled)));
|
autosyncPromises.push(mmkvStorage.setItem('trakt_autosync_enabled', JSON.stringify(autosync.enabled)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autosync.frequency !== undefined) {
|
if (autosync.frequency !== undefined) {
|
||||||
autosyncPromises.push(mmkvStorage.setItem('trakt_sync_frequency', autosync.frequency.toString()));
|
autosyncPromises.push(mmkvStorage.setItem('trakt_sync_frequency', autosync.frequency.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autosync.completionThreshold !== undefined) {
|
if (autosync.completionThreshold !== undefined) {
|
||||||
autosyncPromises.push(mmkvStorage.setItem('trakt_completion_threshold', autosync.completionThreshold.toString()));
|
autosyncPromises.push(mmkvStorage.setItem('trakt_completion_threshold', autosync.completionThreshold.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(autosyncPromises);
|
await Promise.all(autosyncPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[BackupService] Trakt settings and authentication restored');
|
logger.info('[BackupService] Trakt settings and authentication restored');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -912,15 +932,15 @@ export class BackupService {
|
||||||
private async restoreApiKeys(apiKeys: { mdblistApiKey?: string; openRouterApiKey?: string }): Promise<void> {
|
private async restoreApiKeys(apiKeys: { mdblistApiKey?: string; openRouterApiKey?: string }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const setPromises: Promise<void>[] = [];
|
const setPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
if (apiKeys.mdblistApiKey) {
|
if (apiKeys.mdblistApiKey) {
|
||||||
setPromises.push(mmkvStorage.setItem('mdblist_api_key', apiKeys.mdblistApiKey));
|
setPromises.push(mmkvStorage.setItem('mdblist_api_key', apiKeys.mdblistApiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKeys.openRouterApiKey) {
|
if (apiKeys.openRouterApiKey) {
|
||||||
setPromises.push(mmkvStorage.setItem('openrouter_api_key', apiKeys.openRouterApiKey));
|
setPromises.push(mmkvStorage.setItem('openrouter_api_key', apiKeys.openRouterApiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(setPromises);
|
await Promise.all(setPromises);
|
||||||
logger.info('[BackupService] API keys restored');
|
logger.info('[BackupService] API keys restored');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -941,13 +961,13 @@ export class BackupService {
|
||||||
try {
|
try {
|
||||||
const scope = await this.getUserScope();
|
const scope = await this.getUserScope();
|
||||||
const scopedKey = `@user:${scope}:stremio-addon-order`;
|
const scopedKey = `@user:${scope}:stremio-addon-order`;
|
||||||
|
|
||||||
// Restore to both scoped and legacy keys for compatibility
|
// Restore to both scoped and legacy keys for compatibility
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mmkvStorage.setItem(scopedKey, JSON.stringify(addonOrder)),
|
mmkvStorage.setItem(scopedKey, JSON.stringify(addonOrder)),
|
||||||
mmkvStorage.setItem('stremio-addon-order', JSON.stringify(addonOrder))
|
mmkvStorage.setItem('stremio-addon-order', JSON.stringify(addonOrder))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.info('[BackupService] Addon order restored');
|
logger.info('[BackupService] Addon order restored');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[BackupService] Failed to restore addon order:', error);
|
logger.error('[BackupService] Failed to restore addon order:', error);
|
||||||
|
|
@ -990,11 +1010,87 @@ export class BackupService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all watched status markers (watched:movie:* and watched:series:*)
|
||||||
|
private async getWatchedStatus(): Promise<Record<string, boolean>> {
|
||||||
|
try {
|
||||||
|
const allKeys = await mmkvStorage.getAllKeys();
|
||||||
|
const watchedKeys = allKeys.filter(key =>
|
||||||
|
key.startsWith('watched:movie:') || key.startsWith('watched:series:') || key.startsWith('watched:')
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchedStatus: Record<string, boolean> = {};
|
||||||
|
if (watchedKeys.length > 0) {
|
||||||
|
const pairs = await mmkvStorage.multiGet(watchedKeys);
|
||||||
|
for (const [key, value] of pairs) {
|
||||||
|
if (value) {
|
||||||
|
watchedStatus[key] = value === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`[BackupService] Found ${Object.keys(watchedStatus).length} watched status markers`);
|
||||||
|
return watchedStatus;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[BackupService] Failed to get watched status:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get catalog UI preferences (column count, show titles)
|
||||||
|
private async getCatalogUiPreferences(): Promise<{ mobileColumns?: string; showTitles?: string }> {
|
||||||
|
try {
|
||||||
|
const [mobileColumns, showTitles] = await Promise.all([
|
||||||
|
mmkvStorage.getItem('catalog_mobile_columns'),
|
||||||
|
mmkvStorage.getItem('catalog_show_titles')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mobileColumns: mobileColumns || undefined,
|
||||||
|
showTitles: showTitles || undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[BackupService] Failed to get catalog UI preferences:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore watched status markers
|
||||||
|
private async restoreWatchedStatus(watchedStatus: Record<string, boolean>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pairs: [string, string][] = Object.entries(watchedStatus).map(([key, value]) => [key, value ? 'true' : 'false']);
|
||||||
|
if (pairs.length > 0) {
|
||||||
|
await mmkvStorage.multiSet(pairs);
|
||||||
|
}
|
||||||
|
logger.info(`[BackupService] Restored ${pairs.length} watched status markers`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[BackupService] Failed to restore watched status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore catalog UI preferences
|
||||||
|
private async restoreCatalogUiPreferences(prefs: { mobileColumns?: string; showTitles?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const setPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (prefs.mobileColumns) {
|
||||||
|
setPromises.push(mmkvStorage.setItem('catalog_mobile_columns', prefs.mobileColumns));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.showTitles) {
|
||||||
|
setPromises.push(mmkvStorage.setItem('catalog_show_titles', prefs.showTitles));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(setPromises);
|
||||||
|
logger.info('[BackupService] Catalog UI preferences restored');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[BackupService] Failed to restore catalog UI preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private validateBackupData(backupData: any): void {
|
private validateBackupData(backupData: any): void {
|
||||||
if (!backupData.version || !backupData.timestamp || !backupData.data) {
|
if (!backupData.version || !backupData.timestamp || !backupData.data) {
|
||||||
throw new Error('Invalid backup file format');
|
throw new Error('Invalid backup file format');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backupData.version !== this.BACKUP_VERSION) {
|
if (backupData.version !== this.BACKUP_VERSION) {
|
||||||
throw new Error(`Unsupported backup version: ${backupData.version}`);
|
throw new Error(`Unsupported backup version: ${backupData.version}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { mmkvStorage } from './mmkvStorage';
|
import { mmkvStorage } from './mmkvStorage';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
const DEV_URL = '';
|
// Campaign API URL - use env variable, fallback to local dev server
|
||||||
const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || '';
|
const CAMPAIGN_API_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || 'http://localhost:3000';
|
||||||
const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL;
|
|
||||||
|
|
||||||
export type CampaignAction = {
|
export type CampaignAction = {
|
||||||
type: 'link' | 'navigate' | 'dismiss';
|
type: 'link' | 'navigate' | 'dismiss';
|
||||||
|
|
@ -15,7 +14,9 @@ export type CampaignAction = {
|
||||||
export type CampaignContent = {
|
export type CampaignContent = {
|
||||||
title?: string;
|
title?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
mediaType?: 'image' | 'video';
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
videoUrl?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
closeButtonColor?: string;
|
closeButtonColor?: string;
|
||||||
|
|
@ -58,11 +59,16 @@ class CampaignService {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
console.log('[CampaignService] getActiveCampaign called, API URL:', CAMPAIGN_API_URL);
|
||||||
|
|
||||||
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
|
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
|
||||||
|
console.log('[CampaignService] Using cached campaigns');
|
||||||
return this.getNextValidCampaign();
|
return this.getNextValidCampaign();
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = Platform.OS;
|
const platform = Platform.OS;
|
||||||
|
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
|
||||||
|
console.log('[CampaignService] Fetching from:', url);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
|
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
|
||||||
{
|
{
|
||||||
|
|
@ -89,13 +95,20 @@ class CampaignService {
|
||||||
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
|
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
|
||||||
campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`;
|
campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`;
|
||||||
}
|
}
|
||||||
|
if (campaign.content?.videoUrl && campaign.content.videoUrl.startsWith('/')) {
|
||||||
|
campaign.content.videoUrl = `${CAMPAIGN_API_URL}${campaign.content.videoUrl}`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[CampaignService] Fetched campaigns:', campaigns.length, 'CAMPAIGN_API_URL:', CAMPAIGN_API_URL);
|
||||||
|
|
||||||
this.campaignQueue = campaigns;
|
this.campaignQueue = campaigns;
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.lastFetch = now;
|
this.lastFetch = now;
|
||||||
|
|
||||||
return this.getNextValidCampaign();
|
const result = this.getNextValidCampaign();
|
||||||
|
console.log('[CampaignService] Next valid campaign:', result?.id, result?.type);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[CampaignService] Error fetching campaigns:', error);
|
console.warn('[CampaignService] Error fetching campaigns:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue