Tabla de contenido
Una vez más aquí. Hoy vengo a dejar un pequeño programa que puede ser útil para algunas personas hoy en día. En la era del streaming, las listas M3U se han convertido en una herramienta para poder disfrutar de mucho contenido (siempre de manera lega, ¡claro está!. Sin embargo, la gestión de estas listas puede ser tediosa y confusa para muchos, especialmente cuando las listas contienen cientos de enlaces. Para poner mi granito de arena en este problema, he desarrollado una aplicación que puede facilitar la gestión y reproducción de listas M3U. Se trata de un organizador de listas M3U, muy fácil de utilizar.
En este artículo vamos a echar un pequeño vistazo al proceso de desarrollo de la aplicación, incluyendo las funcionalidades principales, la integración de VLC como reproductor multimedia y el repositorio en GitHub donde se pueden encontrar el ejecutable para Windows y el archivo .DEB para Ubuntu …y demás.
El organizador de listas M3U (0rgan1zat0r)
Este pequeño proyecto ha sido desarrollado con Python 3, por lo que es necesario disponer de Python instalado para que funcione. Todo el proyecto lo he dividido en 6 archivos con el código necesario y un archivo .JSON en el que se van a guardar las URL que quiera conservar el usuario.
El código, como digo, se divide en varios archivos. A continuación vamos a ir viéndolos uno a uno para que quede claro que es lo que hace el código y el programa:
M3UOrgan1zat0r.py. El punto de entrada de este organizador de listas m3u
El archivo M3UOrgan1zat0r.py
es el punto de entrada principal de la aplicación M3U Organ1zat0r. Este archivo se encarga de inicializar la aplicación, configurar la ventana principal, y gestionar la interfaz de este organizador de listas m3u.
import sys from PyQt5.QtWidgets import QApplication from PyQt5.QtGui import QIcon from pathlib import Path from organizadorm3u import M3UOrganizer # Directorio del script actual current_directory = Path(__file__).parent if __name__ == '__main__': app = QApplication(sys.argv) mainWin = M3UOrganizer() mainWin.resize(800, 600) # Establecer un icono personalizado icon_path = current_directory / './resources/ordenar-m3u.png' if icon_path.exists(): app.setWindowIcon(QIcon(str(icon_path))) mainWin.show() sys.exit(app.exec_())
Para empezar, vamos a importar los módulos necesarios para que todo funcione:
sys
: Este módulo proporciona acceso a variables y funciones que interactúan fuertemente con el intérprete de Python.PyQt5.QtWidgets.QApplication
: Gestiona la aplicación y su ciclo de eventos. Es esencial para cualquier aplicación que utilice una GUI.PyQt5.QtGui.QIcon
: Permite establecer iconos personalizados para las ventanas de la aplicación.pathlib.Path
: Facilita la manipulación de rutas de archivos de manera independiente al sistema operativo.organizador_m3u.M3UOrganizer
: Esta es la clase principal que define la funcionalidad y la interfaz del organizador de listas M3U. Se importa desde el archivoorganizador_m3u.py
.
current_directory
: Este fragmento de código determina el directorio en el que se encuentra el script actual (M3UOrgan1zat0r.py
). Esto es útil para construir rutas relativas.
if __name__ == '__main__':
: Esta línea asegura que el código dentro de este bloque solo se ejecutará si el script se ejecuta directamente (no si es importado como un módulo en otro script).
app = QApplication(sys.argv)
: Se crea una instancia de QApplication
, que es necesaria para ejecutar cualquier aplicación basada en PyQt5. sys.argv
permite que la aplicación acepte argumentos de la línea de comandos.
mainWin = M3UOrganizer()
: Aquí se crea la instancia de la ventana principal de la aplicación, utilizando la clase M3UOrganizer
, que define toda la lógica y la interfaz de usuario.
mainWin.resize(800, 600)
: Se ajusta el tamaño de la ventana principal a 800×600 píxeles.
icon_path = current_directory: nos servirá para establecer el icono de la aplicación.
mainWin.show()
: Esta línea muestra la ventana principal de la aplicación en pantalla.
sys.exit(app.exec_())
: app.exec_()
inicia el ciclo de eventos de la aplicación, que es lo que mantiene la ventana abierta y responde a las interacciones del usuario. sys.exit()
asegura que la aplicación se cierre completamente cuando se cierra la ventana.
Organizadorm3u.py
Este módulo define la clase M3UOrganizer, que sirve para crear este organizador de listas m3u como una aplicación de escritorio PyQt5, la cual he diseñado para cargar, filtrar, ordenar y gestionar archivos de listas de reproducción M3U. El programa permite al usuario cargar archivos M3U desde el sistema de archivos local o desde una URL, editar y organizar los canales listados, y guardarlos en un nuevo archivo M3U. También incluye integración con VLC para previsualizar streams de vídeo directamente desde la aplicación. Todo ello teniendo el siguiente código como base:
import os from PyQt5.QtWidgets import QMainWindow, QTextEdit, QHBoxLayout, QWidget, QAction, QVBoxLayout, QFileDialog, QMessageBox, QInputDialog, QProgressDialog, QSystemTrayIcon, QMenu, QPushButton, QComboBox, QLabel, QLineEdit from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor, QTextCharFormat, QBrush, QColor, QIcon from pathlib import Path from optionsmenu import show_about_dialog, show_how_to_use_dialog, open_github_url, abrir_vpn, restore_window, show_about_dialog from actions import copy_selection, paste_selection, show_context_menu, open_with_vlc, handle_double_click, guardar_url, ver_urls_guardadas from threads import LoadFileThread, SearchThread import requests # Importa la librería requests para realizar la descarga import logging # Para el manejo de advertencias y errores import vlc import re from actions import VideoDialog # Directorio del script actual current_directory = Path(__file__).parent class M3UOrganizer(QMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) try: # Inicializar la instancia de VLC y el reproductor de medios self.instance = vlc.Instance() self.media_player = self.instance.media_player_new() if not self.media_player: raise Exception("Error al inicializar el reproductor de medios VLC.") except Exception as e: QMessageBox.critical(self, "Error", f"Error al inicializar VLC: {str(e)}") self.media_player = None self.initUI() self.threads = [] # Inicializa el atributo threads self.temp_file_path = None # Añade un atributo para la ruta del archivo temporal self.original_content = [] # Almacena el contenido original sin filtrar ni ordenar def initUI(self): # Establecer un icono personalizado icon_path = current_directory / './resources/ordenar-m3u.png' if icon_path.exists(): self.setWindowIcon(QIcon(str(icon_path))) self.tray_icon = QSystemTrayIcon(QIcon(str(icon_path)), self) self.tray_icon.setToolTip("Organizador m3u") # Crear un menú para el icono de la bandeja del sistema tray_menu = QMenu(self) # Acción para abrir la suscripción de VPN vpn_action = QAction("30 días gratis de VPN", self) vpn_action.triggered.connect(lambda: abrir_vpn(self)) tray_menu.addAction(vpn_action) # Acción para restaurar la ventana principal restore_action = QAction("Restaurar", self) restore_action.triggered.connect(lambda: restore_window(self)) tray_menu.addAction(restore_action) # Acción para abrir la ventana "Acerca de" about_action = QAction("Acerca de", self) about_action.triggered.connect(lambda: show_about_dialog(self)) tray_menu.addAction(about_action) # Acción para salir de la aplicación exit_action = QAction("Salir", self) exit_action.triggered.connect(self.close) tray_menu.addAction(exit_action) # Configurar el menú de la bandeja self.tray_icon.setContextMenu(tray_menu) self.tray_icon.show() else: logging.warning(f"Icono no encontrado en {icon_path}") self.loaded_lines = [] # Inicializar lista para acumular líneas cargadas self.original_lines = [] # Para almacenar la lista original sin filtrar/ordenar self.setWindowTitle('M3U 0rgan1zat0r') # Crear los widgets self.text_left = QTextEdit() self.text_right = QTextEdit() # Hacer que ambos cuadros de texto acepten arrastrar y soltar self.text_left.setAcceptDrops(True) self.text_right.setAcceptDrops(True) # Crear los botones de filtrado, ordenación y reseteo filter_label = QLabel("Filtrar:") self.filter_input = QLineEdit() filter_button = QPushButton("Aplicar") filter_button.clicked.connect(self.filter_list) sort_label = QLabel("Ordenar:") self.sort_selector = QComboBox() self.sort_selector.addItems([ 'Nombre del Canal (A-Z)', 'Nombre del Canal (Z-A)', 'Group-title (A-Z)', 'Group-title (Z-A)' ]) sort_button = QPushButton("Aplicar") sort_button.clicked.connect(self.sort_list) reset_button = QPushButton("Restablecer") reset_button.clicked.connect(self.reset_list) # Layout superior con los botones de filtro, ordenación y reseteo top_layout = QHBoxLayout() top_layout.addWidget(filter_label) top_layout.addWidget(self.filter_input) top_layout.addWidget(filter_button) top_layout.addWidget(sort_label) top_layout.addWidget(self.sort_selector) top_layout.addWidget(sort_button) top_layout.addWidget(reset_button) # Layout principal con los textos h_layout = QHBoxLayout() h_layout.addWidget(self.text_left) h_layout.addWidget(self.text_right) #h_layout.addWidget(self.video_widget) # Añadir el widget de video al diseño # Layout final que combina todo main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addLayout(h_layout) container = QWidget() container.setLayout(main_layout) self.setCentralWidget(container) # Inicializar el reproductor de VLC self.media_player = vlc.MediaPlayer() # self.media_player.set_hwnd(self.video_widget.winId()) # Crear el menú (mantiene el código existente para el menú) menubar = self.menuBar() # Menú Archivo file_menu = menubar.addMenu('Archivo') open_action = QAction('Abrir M3U local', self) open_from_url_action = QAction('Abrir M3U desde URL', self) save_action = QAction('Guardar M3U', self) exit_action = QAction('Salir', self) open_action.triggered.connect(self.load_m3u) open_from_url_action.triggered.connect(self.load_m3u_from_url) save_action.triggered.connect(self.save_m3u) exit_action.triggered.connect(self.close) file_menu.addAction(open_action) file_menu.addAction(open_from_url_action) file_menu.addAction(save_action) file_menu.addAction(exit_action) # Menú Editar edit_menu = menubar.addMenu('Editar') search_action = QAction('Buscar y seleccionar', self) copy_action = QAction('Copiar selección', self) paste_action = QAction('Pegar', self) search_action.triggered.connect(self.search_group_title) copy_action.triggered.connect(lambda: copy_selection(self)) paste_action.triggered.connect(lambda: paste_selection(self)) edit_menu.addAction(search_action) edit_menu.addAction(copy_action) edit_menu.addAction(paste_action) # Acción de Filtrado filter_action = QAction('Filtrar', self) filter_action.triggered.connect(self.filter_list) edit_menu.addAction(filter_action) # Acción de Ordenación sort_action = QAction('Ordenar', self) sort_action.triggered.connect(self.sort_list) edit_menu.addAction(sort_action) # Menú Listas list_menu = menubar.addMenu('Listas') # Subopción guardar URL save_list_action = QAction('Guardar URL', self) save_list_action.triggered.connect(lambda: guardar_url(self) ) list_menu.addAction(save_list_action) # Ver URL view_list_action = QAction('Ver URLS Guardadas', self) view_list_action.triggered.connect(lambda: ver_urls_guardadas(self) ) list_menu.addAction(view_list_action) # Menú Opciones options_menu = menubar.addMenu('Opciones') # Subopción Acerca de about_action = QAction('Acerca de', self) about_action.triggered.connect(lambda: show_about_dialog(self)) options_menu.addAction(about_action) # Subopción Cómo usar how_to_use_action = QAction('Cómo usar', self) how_to_use_action.triggered.connect(lambda: show_how_to_use_dialog(self)) options_menu.addAction(how_to_use_action) # Subopción Abrir URL del repositorio en GitHub open_github_action = QAction('Abrir URL del repositorio en GitHub', self) open_github_action.triggered.connect(lambda: open_github_url(self)) options_menu.addAction(open_github_action) # Conectar el menú contextual del texto izquierdo self.text_left.setContextMenuPolicy(Qt.CustomContextMenu) self.text_left.customContextMenuRequested.connect(lambda position: show_context_menu(self, position)) # Conectar el menú contextual del texto derecho self.text_right.setContextMenuPolicy(Qt.CustomContextMenu) self.text_right.customContextMenuRequested.connect(lambda position: show_context_menu(self, position)) # Conectar la señal de doble clic a una función self.text_left.mouseDoubleClickEvent = lambda event: handle_double_click(self, event) self.text_right.mouseDoubleClickEvent = lambda event: handle_double_click(self, event) def preview_stream_from_menu(self, url): if not self.instance: QMessageBox.critical(self, "Error", "El reproductor VLC no está inicializado.") return video_dialog = VideoDialog(self, instance=self.instance) # Crear una instancia de VideoDialog video_dialog.play_video(url) video_dialog.exec_() def load_m3u(self): options = QFileDialog.Options() file_path, _ = QFileDialog.getOpenFileName(self, "Abrir M3U local", "", "M3U Files (*.m3u);;All Files (*)", options=options) if file_path: self.start_loading_m3u(file_path) def load_m3u_from_url(self): url, ok = QInputDialog.getText(self, 'Abrir M3U desde URL', 'Escribe la URL del archivo M3U:') if ok and url: try: response = requests.get(url, stream=True) response.raise_for_status() # Guardar temporalmente el archivo M3U descargado self.temp_file_path = str(current_directory / "temp_downloaded.m3u") with open(self.temp_file_path, 'wb') as temp_file: for chunk in response.iter_content(chunk_size=1024): if chunk: temp_file.write(chunk) self.start_loading_m3u(self.temp_file_path) except requests.exceptions.RequestException as e: QMessageBox.critical(self, "Error", f"No se pudo descargar el archivo: {str(e)}") self.temp_file_path = None def start_loading_m3u(self, file_path): """ Maneja el proceso de carga de un archivo M3U, ya sea desde un archivo local o una URL descargada. """ self.text_left.clear() # Borra el texto actual antes de cargar el nuevo archivo self.original_lines.clear() # Limpiar la lista original self.progress_dialog = QProgressDialog("Cargando archivo...", "Cancelar", 0, 100, self) self.progress_dialog.setWindowTitle("Cargando") self.progress_dialog.setWindowModality(Qt.WindowModal) self.progress_dialog.setMinimumDuration(0) self.progress_dialog.setAutoClose(False) self.progress_dialog.setValue(0) self.thread = LoadFileThread(file_path) self.threads.append(self.thread) # Conexiones self.thread.progress.connect(self.update_progress) self.thread.lines_loaded.connect(self.append_line_to_original) # Almacenar línea por línea self.thread.finished.connect(self.on_file_loaded) self.progress_dialog.canceled.connect(self.cancel_loading) self.thread.start() def append_line_to_original(self, line): """ Almacena cada línea en la lista original y la añade al texto de la izquierda. """ self.original_lines.append(line) self.append_text_to_left(line) def update_progress(self, value): self.progress_dialog.setValue(value) def append_text_to_left(self, text): # Ignora la línea #EXTM3U if not text.startswith("#EXTM3U"): self.original_content.append(text) # Almacena el contenido original sin la línea #EXTM3U # Aquí pasamos una lista con una sola línea a la función que maneja el coloreado self.append_lines_to_text_edit(self.text_left, [text]) def on_file_loaded(self): self.progress_dialog.close() # Cerrar el QProgressDialog cuando todo haya terminado self.threads.remove(self.sender()) def cancel_loading(self): if self.thread.isRunning(): self.thread.terminate() self.progress_dialog.close() QMessageBox.information(self, "Cancelado", "La carga del archivo ha sido cancelada.") def append_lines_to_text_edit(self, text_edit, lines): cursor = text_edit.textCursor() for line in lines: if line.startswith("#EXTINF:") or line.startswith("http"): self.append_colored_text_with_cursor(cursor, line, QColor('black')) else: self.append_colored_text_with_cursor(cursor, line, QColor('red')) text_edit.setTextCursor(cursor) # Asegura que el cursor esté al final def append_colored_text_with_cursor(self, cursor, text, color): fmt = QTextCharFormat() fmt.setForeground(QBrush(color)) cursor.movePosition(QTextCursor.End) cursor.insertBlock() cursor.insertText(text, fmt) cursor.setCharFormat(QTextCharFormat()) # Resetear el formato def on_lines_loaded(self, line): # Acumula las líneas cargadas self.loaded_lines.append(line) # Muestra las líneas en grupos (por ejemplo, de 10,000) para evitar recargar la UI if len(self.loaded_lines) >= 10000: self.append_lines_to_text_edit(self.text_left, self.loaded_lines) self.loaded_lines.clear() def save_m3u(self): options = QFileDialog.Options() file_path, _ = QFileDialog.getSaveFileName(self, "Guardar M3U", "", "M3U Files (*.m3u);;All Files (*)", options=options) if file_path: content = self.text_right.toPlainText() with open(file_path, 'w') as file: # Añadir #EXTM3U al principio del archivo file.write("#EXTM3U\n") file.write(content) def search_group_title(self): search_term, ok = QInputDialog.getText(self, 'Buscar', 'Escribe el contenido de group-title a buscar:') if ok and search_term: self.thread = SearchThread(self.text_left.toPlainText(), search_term) self.thread.result.connect(self.on_search_finished) self.thread.start() def on_search_finished(self, positions): if not positions: QMessageBox.warning(self, "Advertencia", "No hay resultados para el concepto buscado.") else: fmt = QTextCharFormat() fmt.setBackground(QBrush(QColor('yellow'))) cursor = self.text_left.textCursor() # Resaltar todas las posiciones encontradas for pos in positions: cursor.setPosition(pos) cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, len(positions[pos])) cursor.mergeCharFormat(fmt) def closeEvent(self, event): # Preguntar al usuario si está seguro de cerrar reply = QMessageBox.question(self, 'Confirmar salida', '¿Está seguro de que desea salir?', QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.Yes: # Eliminar el archivo temporal si existe if self.temp_file_path and os.path.exists(self.temp_file_path): os.remove(self.temp_file_path) self.temp_file_path = None if self.media_player is not None: self.media_player.stop() # Cerrar todos los hilos y procesos en ejecución self.close_all_threads_and_processes() event.accept() else: event.ignore() def close_all_threads_and_processes(self): # Aquí deberías cerrar todos los hilos y procesos que estén en ejecución for thread in self.threads: if thread.isRunning(): thread.quit() thread.wait() self.threads.clear() def filter_list(self): """ Filtra la lista M3U en función del texto ingresado en la entrada de filtro. Cada canal está compuesto por un par de líneas (EXTINF y URL). """ filter_term = self.filter_input.text().strip() if not filter_term: QMessageBox.warning(self, "Entrada Vacía", "Por favor, ingrese un término para filtrar.") return filtered_lines = [] # Iterar en pasos de 2 para considerar cada canal (EXTINF + URL) como un bloque for i in range(0, len(self.original_lines), 2): extinf_line = self.original_lines[i] url_line = self.original_lines[i + 1] if i + 1 < len(self.original_lines) else "" # Verificar si el término de filtro aparece en alguna de las dos líneas if filter_term.lower() in extinf_line.lower() or filter_term.lower() in url_line.lower(): filtered_lines.append(extinf_line) filtered_lines.append(url_line) if filtered_lines: self.text_left.clear() self.append_lines_to_text_edit(self.text_left, filtered_lines) else: QMessageBox.information(self, "Sin Resultados", "No se encontraron coincidencias con el criterio de filtrado.") def extract_group_title(self, line): match = re.search(r'group-title="([^"]*)"', line) if match: return match.group(1).strip().lower() return '' # Devuelve una cadena vacía si no se encuentra el group-title def sort_list(self): """ Ordena la lista M3U basada en la opción seleccionada en el combo box. Cada canal está compuesto por un par de líneas (EXTINF y URL). """ sort_criteria = self.sort_selector.currentText() # Crear pares de líneas para cada canal channel_pairs = [(self.original_lines[i], self.original_lines[i + 1]) for i in range(0, len(self.original_lines), 2)] if sort_criteria == 'Nombre del Canal (A-Z)': sorted_pairs = sorted(channel_pairs, key=lambda pair: pair[0]) elif sort_criteria == 'Nombre del Canal (Z-A)': sorted_pairs = sorted(channel_pairs, key=lambda pair: pair[0], reverse=True) elif sort_criteria == 'Group-title (A-Z)': sorted_pairs = sorted(channel_pairs, key=lambda pair: self.extract_group_title(pair[0])) elif sort_criteria == 'Group-title (Z-A)': sorted_pairs = sorted(channel_pairs, key=lambda pair: self.extract_group_title(pair[0]), reverse=True) # Descomprimir los pares en una lista simple de líneas ordenadas sorted_lines = [line for pair in sorted_pairs for line in pair] self.text_left.clear() self.append_lines_to_text_edit(self.text_left, sorted_lines) def reset_list(self): """ Restaura la lista original cargada antes de aplicar filtros u ordenaciones. """ self.text_left.clear() # Filtrar la línea que contiene #EXTM3U antes de mostrar el contenido filtered_lines = [line for line in self.original_content if not line.startswith("#EXTM3U")] # Mostrar el contenido filtrado en el cuadro de texto izquierdo self.append_lines_to_text_edit(self.text_left, filtered_lines) self.filter_input.clear() self.sort_selector.setCurrentIndex(0)
1. Configuración Inicial
- Importaciones: El código importa varios módulos de PyQt5 para crear la interfaz gráfica, así como otras bibliotecas para manejar archivos, realizar solicitudes HTTP (con
requests
), manejar errores (conlogging
), y reproducir videos utilizandovlc
y el módulo python-vlc. - Directorios y recursos: Se define
current_directory
para obtener la ruta del directorio donde se encuentra el script actual. Además, se carga un ícono personalizado para la aplicación.
2. Clase M3UOrganizer
- Inicialización:
- La clase hereda de
QMainWindow
, lo que permite crear una ventana principal para la aplicación. - Se inicializa una instancia del reproductor VLC (
vlc.Instance
) para reproducir medios. - Se preparan algunos atributos para manejar hilos de procesamiento, archivos temporales y el contenido original de las listas M3U.
- La clase hereda de
- Método
initUI
:- Icono y bandeja del sistema: Configura un icono para la ventana y la bandeja del sistema, añadiendo un menú contextual con opciones como abrir una VPN, restaurar la ventana, mostrar información «Acerca de», y salir de la aplicación.
- Widgets principales: Se crean dos cajas de texto (
QTextEdit
) para mostrar la lista M3U original (izquierda de la ventana) y la modificada (derecha de la ventana) que será la que el usuario puede guardar como archivo .m3u. - Botones de filtrado y ordenación: Se añaden botones y campos de entrada para filtrar y ordenar las listas de canales.
- Menús: Se configuran varios menús en la barra de menú (
Archivo
,Editar
,Listas
,Opciones
) con acciones para abrir, guardar, filtrar, ordenar, buscar, y gestionar URLs.
3. Manejo de listas M3U
- Cargar archivos M3U:
- Se pueden cargar listas desde un archivo local o desde una URL. Para este último, se descarga el archivo temporalmente. Este archivo debería eliminarse cuando se cierre el programa.
- El proceso de carga utiliza hilos (
LoadFileThread
) para manejar la tarea de forma asíncrona, evitando que la interfaz gráfica se congele. - El contenido cargado se muestra en el cuadro de texto izquierdo y se almacena en
original_lines
.
- Filtrado y ordenación:
- Filtrado: Permite buscar y mostrar solo los canales que coincidan con un término de búsqueda.
- Ordenación: Ofrece opciones para ordenar la lista alfabéticamente por nombre de canal o por
group-title
(una etiqueta dentro de los archivos M3U).
- Guardado de archivos M3U:
- Permite guardar la lista modificada en un archivo M3U.
- Otras funcionalidades:
- Buscar
group-title
: Busca y resalta en la lista los canales que coincidan con un término de búsqueda. - Previsualización de Streaming: Permite previsualizar la URL de un canal mediante VLC en una ventana de diálogo (
VideoDialog
). - Menú Contextual y Doble Clic: Los cuadros de texto izquierdo y derecho tienen menús contextuales personalizados y manejan eventos de doble clic.
- Buscar
4. Manejo de eventos
- Cierre de la aplicación: Antes de cerrar, la aplicación pregunta al usuario si desea salir, limpia los archivos temporales, detiene el reproductor VLC y finaliza los hilos en ejecución.
5. Hilos y proceso asíncrono
- La carga de archivos y la búsqueda se manejan en hilos separados para mantener la interfaz de usuario en funcionamiento, evitando que se congele.
6. Funciones auxiliares
- Filtrado y ordenación: Métodos para extraer etiquetas de
group-title
, aplicar filtros, ordenar canales y restablecer la lista original. - Manejo de coloreado: Añade colores específicos al texto mostrado en los cuadros de texto para diferenciar tipos de líneas (EXTINF y URLs).
Threads.py
Este módulo contiene dos clases que heredan de QThread y están diseñadas para realizar las operaciones en segundo plano dentro de la aplicación PyQt5. Las clases LoadFileThread y SearchThread proporcionan hilos para cargar archivos y buscar términos específicos en texto, respectivamente.
from PyQt5.QtCore import QThread, pyqtSignal import chardet class LoadFileThread(QThread): progress = pyqtSignal(int) lines_loaded = pyqtSignal(str) finished = pyqtSignal() def __init__(self, file_path, chunk_size=10000): # chunk_size aumentado super().__init__() self.file_path = file_path self.chunk_size = chunk_size def run(self): # Detectar la codificación with open(self.file_path, 'rb') as f: raw_data = f.read(10000) # Leer una pequeña parte del archivo result = chardet.detect(raw_data) encoding = result['encoding'] total_lines = 0 with open(self.file_path, 'r', encoding=encoding, errors='ignore') as file: while file.readline(): total_lines += 1 with open(self.file_path, 'r', encoding=encoding, errors='ignore') as file: for i, line in enumerate(file): if i % self.chunk_size == 0: self.progress.emit(int((i / total_lines) * 100)) self.lines_loaded.emit(line.strip()) self.finished.emit() def process_lines(self, lines): processed_lines = [] for line in lines: line = line.strip() color = 'black' if line.startswith("#EXTINF:") or line.startswith("http") else 'red' processed_lines.append(f"<span style='color:{color}'>{line}</span>") return "\n".join(processed_lines) class SearchThread(QThread): result = pyqtSignal(dict) def __init__(self, text, search_term): super().__init__() self.text = text self.search_term = search_term def run(self): positions = {} current_pos = 0 while current_pos >= 0: current_pos = self.text.find(self.search_term, current_pos) if (current_pos >= 0): positions[current_pos] = self.search_term current_pos += len(self.search_term) self.result.emit(positions)
Las clases:
LoadFileThread
:- Propósito: Leer un archivo M3U línea por línea en un hilo separado, emitir señales de progreso y enviar las líneas leídas a la interfaz.
- Señales:
progress
: Emite el progreso de la carga como un porcentaje.lines_loaded
: Emite cada línea del archivo después de leerla.finished
: Señala que el proceso de carga ha terminado.
- Métodos:
__init__
: Inicializa el hilo con la ruta del archivo y el tamaño de los bloques de lectura (chunk_size
).run
: Detecta la codificación del archivo usando la libreríachardet
, cuenta el número total de líneas y luego las lee en bloques, emitiendo el progreso y las líneas cargadas.process_lines
: (Método adicional que podría no estar en uso) Procesa las líneas para aplicar colores según el tipo de contenido (EXTINF o URL).
SearchThread
:- Propósito: Buscar un término en un texto dado en un hilo separado y devolver las posiciones donde se encuentra el término.
- Señales:
result
: Emite un diccionario con las posiciones donde se encuentra el término de búsqueda.
- Métodos:
__init__
: Inicializa el hilo con el texto completo y el término de búsqueda.run
: Busca todas las ocurrencias del término en el texto y almacena las posiciones encontradas, que luego emite.
Funcionamiento general:
LoadFileThread
: Se encarga de cargar un archivo grande en segundo plano. Primero detecta la codificación del archivo, luego cuenta las líneas y finalmente las lee y emite línea por línea mientras actualiza el progreso.SearchThread
: Se utiliza para realizar búsquedas de términos en un texto largo, encontrando y devolviendo todas las posiciones del término en un diccionario.
Actions.py
El archivo actions.py
contiene las funciones y clases que definen la mayoría de las acciones que se pueden realizar dentro de la aplicación M3U Organ1zat0r. Este código es un ejemplo de una aplicación GUI (interfaz gráfica de usuario) que utiliza PyQt5 y VLC para la manipulación de texto, reproducción de videos y gestión de URLs.
from PyQt5.QtWidgets import (QAction, QDialog, QLineEdit, QApplication, QDialog, QVBoxLayout, QPushButton, QListWidget, QInputDialog, QMenu, QMessageBox, QScrollArea, QWidget, QFileDialog) from PyQt5.QtGui import QTextCursor from PyQt5.QtCore import QProcess, QTimer from PyQt5.QtMultimediaWidgets import QVideoWidget import sys import vlc import json import os def copy_selection(main_window): text_edit = main_window.text_left if main_window.text_left.hasFocus() else main_window.text_right text_edit.copy() def paste_selection(main_window): text_edit = main_window.text_left if main_window.text_left.hasFocus() else main_window.text_right text_edit.paste() def show_context_menu(main_window, position): cursor = main_window.text_left.textCursor() if main_window.text_left.hasFocus() else main_window.text_right.textCursor() selected_text = cursor.selectedText().strip() context_menu = QMenu() # Opción para copiar el texto seleccionado copy_action = QAction("Copiar", main_window) copy_action.triggered.connect(lambda: copy_selection(main_window)) context_menu.addAction(copy_action) # Opción para pegar el texto copiado paste_action = QAction("Pegar", main_window) paste_action.triggered.connect(lambda: paste_selection(main_window)) context_menu.addAction(paste_action) # Opción para abrir con VLC si el texto seleccionado es una URL válida if selected_text.startswith("http://") or selected_text.startswith("https://"): open_with_vlc_action = QAction("Abrir con VLC", main_window) open_with_vlc_action.triggered.connect(lambda: open_with_vlc(main_window, selected_text)) context_menu.addAction(open_with_vlc_action) # Opción para previsualizar en VLC preview_action = QAction("Previsualizar Streaming", main_window) preview_action.triggered.connect(lambda: main_window.preview_stream_from_menu(selected_text)) context_menu.addAction(preview_action) # Opción para seleccionar todo el texto select_all_action = QAction("Seleccionar Todo", main_window) select_all_action.triggered.connect(lambda: (main_window.text_left.selectAll() if main_window.text_left.hasFocus() else main_window.text_right.selectAll())) context_menu.addAction(select_all_action) # Mostrar el menú contextual context_menu.exec_(main_window.text_left.mapToGlobal(position) if main_window.text_left.hasFocus() else main_window.text_right.mapToGlobal(position)) # Funciones para abrir con VLC def is_vlc_installed(command): """Verifica si VLC está instalado intentando ejecutar el comando.""" try: result = QProcess().startDetached(command) return result except Exception: return False def open_with_vlc(main_window, url): if sys.platform.startswith('linux') or sys.platform == 'darwin': # Linux o macOS command = f"vlc {url}" elif sys.platform.startswith('win'): # Windows vlc_path = find_vlc_executable(main_window) if vlc_path is None: QMessageBox.critical(main_window, "Error", "No se encontró el ejecutable de VLC.") return command = f'"{vlc_path}" "{url}"' else: QMessageBox.warning(main_window, "Error", "Sistema operativo no soportado.") return # Verificar si VLC está instalado y disponible if not is_vlc_installed(command): QMessageBox.critical(main_window, "Error", "No se pudo encontrar VLC en el sistema. Por favor, asegúrate de que VLC está instalado.") return try: QProcess.startDetached(command) except Exception as e: QMessageBox.critical(main_window, "Error", f"No se pudo abrir VLC: {str(e)}") def find_vlc_executable(main_window): vlc_executable = "vlc.exe" # Verificar si vlc.exe está en el PATH if is_executable_in_path(vlc_executable): return vlc_executable # Preguntar al usuario la ubicación del ejecutable de VLC vlc_path, _ = QFileDialog.getOpenFileName( main_window, "Como VLC no está en el PATH del sistema. Selecciona el ejecutable de VLC", os.getenv("ProgramFiles", "C:\\"), "VLC executable (vlc.exe)" ) if vlc_path: # Aquí puedes guardar la ruta seleccionada para futuros usos, si es necesario return vlc_path else: return None def is_executable_in_path(executable): # Verifica si el ejecutable está en el PATH del sistema for path in os.environ["PATH"].split(os.pathsep): exe_file = os.path.join(path, executable) if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): return True return False def handle_double_click(main_window, event): # Identificar cuál de los QTextEdit ha recibido el evento text_edit = main_window.text_left if main_window.text_left.viewport().underMouse() else main_window.text_right cursor = text_edit.cursorForPosition(event.pos()) cursor.select(QTextCursor.LineUnderCursor) selected_text = cursor.selectedText() # Personalizar el tamaño de la ventana de edición dialog = QInputDialog(main_window) dialog.setWindowTitle("Editar línea") dialog.setLabelText("Modifica la línea:") dialog.setTextValue(selected_text) dialog.setFixedSize(400, 150) # Ajusta el tamaño de la ventana de edición ok = dialog.exec_() new_text = dialog.textValue() if ok: cursor.insertText(new_text) class VideoDialog(QDialog): def __init__(self, parent=None, instance=None): super(VideoDialog, self).__init__(parent) self.setWindowTitle("Video Preview") self.video_widget = QVideoWidget() self.video_widget.setMinimumSize(640, 480) layout = QVBoxLayout() layout.addWidget(self.video_widget) self.setLayout(layout) self.media_player = None self.instance = instance def closeEvent(self, event): if self.media_player: self.media_player.stop() event.accept() def play_video(self, url): try: # Crear un nuevo objeto de medios desde la URL media = self.instance.media_new(url) if not media: raise Exception("No se pudo crear el objeto de medios VLC.") # Establecer la opción vout en opengl media.add_option('vout=opengl') # Asociar el medio con el reproductor self.media_player = vlc.MediaPlayer() self.media_player.set_media(media) # Establecer el widget de salida de video según el sistema operativo if sys.platform.startswith('linux'): self.media_player.set_xwindow(int(self.video_widget.winId())) elif sys.platform.startswith('win'): self.media_player.set_hwnd(int(self.video_widget.winId())) elif sys.platform.startswith('darwin'): # macOS self.media_player.set_nsobject(int(self.video_widget.winId())) # Comenzar la reproducción (sin mostrar la ventana aún) self.media_player.play() print("Reproduciendo URL:", url) # Mensaje de depuración # Esperar un momento para verificar si el stream comienza QTimer.singleShot(3000, self.check_stream_status) # Esperar 3 segundos antes de verificar except Exception as e: QMessageBox.critical(self, "Error", f"Error al intentar reproducir el stream: {str(e)}") def check_stream_status(self): # Comprobar si el estado es Error, Stopped o Ended if self.media_player.get_state() in [vlc.State.Error, vlc.State.Stopped, vlc.State.Ended]: # Detener el reproductor y mostrar un mensaje de error self.media_player.stop() QMessageBox.warning(self, "Error de reproducción", "No se pudo reproducir el stream. La URL puede estar inactiva o ser incorrecta.") print("El stream no se pudo reproducir.") self.close() # Cerrar la ventana si la reproducción falla else: self.show() # Mostrar la ventana solo si la reproducción es exitosa # Acciones sobre las URL a Guardar def load_urls(): if not os.path.exists("urls_guardadas.json"): return {} with open("urls_guardadas.json", "r") as file: return json.load(file) def save_urls(urls): with open("urls_guardadas.json", "w") as file: json.dump(urls, file, indent=4) def guardar_url(self): # Mostrar un cuadro de diálogo para obtener el nombre y la URL nombre, ok_pressed = QInputDialog.getText(self, "Guardar URL", "Ingrese un nombre para la URL:") if not ok_pressed or not nombre: return # Si el usuario cancela o no ingresa un nombre, salir url, ok_pressed = QInputDialog.getText(self, "Guardar URL", "Ingrese la URL:") if not ok_pressed or not url: return # Si el usuario cancela o no ingresa una URL, salir urls = load_urls() urls[nombre] = url save_urls(urls) QMessageBox.information(self, "Acción completada", f"URL guardada bajo el nombre '{nombre}'") def ver_urls_guardadas(self): urls = load_urls() if not urls: QMessageBox.information(self, "Información", "No hay URLs guardadas.") return # Crear un diálogo para mostrar y gestionar URLs dialog = QDialog(self) dialog.setWindowTitle("URLs Guardadas") layout = QVBoxLayout(dialog) # Crear un área de desplazamiento scroll_area = QScrollArea(dialog) scroll_area.setWidgetResizable(True) # Crear un widget contenedor para el área de desplazamiento container_widget = QWidget() container_layout = QVBoxLayout(container_widget) list_widget = QListWidget(container_widget) for nombre in urls: list_widget.addItem(nombre) container_layout.addWidget(list_widget) container_widget.setLayout(container_layout) scroll_area.setWidget(container_widget) layout.addWidget(scroll_area) # Añadir botones para editar, eliminar y copiar URL edit_button = QPushButton("Editar URL", dialog) delete_button = QPushButton("Eliminar URL", dialog) copy_button = QPushButton("Copiar URL", dialog) def edit_url(): current_item = list_widget.currentItem() if current_item: nombre_original = current_item.text() # Editar el nombre nuevo_nombre, ok_pressed = QInputDialog.getText(dialog, "Editar Nombre", "Modifica el nombre:", text=nombre_original) if not ok_pressed or not nuevo_nombre: QMessageBox.warning(dialog, "Advertencia", "El nombre no puede estar vacío.") return # Editar la URL nueva_url, ok_pressed = QInputDialog.getText(dialog, "Editar URL", "Modifica la URL:", text=urls[nombre_original]) if not ok_pressed or not nueva_url: QMessageBox.warning(dialog, "Advertencia", "La URL no puede estar vacía.") return # Actualizar el registro y guardar del urls[nombre_original] # Eliminar el antiguo nombre urls[nuevo_nombre] = nueva_url save_urls(urls) # Actualizar la lista en la UI current_item.setText(nuevo_nombre) QMessageBox.information(dialog, "Éxito", f"'{nombre_original}' actualizado correctamente a '{nuevo_nombre}'.") def delete_url(): current_item = list_widget.currentItem() if current_item: nombre = current_item.text() del urls[nombre] save_urls(urls) list_widget.takeItem(list_widget.row(current_item)) QMessageBox.information(dialog, "Éxito", f"URL '{nombre}' eliminada correctamente.") def copy_url(): current_item = list_widget.currentItem() if current_item: nombre = current_item.text() url = urls[nombre] clipboard = QApplication.clipboard() clipboard.setText(url) QMessageBox.information(dialog, "Éxito", f"URL '{url}' copiada al portapapeles.") edit_button.clicked.connect(edit_url) delete_button.clicked.connect(delete_url) copy_button.clicked.connect(copy_url) layout.addWidget(edit_button) layout.addWidget(delete_button) layout.addWidget(copy_button) dialog.setLayout(layout) dialog.exec_()
Funcionalidades:
- Funciones de Copiar y Pegar:
copy_selection(main_window)
ypaste_selection(main_window)
: Copian y pegan el texto seleccionado del área de texto que está actualmente enfocada en la ventana principal (text_left
otext_right
).
- Menú contextual:
show_context_menu(main_window, position)
: Muestra un menú contextual con opciones como copiar, pegar, abrir en VLC, previsualizar en VLC, y seleccionar todo el texto. También añade la posibilidad de que si el texto seleccionado es una URL, agrega opciones adicionales para abrirla o previsualizarla con VLC.
- Integración con VLC:
is_vlc_installed(command)
yopen_with_vlc(main_window, url)
: Verifican si VLC está instalado y disponible en el sistema, y luego intentan abrir la URL con VLC.find_vlc_executable(main_window)
: Busca el ejecutable de VLC (vlc.exe
) en el sistema. Si no se encuentra, pide al usuario que seleccione manualmente la ubicación del ejecutable.is_executable_in_path(executable)
: Verifica si un ejecutable está disponible en el PATH del sistema.
- Edición de texto:
handle_double_click(main_window, event)
: Maneja el doble clic en un área de texto, permitiendo al usuario editar la línea seleccionada en un cuadro de diálogo.
- Reproducción de vídeo en una ventana de diálogo:
VideoDialog
: Es una clase que crea un diálogo con un reproductor de video VLC integrado. Se utiliza para previsualizar streams de video. La clase maneja la reproducción del video, así como la verificación de si el stream es reproducible.
- Gestión de URLs guardadas:
load_urls()
ysave_urls(urls)
: Cargan y guardan un diccionario de URLs en un archivo JSON (urls_guardadas.json
).guardar_url(self)
: Muestra un diálogo para que el usuario guarde una nueva URL con un nombre asociado.ver_urls_guardadas(self)
: Muestra un diálogo con una lista de URLs guardadas, permitiendo al usuario editar, eliminar o copiar URLs.
Funciones de gestión de URLs en este organizador de listas m3u:
ver_urls_guardadas(self)
: Crea una interfaz para visualizar las URLs guardadas, donde se puede:- Editar: Modificar el nombre y la URL asociada.
- Eliminar: Quitar una URL de la lista guardada.
- Copiar: Copiar la URL al portapapeles del sistema.
El código en su conjunto proporciona una interfaz completa para funcionar con este organizador de listas m3u (listas de reproducción M3U o M3U8), gestionar URLs de streaming, y reproducir estas URLs usando VLC, todo ello integrado en una aplicación de escritorio en Python.
Este código proporciona funcionalidades para una aplicación GUI, llamada «M3U Organizer», que está diseñada para gestionar listas de reproducción en formato M3U.
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout from PyQt5.QtGui import QPixmap import webbrowser from pathlib import Path current_directory = Path(__file__).parent def show_about_dialog(main_window): dialog = QDialog(main_window) dialog.setWindowTitle("Acerca de M3U Organizer") # Usamos un QHBoxLayout para organizar la imagen a la izquierda y el texto a la derecha layout = QHBoxLayout() # Cargar y mostrar la imagen image_path = current_directory / './resources/logo.png' if image_path.exists(): pixmap = QPixmap(str(image_path)) image_label = QLabel() image_label.setPixmap(pixmap) layout.addWidget(image_label) # Texto de la descripción description_label = QLabel("M3U Organizer (V.0.5) es una herramienta para gestionar listas de reproducción en formato M3U.\n" "Este es un programa escrito por entreunosyceros como un ejercicio práctico de Python.") description_label.setWordWrap(True) layout.addWidget(description_label) dialog.setLayout(layout) # Fijar el tamaño del diálogo para que no se pueda redimensionar dialog.setFixedSize(dialog.sizeHint()) dialog.exec_() def show_how_to_use_dialog(main_window): dialog = QDialog(main_window) dialog.setWindowTitle("Cómo usar M3U Organizer") layout = QVBoxLayout() # Texto de la explicación instruction_text = ("0. Formato de lista .m3u válido:\n" "#EXTM3U\n" "#EXTINF:-1 ETIQUETAS\n" "http://URL-DEL-STREAMING\n" "#EXTINF:-1 ETIQUETAS\n" "http://URL-DEL-STREAMING\n" "---------------------------\n" "1. Usa 'Abrir M3U' para cargar una lista de reproducción en el lado izquierdo de la pantalla.\n" "2. Utiliza la opción 'Abrir con VLC' en el menú contextual del ratón para reproducir la URL seleccionada en el lado izquierdo de la pantalla.\n" "3. En el menú contextual del ratón también podrás seleccionar todo el contenido del lado izquierdo de la pantalla.\n" "4. Usa 'Buscar y seleccionar' para buscar un group-title.\n" "5. El usuario podrá previsualizar el streaming de la URL seleccionada en el lado izquierdo de pantalla.\n También podrá directamente abrir la URL con VLC para ver el streaming." "6. Arrastra el texto seleccionado de un lado a otro de la pantalla.\n Ordena el texto seleccionado del lado derecho de la pantalla arrastrando o utilizando las opciones del menú del ratón.\n" "7. Puedes copiar la selección al panel derecho y guardar la lista modificada como un archivo m3u.\n") instruction_label = QLabel(instruction_text) instruction_label.setWordWrap(True) layout.addWidget(instruction_label) dialog.setLayout(layout) # Fijar el tamaño del diálogo para que no se pueda redimensionar dialog.setFixedSize(dialog.sizeHint()) dialog.exec_() def open_github_url(main_window): # URL del repositorio en GitHub github_url = "https://github.com/sapoclay/organizador-m3u" webbrowser.open(github_url) def abrir_vpn(self): """ Abre la URL para obtener una VPN gratuita durante 30 días en el navegador web predeterminado. """ webbrowser.open("https://www.expressvpn.com/refer-a-friend/30-days-free?locale=es&referrer_id=40141467&utm_campaign=referrals&utm_medium=copy_link&utm_source=referral_dashboard") def restore_window(self): """ Restaura la ventana principal si está minimizada o escondida. """ if self.isMinimized() or not self.isVisible(): self.showNormal() self.activateWindow()
El código proporciona varias funciones que sirven para mostrar información sobre la aplicación, así como guiar al usuario en su uso, y acceder a recursos externos como el repositorio en GitHub y una oferta de VPN. Además, incluye una función para manejar la restauración de la ventana principal de la aplicación. Las interfaces gráficas están construidas utilizando PyQt5, con una organización simple y lógica para intentar mejorar la experiencia del usuario.
Cómo descargar este organizador de listas m3u
Estos son los códigos necesarios para este pequeño proyecto. Puedes descargar el código fuente y el ejecutable desde la página de lanzamientos que se puede encontrar en el repositorio en GitHub en el que lo he colgado.
M3U Organ1zat0r es un organizador de listar M3U sencillo de utilizar, que está diseñado para usuarios que necesitan una solución eficiente y rápida. Con su capacidad para manejar grandes volúmenes de enlaces, realizar búsquedas rápidas y reproducir contenido directamente desde VLC, esta aplicación se puede convertir un una herramienta interesante para los amantes del streaming.
Quizás le vaya añadiendo nuevas funcionalidades a medida que me vayan resultando necesarias en mi día a día.