From cc2e0308d73499d48f9b0807a67f2fdc61665208 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 5 Oct 2025 18:40:06 +0530 Subject: [PATCH] trakt comment added --- src/components/metadata/CommentsSection.tsx | 913 ++++++++++++++++++++ src/hooks/useTraktComments.ts | 129 +++ src/screens/MetadataScreen.tsx | 13 +- src/services/traktService.ts | 370 +++++++- 4 files changed, 1406 insertions(+), 19 deletions(-) create mode 100644 src/components/metadata/CommentsSection.tsx create mode 100644 src/hooks/useTraktComments.ts diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx new file mode 100644 index 0000000..d94b856 --- /dev/null +++ b/src/components/metadata/CommentsSection.tsx @@ -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( + + ); + } + + // Add half star if needed + if (hasHalfStar) { + stars.push( + + ); + } + + // 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( + + ); + } + + return stars; + }; + + const handlePress = () => { + if (hasSpoiler && !isSpoilerRevealed) { + onSpoilerPress(); + } else { + onPress(); + } + }; + + return ( + + {/* Header Section - Fixed at top */} + + + {username} + + {user.vip && ( + + VIP + + )} + + + {/* Rating - Show stars */} + {comment.user_stats?.rating && ( + + {renderCompactStars(comment.user_stats.rating)} + + {comment.user_stats.rating}/10 + + + )} + + {/* Comment Preview - Flexible area that fills space */} + + + {shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment} + + + + {/* Meta Info - Fixed at bottom */} + + + {comment.review && ( + + Review + + )} + {comment.spoiler && ( + + Spoiler + + )} + + + + {formatRelativeTime(comment.created_at)} + + {comment.likes > 0 && ( + + 👍 {comment.likes} + + )} + {comment.replies > 0 && ( + + 💬 {comment.replies} + + )} + + + + ); +}; + +// 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( + + ); + } + + if (hasHalfStar) { + stars.push( + + ); + } + + const emptyStars = 5 - Math.ceil(rating / 2); + for (let i = 0; i < emptyStars; i++) { + stars.push( + + ); + } + + return stars; + }; + + return ( + + + {}} // Prevent closing when clicking on modal content + > + {/* Close Button */} + + + + + {/* User Info */} + + + + {username} + + {user.vip && ( + + VIP + + )} + + + {formatDate(comment.created_at)} + + + + {/* Rating */} + {comment.user_stats?.rating && ( + + {renderStars(comment.user_stats.rating)} + + {comment.user_stats.rating}/10 + + + )} + + {/* Full Comment */} + {shouldBlurModalContent ? ( + + + ⚠️ This comment contains spoilers + + + + Reveal Spoilers + + + + ) : ( + + {comment.comment} + + )} + + {/* Comment Meta */} + + {comment.review && ( + Review + )} + {comment.spoiler && ( + Spoiler + )} + + {comment.likes > 0 && ( + + + + {comment.likes} + + + )} + {comment.replies > 0 && ( + + + + {comment.replies} + + + )} + + + + + + ); +}; + +export const CommentsSection: React.FC = ({ + imdbId, + type, + season, + episode, +}) => { + const { currentTheme } = useTheme(); + const [selectedComment, setSelectedComment] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [revealedSpoilers, setRevealedSpoilers] = useState>(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 }) => ( + handleCommentPress(item)} + isSpoilerRevealed={revealedSpoilers.has(item.id.toString())} + onSpoilerPress={() => handleSpoilerPress(item)} + /> + ), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]); + + const renderEmpty = useCallback(() => { + if (loading) return null; + + return ( + + + + {error ? 'Comments unavailable' : 'No comments yet'} + + + {error + ? 'This content may not be in Trakt\'s database yet' + : 'Be the first to comment on Trakt.tv' + } + + + ); + }, [loading, error, currentTheme]); + + // Don't show section if not authenticated + if (!isAuthenticated) { + return null; + } + + return ( + + + + Trakt Comments + + + + {error && ( + + + + {error} + + + + Retry + + + + )} + + {loading && comments.length === 0 && ( + + + + Loading comments... + + + )} + + {comments.length === 0 ? ( + renderEmpty() + ) : ( + item?.id?.toString() || Math.random().toString()} + renderItem={renderComment} + contentContainerStyle={styles.horizontalList} + onEndReached={() => { + if (hasMore && !loading) { + loadMore(); + } + }} + onEndReachedThreshold={0.5} + ListFooterComponent={ + hasMore ? ( + + + {loading ? ( + + ) : ( + <> + + Load More + + + + )} + + + ) : null + } + /> + )} + + {/* Expanded Comment Modal */} + selectedComment && handleSpoilerPress(selectedComment)} + /> + + ); +}; + +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, + }, +}); diff --git a/src/hooks/useTraktComments.ts b/src/hooks/useTraktComments.ts new file mode 100644 index 0000000..8a729c0 --- /dev/null +++ b/src/hooks/useTraktComments.ts @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 + }; +}; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 38d6325..65df88b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -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 && ( + 0 ? 'show' : 'movie'} + /> + )} + {/* Recommendations Section with skeleton when loading - Lazy loaded */} {type === 'movie' && shouldLoadSecondaryData && ( - diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 5972014..49e4dc2 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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 { + public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise { 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 { 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 { 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 { + 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 { + 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(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 { + 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(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 { + 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(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 { + 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(endpoint, 'GET'); + } catch (error) { + logger.error('[TraktService] Failed to get episode comments:', error); + return []; + } + } + /** * Handle app state changes to reduce memory pressure */