added arabic

This commit is contained in:
tapframe 2026-01-06 15:56:27 +05:30
parent 437645d5fd
commit 334d0b1863
12 changed files with 1534 additions and 109 deletions

View file

@ -9,6 +9,7 @@ import {
} from 'react-native';
import { InteractionManager } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
const { width } = Dimensions.get('window');
const COLUMN_COUNT = 7; // 7 days in a week
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface CalendarEpisode {
id: string;
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
episodes = [],
onSelectDate
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const weekDays = [
t('common.days_short.sun'),
t('common.days_short.mon'),
t('common.days_short.tue'),
t('common.days_short.wed'),
t('common.days_short.thu'),
t('common.days_short.fri'),
t('common.days_short.sat')
];
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
const [uiReady, setUiReady] = useState(false);

View file

@ -9,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
import ContentItem from './ContentItem';
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
interface CatalogSectionProps {
catalog: CatalogContent;
@ -74,10 +75,44 @@ const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
// Use state for the display name to handle async custom name resolution
const [displayName, setDisplayName] = React.useState(catalog.name);
// Re-resolve and format the name when language or catalog data changes
React.useEffect(() => {
const resolveName = async () => {
// 1. Check for user-defined custom name
const customName = await getCatalogDisplayName(
catalog.addon,
catalog.type,
catalog.id,
catalog.originalName || catalog.name
);
// 2. If it's a user setting, use it as is
if (customName !== (catalog.originalName || catalog.name)) {
setDisplayName(customName);
return;
}
// 3. Otherwise, use localized formatting
const formatted = getFormattedCatalogName(
customName,
catalog.type,
t('home.movies'),
t('home.tv_shows'),
t('home.channels')
);
setDisplayName(formatted);
};
resolveName();
}, [catalog.addon, catalog.id, catalog.type, catalog.name, catalog.originalName, i18n.language, t]);
const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
}, [navigation, catalog.addon]);
@ -119,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
]}
numberOfLines={1}
>
{catalog.name}
{displayName}
</Text>
<View
style={[

1172
src/i18n/locales/ar.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,15 @@
"minutes_ago": "{{count}}m ago",
"hours_ago": "{{count}}h ago",
"days_ago": "{{count}}d ago"
},
"days_short": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
}
},
"home": {
@ -41,6 +50,10 @@
"sign_in_desc": "You can sign in anytime from Settings → Account",
"view_all": "View All",
"this_week": "This Week",
"upcoming": "Upcoming",
"recently_released": "Recently Released",
"no_scheduled_episodes": "Series with No Scheduled Episodes",
"check_back_later": "Check back later",
"continue_watching": "Continue Watching",
"up_next": "Up Next",
"up_next_caps": "UP NEXT",
@ -306,7 +319,8 @@
"all": "All",
"failed_tmdb": "Failed to load content from TMDB",
"movies": "Movies",
"tv_shows": "TV Shows"
"tv_shows": "TV Shows",
"channels": "Channels"
},
"streams": {
"back_to_episodes": "Back to Episodes",
@ -563,6 +577,7 @@
"select_language": "Select Language",
"english": "English",
"portuguese": "Portuguese",
"arabic": "Arabic",
"account": "Account",
"content_discovery": "Content & Discovery",
"appearance": "Appearance",
@ -871,6 +886,17 @@
"tv_shows": "TV Shows"
}
},
"calendar": {
"title": "Calendar",
"loading": "Loading calendar...",
"no_scheduled_episodes": "No scheduled episodes",
"check_back_later": "Check back later",
"showing_episodes_for": "Showing episodes for {{date}}",
"show_all_episodes": "Show All Episodes",
"no_episodes_for": "No episodes for {{date}}",
"no_upcoming_found": "No upcoming episodes found",
"add_series_desc": "Add series to your library to see their upcoming episodes here"
},
"mdblist": {
"title": "Rating Sources",
"status_disabled": "MDBList Disabled",

View file

@ -24,6 +24,15 @@
"minutes_ago": "{{count}}m atrás",
"hours_ago": "{{count}}h atrás",
"days_ago": "{{count}}d atrás"
},
"days_short": {
"sun": "Dom",
"mon": "Seg",
"tue": "Ter",
"wed": "Qua",
"thu": "Qui",
"fri": "Sex",
"sat": "Sáb"
}
},
"home": {
@ -41,6 +50,10 @@
"sign_in_desc": "Você pode entrar a qualquer momento em Configurações → Conta",
"view_all": "Ver Tudo",
"this_week": "Esta Semana",
"upcoming": "Próximos",
"recently_released": "Lançados Recentemente",
"no_scheduled_episodes": "Séries sem episódios agendados",
"check_back_later": "Volte mais tarde",
"continue_watching": "Continue Assistindo",
"up_next": "A Seguir",
"up_next_caps": "A SEGUIR",
@ -306,7 +319,8 @@
"all": "Todos",
"failed_tmdb": "Falha ao carregar conteúdo do TMDB",
"movies": "Filmes",
"tv_shows": "Séries"
"tv_shows": "Séries",
"channels": "Canais"
},
"streams": {
"back_to_episodes": "Voltar aos Episódios",
@ -541,6 +555,7 @@
"select_language": "Selecionar Idioma",
"english": "Inglês",
"portuguese": "Português",
"arabic": "Árabe",
"account": "Conta",
"content_discovery": "Conteúdo e Descoberta",
"appearance": "Aparência",
@ -839,6 +854,17 @@
"tv_shows": "Séries e TV"
}
},
"calendar": {
"title": "Calendário",
"loading": "Carregando calendário...",
"no_scheduled_episodes": "Sem episódios agendados",
"check_back_later": "Volte mais tarde",
"showing_episodes_for": "Mostrando episódios para {{date}}",
"show_all_episodes": "Mostrar Todos os Episódios",
"no_episodes_for": "Nenhum episódio para {{date}}",
"no_upcoming_found": "Nenhum episódio futuro encontrado",
"add_series_desc": "Adicione séries à sua biblioteca para ver os próximos episódios aqui"
},
"mdblist": {
"title": "Fontes de Avaliação",
"status_disabled": "MDBList Desativado",

View file

@ -1,7 +1,9 @@
import en from './locales/en.json';
import pt from './locales/pt.json';
import ar from './locales/ar.json';
export const resources = {
en: { translation: en },
pt: { translation: pt },
ar: { translation: ar },
};

View file

@ -16,6 +16,7 @@ import {
import { InteractionManager } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -55,6 +56,7 @@ interface CalendarSection {
}
const CalendarScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { libraryItems, loading: libraryLoading } = useLibrary();
const { currentTheme } = useTheme();
@ -189,7 +191,7 @@ const CalendarScreen = () => {
) : (
<>
<Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}>
No scheduled episodes
{t('calendar.no_scheduled_episodes')}
</Text>
<View style={styles.dateContainer}>
<MaterialIcons
@ -197,7 +199,7 @@ const CalendarScreen = () => {
size={16}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>Check back later</Text>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{t('calendar.check_back_later')}</Text>
</View>
</>
)}
@ -207,16 +209,28 @@ const CalendarScreen = () => {
);
};
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{section.title}
</Text>
</View>
);
const renderSectionHeader = ({ section }: { section: CalendarSection }) => {
// Map section titles to translation keys
const titleKeyMap: Record<string, string> = {
'This Week': 'home.this_week',
'Upcoming': 'home.upcoming',
'Recently Released': 'home.recently_released',
'Series with No Scheduled Episodes': 'home.no_scheduled_episodes'
};
const displayTitle = titleKeyMap[section.title] ? t(titleKeyMap[section.title]) : section.title;
return (
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{displayTitle}
</Text>
</View>
);
};
// Process all episodes once data is loaded - using memory-efficient approach
const allEpisodes = React.useMemo(() => {
@ -276,7 +290,7 @@ const CalendarScreen = () => {
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading calendar...</Text>
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>{t('calendar.loading')}</Text>
</View>
</SafeAreaView>
);
@ -293,14 +307,14 @@ const CalendarScreen = () => {
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('calendar.title')}</Text>
<View style={{ width: 40 }} />
</View>
{selectedDate && filteredEpisodes.length > 0 && (
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
Showing episodes for {format(selectedDate, 'MMMM d, yyyy')}
{t('calendar.showing_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
</Text>
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
<MaterialIcons name="close" size={18} color={currentTheme.colors.text} />
@ -337,14 +351,14 @@ const CalendarScreen = () => {
<View style={styles.emptyFilterContainer}>
<MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}>
No episodes for {format(selectedDate, 'MMMM d, yyyy')}
{t('calendar.no_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
</Text>
<TouchableOpacity
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
onPress={clearDateFilter}
>
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.text }]}>
Show All Episodes
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.white }]}>
{t('calendar.show_all_episodes')}
</Text>
</TouchableOpacity>
</View>
@ -373,10 +387,10 @@ const CalendarScreen = () => {
<View style={styles.emptyContainer}>
<MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.text }]}>
No upcoming episodes found
{t('calendar.no_upcoming_found')}
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Add series to your library to see their upcoming episodes here
{t('calendar.add_series_desc')}
</Text>
</View>
)}

View file

@ -39,6 +39,7 @@ if (Platform.OS === 'ios') {
}
}
import { logger } from '../utils/logger';
import { getFormattedCatalogName } from '../utils/catalogNameUtils';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import { mmkvStorage } from '../services/mmkvStorage';
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
@ -60,6 +61,28 @@ const SPACING = {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints (matching CatalogSection)
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (deviceWidth: number) => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(width);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Dynamic column and spacing calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120;
@ -130,14 +153,28 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.primary,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingBottom: 4,
paddingTop: 8,
width: '100%',
},
titleContainer: {
position: 'relative',
marginBottom: SPACING.md,
},
catalogTitle: {
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -2,
left: 16,
borderRadius: 2,
opacity: 0.8,
},
list: {
padding: SPACING.lg,
paddingTop: SPACING.sm,
@ -330,19 +367,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Create display name with proper type suffix
const createDisplayName = (catalogName: string) => {
if (!catalogName) return '';
// Check if the name already includes content type indicators
const lowerName = catalogName.toLowerCase();
const contentType = type === 'movie' ? t('catalog.movies') : type === 'series' ? t('catalog.tv_shows') : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
// If the name already contains type information, return as is
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
return catalogName;
}
// Otherwise append the content type
return `${catalogName} ${contentType}`;
return getFormattedCatalogName(
catalogName,
type,
t('catalog.movies'),
t('catalog.tv_shows'),
t('catalog.channels')
);
};
// Use actual catalog name if available, otherwise fallback to custom name or original name
@ -350,7 +381,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows')}` :
(type === 'movie' ? t('catalog.movies') : type === 'series' ? t('catalog.tv_shows') : `${type.charAt(0).toUpperCase() + type.slice(1)}s`));
(originalName ? createDisplayName(originalName) : (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))));
// Add effect to get the actual catalog name and filter extras from addon manifest
useEffect(() => {
@ -895,7 +926,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || originalName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{renderLoadingState()}
</SafeAreaView>
);
@ -914,7 +963,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{renderErrorState()}
</SafeAreaView>
);
@ -932,7 +999,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{/* Filter chip bar - shows when catalog has filterable extras */}
{catalogExtras.length > 0 && (

View file

@ -45,7 +45,11 @@ import * as Haptics from 'expo-haptics';
import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { storageService } from '../services/storageService';
import { getCatalogDisplayName, clearCustomNameCache } from '../utils/catalogNameUtils';
import {
getCatalogDisplayName,
getFormattedCatalogName,
clearCustomNameCache
} from '../utils/catalogNameUtils';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
@ -95,13 +99,6 @@ type HomeScreenListItem =
| { type: 'welcome'; key: string }
| { type: 'loadMore'; key: string };
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
{ id: 'movie', name: 'Movies' },
{ id: 'series', name: 'Series' },
{ id: 'channel', name: 'Channels' },
];
const SkeletonCatalog = React.memo(() => {
const { currentTheme } = useTheme();
return (
@ -279,21 +276,13 @@ const HomeScreen = () => {
const isCustom = displayName !== originalName;
if (!isCustom) {
// De-duplicate repeated words (case-insensitive)
const words = displayName.split(' ').filter(Boolean);
const uniqueWords: string[] = [];
const seen = new Set<string>();
for (const w of words) {
const lw = w.toLowerCase();
if (!seen.has(lw)) { uniqueWords.push(w); seen.add(lw); }
}
displayName = uniqueWords.join(' ');
// Append content type if not present
const contentType = catalog.type === 'movie' ? t('home.movies') : t('home.tv_shows');
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
displayName = getFormattedCatalogName(
displayName,
catalog.type,
t('home.movies'),
t('home.tv_shows'),
t('home.channels')
);
}
const catalogContent = {
@ -301,6 +290,7 @@ const HomeScreen = () => {
type: catalog.type,
id: catalog.id,
name: displayName,
originalName: originalName,
items
};

View file

@ -586,7 +586,11 @@ const SettingsScreen: React.FC = () => {
<SettingsCard title="GENERAL">
<SettingItem
title={t('settings.language')}
description={i18n.language === 'pt' ? t('settings.portuguese') : t('settings.english')}
description={
i18n.language === 'pt' ? t('settings.portuguese') :
i18n.language === 'ar' ? t('settings.arabic') :
t('settings.english')
}
icon="globe"
renderControl={() => <ChevronRight />}
onPress={() => setLanguageModalVisible(true)}
@ -865,6 +869,28 @@ const SettingsScreen: React.FC = () => {
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'ar' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('ar');
setLanguageModalVisible(false);
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'ar' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.arabic')}
</Text>
{i18n.language === 'ar' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>

View file

@ -54,7 +54,6 @@ export interface StreamingContent {
id: string;
type: string;
name: string;
addonId?: string;
tmdbId?: number;
poster: string;
posterShape?: 'poster' | 'square' | 'landscape';
@ -140,6 +139,7 @@ export interface CatalogContent {
type: string;
id: string;
name: string;
originalName?: string;
genre?: string;
items: StreamingContent[];
}
@ -375,7 +375,7 @@ class CatalogService {
if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta, addon.id));
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name; if customized, respect it as-is
const originalName = catalog.name || catalog.id;
@ -467,7 +467,7 @@ class CatalogService {
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta, addon.id));
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
@ -704,7 +704,7 @@ class CatalogService {
});
// Add to recent content using enhanced conversion for full metadata
const content = this.convertMetaToStreamingContentEnhanced(meta, preferredAddonId);
const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content);
// Check if it's in the library
@ -798,7 +798,7 @@ class CatalogService {
if (meta) {
// Use basic conversion without enhanced metadata processing
const content = this.convertMetaToStreamingContent(meta, preferredAddonId);
const content = this.convertMetaToStreamingContent(meta);
// Check if it's in the library
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
@ -817,7 +817,7 @@ class CatalogService {
}
}
private convertMetaToStreamingContent(meta: Meta, addonId?: string): StreamingContent {
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
// Basic conversion for catalog display - no enhanced metadata processing
// Use addon's poster if available, otherwise use placeholder
let posterUrl = meta.poster;
@ -835,7 +835,6 @@ class CatalogService {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: posterUrl,
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background,
@ -852,13 +851,12 @@ class CatalogService {
}
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
private convertMetaToStreamingContentEnhanced(meta: Meta, addonId?: string): StreamingContent {
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
// Enhanced conversion to utilize all available metadata from addons
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: meta.posterShape || 'poster',
banner: meta.background,
@ -1145,23 +1143,22 @@ class CatalogService {
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
catalog.extraSupported?.includes('genre');
// If genre is specified but not supported, we still fetch but without the filter
// This ensures we don't skip addons that don't support the filter
// If genre is specified, only use catalogs that support genre OR have no filter restrictions
// If genre is specified but catalog doesn't support genre filter, skip it
if (genre && !supportsGenre) {
continue;
}
const manifest = manifests.find(m => m.id === addon.id);
if (!manifest) continue;
const fetchPromise = (async () => {
try {
// Only apply genre filter if supported
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.slice(0, limit).map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id // Attach addon ID to each result
}));
const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
return {
addonName: addon.name,
items
@ -1213,35 +1210,22 @@ class CatalogService {
catalogId: string,
type: string,
genre?: string,
page: number = 1
limit: number = 20
): Promise<StreamingContent[]> {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const manifest = manifests.find(m => m.id === addonId);
if (!manifest) {
logger.error(`Addon ${addonId} not found`);
return [];
}
// Find the catalog to check if it supports genre filter
const addon = (await this.getAllAddons()).find(a => a.id === addonId);
const catalog = addon?.catalogs?.find(c => c.id === catalogId);
// Check if catalog supports genre filter
const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') ||
catalog?.extraSupported?.includes('genre');
// Only apply genre filter if the catalog supports it
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, 1, filters);
if (metas && metas.length > 0) {
return metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addonId
}));
return metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
}
return [];
} catch (error) {
@ -1534,10 +1518,7 @@ class CatalogService {
const metas = response.data?.metas || [];
if (metas.length > 0) {
const items = metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id
}));
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
logger.log(`Found ${items.length} results from ${addon.name}`);
return items;
}
@ -1625,4 +1606,4 @@ class CatalogService {
}
export const catalogService = CatalogService.getInstance();
export default catalogService;
export default catalogService;

View file

@ -38,9 +38,66 @@ export async function getCatalogDisplayName(addonId: string, type: string, catal
return customNames[key] || originalName;
}
// Function to clear the cache if settings are updated elsewhere
// Function to clear the cache if settings are updated elsewhere
export function clearCustomNameCache() {
customNamesCache = {}; // Reset to empty object
cacheTimestamp = 0; // Invalidate timestamp
logger.info('Custom catalog name cache cleared.');
}
/**
* Formats a catalog name by de-duplicating words, removing redundant English suffixes,
* and appending localized content type
*/
export function getFormattedCatalogName(
originalName: string,
type: string,
localizedMovie: string,
localizedSeries: string,
localizedChannels?: string
): string {
if (!originalName) return '';
// 1. De-duplicate repeated words (case-insensitive)
const words = originalName.split(' ').filter(Boolean);
const uniqueWords: string[] = [];
const seen = new Set<string>();
for (const w of words) {
const lw = w.toLowerCase();
if (!seen.has(lw)) {
uniqueWords.push(w);
seen.add(lw);
}
}
let processedName = uniqueWords.join(' ');
// 2. Remove redundant English suffixes if they exist
const redundantSuffixes = [' movies', ' movie', ' series', ' tv shows', ' tv show', ' shows', ' show', ' channels', ' channel'];
const lowerName = processedName.toLowerCase();
for (const suffix of redundantSuffixes) {
if (lowerName.endsWith(suffix)) {
processedName = processedName.substring(0, processedName.length - suffix.length).trim();
break;
}
}
// 3. Determine the localized content type suffix
let contentType = '';
if (type === 'movie') {
contentType = localizedMovie;
} else if (type === 'series' || type === 'tv') {
contentType = localizedSeries;
} else if (type === 'channel' && localizedChannels) {
contentType = localizedChannels;
}
if (!contentType) return processedName;
// 4. If the processed name already contains the localized content type, return it
if (processedName.toLowerCase().includes(contentType.toLowerCase())) {
return processedName;
}
return `${processedName} ${contentType}`;
}