Fix VOD page not displaying correct order while changing pages.

This commit is contained in:
SergeantPanda 2025-09-14 19:27:11 -05:00
parent f8e91155e2
commit 5e661ea208
6 changed files with 380 additions and 157 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)),

View file

@ -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;
}
}

View file

@ -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 = () => {
</Flex>
) : (
<>
{filters.type === 'series' ? (
<Grid gutter="md">
{Object.values(series).map((seriesItem) => (
<Grid.Col
span={12 / columns}
key={seriesItem.id}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
<SeriesCard
series={seriesItem}
onClick={handleSeriesClick}
/>
</Grid.Col>
))}
</Grid>
) : (
<Grid gutter="md">
{getDisplayData().map((item) => (
<Grid.Col
span={12 / columns}
key={item.id}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
{item._vodType === 'series' ? (
<SeriesCard series={item} onClick={handleSeriesClick} />
) : (
<VODCard vod={item} onClick={handleVODCardClick} />
)}
</Grid.Col>
))}
</Grid>
)}
<Grid gutter="md">
{getDisplayData().map((item) => (
<Grid.Col
span={12 / columns}
key={`${item.contentType}_${item.id}`}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
{item.contentType === 'series' ? (
<SeriesCard series={item} onClick={handleSeriesClick} />
) : (
<VODCard vod={item} onClick={handleVODCardClick} />
)}
</Grid.Col>
))}
</Grid>
{/* Pagination */}
{totalPages > 1 && (

View file

@ -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;