mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
trakt comment added
This commit is contained in:
parent
ca52a81141
commit
cc2e0308d7
4 changed files with 1406 additions and 19 deletions
913
src/components/metadata/CommentsSection.tsx
Normal file
913
src/components/metadata/CommentsSection.tsx
Normal file
|
|
@ -0,0 +1,913 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { TraktContentComment } from '../../services/traktService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useTraktComments } from '../../hooks/useTraktComments';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface CommentsSectionProps {
|
||||
imdbId: string;
|
||||
type: 'movie' | 'show';
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: TraktContentComment;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
// Compact comment card for horizontal scrolling
|
||||
const CompactCommentCard: React.FC<{
|
||||
comment: TraktContentComment;
|
||||
theme: any;
|
||||
onPress: () => void;
|
||||
isSpoilerRevealed: boolean;
|
||||
onSpoilerPress: () => void;
|
||||
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
// Safety check - ensure comment data exists
|
||||
if (!comment || !comment.comment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle missing user data gracefully
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous';
|
||||
|
||||
// Handle spoiler content
|
||||
const hasSpoiler = comment.spoiler;
|
||||
const shouldBlurContent = hasSpoiler && !isSpoilerRevealed;
|
||||
|
||||
const truncatedComment = comment.comment.length > 100
|
||||
? comment.comment.substring(0, 100) + '...'
|
||||
: comment.comment;
|
||||
|
||||
// Format relative time
|
||||
const formatRelativeTime = (dateString: string) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const commentDate = new Date(dateString);
|
||||
const diffMs = now.getTime() - commentDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
// For older dates, show month/day
|
||||
return commentDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Render stars for rating (convert 1-10 rating to 1-5 stars)
|
||||
const renderCompactStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating / 2); // Convert 10-point scale to 5 stars
|
||||
const hasHalfStar = rating % 2 >= 1;
|
||||
|
||||
// Add full stars
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<MaterialIcons key={`full-${i}`} name="star" size={10} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
// Add half star if needed
|
||||
if (hasHalfStar) {
|
||||
stars.push(
|
||||
<MaterialIcons key="half" name="star-half" size={10} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
// Add empty stars to make 5 total
|
||||
const filledStars = fullStars + (hasHalfStar ? 1 : 0);
|
||||
const emptyStars = 5 - filledStars;
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<MaterialIcons key={`empty-${i}`} name="star-border" size={10} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (hasSpoiler && !isSpoilerRevealed) {
|
||||
onSpoilerPress();
|
||||
} else {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.compactCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Header Section - Fixed at top */}
|
||||
<View style={styles.compactHeader}>
|
||||
<Text style={[styles.compactUsername, { color: theme.colors.highEmphasis }]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.miniVipBadge}>
|
||||
<Text style={styles.miniVipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Rating - Show stars */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.compactRating}>
|
||||
{renderCompactStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.compactRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Preview - Flexible area that fills space */}
|
||||
<View style={[styles.commentContainer, shouldBlurContent ? styles.blurredContent : undefined]}>
|
||||
<Text
|
||||
style={[styles.compactComment, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={shouldBlurContent ? 3 : undefined}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Meta Info - Fixed at bottom */}
|
||||
<View style={styles.compactMeta}>
|
||||
<View style={styles.compactBadges}>
|
||||
{comment.review && (
|
||||
<View style={[styles.miniReviewBadgeContainer, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.miniBadgeText}>Review</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.spoiler && (
|
||||
<View style={[styles.miniSpoilerBadgeContainer, { backgroundColor: theme.colors.error }]}>
|
||||
<Text style={styles.miniBadgeText}>Spoiler</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.compactStats}>
|
||||
<Text style={[styles.compactTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</Text>
|
||||
{comment.likes > 0 && (
|
||||
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
|
||||
👍 {comment.likes}
|
||||
</Text>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
|
||||
💬 {comment.replies}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Expanded comment modal
|
||||
const ExpandedCommentModal: React.FC<{
|
||||
comment: TraktContentComment | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
theme: any;
|
||||
isSpoilerRevealed: boolean;
|
||||
onSpoilerPress: () => void;
|
||||
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
if (!comment) return null;
|
||||
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous User';
|
||||
const hasSpoiler = comment.spoiler;
|
||||
const shouldBlurModalContent = hasSpoiler && !isSpoilerRevealed;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown date';
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number | null) => {
|
||||
if (rating === null) return null;
|
||||
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating / 2);
|
||||
const hasHalfStar = rating % 2 >= 1;
|
||||
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<MaterialIcons key={`full-${i}`} name="star" size={16} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
if (hasHalfStar) {
|
||||
stars.push(
|
||||
<MaterialIcons key="half" name="star-half" size={16} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStars = 5 - Math.ceil(rating / 2);
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<MaterialIcons key={`empty-${i}`} name="star-border" size={16} color="#FFD700" />
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalContent, {
|
||||
backgroundColor: theme.colors.darkGray || '#0A0C0C',
|
||||
borderColor: theme.colors.border || '#CCCCCC',
|
||||
borderWidth: 1
|
||||
}]}
|
||||
activeOpacity={1}
|
||||
onPress={() => {}} // Prevent closing when clicking on modal content
|
||||
>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{formatDate(comment.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<Text style={[styles.spoilerWarning, { color: theme.colors.error }]}>
|
||||
⚠️ This comment contains spoilers
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
>
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.white }]}>
|
||||
Reveal Spoilers
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.modalComment, { color: theme.colors.highEmphasis }]}>
|
||||
{comment.comment}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.review && (
|
||||
<Text style={[styles.reviewBadge, { color: theme.colors.primary }]}>Review</Text>
|
||||
)}
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerBadge, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
||||
imdbId,
|
||||
type,
|
||||
season,
|
||||
episode,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const [selectedComment, setSelectedComment] = useState<TraktContentComment | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
comments,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
isAuthenticated,
|
||||
loadMore,
|
||||
refresh,
|
||||
} = useTraktComments({
|
||||
imdbId,
|
||||
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
|
||||
season !== undefined ? 'season' : 'show') : 'movie',
|
||||
season,
|
||||
episode,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const handleCommentPress = useCallback((comment: TraktContentComment) => {
|
||||
setSelectedComment(comment);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setModalVisible(false);
|
||||
setSelectedComment(null);
|
||||
}, []);
|
||||
|
||||
const handleSpoilerPress = useCallback((comment: TraktContentComment) => {
|
||||
Alert.alert(
|
||||
'Spoiler Warning',
|
||||
'This comment contains spoilers. Are you sure you want to reveal it?',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Reveal Spoilers',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderComment = useCallback(({ item }: { item: TraktContentComment }) => (
|
||||
<CompactCommentCard
|
||||
comment={item}
|
||||
theme={currentTheme}
|
||||
onPress={() => handleCommentPress(item)}
|
||||
isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
|
||||
onSpoilerPress={() => handleSpoilerPress(item)}
|
||||
/>
|
||||
), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
|
||||
|
||||
const renderEmpty = useCallback(() => {
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{error ? 'Comments unavailable' : 'No comments yet'}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||
{error
|
||||
? 'This content may not be in Trakt\'s database yet'
|
||||
: 'Be the first to comment on Trakt.tv'
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [loading, error, currentTheme]);
|
||||
|
||||
// Don't show section if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trakt Comments
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={[styles.errorContainer, { backgroundColor: currentTheme.colors.card }]}>
|
||||
<MaterialIcons name="error-outline" size={20} color={currentTheme.colors.error} />
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: currentTheme.colors.error }]}
|
||||
onPress={refresh}
|
||||
>
|
||||
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
|
||||
Retry
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && comments.length === 0 && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Loading comments...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{comments.length === 0 ? (
|
||||
renderEmpty()
|
||||
) : (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={comments}
|
||||
keyExtractor={(item) => item?.id?.toString() || Math.random().toString()}
|
||||
renderItem={renderComment}
|
||||
contentContainerStyle={styles.horizontalList}
|
||||
onEndReached={() => {
|
||||
if (hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
hasMore ? (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.card }]}
|
||||
onPress={loadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
Load More
|
||||
</Text>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expanded Comment Modal */}
|
||||
<ExpandedCommentModal
|
||||
comment={selectedComment}
|
||||
visible={modalVisible}
|
||||
onClose={handleModalClose}
|
||||
theme={currentTheme}
|
||||
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
|
||||
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
horizontalList: {
|
||||
paddingRight: 16,
|
||||
},
|
||||
compactCard: {
|
||||
width: 280,
|
||||
height: 160,
|
||||
padding: 12,
|
||||
marginRight: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
compactHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
compactUsername: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
miniVipBadge: {
|
||||
backgroundColor: '#FFD700',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 6,
|
||||
marginLeft: 6,
|
||||
},
|
||||
miniVipText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
compactRating: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
gap: 2,
|
||||
},
|
||||
compactRatingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
},
|
||||
commentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
compactComment: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
blurredContent: {
|
||||
opacity: 0.3,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
compactMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
compactBadges: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
miniReviewBadgeContainer: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
miniSpoilerBadgeContainer: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
miniBadgeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
compactStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
compactStat: {
|
||||
fontSize: 12,
|
||||
},
|
||||
compactTime: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
loadMoreContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
commentItem: {
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
commentHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
username: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
vipBadge: {
|
||||
backgroundColor: '#FFD700',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
marginLeft: 8,
|
||||
},
|
||||
vipText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
ratingText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
commentText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
marginBottom: 12,
|
||||
},
|
||||
commentMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
reviewBadge: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
},
|
||||
spoilerBadge: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
marginLeft: 8,
|
||||
},
|
||||
metaRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
likesContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
likesText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
repliesContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
repliesText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
loadMoreButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginRight: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginTop: 12,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyList: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
},
|
||||
retryButton: {
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
marginLeft: 12,
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '80%',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
paddingTop: 20,
|
||||
},
|
||||
modalUsername: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
modalDate: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
modalRating: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalRatingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
modalComment: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
spoilerContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
spoilerWarning: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
revealButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
revealButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
modalStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
129
src/hooks/useTraktComments.ts
Normal file
129
src/hooks/useTraktComments.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { TraktService, TraktContentComment } from '../services/traktService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface UseTraktCommentsProps {
|
||||
imdbId: string;
|
||||
tmdbId?: number;
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
season?: number;
|
||||
episode?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const useTraktComments = ({
|
||||
imdbId,
|
||||
tmdbId,
|
||||
type,
|
||||
season,
|
||||
episode,
|
||||
enabled = true
|
||||
}: UseTraktCommentsProps) => {
|
||||
const [comments, setComments] = useState<TraktContentComment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
const COMMENTS_PER_PAGE = 10;
|
||||
|
||||
// Check authentication status
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
const authenticated = await traktService.isAuthenticated();
|
||||
setIsAuthenticated(authenticated);
|
||||
} catch (error) {
|
||||
logger.error('[useTraktComments] Failed to check authentication:', error);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
checkAuth();
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const loadComments = useCallback(async (pageNum: number = 1, append: boolean = false) => {
|
||||
if (!enabled || !imdbId || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const traktService = TraktService.getInstance();
|
||||
let fetchedComments: TraktContentComment[] = [];
|
||||
|
||||
console.log(`[useTraktComments] Loading comments for ${type} - IMDb: ${imdbId}, TMDB: ${tmdbId}, page: ${pageNum}`);
|
||||
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
fetchedComments = await traktService.getMovieComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
|
||||
break;
|
||||
case 'show':
|
||||
fetchedComments = await traktService.getShowComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
|
||||
break;
|
||||
case 'season':
|
||||
if (season !== undefined) {
|
||||
fetchedComments = await traktService.getSeasonComments(imdbId, season, pageNum, COMMENTS_PER_PAGE);
|
||||
}
|
||||
break;
|
||||
case 'episode':
|
||||
if (season !== undefined && episode !== undefined) {
|
||||
fetchedComments = await traktService.getEpisodeComments(imdbId, season, episode, pageNum, COMMENTS_PER_PAGE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if there are more comments (basic heuristic: if we got the full page, there might be more)
|
||||
setHasMore(fetchedComments.length === COMMENTS_PER_PAGE);
|
||||
|
||||
setComments(prevComments => {
|
||||
if (append) {
|
||||
const newComments = [...prevComments, ...fetchedComments];
|
||||
console.log(`[useTraktComments] Appended ${fetchedComments.length} comments, total: ${newComments.length}`);
|
||||
return newComments;
|
||||
} else {
|
||||
console.log(`[useTraktComments] Loaded ${fetchedComments.length} comments`);
|
||||
return fetchedComments;
|
||||
}
|
||||
});
|
||||
|
||||
setPage(pageNum);
|
||||
} catch (error) {
|
||||
logger.error('[useTraktComments] Failed to load comments:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load comments');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, imdbId, tmdbId, type, season, episode, isAuthenticated]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && hasMore && isAuthenticated) {
|
||||
loadComments(page + 1, true);
|
||||
}
|
||||
}, [loading, hasMore, page, loadComments, isAuthenticated]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadComments(1, false);
|
||||
}, [loadComments]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadComments(1, false);
|
||||
}, [loadComments]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
isAuthenticated,
|
||||
loadMore,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ import { SeriesContent } from '../components/metadata/SeriesContent';
|
|||
import { MovieContent } from '../components/metadata/MovieContent';
|
||||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { CommentsSection } from '../components/metadata/CommentsSection';
|
||||
import { RouteParams, Episode } from '../types/metadata';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
|
|
@ -63,6 +64,7 @@ const MemoizedSeriesContent = memo(SeriesContent);
|
|||
const MemoizedMovieContent = memo(MovieContent);
|
||||
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
|
||||
const MemoizedRatingsSection = memo(RatingsSection);
|
||||
const MemoizedCommentsSection = memo(CommentsSection);
|
||||
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
||||
|
||||
const MetadataScreen: React.FC = () => {
|
||||
|
|
@ -672,9 +674,18 @@ const MetadataScreen: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Comments Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData && imdbId && (
|
||||
<MemoizedCommentsSection
|
||||
imdbId={imdbId}
|
||||
tmdbId={tmdbId || undefined}
|
||||
type={Object.keys(groupedEpisodes).length > 0 ? 'show' : 'movie'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
|
||||
{type === 'movie' && shouldLoadSecondaryData && (
|
||||
<MemoizedMoreLikeThisSection
|
||||
<MemoizedMoreLikeThisSection
|
||||
recommendations={recommendations}
|
||||
loadingRecommendations={loadingRecommendations}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -383,6 +383,177 @@ export interface TraktHistoryRemoveResponse {
|
|||
};
|
||||
}
|
||||
|
||||
// Comment types
|
||||
export interface TraktComment {
|
||||
id: number;
|
||||
comment: string;
|
||||
spoiler: boolean;
|
||||
review: boolean;
|
||||
parent_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
replies: number;
|
||||
likes: number;
|
||||
user_stats?: {
|
||||
rating?: number | null;
|
||||
play_count?: number;
|
||||
completed_count?: number;
|
||||
};
|
||||
user: {
|
||||
username: string;
|
||||
private: boolean;
|
||||
name?: string;
|
||||
vip: boolean;
|
||||
vip_ep: boolean;
|
||||
ids: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TraktMovieComment {
|
||||
type: 'movie';
|
||||
movie: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
slug: string;
|
||||
imdb: string;
|
||||
tmdb: number;
|
||||
};
|
||||
};
|
||||
comment: TraktComment;
|
||||
}
|
||||
|
||||
export interface TraktShowComment {
|
||||
type: 'show';
|
||||
show: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
slug: string;
|
||||
tvdb?: number;
|
||||
imdb: string;
|
||||
tmdb: number;
|
||||
};
|
||||
};
|
||||
comment: TraktComment;
|
||||
}
|
||||
|
||||
export interface TraktSeasonComment {
|
||||
type: 'season';
|
||||
season: {
|
||||
number: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
tvdb?: number;
|
||||
tmdb?: number;
|
||||
};
|
||||
};
|
||||
show: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
slug: string;
|
||||
tvdb?: number;
|
||||
imdb: string;
|
||||
tmdb: number;
|
||||
};
|
||||
};
|
||||
comment: TraktComment;
|
||||
}
|
||||
|
||||
export interface TraktEpisodeComment {
|
||||
type: 'episode';
|
||||
episode: {
|
||||
season: number;
|
||||
number: number;
|
||||
title: string;
|
||||
ids: {
|
||||
trakt: number;
|
||||
tvdb?: number;
|
||||
imdb?: string;
|
||||
tmdb?: number;
|
||||
};
|
||||
};
|
||||
show: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
slug: string;
|
||||
tvdb?: number;
|
||||
imdb: string;
|
||||
tmdb: number;
|
||||
};
|
||||
};
|
||||
comment: TraktComment;
|
||||
}
|
||||
|
||||
export interface TraktListComment {
|
||||
type: 'list';
|
||||
list: {
|
||||
name: string;
|
||||
description?: string;
|
||||
privacy: string;
|
||||
share_link?: string;
|
||||
display_numbers: boolean;
|
||||
allow_comments: boolean;
|
||||
updated_at: string;
|
||||
item_count: number;
|
||||
comment_count: number;
|
||||
likes: number;
|
||||
ids: {
|
||||
trakt: number;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
comment: TraktComment;
|
||||
}
|
||||
|
||||
// Simplified comment type based on actual API response
|
||||
export interface TraktContentComment {
|
||||
id: number;
|
||||
comment: string;
|
||||
spoiler: boolean;
|
||||
review: boolean;
|
||||
parent_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
replies: number;
|
||||
likes: number;
|
||||
language: string;
|
||||
user_rating?: number;
|
||||
user_stats?: {
|
||||
rating?: number;
|
||||
play_count?: number;
|
||||
completed_count?: number;
|
||||
};
|
||||
user: {
|
||||
username: string;
|
||||
private: boolean;
|
||||
deleted?: boolean;
|
||||
name?: string;
|
||||
vip: boolean;
|
||||
vip_ep: boolean;
|
||||
director?: boolean;
|
||||
ids: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the old types for backward compatibility if needed
|
||||
export type TraktContentCommentLegacy =
|
||||
| TraktMovieComment
|
||||
| TraktShowComment
|
||||
| TraktSeasonComment
|
||||
| TraktEpisodeComment
|
||||
| TraktListComment;
|
||||
|
||||
export class TraktService {
|
||||
private static instance: TraktService;
|
||||
private accessToken: string | null = null;
|
||||
|
|
@ -1062,24 +1233,56 @@ export class TraktService {
|
|||
/**
|
||||
* Get trakt id from IMDb id
|
||||
*/
|
||||
public async getTraktIdFromImdbId(imdbId: string, type: 'movies' | 'shows'): Promise<number | null> {
|
||||
public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${imdbId}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'trakt-api-version': '2',
|
||||
'trakt-api-key': TRAKT_CLIENT_ID
|
||||
// Clean IMDb ID - remove 'tt' prefix if present
|
||||
const cleanImdbId = imdbId.startsWith('tt') ? imdbId.substring(2) : imdbId;
|
||||
|
||||
logger.log(`[TraktService] Searching Trakt for ${type} with IMDb ID: ${cleanImdbId}`);
|
||||
|
||||
// Try multiple search approaches
|
||||
const searchUrls = [
|
||||
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
|
||||
`${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
|
||||
// Also try with the full tt-prefixed ID in case the API accepts it
|
||||
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}`
|
||||
];
|
||||
|
||||
for (const searchUrl of searchUrls) {
|
||||
try {
|
||||
logger.log(`[TraktService] Trying search URL: ${searchUrl}`);
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'trakt-api-version': '2',
|
||||
'trakt-api-key': TRAKT_CLIENT_ID
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.warn(`[TraktService] Search attempt failed (${response.status}): ${errorText}`);
|
||||
continue; // Try next URL
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logger.log(`[TraktService] Search response data:`, data);
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const traktId = data[0][type]?.ids?.trakt;
|
||||
if (traktId) {
|
||||
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
|
||||
return traktId;
|
||||
}
|
||||
}
|
||||
} catch (urlError) {
|
||||
logger.warn(`[TraktService] URL attempt failed:`, urlError);
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Trakt ID: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
return data[0][type.slice(0, -1)].ids.trakt;
|
||||
}
|
||||
logger.warn(`[TraktService] No results found for IMDb ID: ${cleanImdbId} after trying all search methods`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error);
|
||||
|
|
@ -1092,7 +1295,7 @@ export class TraktService {
|
|||
*/
|
||||
public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||
if (!traktId) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1124,7 +1327,7 @@ export class TraktService {
|
|||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1165,7 +1368,7 @@ export class TraktService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||
if (!traktId) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1191,7 +1394,7 @@ export class TraktService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2160,6 +2363,137 @@ export class TraktService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trakt id from TMDB id (fallback method)
|
||||
*/
|
||||
public async getTraktIdFromTmdbId(tmdbId: number, type: 'movie' | 'show'): Promise<number | null> {
|
||||
try {
|
||||
logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
|
||||
|
||||
const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'trakt-api-version': '2',
|
||||
'trakt-api-key': TRAKT_CLIENT_ID
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.warn(`[TraktService] TMDB search failed (${response.status}): ${errorText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logger.log(`[TraktService] TMDB search response:`, data);
|
||||
if (data && data.length > 0) {
|
||||
const traktId = data[0][type]?.ids?.trakt;
|
||||
if (traktId) {
|
||||
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
|
||||
return traktId;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`[TraktService] No TMDB results found for TMDB ID: ${tmdbId}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get Trakt ID from TMDB ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for a movie
|
||||
*/
|
||||
public async getMovieComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||
try {
|
||||
let traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||||
|
||||
// Fallback to TMDB ID if IMDb search failed
|
||||
if (!traktId && tmdbId) {
|
||||
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||||
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'movie');
|
||||
}
|
||||
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for movie with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const endpoint = `/movies/${traktId}/comments?page=${page}&limit=${limit}`;
|
||||
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||
console.log(`[TraktService] Movie comments response:`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get movie comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for a show
|
||||
*/
|
||||
public async getShowComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||
try {
|
||||
let traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
|
||||
// Fallback to TMDB ID if IMDb search failed
|
||||
if (!traktId && tmdbId) {
|
||||
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||||
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'show');
|
||||
}
|
||||
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const endpoint = `/shows/${traktId}/comments?page=${page}&limit=${limit}`;
|
||||
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||
console.log(`[TraktService] Show comments response:`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get show comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for a season
|
||||
*/
|
||||
public async getSeasonComments(imdbId: string, season: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const endpoint = `/shows/${traktId}/seasons/${season}/comments?page=${page}&limit=${limit}`;
|
||||
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get season comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for an episode
|
||||
*/
|
||||
public async getEpisodeComments(imdbId: string, season: number, episode: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const endpoint = `/shows/${traktId}/seasons/${season}/episodes/${episode}/comments?page=${page}&limit=${limit}`;
|
||||
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get episode comments:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app state changes to reduce memory pressure
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue