diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 4246373e..469ec773 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -6,6 +6,8 @@ from .api_views import ( ChannelGroupViewSet, BulkDeleteStreamsAPIView, BulkDeleteChannelsAPIView, + BulkDeleteLogosAPIView, + CleanupUnusedLogosAPIView, LogoViewSet, ChannelProfileViewSet, UpdateChannelMembershipAPIView, @@ -28,6 +30,8 @@ urlpatterns = [ # Bulk delete is a single APIView, not a ViewSet path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'), path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'), + path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'), + path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'), path('channels//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 310fccbb..d89ca238 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1023,6 +1023,142 @@ class BulkDeleteChannelsAPIView(APIView): ) +# ───────────────────────────────────────────────────────── +# 6) Bulk Delete Logos +# ───────────────────────────────────────────────────────── +class BulkDeleteLogosAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Bulk delete logos by ID", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["logo_ids"], + properties={ + "logo_ids": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_INTEGER), + description="Logo IDs to delete", + ) + }, + ), + responses={204: "Logos deleted"}, + ) + def delete(self, request): + logo_ids = request.data.get("logo_ids", []) + delete_files = request.data.get("delete_files", False) + + # Get logos and their usage info before deletion + logos_to_delete = Logo.objects.filter(id__in=logo_ids) + total_channels_affected = 0 + local_files_deleted = 0 + + for logo in logos_to_delete: + # Handle file deletion for local files + if delete_files and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + local_files_deleted += 1 + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file {logo.url}: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + if logo.channels.exists(): + channel_count = logo.channels.count() + total_channels_affected += channel_count + # Remove logo from channels + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") + + # Delete logos + deleted_count = logos_to_delete.delete()[0] + + message = f"Successfully deleted {deleted_count} logos" + if total_channels_affected > 0: + message += f" and removed them from {total_channels_affected} channels" + if local_files_deleted > 0: + message += f" and deleted {local_files_deleted} local files" + + return Response( + {"message": message}, + status=status.HTTP_204_NO_CONTENT + ) + + +class CleanupUnusedLogosAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Delete all logos that are not used by any channels", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "delete_files": openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Whether to delete local logo files from disk", + default=False + ) + }, + ), + responses={200: "Cleanup completed"}, + ) + def post(self, request): + """Delete all logos with no channel associations""" + delete_files = request.data.get("delete_files", False) + + unused_logos = Logo.objects.filter(channels__isnull=True) + deleted_count = unused_logos.count() + logo_names = list(unused_logos.values_list('name', flat=True)) + local_files_deleted = 0 + + # Handle file deletion for local files if requested + if delete_files: + for logo in unused_logos: + if logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + local_files_deleted += 1 + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file {logo.url}: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Delete the unused logos + unused_logos.delete() + + message = f"Successfully deleted {deleted_count} unused logos" + if local_files_deleted > 0: + message += f" and deleted {local_files_deleted} local files" + + return Response({ + "message": message, + "deleted_count": deleted_count, + "deleted_logos": logo_names, + "local_files_deleted": local_files_deleted + }) + + class LogoViewSet(viewsets.ModelViewSet): queryset = Logo.objects.all() serializer_class = LogoSerializer @@ -1040,6 +1176,24 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Optimize queryset with prefetch and add filtering""" + queryset = Logo.objects.prefetch_related('channels').order_by('name') + + # Filter by usage + used_filter = self.request.query_params.get('used', None) + if used_filter == 'true': + queryset = queryset.filter(channels__isnull=False).distinct() + elif used_filter == 'false': + queryset = queryset.filter(channels__isnull=True) + + # Filter by name + name_filter = self.request.query_params.get('name', None) + if name_filter: + queryset = queryset.filter(name__icontains=name_filter) + + return queryset + def create(self, request, *args, **kwargs): """Create a new logo entry""" serializer = self.get_serializer(data=request.data) @@ -1053,15 +1207,28 @@ class LogoViewSet(viewsets.ModelViewSet): return super().update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): - """Delete a logo""" + """Delete a logo and remove it from any channels using it""" logo = self.get_object() + delete_file = request.query_params.get('delete_file', 'false').lower() == 'true' - # Check if logo is being used by any channels + # Check if it's a local file that should be deleted + if delete_file and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Instead of preventing deletion, remove the logo from channels if logo.channels.exists(): - return Response( - {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, - status=status.HTTP_400_BAD_REQUEST - ) + channel_count = logo.channels.count() + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") return super().destroy(request, *args, **kwargs) @@ -1073,6 +1240,16 @@ class LogoViewSet(viewsets.ModelViewSet): ) file = request.FILES["file"] + + # Validate file + try: + from dispatcharr.utils import validate_logo_file + validate_logo_file(file) + except Exception as e: + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + file_name = file.name file_path = os.path.join("/data/logos", file_name) @@ -1088,8 +1265,10 @@ class LogoViewSet(viewsets.ModelViewSet): }, ) + # Use get_serializer to ensure proper context + serializer = self.get_serializer(logo) return Response( - LogoSerializer(logo, context={'request': request}).data, + serializer.data, status=status.HTTP_201_CREATED, ) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 84a68e50..82b5f808 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -20,10 +20,23 @@ from django.utils import timezone class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() + channel_count = serializers.SerializerMethodField() + is_used = serializers.SerializerMethodField() + channel_names = serializers.SerializerMethodField() class Meta: model = Logo - fields = ["id", "name", "url", "cache_url"] + fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"] + + def validate_url(self, value): + """Validate that the URL is unique for creation or update""" + if self.instance and self.instance.url == value: + return value + + if Logo.objects.filter(url=value).exists(): + raise serializers.ValidationError("A logo with this URL already exists.") + + return value def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" @@ -34,6 +47,22 @@ class LogoSerializer(serializers.ModelSerializer): ) return reverse("api:channels:logo-cache", args=[obj.id]) + def get_channel_count(self, obj): + """Get the number of channels using this logo""" + return obj.channels.count() + + def get_is_used(self, obj): + """Check if this logo is used by any channels""" + return obj.channels.exists() + + def get_channel_names(self, obj): + """Get the names of channels using this logo (limited to first 5)""" + channels = obj.channels.all()[:5] + names = [channel.name for channel in channels] + if obj.channels.count() > 5: + names.append(f"...and {obj.channels.count() - 5} more") + return names + # # Stream diff --git a/core/tasks.py b/core/tasks.py index e8b36162..47bc8cf0 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -21,10 +21,12 @@ logger = logging.getLogger(__name__) EPG_WATCH_DIR = '/data/epgs' M3U_WATCH_DIR = '/data/m3us' +LOGO_WATCH_DIR = '/data/logos' MIN_AGE_SECONDS = 6 STARTUP_SKIP_AGE = 30 REDIS_PREFIX = "processed_file:" REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional) +SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'] # Store the last known value to compare with new data last_known_data = {} @@ -56,10 +58,11 @@ def scan_and_process_files(): global _first_scan_completed redis_client = RedisClient.get_client() now = time.time() + # Check if directories exist - dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR]) + dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR]) if not dirs_exist: - throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)})", "watch_dirs_missing") + throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing") # Process M3U files m3u_files = [f for f in os.listdir(M3U_WATCH_DIR) @@ -266,6 +269,126 @@ def scan_and_process_files(): logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors") + # Process Logo files (including subdirectories) + try: + logo_files = [] + if os.path.exists(LOGO_WATCH_DIR): + for root, dirs, files in os.walk(LOGO_WATCH_DIR): + for filename in files: + logo_files.append(os.path.join(root, filename)) + logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)") + except Exception as e: + logger.error(f"Error listing LOGO directory: {e}") + logo_files = [] + + logo_processed = 0 + logo_skipped = 0 + logo_errors = 0 + + for filepath in logo_files: + filename = os.path.basename(filepath) + + if not os.path.isfile(filepath): + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a file") + else: + logger.debug(f"Skipping {filename}: Not a file") + logo_skipped += 1 + continue + + # Check if file has supported logo extension + file_ext = os.path.splitext(filename)[1].lower() + if file_ext not in SUPPORTED_LOGO_FORMATS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a supported logo format") + else: + logger.debug(f"Skipping {filename}: Not a supported logo format") + logo_skipped += 1 + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Check if logo already exists in database + if not stored_mtime and age > STARTUP_SKIP_AGE: + from apps.channels.models import Logo + existing_logo = Logo.objects.filter(url=filepath).exists() + if existing_logo: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already exists in database") + else: + logger.debug(f"Skipping {filename}: Already exists in database") + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + logo_skipped += 1 + continue + else: + logger.debug(f"Processing {filename} despite age: Not found in database") + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + else: + logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + logo_skipped += 1 + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already processed this version") + else: + logger.debug(f"Skipping {filename}: Already processed this version") + logo_skipped += 1 + continue + + try: + from apps.channels.models import Logo + + # Create logo entry with just the filename (without extension) as name + logo_name = os.path.splitext(filename)[0] + + logo, created = Logo.objects.get_or_create( + url=filepath, + defaults={ + "name": logo_name, + } + ) + + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + if created: + logger.info(f"Created new logo entry: {logo_name}") + else: + logger.debug(f"Logo entry already exists: {logo_name}") + + logo_processed += 1 + + except Exception as e: + logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True) + logo_errors += 1 + continue + + logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors") + + # Send summary websocket update for logo processing + if logo_processed > 0 or logo_errors > 0: + send_websocket_update( + "updates", + "update", + { + "success": True, + "type": "logo_processing_summary", + "processed": logo_processed, + "skipped": logo_skipped, + "errors": logo_errors, + "total_files": len(logo_files), + "message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors" + } + ) + # Mark that the first scan is complete _first_scan_completed = True diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 767913c6..5e1ad087 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -21,11 +21,11 @@ def json_success_response(data=None, status=200): def validate_logo_file(file): """Validate uploaded logo file size and MIME type.""" - valid_mime_types = ["image/jpeg", "image/png", "image/gif"] + valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] if file.content_type not in valid_mime_types: - raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF.") - if file.size > 2 * 1024 * 1024: - raise ValidationError("File too large. Max 2MB.") + raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.") + if file.size > 5 * 1024 * 1024: # Increased to 5MB + raise ValidationError("File too large. Max 5MB.") def get_client_ip(request): diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a057be50..4467759e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import Users from './pages/Users'; +import LogosPage from './pages/Logos'; import useAuthStore from './store/auth'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; @@ -133,6 +134,7 @@ const App = () => { } /> } /> } /> + } /> ) : ( } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 538ffda3..2e210461 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -218,6 +218,7 @@ export const WebsocketProvider = ({ children }) => { } updatePlaylist(updateData); + fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date } else { // Log when playlist can't be found for debugging purposes console.warn( @@ -417,6 +418,16 @@ export const WebsocketProvider = ({ children }) => { } break; + case 'logo_processing_summary': + notifications.show({ + title: 'Logo Processing Summary', + message: `${parsedEvent.data.message}`, + color: 'blue', + autoClose: 5000, + }); + fetchLogos(); + break; + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` @@ -487,6 +498,7 @@ export const WebsocketProvider = ({ children }) => { const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview); const fetchEPGData = useEPGsStore((s) => s.fetchEPGData); const fetchEPGs = useEPGsStore((s) => s.fetchEPGs); + const fetchLogos = useChannelsStore((s) => s.fetchLogos); const ret = useMemo(() => { return [isReady, ws.current?.send.bind(ws.current), val]; diff --git a/frontend/src/api.js b/frontend/src/api.js index 5812a4b9..7cbb6214 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1226,9 +1226,10 @@ export default class API { } } - static async getLogos() { + static async getLogos(params = {}) { try { - const response = await request(`${host}/api/channels/logos/`); + const queryParams = new URLSearchParams(params); + const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`); return response; } catch (e) { @@ -1236,21 +1237,165 @@ export default class API { } } + static async fetchLogos() { + try { + const response = await this.getLogos(); + useChannelsStore.getState().setLogos(response); + return response; + } catch (e) { + errorNotification('Failed to fetch logos', e); + } + } + static async uploadLogo(file) { try { const formData = new FormData(); formData.append('file', file); - const response = await request(`${host}/api/channels/logos/upload/`, { + // Add timeout handling for file uploads + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + const response = await fetch(`${host}/api/channels/logos/upload/`, { method: 'POST', body: formData, + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const error = new Error(`HTTP error! Status: ${response.status}`); + let errorBody = await response.text(); + + try { + errorBody = JSON.parse(errorBody); + } catch (e) { + // If parsing fails, leave errorBody as the raw text + } + + error.status = response.status; + error.response = response; + error.body = errorBody; + throw error; + } + + const result = await response.json(); + useChannelsStore.getState().addLogo(result); + return result; + } catch (e) { + if (e.name === 'AbortError') { + const timeoutError = new Error('Upload timed out. Please try again.'); + timeoutError.code = 'NETWORK_ERROR'; + throw timeoutError; + } + errorNotification('Failed to upload logo', e); + throw e; + } + } + + static async createLogo(values) { + try { + const response = await request(`${host}/api/channels/logos/`, { + method: 'POST', + body: values, }); useChannelsStore.getState().addLogo(response); return response; } catch (e) { - errorNotification('Failed to upload logo', e); + errorNotification('Failed to create logo', e); + } + } + + static async updateLogo(id, values) { + try { + // Convert values to FormData for the multipart/form-data content type + const formData = new FormData(); + + // Add each field to the form data + Object.keys(values).forEach(key => { + if (values[key] !== null && values[key] !== undefined) { + formData.append(key, values[key]); + } + }); + + const response = await request(`${host}/api/channels/logos/${id}/`, { + method: 'PUT', + body: formData, // Send as FormData instead of JSON + }); + + useChannelsStore.getState().updateLogo(response); + + return response; + } catch (e) { + errorNotification('Failed to update logo', e); + } + } + + static async deleteLogo(id, deleteFile = false) { + try { + const params = new URLSearchParams(); + if (deleteFile) { + params.append('delete_file', 'true'); + } + + const url = `${host}/api/channels/logos/${id}/?${params.toString()}`; + await request(url, { + method: 'DELETE', + }); + + useChannelsStore.getState().removeLogo(id); + + return true; + } catch (e) { + errorNotification('Failed to delete logo', e); + } + } + + static async deleteLogos(ids, deleteFiles = false) { + try { + const body = { logo_ids: ids }; + if (deleteFiles) { + body.delete_files = true; + } + + await request(`${host}/api/channels/logos/bulk-delete/`, { + method: 'DELETE', + body: body, + }); + + // Remove multiple logos from store + ids.forEach(id => { + useChannelsStore.getState().removeLogo(id); + }); + + return true; + } catch (e) { + errorNotification('Failed to delete logos', e); + } + } + + static async cleanupUnusedLogos(deleteFiles = false) { + try { + const body = {}; + if (deleteFiles) { + body.delete_files = true; + } + + const response = await request(`${host}/api/channels/logos/cleanup/`, { + method: 'POST', + body: body, + }); + + return response; + } catch (e) { + errorNotification('Failed to cleanup unused logos', e); + throw e; } } diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 8f96708d..1cfbe84d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -29,12 +29,15 @@ const ConfirmationDialog = ({ onSuppressChange, size = 'md', zIndex = 1000, + showDeleteFileOption = false, + deleteFileLabel = "Also delete files from disk", }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const [suppressChecked, setSuppressChecked] = useState( isWarningSuppressed(actionKey) ); + const [deleteFiles, setDeleteFiles] = useState(false); const handleToggleSuppress = (e) => { setSuppressChecked(e.currentTarget.checked); @@ -47,13 +50,23 @@ const ConfirmationDialog = ({ if (suppressChecked) { suppressWarning(actionKey); } - onConfirm(); + if (showDeleteFileOption) { + onConfirm(deleteFiles); + } else { + onConfirm(); + } + setDeleteFiles(false); // Reset for next time + }; + + const handleClose = () => { + setDeleteFiles(false); // Reset for next time + onClose(); }; return ( )} + {showDeleteFileOption && ( + setDeleteFiles(event.currentTarget.checked)} + label={deleteFileLabel} + mb="md" + /> + )} + - + + + + + + ); +}; + +export default LogoForm; diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx new file mode 100644 index 00000000..09c8d38c --- /dev/null +++ b/frontend/src/components/tables/LogosTable.jsx @@ -0,0 +1,793 @@ +import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import API from '../../api'; +import LogoForm from '../forms/Logo'; +import useChannelsStore from '../../store/channels'; +import useLocalStorage from '../../hooks/useLocalStorage'; +import { + SquarePlus, + SquareMinus, + SquarePen, + ExternalLink, + Filter, + Trash2, + Trash, +} from 'lucide-react'; +import { + ActionIcon, + Box, + Text, + Paper, + Button, + Flex, + Group, + useMantineTheme, + LoadingOverlay, + Stack, + Image, + Center, + Badge, + Tooltip, + Select, + TextInput, + Menu, + Checkbox, + Pagination, + NativeSelect, +} from '@mantine/core'; +import { CustomTable, useTable } from './CustomTable'; +import ConfirmationDialog from '../ConfirmationDialog'; +import { notifications } from '@mantine/notifications'; + +const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { + const [tableSize, _] = useLocalStorage('table-size', 'default'); + + const onEdit = useCallback(() => { + editLogo(row.original); + }, [row.original, editLogo]); + + const onDelete = useCallback(() => { + deleteLogo(row.original.id); + }, [row.original.id, deleteLogo]); + + const iconSize = + tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; + + return ( + + + + + + + + + + + + ); +}; + +const LogosTable = () => { + const theme = useMantineTheme(); + + /** + * STORES + */ + const { logos, fetchLogos } = useChannelsStore(); + + /** + * useState + */ + const [selectedLogo, setSelectedLogo] = useState(null); + const [logoModalOpen, setLogoModalOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [logoToDelete, setLogoToDelete] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + const [isBulkDelete, setIsBulkDelete] = useState(false); + const [isCleaningUp, setIsCleaningUp] = useState(false); + const [filters, setFilters] = useState({ + name: '', + used: 'all' + }); + const [debouncedNameFilter, setDebouncedNameFilter] = useState(''); + const [selectedRows, setSelectedRows] = useState(new Set()); + const [pageSize, setPageSize] = useLocalStorage('logos-page-size', 25); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: pageSize, + }); + const [paginationString, setPaginationString] = useState(''); + + // Debounce the name filter + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedNameFilter(filters.name); + }, 300); // 300ms delay + + return () => clearTimeout(timer); + }, [filters.name]); + + const data = useMemo(() => { + const logosArray = Object.values(logos || {}); + + // Apply filters + let filteredLogos = logosArray; + + if (debouncedNameFilter) { + filteredLogos = filteredLogos.filter(logo => + logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) + ); + } + + if (filters.used === 'used') { + filteredLogos = filteredLogos.filter(logo => logo.is_used); + } else if (filters.used === 'unused') { + filteredLogos = filteredLogos.filter(logo => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); + }, [logos, debouncedNameFilter, filters.used]); + + // Get paginated data + const paginatedData = useMemo(() => { + const startIndex = pagination.pageIndex * pagination.pageSize; + const endIndex = startIndex + pagination.pageSize; + return data.slice(startIndex, endIndex); + }, [data, pagination.pageIndex, pagination.pageSize]); + + // Calculate unused logos count + const unusedLogosCount = useMemo(() => { + const allLogos = Object.values(logos || {}); + return allLogos.filter(logo => !logo.is_used).length; + }, [logos]); + + /** + * Functions + */ + const executeDeleteLogo = useCallback(async (id, deleteFile = false) => { + setIsLoading(true); + try { + await API.deleteLogo(id, deleteFile); + await fetchLogos(); + notifications.show({ + title: 'Success', + message: 'Logo deleted successfully', + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logo', + color: 'red', + }); + } finally { + setIsLoading(false); + setConfirmDeleteOpen(false); + setDeleteTarget(null); + setLogoToDelete(null); + setIsBulkDelete(false); + setSelectedRows(new Set()); // Clear selections + } + }, [fetchLogos]); + + const executeBulkDelete = useCallback(async (deleteFiles = false) => { + if (selectedRows.size === 0) return; + + setIsLoading(true); + try { + await API.deleteLogos(Array.from(selectedRows), deleteFiles); + await fetchLogos(); + + notifications.show({ + title: 'Success', + message: `${selectedRows.size} logos deleted successfully`, + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logos', + color: 'red', + }); + } finally { + setIsLoading(false); + setConfirmDeleteOpen(false); + setIsBulkDelete(false); + setSelectedRows(new Set()); // Clear selections + } + }, [selectedRows, fetchLogos]); + + const executeCleanupUnused = useCallback(async (deleteFiles = false) => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedLogos(deleteFiles); + await fetchLogos(); // Refresh the logos list + + let message = `Successfully deleted ${result.deleted_count} unused logos`; + if (result.local_files_deleted > 0) { + message += ` and deleted ${result.local_files_deleted} local files`; + } + + notifications.show({ + title: 'Cleanup Complete', + message: message, + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused logos', + color: 'red', + }); + } finally { + setIsCleaningUp(false); + setConfirmCleanupOpen(false); + setSelectedRows(new Set()); // Clear selections after cleanup + } + }, [fetchLogos]); + + const editLogo = useCallback(async (logo = null) => { + setSelectedLogo(logo); + setLogoModalOpen(true); + }, []); + + const deleteLogo = useCallback(async (id) => { + const logosArray = Object.values(logos || {}); + const logo = logosArray.find((l) => l.id === id); + setLogoToDelete(logo); + setDeleteTarget(id); + setIsBulkDelete(false); + setConfirmDeleteOpen(true); + }, [logos]); + + const handleSelectRow = useCallback((id, checked) => { + setSelectedRows(prev => { + const newSet = new Set(prev); + if (checked) { + newSet.add(id); + } else { + newSet.delete(id); + } + return newSet; + }); + }, []); + + const handleSelectAll = useCallback((checked) => { + if (checked) { + setSelectedRows(new Set(data.map(logo => logo.id))); + } else { + setSelectedRows(new Set()); + } + }, [data]); + + const deleteBulkLogos = useCallback(() => { + if (selectedRows.size === 0) return; + + setIsBulkDelete(true); + setLogoToDelete(null); + setDeleteTarget(Array.from(selectedRows)); + setConfirmDeleteOpen(true); + }, [selectedRows]); + + const handleCleanupUnused = useCallback(() => { + setConfirmCleanupOpen(true); + }, []); + + // Clear selections when logos data changes (e.g., after filtering) + useEffect(() => { + setSelectedRows(new Set()); + }, [data.length]); + + // Update pagination when pageSize changes + useEffect(() => { + setPagination(prev => ({ + ...prev, + pageSize: pageSize, + })); + }, [pageSize]); + + // Calculate pagination string + useEffect(() => { + const startItem = pagination.pageIndex * pagination.pageSize + 1; + const endItem = Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + data.length + ); + setPaginationString(`${startItem} to ${endItem} of ${data.length}`); + }, [pagination.pageIndex, pagination.pageSize, data.length]); + + // Calculate page count + const pageCount = useMemo(() => { + return Math.ceil(data.length / pagination.pageSize); + }, [data.length, pagination.pageSize]); + + /** + * useMemo + */ + const columns = useMemo( + () => [ + { + id: 'select', + header: ({ table }) => ( + 0 && selectedRows.size === data.length} + indeterminate={selectedRows.size > 0 && selectedRows.size < data.length} + onChange={(event) => handleSelectAll(event.currentTarget.checked)} + size="sm" + /> + ), + cell: ({ row }) => ( + handleSelectRow(row.original.id, event.currentTarget.checked)} + size="sm" + /> + ), + size: 50, + enableSorting: false, + }, + { + header: 'Preview', + accessorKey: 'cache_url', + size: 80, + enableSorting: false, + cell: ({ getValue, row }) => ( +
+ {row.original.name} { + e.target.style.transform = 'scale(1.5)'; + }} + onMouseLeave={(e) => { + e.target.style.transform = 'scale(1)'; + }} + /> +
+ ), + }, + { + header: 'Name', + accessorKey: 'name', + size: 200, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + header: 'Usage', + accessorKey: 'channel_count', + size: 120, + cell: ({ getValue, row }) => { + const count = getValue(); + const channelNames = row.original.channel_names || []; + + if (count === 0) { + return ( + + Unused + + ); + } + + return ( + + Used by {count} channel{count !== 1 ? 's' : ''}: + {channelNames.map((name, index) => ( + • {name} + ))} + + } + multiline + width={220} + > + + {count} channel{count !== 1 ? 's' : ''} + + + ); + }, + }, + { + header: 'URL', + accessorKey: 'url', + cell: ({ getValue }) => ( + + + + {getValue()} + + + {getValue()?.startsWith('http') && ( + window.open(getValue(), '_blank')} + > + + + )} + + ), + }, + { + id: 'actions', + size: 80, + header: 'Actions', + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + ], + [theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length] + ); + + const closeLogoForm = () => { + setSelectedLogo(null); + setLogoModalOpen(false); + fetchLogos(); // Refresh the logos list + }; + + const renderHeaderCell = (header) => { + return ( + + {header.column.columnDef.header} + + ); + }; + + const onRowSelectionChange = useCallback((newSelection) => { + setSelectedRows(new Set(newSelection)); + }, []); + + const onPageSizeChange = (e) => { + const newPageSize = parseInt(e.target.value); + setPageSize(newPageSize); + setPagination(prev => ({ + ...prev, + pageSize: newPageSize, + pageIndex: 0, // Reset to first page + })); + }; + + const onPageIndexChange = (pageIndex) => { + if (!pageIndex || pageIndex > pageCount) { + return; + } + + setPagination(prev => ({ + ...prev, + pageIndex: pageIndex - 1, + })); + }; + + const table = useTable({ + columns, + data: paginatedData, + allRowIds: paginatedData.map((logo) => logo.id), + enablePagination: false, // Disable internal pagination since we're handling it manually + enableRowSelection: true, + enableRowVirtualization: false, + renderTopToolbar: false, + manualSorting: false, + manualFiltering: false, + manualPagination: true, // Enable manual pagination + onRowSelectionChange: onRowSelectionChange, + headerCellRenderFns: { + actions: renderHeaderCell, + cache_url: renderHeaderCell, + name: renderHeaderCell, + url: renderHeaderCell, + channel_count: renderHeaderCell, + }, + }); + + return ( + <> + + + + + Logos + + + ({data.length} logo{data.length !== 1 ? 's' : ''}) + + + + + {/* Top toolbar */} + + + { + const value = event.target.value; + setFilters(prev => ({ + ...prev, + name: value + })); + }} + size="xs" + style={{ width: 200 }} + /> +