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;