diff --git a/apps/vod/api_urls.py b/apps/vod/api_urls.py index b49e79e3..ffccc3f5 100644 --- a/apps/vod/api_urls.py +++ b/apps/vod/api_urls.py @@ -5,6 +5,7 @@ from .api_views import ( EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, + UnifiedContentViewSet, ) app_name = 'vod' @@ -14,5 +15,6 @@ router.register(r'movies', MovieViewSet, basename='movie') router.register(r'episodes', EpisodeViewSet, basename='episode') router.register(r'series', SeriesViewSet, basename='series') router.register(r'categories', VODCategoryViewSet, basename='vodcategory') +router.register(r'all', UnifiedContentViewSet, basename='unified-content') urlpatterns = router.urls diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index b72ae035..517038a6 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -469,3 +469,203 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] + + +class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet that combines Movies and Series for unified 'All' view""" + queryset = Movie.objects.none() # Empty queryset, we override list method + serializer_class = MovieSerializer # Default serializer, overridden in list + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def list(self, request, *args, **kwargs): + """Override list to handle unified content properly - database-level approach""" + import logging + from django.db import connection + + logger = logging.getLogger(__name__) + logger.error("=== UnifiedContentViewSet.list() called ===") + + try: + # Get pagination parameters + page_size = int(request.query_params.get('page_size', 24)) + page_number = int(request.query_params.get('page', 1)) + + logger.error(f"Page {page_number}, page_size {page_size}") + + # Calculate offset for unified pagination + offset = (page_number - 1) * page_size + + # For high page numbers, use raw SQL for efficiency + # This avoids loading and sorting massive amounts of data in Python + + search = request.query_params.get('search', '') + category = request.query_params.get('category', '') + + # Build WHERE clauses + where_conditions = [ + # Only active content + "movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)", + "series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)" + ] + + params = [] + + if search: + where_conditions[0] += " AND LOWER(movies.name) LIKE %s" + where_conditions[1] += " AND LOWER(series.name) LIKE %s" + search_param = f"%{search.lower()}%" + params.extend([search_param, search_param]) + + if category: + if '|' in category: + cat_name, cat_type = category.split('|', 1) + if cat_type == 'movie': + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] = "1=0" # Exclude series + params.append(cat_name) + elif cat_type == 'series': + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + where_conditions[0] = "1=0" # Exclude movies + params.append(cat_name) + else: + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + params.extend([category, category]) + + # Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination + # This is much more efficient than Python sorting + sql = f""" + WITH unified_content AS ( + SELECT + movies.id, + movies.uuid, + movies.name, + movies.description, + movies.year, + movies.rating, + movies.genre, + movies.duration_secs as duration, + movies.created_at, + movies.updated_at, + movies.custom_properties, + movies.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'movie' as content_type + FROM vod_movie movies + LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id + WHERE {where_conditions[0]} + + UNION ALL + + SELECT + series.id, + series.uuid, + series.name, + series.description, + series.year, + series.rating, + series.genre, + NULL as duration, + series.created_at, + series.updated_at, + series.custom_properties, + series.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'series' as content_type + FROM vod_series series + LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id + WHERE {where_conditions[1]} + ) + SELECT * FROM unified_content + ORDER BY LOWER(name), id + LIMIT %s OFFSET %s + """ + + params.extend([page_size, offset]) + + logger.error(f"Executing SQL with LIMIT {page_size} OFFSET {offset}") + + with connection.cursor() as cursor: + cursor.execute(sql, params) + columns = [col[0] for col in cursor.description] + results = [] + + for row in cursor.fetchall(): + item_dict = dict(zip(columns, row)) + + # Build logo object in the format expected by frontend + logo_data = None + if item_dict['logo_id']: + logo_data = { + 'id': item_dict['logo_id'], + 'name': item_dict['logo_name'], + 'url': item_dict['logo_url'], + 'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None, + 'channel_count': 0, # We don't need this for VOD + 'is_used': True, + 'channel_names': [] # We don't need this for VOD + } + + # Convert to the format expected by frontend + formatted_item = { + 'id': item_dict['id'], + 'uuid': str(item_dict['uuid']), + 'name': item_dict['name'], + 'description': item_dict['description'] or '', + 'year': item_dict['year'], + 'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0, + 'genre': item_dict['genre'] or '', + 'duration': item_dict['duration'], + 'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None, + 'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None, + 'custom_properties': item_dict['custom_properties'] or {}, + 'logo': logo_data, + 'content_type': item_dict['content_type'] + } + results.append(formatted_item) + + logger.error(f"Retrieved {len(results)} results via SQL") + + # Get total count estimate (for pagination info) + # Use a separate efficient count query + count_sql = f""" + SELECT COUNT(*) FROM ( + SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]} + UNION ALL + SELECT 1 FROM vod_series series WHERE {where_conditions[1]} + ) as total_count + """ + + count_params = params[:-2] # Remove LIMIT and OFFSET params + + with connection.cursor() as cursor: + cursor.execute(count_sql, count_params) + total_count = cursor.fetchone()[0] + + response_data = { + 'count': total_count, + 'next': offset + page_size < total_count, + 'previous': page_number > 1, + 'results': results + } + + return Response(response_data) + + except Exception as e: + logger.error(f"Error in UnifiedContentViewSet.list(): {e}") + import traceback + logger.error(traceback.format_exc()) + return Response({'error': str(e)}, status=500) \ No newline at end of file diff --git a/apps/vod/urls.py b/apps/vod/urls.py index f90e3fb6..3cea96a5 100644 --- a/apps/vod/urls.py +++ b/apps/vod/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet +from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, UnifiedContentViewSet app_name = 'vod' @@ -9,7 +9,7 @@ router.register(r'movies', MovieViewSet) router.register(r'episodes', EpisodeViewSet) router.register(r'series', SeriesViewSet) router.register(r'categories', VODCategoryViewSet) -router.register(r'connections', VODConnectionViewSet) +router.register(r'all', UnifiedContentViewSet, basename='unified-content') urlpatterns = [ path('api/', include(router.urls)), diff --git a/frontend/src/api.js b/frontend/src/api.js index 1c25f1cf..956f3ece 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2077,7 +2077,14 @@ export default class API { ); return response; } catch (e) { - errorNotification('Failed to retrieve movies', e); + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve movies', e); + } + throw e; } } @@ -2088,7 +2095,39 @@ export default class API { ); return response; } catch (e) { - errorNotification('Failed to retrieve series', e); + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve series', e); + } + throw e; + } + } + + static async getAllContent(params = new URLSearchParams()) { + try { + console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`); + const response = await request( + `${host}/api/vod/all/?${params.toString()}` + ); + console.log('getAllContent raw response:', response); + return response; + } catch (e) { + console.error('getAllContent error:', e); + console.error('Error status:', e.status); + console.error('Error body:', e.body); + console.error('Error message:', e.message); + + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve content', e); + } + throw e; } } diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index 16d43a9a..3c9e2b0f 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -264,8 +264,7 @@ const useCardColumns = () => { }; const VODsPage = () => { - const movies = useVODStore((s) => s.movies); - const series = useVODStore((s) => s.series); + const currentPageContent = useVODStore((s) => s.currentPageContent); // Direct subscription const allCategories = useVODStore((s) => s.categories); const filters = useVODStore((s) => s.filters); const currentPage = useVODStore((s) => s.currentPage); @@ -288,8 +287,7 @@ const VODsPage = () => { setPageSize(Number(value)); localStorage.setItem('vodsPageSize', value); }; - const fetchMovies = useVODStore((s) => s.fetchMovies); - const fetchSeries = useVODStore((s) => s.fetchSeries); + const fetchContent = useVODStore((s) => s.fetchContent); const fetchCategories = useVODStore((s) => s.fetchCategories); // const showVideo = useVideoStore((s) => s.showVideo); - removed as unused @@ -307,36 +305,10 @@ const VODsPage = () => { // Helper function to get display data based on current filters const getDisplayData = () => { - if (filters.type === 'series') { - return Object.values(series).map((item) => ({ - ...item, - _vodType: 'series', - })); - } else if (filters.type === 'movies') { - return Object.values(movies).map((item) => ({ - ...item, - _vodType: 'movie', - })); - } else { - // 'all' - combine movies and series, tagging each with its type, then sort alphabetically by name/title - const combined = [ - ...Object.values(movies).map((item) => ({ - ...item, - _vodType: 'movie', - })), - ...Object.values(series).map((item) => ({ - ...item, - _vodType: 'series', - })), - ]; - return combined.sort((a, b) => { - const nameA = (a.name || a.title || '').toLowerCase(); - const nameB = (b.name || b.title || '').toLowerCase(); - if (nameA < nameB) return -1; - if (nameA > nameB) return 1; - return 0; - }); - } + return (currentPageContent || []).map((item) => ({ + ...item, + _vodType: item.contentType === 'movie' ? 'movie' : 'series', + })); }; useEffect(() => { @@ -360,17 +332,8 @@ const VODsPage = () => { }, [fetchCategories]); useEffect(() => { - if (filters.type === 'series') { - fetchSeries().finally(() => setInitialLoad(false)); - } else if (filters.type === 'movies') { - fetchMovies().finally(() => setInitialLoad(false)); - } else { - // 'all': fetch both movies and series - Promise.all([fetchMovies(), fetchSeries()]).finally(() => - setInitialLoad(false) - ); - } - }, [filters, currentPage, pageSize, fetchMovies, fetchSeries]); + fetchContent().finally(() => setInitialLoad(false)); + }, [filters, currentPage, pageSize, fetchContent]); const handleVODCardClick = (vod) => { setSelectedVOD(vod); @@ -464,46 +427,25 @@ const VODsPage = () => { ) : ( <> - {filters.type === 'series' ? ( - - {Object.values(series).map((seriesItem) => ( - - - - ))} - - ) : ( - - {getDisplayData().map((item) => ( - - {item._vodType === 'series' ? ( - - ) : ( - - )} - - ))} - - )} + + {getDisplayData().map((item) => ( + + {item.contentType === 'series' ? ( + + ) : ( + + )} + + ))} + {/* Pagination */} {totalPages > 1 && ( diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index dd3f5b8a..b0aecd61 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -2,8 +2,8 @@ import { create } from 'zustand'; import api from '../api'; const useVODStore = create((set, get) => ({ - movies: {}, - series: {}, + content: {}, // Store for individual content details (when fetching movie/series details) + currentPageContent: [], // Store the current page's results episodes: {}, categories: {}, loading: false, @@ -34,12 +34,12 @@ const useVODStore = create((set, get) => ({ currentPage: 1, // Reset to first page when page size changes })), - fetchMovies: async () => { + fetchContent: async () => { try { set({ loading: true, error: null }); const state = get(); - const params = new URLSearchParams(); + const params = new URLSearchParams(); params.append('page', state.currentPage); params.append('page_size', state.pageSize); @@ -51,60 +51,62 @@ const useVODStore = create((set, get) => ({ params.append('category', state.filters.category); } - const response = await api.getMovies(params); + let allResults = []; + let totalCount = 0; - // Handle both paginated and non-paginated responses - const results = response.results || response; - const count = response.count || results.length; + if (state.filters.type === 'movies') { + // Fetch only movies + const response = await api.getMovies(params); + const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'movie' })); + totalCount = response.count || results.length; + } else if (state.filters.type === 'series') { + // Fetch only series + const response = await api.getSeries(params); + const results = response.results || response; + allResults = results.map((item) => ({ + ...item, + contentType: 'series', + })); + totalCount = response.count || results.length; + } else { + // Use the new unified backend endpoint for 'all' view + const response = await api.getAllContent(params); + console.log('getAllContent response:', response); + console.log('response type:', typeof response); + console.log( + 'response keys:', + response ? Object.keys(response) : 'no response' + ); + const results = response.results || response; + console.log('results:', results); + console.log('results type:', typeof results); + console.log('results is array:', Array.isArray(results)); + + // Check if results is actually an array before calling map + if (!Array.isArray(results)) { + console.error('Results is not an array:', results); + throw new Error('Invalid response format - results is not an array'); + } + + // The backend already provides content_type and proper sorting/pagination + allResults = results.map((item) => ({ + ...item, + contentType: item.content_type, // Backend provides this field + })); + totalCount = response.count || results.length; + } + + // Store the current page results directly (don't accumulate all pages) set({ - movies: results.reduce((acc, movie) => { - acc[movie.id] = movie; - return acc; - }, {}), - totalCount: count, + currentPageContent: allResults, // This is the paginated data for current page + totalCount, loading: false, }); } catch (error) { - console.error('Failed to fetch movies:', error); - set({ error: 'Failed to load movies.', loading: false }); - } - }, - - fetchSeries: async () => { - set({ loading: true, error: null }); - try { - const state = get(); - const params = new URLSearchParams(); - - params.append('page', state.currentPage); - params.append('page_size', state.pageSize); - - if (state.filters.search) { - params.append('search', state.filters.search); - } - - if (state.filters.category) { - params.append('category', state.filters.category); - } - - const response = await api.getSeries(params); - - // Handle both paginated and non-paginated responses - const results = response.results || response; - const count = response.count || results.length; - - set({ - series: results.reduce((acc, series) => { - acc[series.id] = series; - return acc; - }, {}), - totalCount: count, - loading: false, - }); - } catch (error) { - console.error('Failed to fetch series:', error); - set({ error: 'Failed to load series.', loading: false }); + console.error('Failed to fetch content:', error); + set({ error: 'Failed to load content.', loading: false }); } }, @@ -158,9 +160,12 @@ const useVODStore = create((set, get) => ({ }; console.log('Fetched Movie Details:', movieDetails); set((state) => ({ - movies: { - ...state.movies, - [movieDetails.id]: movieDetails, + content: { + ...state.content, + [`movie_${movieDetails.id}`]: { + ...movieDetails, + contentType: 'movie', + }, }, loading: false, })); @@ -261,36 +266,48 @@ const useVODStore = create((set, get) => ({ addMovie: (movie) => set((state) => ({ - movies: { ...state.movies, [movie.id]: movie }, + content: { + ...state.content, + [`movie_${movie.id}`]: { ...movie, contentType: 'movie' }, + }, })), updateMovie: (movie) => set((state) => ({ - movies: { ...state.movies, [movie.id]: movie }, + content: { + ...state.content, + [`movie_${movie.id}`]: { ...movie, contentType: 'movie' }, + }, })), removeMovie: (movieId) => set((state) => { - const updatedMovies = { ...state.movies }; - delete updatedMovies[movieId]; - return { movies: updatedMovies }; + const updatedContent = { ...state.content }; + delete updatedContent[`movie_${movieId}`]; + return { content: updatedContent }; }), addSeries: (series) => set((state) => ({ - series: { ...state.series, [series.id]: series }, + content: { + ...state.content, + [`series_${series.id}`]: { ...series, contentType: 'series' }, + }, })), updateSeries: (series) => set((state) => ({ - series: { ...state.series, [series.id]: series }, + content: { + ...state.content, + [`series_${series.id}`]: { ...series, contentType: 'series' }, + }, })), removeSeries: (seriesId) => set((state) => { - const updatedSeries = { ...state.series }; - delete updatedSeries[seriesId]; - return { series: updatedSeries }; + const updatedContent = { ...state.content }; + delete updatedContent[`series_${seriesId}`]; + return { content: updatedContent }; }), fetchSeriesInfo: async (seriesId) => { @@ -369,9 +386,9 @@ const useVODStore = create((set, get) => ({ } set((state) => ({ - series: { - ...state.series, - [seriesInfo.id]: seriesInfo, + content: { + ...state.content, + [`series_${seriesInfo.id}`]: { ...seriesInfo, contentType: 'series' }, }, loading: false, })); @@ -387,6 +404,29 @@ const useVODStore = create((set, get) => ({ throw error; } }, + + // Helper methods for getting filtered content + getFilteredContent: () => { + const state = get(); + // Return the current page content directly - backend handles all filtering/pagination + return state.currentPageContent; + }, + + getMovies: () => { + const state = get(); + return Object.values(state.content).filter( + (item) => item.contentType === 'movie' + ); + }, + + getSeries: () => { + const state = get(); + return Object.values(state.content).filter( + (item) => item.contentType === 'series' + ); + }, + + clearContent: () => set({ content: {}, totalCount: 0 }), })); export default useVODStore;