Subida SOLO de DRMPlayer

This commit is contained in:
Filipinos 2025-06-19 04:02:44 +02:00
commit 22d35b2718
56 changed files with 17372 additions and 0 deletions

334
_locales/en/messages.json Normal file
View File

@ -0,0 +1,334 @@
{
"pageTitle": { "message": "DRM Player | Advanced Player" },
"appName": { "message": "DRM Player" },
"filterGroupsLabel": { "message": "Filter Groups" },
"allGroupsOption": { "message": "📂 All groups" },
"groupsLabel": { "message": "Groups" },
"allGroupsListItem": { "message": "All Groups" },
"searchPlaceholder": { "message": "Search channels..." },
"advancedEditorButton": { "message": "Editor" },
"providersButton": { "message": "Providers" },
"listManagementButton": { "message": "Lists" },
"loadListsButton": { "message": "Load Lists" },
"saveListsButton": { "message": "Save Lists" },
"downloadM3UButton": { "message": "Download M3U" },
"epgButton": { "message": "EPG" },
"settingsButton": { "message": "Settings" },
"loadUrlButton": { "message": "Load URL" },
"loadFileInputTitle": { "message": "Select local M3U file" },
"allChannelsTab": { "message": "All" },
"favoritesTab": { "message": "Favorites" },
"historyTab": { "message": "History" },
"backButton": { "message": "Back" },
"availableChannelsTitle": { "message": "Available Channels" },
"paginationPrev": { "message": "Prev" },
"paginationNext": { "message": "Next" },
"playerTitle": { "message": "Player" },
"minimizeButton": { "message": "Minimize" },
"closeButton": { "message": "Close" },
"nowLabel": { "message": "Now:" },
"nextLabel": { "message": "Next:" },
"channelListTitle": { "message": "Channel List" },
"advancedEditorTitle": { "message": "Advanced M3U Editor" },
"noFileLoaded": { "message": "No file loaded" },
"searchInListPlaceholder": { "message": "Search in list..." },
"allGroups": { "message": "All Groups" },
"deleteSelected": { "message": "Delete Sel." },
"clearSelection": { "message": "Clear Sel." },
"multiEdit": { "message": "Multi-Edit" },
"logoHeader": { "message": "Logo" },
"nameHeader": { "message": "Name" },
"urlHeader": { "message": "URL" },
"epgIdHeader": { "message": "EPG ID" },
"channelNumHeader": { "message": "Num" },
"actionsHeader": { "message": "Actions" },
"editorPlaceholder": { "message": "Select a channel to edit its details." },
"channelEditorTitle": { "message": "Channel Editor" },
"logoPreviewAlt": { "message": "Logo preview" },
"channelNameLabel": { "message": "Channel Name" },
"epgIdLabel": { "message": "EPG ID (tvg-id)" },
"channelNumLabel": { "message": "Ch. Number (ch-number)" },
"logoLabel": { "message": "Logo (tvg-logo)" },
"streamUrlLabel": { "message": "Stream URL" },
"groupLabel": { "message": "Group (group-title)" },
"favoriteLabel": { "message": "Favorite" },
"hideChannelLabel": { "message": "Hide channel" },
"advancedSettingsDRM": { "message": "Advanced Settings / DRM" },
"licenseTypeLabel": { "message": "DRM License Type (license_type)" },
"licenseKeyLabel": { "message": "DRM License Key/URL (license_key)" },
"streamHeadersLabel": { "message": "DRM Stream Headers (stream_headers)" },
"vlcUserAgentLabel": { "message": "VLC User-Agent (#EXTVLCOPT:http-user-agent)" },
"testButton": { "message": "Test" },
"deleteButton": { "message": "Delete" },
"saveButton": { "message": "Save" },
"closeEditorButton": { "message": "Close Editor" },
"applyChangesAndCloseButton": { "message": "Apply Changes & Close" },
"multiEditTitle": { "message": "Multiple Channel Edit" },
"multiEditDescription": { "message": "Apply changes to all {count} selected channels. Only enabled fields will be modified." },
"changeGroupLabel": { "message": "Change Group" },
"newGroupNamePlaceholder": { "message": "New group name..." },
"modifyFavoriteLabel": { "message": "Modify Favorite" },
"addToFavoritesOption": { "message": "Add to Favorites" },
"removeFromFavoritesOption": { "message": "Remove from Favorites" },
"modifyVisibilityLabel": { "message": "Modify Visibility" },
"hideChannelsOption": { "message": "Hide Channels" },
"showChannelsOption": { "message": "Show Channels" },
"headersAndDRM": { "message": "Headers & DRM" },
"setUserAgentLabel": { "message": "Set User-Agent (VLC)" },
"userAgentPlaceholder": { "message": "User-Agent for #EXTVLCOPT..." },
"setStreamHeadersLabel": { "message": "Add/Overwrite Stream Headers (Kodi)" },
"streamHeadersPlaceholder": { "message": "key1=value1|key2=value2..." },
"appendHeadersOption": { "message": "Append/Update Headers" },
"replaceHeadersOption": { "message": "Replace All Headers" },
"applyChangesButton": { "message": "Apply Changes" },
"saveM3UModalTitle": { "message": "Save Current M3U List" },
"saveM3UModalDescription": { "message": "Enter a name to save the currently loaded M3U list to the extension's local database." },
"listNameLabel": { "message": "List Name:" },
"listNamePlaceholder": { "message": "E.g.: MyFavoriteTV_List" },
"saveListButton": { "message": "Save List" },
"daznTokenModalTitle": { "message": "DAZN Authentication Token Required" },
"daznTokenModalDescription": { "message": "To update DAZN channels, please enter your full DAZN Bearer Token." },
"daznTokenModalHint": { "message": "This token can be obtained from your browser's developer tools by inspecting network requests while DAZN is active and logged in." },
"daznTokenLabel": { "message": "DAZN Token (Bearer):" },
"daznTokenPlaceholder": { "message": "Paste your full Bearer token here..." },
"rememberTokenLabel": { "message": "Remember this token (will be saved locally in settings)" },
"submitTokenButton": { "message": "Submit Token" },
"loadFromDBModalTitle": { "message": "Saved Lists" },
"loadingLists": { "message": "Loading lists..." },
"loadButton": { "message": "Load" },
"epgModalTitle": { "message": "Program Guide (EPG)" },
"epgUrlPlaceholder": { "message": "📅 XMLTV EPG file URL" },
"loadEpgButton": { "message": "Load/Update EPG" },
"movistarVODModalTitle": { "message": "Movistar+ VOD/Catchup" },
"selectDateLabel": { "message": "Select Date:" },
"loadEpgDayButton": { "message": "Load Day's EPG" },
"searchProgramPlaceholder": { "message": "Search program..." },
"allChannelsOption": { "message": "All channels" },
"allGenresOption": { "message": "All genres" },
"noProgramsFound": { "message": "No programs found for the selected date/filters." },
"pageInfo": { "message": "Page {currentPage} of {totalPages} ({totalItems} results)" },
"previousButton": { "message": "Previous" },
"nextButton": { "message": "Next" },
"programDetailsTitle": { "message": "Program Details" },
"playProgramButton": { "message": "Play" },
"addToListButton": { "message": "Add to M3U List" },
"xtreamModalTitle": { "message": "Xtream Codes Server Connection" },
"xtreamModalDescription": { "message": "Enter your Xtream server details. The M3U URL will be generated automatically." },
"xtreamServerNameLabel": { "message": "Name for Saving (Optional):" },
"xtreamHostLabel": { "message": "Server Host (e.g., http://domain.com:port):" },
"xtreamUserLabel": { "message": "User:" },
"xtreamPasswordLabel": { "message": "Password:" },
"xtreamOutputTypeLabel": { "message": "Preferred Output Type:" },
"xtreamM3uPlusOption": { "message": "M3U Plus (Recommended)" },
"xtreamTsOption": { "message": "TS" },
"xtreamHlsOption": { "message": "HLS (m3u8)" },
"xtreamOutputHint": { "message": "Affects the format of the stream URLs." },
"xtreamContentToLoadLabel": { "message": "Content to Load:" },
"xtreamLiveChannels": { "message": "Live Channels" },
"xtreamVod": { "message": "VOD (Movies)" },
"xtreamSeries": { "message": "Series" },
"xtreamFetchEpgLabel": { "message": "Try to fetch EPG from server" },
"xtreamForceGroupSelectionLabel": { "message": "Force group selection" },
"xtreamForceGroupSelectionHint": { "message": "Check this if you want to change your group selection for this server." },
"xtreamSavedServersLabel": { "message": "Saved Servers" },
"xtreamNoSavedServers": { "message": "No saved servers." },
"xtreamSaveConnectionButton": { "message": "Save Current Connection" },
"xtreamConnectButton": { "message": "Connect and Load" },
"xtreamGroupSelectionTitle": { "message": "Select Xtream Groups" },
"xtreamGroupSelectionDescription": { "message": "Select the groups from each category you want to load into the list." },
"xtreamLiveGroupsLabel": { "message": "Live Groups" },
"xtreamVodGroupsLabel": { "message": "VOD Groups" },
"xtreamSeriesGroupsLabel": { "message": "Series Groups" },
"selectAll": { "message": "All" },
"deselectAll": { "message": "None" },
"loading": { "message": "Loading..." },
"loadSelectedButton": { "message": "Load Selected" },
"xcodecPanelsTitle": { "message": "XCodec Panel Management" },
"xcodecPanelFormLabel": { "message": "Panel Form" },
"xcodecPanelNameLabel": { "message": "Panel Name (Optional):" },
"xcodecServerUrlLabel": { "message": "X-UI/XC Server URL:" },
"xcodecApiTokenLabel": { "message": "API Token (if required):" },
"xcodecSavePanelButton": { "message": "Save Panel" },
"xcodecClearFormButton": { "message": "Clear" },
"xcodecSavedPanelsLabel": { "message": "Saved Panels" },
"xcodecImportPresetButton": { "message": "Import Preset Panels" },
"xcodecNoSavedPanels": { "message": "No saved panels." },
"xcodecProcessAllButton": { "message": "Process All" },
"xcodecProcessFormButton": { "message": "Process Panel (Form)" },
"xcodecPreviewTitle": { "message": "XCodec Panel Preview" },
"xcodecPreviewStatsLoading": { "message": "Loading stats..." },
"xcodecPanelGroupsLabel": { "message": "Panel Groups" },
"xcodecSelectAllGroupsButton": { "message": "Select/Deselect All Groups" },
"xcodecChannelsInGroupLabel": { "message": "Channels in Selected Group" },
"xcodecSelectGroupHint": { "message": "Select a group to see its channels." },
"xcodecSelectAllInGroupButton": { "message": "Select/Deselect All in Group" },
"xcodecAddSelectedButton": { "message": "Add Selected" },
"xcodecAddAllValidButton": { "message": "Add All Valid" },
"settingsTitle": { "message": "Player Settings" },
"settingsGeneralUITab": { "message": "General & UI" },
"settingsPlayerTab": { "message": "Player" },
"settingsNetworkTab": { "message": "Network (Shaka)" },
"settingsEpgTab": { "message": "EPG" },
"settingsXCodecTab": { "message": "XCodec" },
"settingsBarTvTab": { "message": "BarTV" },
"settingsOrangeTvTab": { "message": "OrangeTV" },
"settingsGlobalNetworkTab": { "message": "Global Network" },
"settingsDaznTab": { "message": "DAZN" },
"settingsMovistarTab": { "message": "Movistar+" },
"settingsSendM3uTab": { "message": "Send M3U" },
"settingsDataManagementTab": { "message": "Data Management" },
"settingsUIAppearanceTitle": { "message": "User Interface & Appearance" },
"languageLabel": { "message": "Language:" },
"themeLabel": { "message": "Color Theme:" },
"greenTheme": { "message": "Green (Default)" },
"blueTheme": { "message": "Blue" },
"purpleTheme": { "message": "Purple" },
"orangeTheme": { "message": "Orange" },
"fontLabel": { "message": "Main Font:" },
"systemFont": { "message": "System (Default)" },
"sansSerifFont": { "message": "Generic Sans-Serif" },
"serifFont": { "message": "Generic Serif" },
"monospaceFont": { "message": "Generic Monospace" },
"cardSizeLabel": { "message": "Channel Card Size:" },
"channelsPerPageLabel": { "message": "Channels per Page:" },
"storeLastM3ULabel": { "message": "Store Last M3U List (<4MB)" },
"backgroundAnimationLabel": { "message": "Background Animation (Particles)" },
"particleOpacityLabel": { "message": "Particle Opacity:" },
"cardDisplaySettingsTitle": { "message": "Channel Card Display" },
"logoAspectRatioLabel": { "message": "Logo Aspect Ratio:" },
"aspectRatio169": { "message": "16:9 (Widescreen)" },
"aspectRatio43": { "message": "4:3 (Standard)" },
"aspectRatio11": { "message": "1:1 (Square)" },
"aspectRatio21": { "message": "2:1 (Cinematic)" },
"aspectRatioAuto": { "message": "Automatic (Container's Original)" },
"showChannelNumberLabel": { "message": "Show Channel Number" },
"showChannelGroupLabel": { "message": "Show Channel Group" },
"showEpgInfoLabel": { "message": "Show EPG Info (Now/Next)" },
"showFavButtonLabel": { "message": "Show Favorite Button" },
"compactCardViewLabel": { "message": "Compact card view" },
"enableHoverPreviewLabel": { "message": "Enable preview on hover" },
"shakaPlayerSettingsTitle": { "message": "Shaka Player Configuration" },
"persistentControlsLabel": { "message": "Player Controls Always Visible" },
"persistFiltersLabel": { "message": "Remember Filters between sessions" },
"playerWindowOpacityLabel": { "message": "Player Window Opacity:" },
"playerBufferLabel": { "message": "Player buffer (seconds):" },
"maxVideoHeightLabel": { "message": "Preferred Max Video Height (ABR):" },
"noRestrictionOption": { "message": "Automatic (No restriction)" },
"preferredAudioLabel": { "message": "Preferred Audio:" },
"preferredSubtitlesLabel": { "message": "Preferred Subtitles:" },
"lowLatencyModeLabel": { "message": "Low Latency Mode (Live Streaming)" },
"liveCatchUpModeLabel": { "message": "Aggressive Live Sync (Live Catch-up)" },
"enableAbrLabel": { "message": "Enable ABR (Adaptive Bitrate)" },
"abrInitialBandwidthLabel": { "message": "ABR initial bandwidth (Kbps):" },
"jumpLargeGapsLabel": { "message": "Jump Large Gaps in Stream (Live)" },
"dashPresentationDelayLabel": { "message": "DASH Presentation Delay (seconds):" },
"dashPresentationDelayHint": { "message": "For DASH streams. Defines how far behind the 'live' edge playback will begin." },
"avSyncThresholdLabel": { "message": "A/V Sync Threshold (seconds):" },
"avSyncThresholdHint": { "message": "Maximum allowed difference between audio and video before a correction is attempted." },
"networkRetrySettingsTitle": { "message": "Network Retry Configuration (Shaka)" },
"manifestMaxRetriesLabel": { "message": "Manifest Max Retries:" },
"manifestTimeoutLabel": { "message": "Manifest Timeout (ms):" },
"segmentMaxRetriesLabel": { "message": "Segment Max Retries:" },
"segmentTimeoutLabel": { "message": "Segment Timeout (ms):" },
"epgSettingsTitle": { "message": "Program Guide (EPG)" },
"defaultEpgUrlLabel": { "message": "Default XMLTV EPG URL (EPG Modal):" },
"enableEpgNameMatchingLabel": { "message": "Enable EPG Matching by Name (XMLTV)" },
"epgNameMatchingHint": { "message": "If tvg-id fails, try matching by name (less accurate)." },
"epgNameMatchThresholdLabel": { "message": "EPG Name Similarity Threshold (XMLTV):" },
"epgDensityLabel": { "message": "EPG Visual Density:" },
"epgDensityHint": { "message": "Pixels per hour on the timeline. Higher = wider, more detail. Lower = more compact." },
"useMovistarVodAsEpgLabel": { "message": "Use Movistar+ VOD data as EPG (experimental)" },
"useMovistarVodAsEpgHint": { "message": "Integrates the current day's EPG from Movistar VOD for the Movistar channels in your list." },
"rematchEpgNowButton": { "message": "Rematch EPG Now" },
"rematchEpgHint": { "message": "Requires a loaded M3U list and EPG." },
"xcodecSettingsTitle": { "message": "XCodec Panel Configuration" },
"corsProxyUrlLabel": { "message": "CORS Proxy URL (Optional):" },
"corsProxyUrlHint": { "message": "Enter a CORS proxy URL if XCodec panels have CORS issues. The panel URL will be appended (e.g., `proxy.com/?url=http://panel.com`). Leave empty for direct calls." },
"ignorePanelsOverStreamsLabel": { "message": "Ignore Panels with more than X Streams (0 to disable):" },
"ignorePanelsOverStreamsHint": { "message": "If a panel has more streams than this value, it won't be processed when adding directly (does not affect preview)." },
"batchSizeLabel": { "message": "Batch Size for Configs:" },
"batchSizeHint": { "message": "Number of stream configurations to request simultaneously." },
"apiTimeoutLabel": { "message": "API Request Timeout (ms):" },
"apiTimeoutHint": { "message": "Maximum wait time for each call to the panel's API." },
"barTvCredentialsTitle": { "message": "BarTV Credentials" },
"emailLabel": { "message": "Email:" },
"passwordLabel": { "message": "Password:" },
"barTvCredentialsHint": { "message": "Enter your BarTV credentials to load the channels." },
"orangeTvCredentialsTitle": { "message": "OrangeTV Credentials" },
"userLabel": { "message": "User:" },
"orangeTvGroupSelectionTitle": { "message": "OrangeTV Channel Group Selection" },
"orangeTvGroupSelectionHint": { "message": "If no group is selected, all available groups will be included when loading OrangeTV channels." },
"globalNetworkSettingsTitle": { "message": "Global Network Configuration" },
"globalUserAgentLabel": { "message": "Global User-Agent (Optional):" },
"globalUserAgentHint": { "message": "Applicable if the channel does not define its own via KODIPROP, EXTVLCOPT, or EXTHTTP." },
"globalReferrerLabel": { "message": "Global Referrer (Optional):" },
"globalReferrerHint": { "message": "Applicable if the channel does not define its own." },
"additionalGlobalHeadersLabel": { "message": "Additional Global Headers (JSON):" },
"additionalGlobalHeadersHint": { "message": "Will be merged with channel headers (channel takes precedence)." },
"daznSettingsTitle": { "message": "DAZN Configuration" },
"daznAuthTokenLabel": { "message": "DAZN Authentication Token:" },
"daznAuthTokenHint": { "message": "This token will be used to fetch and update DAZN channels in your M3U list. It is stored securely." },
"movistarManagementTitle": { "message": "Movistar+ Management" },
"movistarManagementDescription": { "message": "This section allows managing authentication and tokens for Movistar+." },
"movistarLoginTitle": { "message": "Login / Get Tokens" },
"movistarLoginButton": { "message": "Login and Get Tokens" },
"movistarSavedLongTokensTitle": { "message": "Saved Long-Session Tokens" },
"movistarTokenIdHeader": { "message": "ID" },
"movistarAccountHeader": { "message": "Account" },
"movistarDeviceIdHeader": { "message": "Device ID" },
"movistarExpiresHeader": { "message": "Expires" },
"movistarStatusHeader": { "message": "Status" },
"movistarActionHeader": { "message": "Action" },
"movistarLoading": { "message": "Loading..." },
"movistarValidateAllButton": { "message": "Validate All" },
"movistarDeleteExpiredButton": { "message": "Del. Expired" },
"movistarAddJwtLabel": { "message": "Add JWT:" },
"movistarDeviceIdLabel": { "message": "Device ID:" },
"movistarAddManualButton": { "message": "Add Manual Token" },
"movistarDeviceManagementTitle": { "message": "Device Management for Token:" },
"movistarLoadDevicesHint": { "message": "Load devices for the selected token above." },
"movistarLoadDevicesButton": { "message": "Load Devices" },
"movistarAssociateDeviceButton": { "message": "Associate Selected" },
"movistarRegisterNewDeviceButton": { "message": "Register New" },
"movistarCurrentCdnTokenTitle": { "message": "Current Short (CDN) Token" },
"movistarCdnTokenLabel": { "message": "CDN Token (X-TCDN-Token):" },
"movistarCdnExpiresLabel": { "message": "Expires:" },
"movistarRefreshCdnButton": { "message": "Refresh CDN Token" },
"movistarCopyCdnButton": { "message": "Copy CDN" },
"movistarApplyToChannelsButton": { "message": "Apply to Channels" },
"movistarVodCacheManagementTitle": { "message": "Movistar+ VOD Cache Management" },
"movistarVodCacheSavedDaysLabel": { "message": "Saved VOD data days:" },
"movistarVodCacheEstimatedSizeLabel": { "message": "Estimated cache size:" },
"movistarVodCacheDaysToKeepLabel": { "message": "Days to keep in cache (1-90):" },
"movistarClearVodCacheButton": { "message": "Clear Movistar+ VOD Cache Now" },
"movistarLogLabel": { "message": "Action Log:" },
"sendM3uToServerTitle": { "message": "Send M3U List to Server" },
"phpServerUrlLabel": { "message": "PHP Server URL:" },
"phpServerUrlHint": { "message": "Enter the full URL of the PHP script on your server that will receive the M3U file." },
"sendM3uToServerButton": { "message": "Send Loaded M3U List Now" },
"sendM3uToServerHint": { "message": "The currently loaded M3U list in the player will be sent to the specified server." },
"phpScriptGeneratorTitle": { "message": "PHP Script Generator (receive_m3u.php)" },
"phpScriptGeneratorHint": { "message": "Use this generator to create a custom PHP script for your server. Configure the options and then copy the generated code." },
"securityOptions": { "message": "Security Options" },
"requireSecretKeyLabel": { "message": "Require secret key" },
"keyLabel": { "message": "Key" },
"restrictToExtensionIdLabel": { "message": "Restrict to this Extension ID" },
"fileOptions": { "message": "File Options" },
"savePathLabel": { "message": "Save path on server" },
"savePathHint": { "message": "Absolute path. If left empty, saves in the same directory as the script." },
"filenameLabel": { "message": "Filename:" },
"keepOriginalFilenameLabel": { "message": "Keep original filename (sanitized)" },
"useFixedFilenameLabel": { "message": "Use fixed filename:" },
"addTimestampLabel": { "message": "Add timestamp to filename" },
"overwriteLabel": { "message": "Overwrite if file already exists" },
"generatedScriptLabel": { "message": "Generated Script" },
"generateScriptButton": { "message": "Generate Script" },
"copyScriptButton": { "message": "Copy Script" },
"dataManagementTitle": { "message": "Application Data Management" },
"exportSettingsButton": { "message": "Export Settings" },
"importSettingsButton": { "message": "Import Settings" },
"clearCacheButton": { "message": "Clear Cache & Local Data" },
"clearCacheHint": { "message": "This deletes: history, favorites, saved lists, Xtream servers, XCodec panels, EPG, DAZN token, and Movistar tokens. The page will reload." },
"settingsSaveAndApply": { "message": "Save and Apply Settings" },
"settingsCancel": { "message": "Cancel" }
}

334
_locales/es/messages.json Normal file
View File

@ -0,0 +1,334 @@
{
"pageTitle": { "message": "DRM Player | Player Avanzado" },
"appName": { "message": "DRM Player" },
"filterGroupsLabel": { "message": "Filtrar Grupos" },
"allGroupsOption": { "message": "📂 Todos los grupos" },
"groupsLabel": { "message": "Grupos" },
"allGroupsListItem": { "message": "Todos los Grupos" },
"searchPlaceholder": { "message": "Buscar canales..." },
"advancedEditorButton": { "message": "Editor" },
"providersButton": { "message": "Proveedores" },
"listManagementButton": { "message": "Listas" },
"loadListsButton": { "message": "Cargar Listas" },
"saveListsButton": { "message": "Guardar Listas" },
"downloadM3UButton": { "message": "Descargar M3U" },
"epgButton": { "message": "EPG" },
"settingsButton": { "message": "Ajustes" },
"loadUrlButton": { "message": "Cargar URL" },
"loadFileInputTitle": { "message": "Seleccionar archivo M3U local" },
"allChannelsTab": { "message": "Todos" },
"favoritesTab": { "message": "Favoritos" },
"historyTab": { "message": "Historial" },
"backButton": { "message": "Volver" },
"availableChannelsTitle": { "message": "Canales Disponibles" },
"paginationPrev": { "message": "Ant." },
"paginationNext": { "message": "Sig." },
"playerTitle": { "message": "Reproductor" },
"minimizeButton": { "message": "Minimizar" },
"closeButton": { "message": "Cerrar" },
"nowLabel": { "message": "Ahora:" },
"nextLabel": { "message": "Siguiente:" },
"channelListTitle": { "message": "Lista de Canales" },
"advancedEditorTitle": { "message": "Editor Avanzado M3U" },
"noFileLoaded": { "message": "Ningún archivo cargado" },
"searchInListPlaceholder": { "message": "Buscar en la lista..." },
"allGroups": { "message": "Todos los Grupos" },
"deleteSelected": { "message": "Eliminar Sel." },
"clearSelection": { "message": "Limpiar Sel." },
"multiEdit": { "message": "Multi-Editar" },
"logoHeader": { "message": "Logo" },
"nameHeader": { "message": "Nombre" },
"urlHeader": { "message": "URL" },
"epgIdHeader": { "message": "EPG ID" },
"channelNumHeader": { "message": "Num" },
"actionsHeader": { "message": "Acciones" },
"editorPlaceholder": { "message": "Selecciona un canal para editar sus detalles." },
"channelEditorTitle": { "message": "Editor de Canal" },
"logoPreviewAlt": { "message": "Vista previa del logo" },
"channelNameLabel": { "message": "Nombre del Canal" },
"epgIdLabel": { "message": "EPG ID (tvg-id)" },
"channelNumLabel": { "message": "Núm. Canal (ch-number)" },
"logoLabel": { "message": "Logo (tvg-logo)" },
"streamUrlLabel": { "message": "URL del Stream" },
"groupLabel": { "message": "Grupo (group-title)" },
"favoriteLabel": { "message": "Favorito" },
"hideChannelLabel": { "message": "Ocultar canal" },
"advancedSettingsDRM": { "message": "Ajustes Avanzados / DRM" },
"licenseTypeLabel": { "message": "Tipo Licencia DRM (license_type)" },
"licenseKeyLabel": { "message": "Clave/URL Licencia DRM (license_key)" },
"streamHeadersLabel": { "message": "Cabeceras Stream DRM (stream_headers)" },
"vlcUserAgentLabel": { "message": "VLC User-Agent (#EXTVLCOPT:http-user-agent)" },
"testButton": { "message": "Probar" },
"deleteButton": { "message": "Eliminar" },
"saveButton": { "message": "Guardar" },
"closeEditorButton": { "message": "Cerrar Editor" },
"applyChangesAndCloseButton": { "message": "Aplicar Cambios y Cerrar" },
"multiEditTitle": { "message": "Edición Múltiple de Canales" },
"multiEditDescription": { "message": "Aplica cambios a todos los {count} canales seleccionados. Solo los campos activados se modificarán." },
"changeGroupLabel": { "message": "Cambiar Grupo" },
"newGroupNamePlaceholder": { "message": "Nuevo nombre de grupo..." },
"modifyFavoriteLabel": { "message": "Modificar Favorito" },
"addToFavoritesOption": { "message": "Añadir a Favoritos" },
"removeFromFavoritesOption": { "message": "Quitar de Favoritos" },
"modifyVisibilityLabel": { "message": "Modificar Visibilidad" },
"hideChannelsOption": { "message": "Ocultar Canales" },
"showChannelsOption": { "message": "Mostrar Canales" },
"headersAndDRM": { "message": "Cabeceras y DRM" },
"setUserAgentLabel": { "message": "Establecer User-Agent (VLC)" },
"userAgentPlaceholder": { "message": "User-Agent para #EXTVLCOPT..." },
"setStreamHeadersLabel": { "message": "Añadir/Sobrescribir Cabeceras de Stream (Kodi)" },
"streamHeadersPlaceholder": { "message": "key1=value1|key2=value2..." },
"appendHeadersOption": { "message": "Añadir/Actualizar Cabeceras" },
"replaceHeadersOption": { "message": "Reemplazar Todas las Cabeceras" },
"applyChangesButton": { "message": "Aplicar Cambios" },
"saveM3UModalTitle": { "message": "Guardar Lista M3U Actual" },
"saveM3UModalDescription": { "message": "Introduce un nombre para guardar la lista M3U cargada actualmente en la base de datos local de la extensión." },
"listNameLabel": { "message": "Nombre de la Lista:" },
"listNamePlaceholder": { "message": "Ej: MiListaFavorita_TV" },
"saveListButton": { "message": "Guardar Lista" },
"daznTokenModalTitle": { "message": "Token de Autenticación DAZN Requerido" },
"daznTokenModalDescription": { "message": "Para actualizar los canales de DAZN, por favor, introduce tu Bearer Token completo de DAZN." },
"daznTokenModalHint": { "message": "Este token se puede obtener de las herramientas de desarrollador de tu navegador al inspeccionar las solicitudes de red mientras DAZN está activo y logueado." },
"daznTokenLabel": { "message": "Token de DAZN (Bearer):" },
"daznTokenPlaceholder": { "message": "Pega aquí tu Bearer token completo..." },
"rememberTokenLabel": { "message": "Recordar este token (se guardará localmente en los ajustes)" },
"submitTokenButton": { "message": "Enviar Token" },
"loadFromDBModalTitle": { "message": "Listas Guardadas" },
"loadingLists": { "message": "Cargando listas..." },
"loadButton": { "message": "Cargar" },
"epgModalTitle": { "message": "Guía de Programación (EPG)" },
"epgUrlPlaceholder": { "message": "📅 URL del archivo XMLTV EPG" },
"loadEpgButton": { "message": "Cargar/Actualizar EPG" },
"movistarVODModalTitle": { "message": "Movistar+ VOD/Catchup" },
"selectDateLabel": { "message": "Seleccionar Fecha:" },
"loadEpgDayButton": { "message": "Cargar EPG Día" },
"searchProgramPlaceholder": { "message": "Buscar programa..." },
"allChannelsOption": { "message": "Todos los canales" },
"allGenresOption": { "message": "Todos los géneros" },
"noProgramsFound": { "message": "No se encontraron programas para la fecha/filtros seleccionados." },
"pageInfo": { "message": "Página {currentPage} de {totalPages} ({totalItems} resultados)" },
"previousButton": { "message": "Anterior" },
"nextButton": { "message": "Siguiente" },
"programDetailsTitle": { "message": "Detalles del Programa" },
"playProgramButton": { "message": "Reproducir" },
"addToListButton": { "message": "Añadir a Lista M3U" },
"xtreamModalTitle": { "message": "Conexión a Servidor Xtream Codes" },
"xtreamModalDescription": { "message": "Introduce los detalles de tu servidor Xtream. La URL M3U se generará automáticamente." },
"xtreamServerNameLabel": { "message": "Nombre para Guardar (Opcional):" },
"xtreamHostLabel": { "message": "Host del Servidor (ej: http://dominio.com:puerto):" },
"xtreamUserLabel": { "message": "Usuario:" },
"xtreamPasswordLabel": { "message": "Contraseña:" },
"xtreamOutputTypeLabel": { "message": "Tipo de Salida Preferido:" },
"xtreamM3uPlusOption": { "message": "M3U Plus (Recomendado)" },
"xtreamTsOption": { "message": "TS" },
"xtreamHlsOption": { "message": "HLS (m3u8)" },
"xtreamOutputHint": { "message": "Afecta al formato de las URLs de los streams." },
"xtreamContentToLoadLabel": { "message": "Contenido a Cargar:" },
"xtreamLiveChannels": { "message": "Canales en Vivo" },
"xtreamVod": { "message": "VOD (Películas)" },
"xtreamSeries": { "message": "Series" },
"xtreamFetchEpgLabel": { "message": "Intentar obtener EPG del servidor" },
"xtreamForceGroupSelectionLabel": { "message": "Forzar selección de grupos" },
"xtreamForceGroupSelectionHint": { "message": "Marca esto si quieres cambiar tu selección de grupos para este servidor." },
"xtreamSavedServersLabel": { "message": "Servidores Guardados" },
"xtreamNoSavedServers": { "message": "No hay servidores guardados." },
"xtreamSaveConnectionButton": { "message": "Guardar Conexión Actual" },
"xtreamConnectButton": { "message": "Conectar y Cargar" },
"xtreamGroupSelectionTitle": { "message": "Seleccionar Grupos de Xtream" },
"xtreamGroupSelectionDescription": { "message": "Selecciona los grupos de cada categoría que deseas cargar en la lista." },
"xtreamLiveGroupsLabel": { "message": "Grupos en Vivo" },
"xtreamVodGroupsLabel": { "message": "Grupos VOD" },
"xtreamSeriesGroupsLabel": { "message": "Grupos Series" },
"selectAll": { "message": "Todos" },
"deselectAll": { "message": "Ninguno" },
"loading": { "message": "Cargando..." },
"loadSelectedButton": { "message": "Cargar Seleccionados" },
"xcodecPanelsTitle": { "message": "Gestión de Paneles XCodec" },
"xcodecPanelFormLabel": { "message": "Formulario del Panel" },
"xcodecPanelNameLabel": { "message": "Nombre del Panel (Opcional):" },
"xcodecServerUrlLabel": { "message": "URL del Servidor X-UI/XC:" },
"xcodecApiTokenLabel": { "message": "Token API (si es requerido):" },
"xcodecSavePanelButton": { "message": "Guardar Panel" },
"xcodecClearFormButton": { "message": "Limpiar" },
"xcodecSavedPanelsLabel": { "message": "Paneles Guardados" },
"xcodecImportPresetButton": { "message": "Importar Paneles Predefinidos" },
"xcodecNoSavedPanels": { "message": "No hay paneles guardados." },
"xcodecProcessAllButton": { "message": "Procesar Todos" },
"xcodecProcessFormButton": { "message": "Procesar Panel (Formulario)" },
"xcodecPreviewTitle": { "message": "Previsualización Panel XCodec" },
"xcodecPreviewStatsLoading": { "message": "Cargando estadísticas..." },
"xcodecPanelGroupsLabel": { "message": "Grupos del Panel" },
"xcodecSelectAllGroupsButton": { "message": "Seleccionar/Deseleccionar Todos los Grupos" },
"xcodecChannelsInGroupLabel": { "message": "Canales en Grupo Seleccionado" },
"xcodecSelectGroupHint": { "message": "Selecciona un grupo para ver los canales." },
"xcodecSelectAllInGroupButton": { "message": "Seleccionar/Deseleccionar Todos en Grupo" },
"xcodecAddSelectedButton": { "message": "Añadir Seleccionados" },
"xcodecAddAllValidButton": { "message": "Añadir Todos los Válidos" },
"settingsTitle": { "message": "Ajustes del Reproductor" },
"settingsGeneralUITab": { "message": "General y UI" },
"settingsPlayerTab": { "message": "Reproductor" },
"settingsNetworkTab": { "message": "Red (Shaka)" },
"settingsEpgTab": { "message": "EPG" },
"settingsXCodecTab": { "message": "XCodec" },
"settingsBarTvTab": { "message": "BarTV" },
"settingsOrangeTvTab": { "message": "OrangeTV" },
"settingsGlobalNetworkTab": { "message": "Red Global" },
"settingsDaznTab": { "message": "DAZN" },
"settingsMovistarTab": { "message": "Movistar+" },
"settingsSendM3uTab": { "message": "Enviar M3U" },
"settingsDataManagementTab": { "message": "Gestión de Datos" },
"settingsUIAppearanceTitle": { "message": "Interfaz de Usuario y Apariencia" },
"languageLabel": { "message": "Idioma (Language):" },
"themeLabel": { "message": "Tema de Color:" },
"greenTheme": { "message": "Verde (Predeterminado)" },
"blueTheme": { "message": "Azul" },
"purpleTheme": { "message": "Púrpura" },
"orangeTheme": { "message": "Naranja" },
"fontLabel": { "message": "Fuente Principal:" },
"systemFont": { "message": "Sistema (Predeterminada)" },
"sansSerifFont": { "message": "Sans-Serif Genérica" },
"serifFont": { "message": "Serif Genérica" },
"monospaceFont": { "message": "Monospace Genérica" },
"cardSizeLabel": { "message": "Tamaño de Tarjetas de Canal:" },
"channelsPerPageLabel": { "message": "Canales por Página:" },
"storeLastM3ULabel": { "message": "Almacenar Última Lista M3U (<4MB)" },
"backgroundAnimationLabel": { "message": "Animación de Fondo (Partículas)" },
"particleOpacityLabel": { "message": "Opacidad de Partículas:" },
"cardDisplaySettingsTitle": { "message": "Visualización en Tarjetas de Canal" },
"logoAspectRatioLabel": { "message": "Ratio de Aspecto del Logo:" },
"aspectRatio169": { "message": "16:9 (Panorámico)" },
"aspectRatio43": { "message": "4:3 (Estándar)" },
"aspectRatio11": { "message": "1:1 (Cuadrado)" },
"aspectRatio21": { "message": "2:1 (Cinemático)" },
"aspectRatioAuto": { "message": "Automático (Original del Contenedor)" },
"showChannelNumberLabel": { "message": "Mostrar Número de Canal" },
"showChannelGroupLabel": { "message": "Mostrar Grupo del Canal" },
"showEpgInfoLabel": { "message": "Mostrar Información EPG (Ahora/Siguiente)" },
"showFavButtonLabel": { "message": "Mostrar Botón de Favoritos" },
"compactCardViewLabel": { "message": "Vista de tarjetas compacta" },
"enableHoverPreviewLabel": { "message": "Habilitar previsualización al pasar el ratón" },
"shakaPlayerSettingsTitle": { "message": "Configuración del Reproductor Shaka" },
"persistentControlsLabel": { "message": "Controles del Reproductor Siempre Visibles" },
"persistFiltersLabel": { "message": "Recordar Filtros entre sesiones" },
"playerWindowOpacityLabel": { "message": "Transparencia de la Ventana del Reproductor:" },
"playerBufferLabel": { "message": "Buffer del reproductor (segundos):" },
"maxVideoHeightLabel": { "message": "Altura Máxima de Video Preferida (ABR):" },
"noRestrictionOption": { "message": "Automático (Sin restricción)" },
"preferredAudioLabel": { "message": "Audio Preferido:" },
"preferredSubtitlesLabel": { "message": "Subtítulos Preferidos:" },
"lowLatencyModeLabel": { "message": "Modo Baja Latencia (Streaming en Vivo)" },
"liveCatchUpModeLabel": { "message": "Sincronización Agresiva en Vivo (Live Catch-up)" },
"enableAbrLabel": { "message": "Habilitar ABR (Adaptación de Bitrate)" },
"abrInitialBandwidthLabel": { "message": "Ancho de banda inicial ABR (Kbps):" },
"jumpLargeGapsLabel": { "message": "Saltar Huecos Grandes en Stream (Live)" },
"dashPresentationDelayLabel": { "message": "Retraso Presentación DASH (segundos):" },
"dashPresentationDelayHint": { "message": "Para streams DASH. Define cuánto detrás del borde \"en vivo\" comenzará la reproducción." },
"avSyncThresholdLabel": { "message": "Umbral Sincronización A/V (segundos):" },
"avSyncThresholdHint": { "message": "Diferencia máxima permitida entre audio y video antes de intentar una corrección." },
"networkRetrySettingsTitle": { "message": "Configuración de Reintentos de Red (Shaka)" },
"manifestMaxRetriesLabel": { "message": "Máx. Reintentos Manifiesto:" },
"manifestTimeoutLabel": { "message": "Timeout Manifiesto (ms):" },
"segmentMaxRetriesLabel": { "message": "Máx. Reintentos Segmento:" },
"segmentTimeoutLabel": { "message": "Timeout Segmento (ms):" },
"epgSettingsTitle": { "message": "Guía de Programación (EPG)" },
"defaultEpgUrlLabel": { "message": "URL EPG XMLTV por Defecto (Modal EPG):" },
"enableEpgNameMatchingLabel": { "message": "Habilitar Coincidencia EPG (XMLTV) por Nombre" },
"epgNameMatchingHint": { "message": "Si tvg-id falla, intenta por nombre (menos preciso)." },
"epgNameMatchThresholdLabel": { "message": "Umbral Similitud Nombre EPG (XMLTV):" },
"epgDensityLabel": { "message": "Densidad Visual de la Guía EPG:" },
"epgDensityHint": { "message": "Píxeles por hora en la línea de tiempo. Más alto = más ancho, más detalle. Más bajo = más compacto." },
"useMovistarVodAsEpgLabel": { "message": "Usar datos VOD de Movistar+ como EPG (experimental)" },
"useMovistarVodAsEpgHint": { "message": "Integra la EPG del día actual de Movistar VOD para los canales de Movistar en tu lista." },
"rematchEpgNowButton": { "message": "Re-emparejar EPG Ahora" },
"rematchEpgHint": { "message": "Necesita una lista M3U y un EPG cargados." },
"xcodecSettingsTitle": { "message": "Configuración de Paneles XCodec" },
"corsProxyUrlLabel": { "message": "URL del Proxy CORS (Opcional):" },
"corsProxyUrlHint": { "message": "Introduce la URL de un proxy CORS si los paneles XCodec tienen problemas de CORS. La URL del panel se añadirá al final (ej: `proxy.com/?url=http://panel.com`). Déjalo vacío para llamadas directas." },
"ignorePanelsOverStreamsLabel": { "message": "Ignorar Paneles con más de X Streams (0 para deshabilitar):" },
"ignorePanelsOverStreamsHint": { "message": "Si un panel tiene más streams que este valor, no se procesará al añadir directamente (no afecta a la previsualización)." },
"batchSizeLabel": { "message": "Tamaño de Lote (Batch) para Configs:" },
"batchSizeHint": { "message": "Número de configuraciones de stream a pedir simultáneamente." },
"apiTimeoutLabel": { "message": "Timeout por Petición API (ms):" },
"apiTimeoutHint": { "message": "Tiempo máximo de espera para cada llamada a la API del panel." },
"barTvCredentialsTitle": { "message": "Credenciales de BarTV" },
"emailLabel": { "message": "Email:" },
"passwordLabel": { "message": "Contraseña:" },
"barTvCredentialsHint": { "message": "Introduce tus credenciales de BarTV para poder cargar los canales." },
"orangeTvCredentialsTitle": { "message": "Credenciales de OrangeTV" },
"userLabel": { "message": "Usuario:" },
"orangeTvGroupSelectionTitle": { "message": "Selección de Grupos de Canales OrangeTV" },
"orangeTvGroupSelectionHint": { "message": "Si no se selecciona ningún grupo, se incluirán todos los grupos disponibles al cargar canales de OrangeTV." },
"globalNetworkSettingsTitle": { "message": "Configuración Global de Red" },
"globalUserAgentLabel": { "message": "User-Agent Global (Opcional):" },
"globalUserAgentHint": { "message": "Aplicable si el canal no define uno propio vía KODIPROP, EXTVLCOPT o EXTHTTP." },
"globalReferrerLabel": { "message": "Referrer Global (Opcional):" },
"globalReferrerHint": { "message": "Aplicable si el canal no define uno propio." },
"additionalGlobalHeadersLabel": { "message": "Cabeceras Adicionales Globales (JSON):" },
"additionalGlobalHeadersHint": { "message": "Se fusionarán con cabeceras del canal (canal tiene precedencia)." },
"daznSettingsTitle": { "message": "Configuración de DAZN" },
"daznAuthTokenLabel": { "message": "Token de Autenticación DAZN:" },
"daznAuthTokenHint": { "message": "Este token se usará para obtener y actualizar los canales de DAZN en tu lista M3U. Se guarda de forma segura." },
"movistarManagementTitle": { "message": "Gestión de Movistar+" },
"movistarManagementDescription": { "message": "Esta sección permite gestionar la autenticación y los tokens para Movistar+." },
"movistarLoginTitle": { "message": "Iniciar Sesión / Obtener Tokens" },
"movistarLoginButton": { "message": "Iniciar Sesión y Obtener Tokens" },
"movistarSavedLongTokensTitle": { "message": "Tokens de Sesión Larga Guardados" },
"movistarTokenIdHeader": { "message": "ID" },
"movistarAccountHeader": { "message": "Cuenta" },
"movistarDeviceIdHeader": { "message": "Device ID" },
"movistarExpiresHeader": { "message": "Expira" },
"movistarStatusHeader": { "message": "Estado" },
"movistarActionHeader": { "message": "Acción" },
"movistarLoading": { "message": "Cargando..." },
"movistarValidateAllButton": { "message": "Validar Todos" },
"movistarDeleteExpiredButton": { "message": "Elim. Expirados" },
"movistarAddJwtLabel": { "message": "Añadir JWT:" },
"movistarDeviceIdLabel": { "message": "Device ID:" },
"movistarAddManualButton": { "message": "Añadir Token Manualmente" },
"movistarDeviceManagementTitle": { "message": "Gestión de Dispositivos para Token:" },
"movistarLoadDevicesHint": { "message": "Carga los dispositivos para el token seleccionado arriba." },
"movistarLoadDevicesButton": { "message": "Cargar Dispositivos" },
"movistarAssociateDeviceButton": { "message": "Asociar Seleccionado" },
"movistarRegisterNewDeviceButton": { "message": "Registrar Nuevo" },
"movistarCurrentCdnTokenTitle": { "message": "Token Corto (CDN) Actual" },
"movistarCdnTokenLabel": { "message": "Token CDN (X-TCDN-Token):" },
"movistarCdnExpiresLabel": { "message": "Expira:" },
"movistarRefreshCdnButton": { "message": "Refrescar Token CDN" },
"movistarCopyCdnButton": { "message": "Copiar CDN" },
"movistarApplyToChannelsButton": { "message": "Aplicar a Canales" },
"movistarVodCacheManagementTitle": { "message": "Gestión de Caché VOD Movistar+" },
"movistarVodCacheSavedDaysLabel": { "message": "Días de datos VOD guardados:" },
"movistarVodCacheEstimatedSizeLabel": { "message": "Tamaño estimado de la caché:" },
"movistarVodCacheDaysToKeepLabel": { "message": "Días a mantener en caché (1-90):" },
"movistarClearVodCacheButton": { "message": "Limpiar Caché VOD Movistar+ Ahora" },
"movistarLogLabel": { "message": "Registro de Acciones:" },
"sendM3uToServerTitle": { "message": "Enviar Lista M3U a Servidor" },
"phpServerUrlLabel": { "message": "URL del Servidor PHP:" },
"phpServerUrlHint": { "message": "Introduce la URL completa del script PHP en tu servidor que recibirá el archivo M3U." },
"sendM3uToServerButton": { "message": "Enviar Lista M3U Cargada Ahora" },
"sendM3uToServerHint": { "message": "La lista M3U actualmente cargada en el reproductor se enviará al servidor especificado." },
"phpScriptGeneratorTitle": { "message": "Generador de Script PHP (receive_m3u.php)" },
"phpScriptGeneratorHint": { "message": "Usa este generador para crear un script PHP personalizado para tu servidor. Configura las opciones y luego copia el código generado." },
"securityOptions": { "message": "Opciones de Seguridad" },
"requireSecretKeyLabel": { "message": "Requerir clave secreta" },
"keyLabel": { "message": "Clave" },
"restrictToExtensionIdLabel": { "message": "Restringir a esta ID de Extensión" },
"fileOptions": { "message": "Opciones de Archivo" },
"savePathLabel": { "message": "Ruta de guardado en servidor" },
"savePathHint": { "message": "Ruta absoluta. Si se deja vacía, se guarda en el mismo directorio que el script." },
"filenameLabel": { "message": "Nombre del archivo:" },
"keepOriginalFilenameLabel": { "message": "Mantener nombre original (sanitizado)" },
"useFixedFilenameLabel": { "message": "Usar nombre fijo:" },
"addTimestampLabel": { "message": "Añadir fecha/hora al nombre del archivo" },
"overwriteLabel": { "message": "Sobrescribir si el archivo ya existe" },
"generatedScriptLabel": { "message": "Script Generado" },
"generateScriptButton": { "message": "Generar Script" },
"copyScriptButton": { "message": "Copiar Script" },
"dataManagementTitle": { "message": "Gestión de Datos de la Aplicación" },
"exportSettingsButton": { "message": "Exportar Ajustes" },
"importSettingsButton": { "message": "Importar Ajustes" },
"clearCacheButton": { "message": "Limpiar Caché y Datos Locales" },
"clearCacheHint": { "message": "Esto borra: historial, favoritos, listas guardadas, servidores Xtream, paneles XCodec, EPG, token DAZN y tokens Movistar. La página se recargará." },
"settingsSaveAndApply": { "message": "Guardar y Aplicar Ajustes" },
"settingsCancel": { "message": "Cancelar" }
}

189
atresplayer_handler.js Normal file
View File

@ -0,0 +1,189 @@
const ATRESPLAYER_USER_AGENT = 'Mozilla/5.0 (SMART-TV; Linux; Tizen 4.0) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/2.1 Chrome/56.0.2924.0 TV Safari/537.36';
const ATRESPLAYER_INITIAL_URL = "https://api.atresplayer.com/client/v1/row/live/5a6b32667ed1a834493ec03b";
const ATRESPLAYER_API_HOST = "api.atresplayer.com";
async function setGlobalAtresplayerHeaders() {
if (!chrome.runtime?.id) return false;
const headersToSet = [{ header: 'User-Agent', value: ATRESPLAYER_USER_AGENT }];
try {
await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
cmd: "updateHeadersRules",
requestHeaders: headersToSet,
urlFilter: `*://${ATRESPLAYER_API_HOST}/*`,
initiatorDomain: chrome.runtime.id
}, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (response && response.success) {
resolve(response);
} else {
reject(response ? response.error : 'Fallo al actualizar reglas DNR para Atresplayer.');
}
});
});
await new Promise(resolve => setTimeout(resolve, 200));
return true;
} catch (error) {
console.error("[Atresplayer] Error estableciendo cabeceras dinámicas globales:", error);
if (typeof showNotification === 'function') showNotification("Error configurando cabeceras de red para Atresplayer.", "error");
return false;
}
}
async function clearGlobalAtresplayerHeaders() {
if (!chrome.runtime?.id) return;
try {
await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ cmd: "clearAllDnrHeaders" }, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (response && response.success) {
resolve(response);
} else {
reject(response ? response.error : 'Fallo al limpiar reglas DNR tras Atresplayer.');
}
});
});
} catch (error) {
console.error("[Atresplayer] Error limpiando cabeceras dinámicas globales:", error);
}
}
async function fetchAtresplayerJSON(url) {
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
let errorBody = '';
try { errorBody = await response.text(); } catch (e) {}
console.error(`Error fetch Atresplayer JSON (${url}): ${response.status} ${response.statusText}`, errorBody.substring(0,200));
throw new Error(`Error HTTP ${response.status} para ${url}. ${errorBody.substring(0,100)}`);
}
return await response.json();
} catch (error) {
console.error(`Excepción fetch/parse Atresplayer JSON (${url}):`, error);
throw error;
}
}
async function getChannelDetails(item) {
try {
const channelDetail = await fetchAtresplayerJSON(item.link.href);
if (channelDetail && channelDetail.urlVideo) {
const urlParts = item.link.url.split('/');
const extractedChannelId = urlParts.length > 2 ? urlParts[urlParts.length - 2] : null;
let logoUrl = item.logoURL || '';
if (!logoUrl && item.image && item.image.images) {
if (item.image.images.VERTICAL && item.image.images.VERTICAL.path) {
logoUrl = item.image.images.VERTICAL.path + "ws_275_403.png";
} else if (item.image.images.HORIZONTAL && item.image.images.HORIZONTAL.path) {
logoUrl = item.image.images.HORIZONTAL.path + "ws_378_213.png";
}
}
return {
title: item.title || 'Desconocido',
tvgId: item.mainChannel || item.contentId || (extractedChannelId ? `atres.${extractedChannelId}` : `atres.${item.title.replace(/\s+/g, '_').toLowerCase()}`),
logo: logoUrl,
description: item.description || '',
urlVideoPage: channelDetail.urlVideo,
channelKey: extractedChannelId || item.title.toLowerCase().replace(/[^a-z0-9]/g,'')
};
}
} catch (e) {
console.warn(`Error obteniendo detalles para el canal "${item.title || 'Desconocido'}":`, e.message);
}
return null;
}
async function getM3u8Source(channelInfo) {
if (!channelInfo || !channelInfo.urlVideoPage) return null;
try {
const videoSourceData = await fetchAtresplayerJSON(channelInfo.urlVideoPage);
if (videoSourceData && videoSourceData.sourcesLive && Array.isArray(videoSourceData.sourcesLive)) {
const hlsSource = videoSourceData.sourcesLive.find(
source => source.type === 'application/hls+legacy' && source.src
);
if (hlsSource) {
return { ...channelInfo, m3u8Url: hlsSource.src };
}
}
} catch (e) {
console.warn(`Error obteniendo fuente M3U8 para "${channelInfo.title}":`, e.message);
}
return null;
}
async function generateM3UAtresplayer() {
if (typeof showLoading === 'function') showLoading(true, "Cargando canales de Atresplayer...");
const m3uLines = ["#EXTM3U"];
let headersSetSuccessfully = false;
const atresSourceName = "Atresplayer";
try {
headersSetSuccessfully = await setGlobalAtresplayerHeaders();
if (!headersSetSuccessfully) {
throw new Error("No se pudieron establecer las cabeceras globales para Atresplayer.");
}
const initialData = await fetchAtresplayerJSON(ATRESPLAYER_INITIAL_URL);
if (!initialData || !initialData.itemRows || !Array.isArray(initialData.itemRows)) {
throw new Error("Respuesta inicial de Atresplayer inválida o vacía.");
}
const liveChannelItems = initialData.itemRows.filter(
item => item.link && item.link.pageType === 'LIVE_CHANNEL' && item.link.href
);
if (liveChannelItems.length === 0) {
throw new Error("No se encontraron items de canal en vivo en la respuesta inicial.");
}
if (typeof showLoading === 'function') showLoading(true, `Obteniendo detalles de ${liveChannelItems.length} canales...`);
const channelDetailsPromises = liveChannelItems.map(item => getChannelDetails(item));
const channelsWithDetails = (await Promise.all(channelDetailsPromises)).filter(Boolean);
if (channelsWithDetails.length === 0) {
throw new Error("No se pudieron obtener detalles para ningún canal.");
}
if (typeof showLoading === 'function') showLoading(true, `Obteniendo URLs M3U8 para ${channelsWithDetails.length} canales...`);
const m3u8SrcPromises = channelsWithDetails.map(channelInfo => getM3u8Source(channelInfo));
const finalChannelData = (await Promise.all(m3u8SrcPromises)).filter(Boolean);
if (finalChannelData.length === 0) {
throw new Error("No se pudieron obtener URLs M3U8 para ningún canal.");
}
finalChannelData.forEach(ch => {
m3uLines.push(`#EXTINF:-1 tvg-id="${ch.tvgId}" tvg-logo="${ch.logo}" group-title="Atresplayer",${ch.title}`);
m3uLines.push(ch.m3u8Url);
});
const m3uString = m3uLines.join("\n") + "\n";
if (typeof removeChannelsBySourceOrigin === 'function') {
removeChannelsBySourceOrigin(atresSourceName);
}
if (typeof appendM3UContent === 'function') {
appendM3UContent(m3uString, atresSourceName);
} else {
console.error("appendM3UContent no encontrada. Usando fallback processM3UContent.");
processM3UContent(m3uString, atresSourceName, true);
}
} catch (error) {
console.error("Error generando M3U de Atresplayer:", error);
if (typeof showNotification === 'function') showNotification(`Error cargando Atresplayer: ${error.message}`, 'error');
} finally {
if (headersSetSuccessfully) {
await clearGlobalAtresplayerHeaders();
}
if (typeof showLoading === 'function') showLoading(false);
}
}

115
background.js Normal file
View File

@ -0,0 +1,115 @@
const DNR_RULE_ID_HEADERS = 1;
async function clearDnrRules(ruleIdsToRemove) {
try {
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
const existingRuleIds = existingRules.map(rule => rule.id);
const finalRuleIdsToRemove = ruleIdsToRemove.filter(id => existingRuleIds.includes(id));
if (finalRuleIdsToRemove.length > 0) {
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: finalRuleIdsToRemove
});
}
} catch (e) {
if (e.message && !e.message.toLowerCase().includes("rule with id") && !e.message.toLowerCase().includes("not found")) {
console.warn("[DNR Background] Error al limpiar reglas DNR:", e.message);
}
}
}
chrome.runtime.onStartup.addListener(async () => {
await clearDnrRules([DNR_RULE_ID_HEADERS]);
});
chrome.runtime.onInstalled.addListener(async (details) => {
await clearDnrRules([DNR_RULE_ID_HEADERS]);
if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
const playerUrl = chrome.runtime.getURL("player.html");
chrome.tabs.create({ url: playerUrl });
}
});
chrome.action.onClicked.addListener((tab) => {
const playerUrl = chrome.runtime.getURL("player.html");
chrome.tabs.query({ url: playerUrl }, (tabs) => {
if (tabs.length > 0) {
chrome.tabs.update(tabs[0].id, { active: true });
if (tabs[0].windowId) {
chrome.windows.update(tabs[0].windowId, { focused: true });
}
} else {
chrome.tabs.create({ url: playerUrl });
}
});
});
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.cmd === "updateHeadersRules") {
const headersToSet = request.requestHeaders || [];
let effectiveUrlFilter = "*://*/*";
if (request.urlFilter) {
effectiveUrlFilter = request.urlFilter;
}
let initiatorDomainsCondition = {};
if (request.initiatorDomain) {
initiatorDomainsCondition.initiatorDomains = [request.initiatorDomain];
}
clearDnrRules([DNR_RULE_ID_HEADERS]).then(async () => {
if (headersToSet.length > 0) {
const newRequestHeadersDNR = headersToSet.map(h => ({
header: h.header,
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
value: String(h.value)
}));
const newRuleCondition = {
urlFilter: effectiveUrlFilter,
resourceTypes: Object.values(chrome.declarativeNetRequest.ResourceType)
};
if (initiatorDomainsCondition.initiatorDomains && initiatorDomainsCondition.initiatorDomains.length > 0) {
newRuleCondition.initiatorDomains = initiatorDomainsCondition.initiatorDomains;
}
const newRule = {
id: DNR_RULE_ID_HEADERS,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
requestHeaders: newRequestHeadersDNR
},
condition: newRuleCondition
};
chrome.declarativeNetRequest.updateDynamicRules({
addRules: [newRule]
}, async () => {
if (chrome.runtime.lastError) {
sendResponse({ success: false, error: chrome.runtime.lastError.message });
} else {
sendResponse({ success: true });
}
});
} else {
sendResponse({ success: true, message: "No hay cabeceras para aplicar, solo se limpiaron reglas." });
}
}).catch(error => {
sendResponse({ success: false, error: "Error al limpiar reglas previas: " + error.message });
});
return true;
}
if (request.cmd === "clearAllDnrHeaders") {
clearDnrRules([DNR_RULE_ID_HEADERS])
.then(async () => {
sendResponse({ success: true, message: "Reglas DNR limpiadas." });
})
.catch(error => {
sendResponse({ success: false, error: "Error limpiando reglas: " + error.message });
});
return true;
}
return false;
});

234
bartv_handler.js Normal file
View File

@ -0,0 +1,234 @@
const BARTV_API_HOST = "core.bartv.es";
const BARTV_USER_AGENT = "Mozilla/5.0 (SMART-TV; Linux; Tizen 4.0) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/2.1 Chrome/56.0.2924.0 TV Safari/537.36";
const BARTV_ORIGIN = "https://samsung.bartv.es";
const BARTV_REFERER = "https://samsung.bartv.es/";
const BARTV_LOGIN_URL = "https://core.bartv.es/v1/auth/login?partner=bares";
const BARTV_MEDIA_URL_TEMPLATE = "https://core.bartv.es/v1/media/{mediaId}?drm=widevine&token={token}&device=tv&appv=311&ll=true&partner=bares";
const CHANNEL_NAMES_BARTV = {
"24h-live": {"nombre": "LaLiga TV BAR", "logo": "https://www.movistarplus.es/recorte/m-NEO/canal/LIGBAR.png"},
"ppv-02": {"nombre": "LaLiga TV BAR 2", "logo": "https://www.movistarplus.es/recorte/m-NEO/canal/LIGBA1.png"},
"ppv-03": {"nombre": "LaLiga TV BAR 3", "logo": "https://www.movistarplus.es/recorte/m-NEO/canal/LIGBA2.png"},
"ppv-04": {"nombre": "LALIGA +", "logo": "https://ver.clictv.es/RTEFacade/images/attachments/LALIGA_PLUS_BARES.png"},
"24h-live-golstadium": {"nombre": "GOLSTADIUM", "logo": "https://pbs.twimg.com/profile_images/1814029026840793088/GPf672XK_400x400.jpg"},
"24h-live-gol": {"nombre": "GOLPLAY", "logo": "https://storage.googleapis.com/laligatvbar/assets/img/taquillas/bg-gol-black.jpg"},
"smb-24h": {"nombre": "LALIGA TV HYPERMOTION", "logo": "https://estatico.emisiondof6.com/recorte/m-NEONEGR/canal/MLIGS"},
"smb-02": {"nombre": "LALIGA TV HYPERMOTION 2", "logo": "https://estatico.emisiondof6.com/recorte/m-NEONEGR/canal/MLIGS2"},
"smb-03": {"nombre": "LALIGA TV HYPERMOTION 3", "logo": "https://estatico.emisiondof6.com/recorte/m-NEONEGR/canal/MLIGS3"},
"dazn-00": {"nombre": "DAZN F1", "logo": "https://ver.clictv.es/RTEFacade/images/attachments/DAZN F1.png"},
"dazn-01": {"nombre": "DAZN 1", "logo": "https://ver.clictv.es/RTEFacade/images/attachments/DAZN1.png"},
"dazn-02": {"nombre": "DAZN 2", "logo": "https://ver.clictv.es/RTEFacade/images/attachments/DAZN2.png"},
"euro-01": {"nombre": "EUROSPORT 1", "logo": "https://storage.googleapis.com/laligatvbar/assets/img/taquillas/eurosport-1.jpg"},
"euro-02": {"nombre": "EUROSPORT 2", "logo": "https://storage.googleapis.com/laligatvbar/assets/img/taquillas/eurosport-2.jpg"},
};
async function setDynamicHeadersBarTv(specificHeadersArray) {
if (!chrome.runtime?.id) return false;
try {
await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
cmd: "updateHeadersRules",
requestHeaders: specificHeadersArray,
urlFilter: `*://${BARTV_API_HOST}/*`,
initiatorDomain: chrome.runtime.id
}, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (response && response.success) {
resolve(response);
} else {
reject(response ? response.error : 'Fallo al actualizar reglas DNR para BarTV.');
}
});
});
await new Promise(resolve => setTimeout(resolve, 200));
return true;
} catch (error) {
console.error("[BarTV] Error estableciendo cabeceras dinámicas globales:", error);
if (typeof showNotification === 'function') showNotification("Error configurando cabeceras de red para BarTV.", "error");
return false;
}
}
async function clearDynamicHeadersBarTv() {
if (!chrome.runtime?.id) return;
try {
await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ cmd: "clearAllDnrHeaders" }, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (response && response.success) {
resolve(response);
} else {
reject(response ? response.error : 'Fallo al limpiar reglas DNR tras BarTV.');
}
});
});
} catch (error) {
console.error("[BarTV] Error limpiando cabeceras dinámicas globales:", error);
}
}
async function loginBarTv(email, password) {
const loginHeaders = {
"Content-Type": "application/json; charset=UTF-8",
"Host": BARTV_API_HOST,
"Origin": BARTV_ORIGIN,
"Referer": BARTV_REFERER,
"User-Agent": BARTV_USER_AGENT
};
const dnrHeaders = Object.entries(loginHeaders).map(([key, value]) => ({ header: key, value: value }));
if (!await setDynamicHeadersBarTv(dnrHeaders)) {
throw new Error("No se pudieron establecer cabeceras para login BarTV.");
}
try {
const response = await fetch(BARTV_LOGIN_URL, {
method: 'POST',
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error(`HTTP error en login BarTV: ${response.status}`);
}
const data = await response.json();
if (data && data.success && data.success.token) {
return data.success.token;
} else {
throw new Error("Login BarTV fallido o formato de respuesta inesperado.");
}
} finally {
}
}
async function fetchBarTvChannelDetails(mediaId, token) {
const url = BARTV_MEDIA_URL_TEMPLATE.replace("{mediaId}", mediaId).replace("{token}", token);
const fetchHeaders = {
"Host": BARTV_API_HOST,
"Origin": BARTV_ORIGIN,
"Referer": BARTV_REFERER,
"User-Agent": BARTV_USER_AGENT
};
const dnrHeaders = Object.entries(fetchHeaders).map(([key, value]) => ({ header: key, value: value }));
if (!await setDynamicHeadersBarTv(dnrHeaders)) {
throw new Error(`No se pudieron establecer cabeceras para obtener detalles del canal ${mediaId}.`);
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status} para ${mediaId}`);
}
const data = await response.json();
if (data.manifestUrl && data.protData && data.protData.licenseUrl) {
return {
manifestUrl: data.manifestUrl,
licenseUrl: data.protData.licenseUrl
};
} else {
throw new Error(`Datos incompletos para ${mediaId}`);
}
} finally {
}
}
async function generateM3uBarTv() {
if (typeof showLoading === 'function') showLoading(true, "Cargando canales de BarTV...");
const barTvSourceName = "BarTV";
let headersSetSuccessfully = false;
try {
const email = userSettings.barTvEmail;
const password = userSettings.barTvPassword;
if (!email || !password) {
if (typeof showNotification === 'function') showNotification("Credenciales de BarTV no configuradas en Ajustes.", "warning");
throw new Error("Credenciales BarTV no configuradas.");
}
const token = await loginBarTv(email, password);
headersSetSuccessfully = true;
if (typeof showNotification === 'function') showNotification("Login en BarTV exitoso.", "success");
const channelDetailsPromises = [];
for (const mediaId in CHANNEL_NAMES_BARTV) {
channelDetailsPromises.push(
fetchBarTvChannelDetails(mediaId, token)
.then(details => ({ ...details, mediaId, ...CHANNEL_NAMES_BARTV[mediaId] }))
.catch(e => {
console.warn(`Error obteniendo detalles para ${CHANNEL_NAMES_BARTV[mediaId].nombre}: ${e.message}`);
return null;
})
);
await new Promise(resolve => setTimeout(resolve, 300));
}
const allChannelData = (await Promise.all(channelDetailsPromises)).filter(Boolean);
if (allChannelData.length === 0) {
throw new Error("No se pudieron obtener detalles para ningún canal de BarTV.");
}
if (typeof showNotification === 'function') showNotification(`Obtenidos ${allChannelData.length} canales de BarTV.`, "info");
let globalLicenseJwt = null;
if (allChannelData.length > 0) {
try {
const lastLicenseUrl = allChannelData[allChannelData.length - 1].licenseUrl;
const parsedUrl = new URL(lastLicenseUrl);
globalLicenseJwt = parsedUrl.searchParams.get("license");
} catch (e) {
console.warn("No se pudo extraer JWT global de la última licencia:", e);
}
}
if (!globalLicenseJwt) {
console.warn("No se pudo obtener un JWT de licencia global. Las licencias podrían no funcionar.");
}
const m3uLines = ["#EXTM3U"];
allChannelData.forEach(ch => {
m3uLines.push(`#EXTINF:-1 tvg-logo="${ch.logo}" group-title="BAR TV",${ch.nombre}`);
m3uLines.push(`#EXTVLCOPT:http-user-agent=${BARTV_USER_AGENT}`);
m3uLines.push("#KODIPROP:inputstream.adaptive.manifest_type=mpd");
m3uLines.push("#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha");
let finalLicenseUrl = ch.licenseUrl;
if (globalLicenseJwt) {
try {
const parsedOriginalLicense = new URL(ch.licenseUrl);
parsedOriginalLicense.searchParams.set("license", globalLicenseJwt);
finalLicenseUrl = parsedOriginalLicense.toString();
} catch (e) {
console.warn(`Error reemplazando JWT en licencia para ${ch.nombre}, usando original: ${e}`);
}
}
m3uLines.push(`#KODIPROP:inputstream.adaptive.license_key=${finalLicenseUrl}`);
m3uLines.push(ch.manifestUrl);
});
const m3uString = m3uLines.join("\n") + "\n\n";
if (typeof removeChannelsBySourceOrigin === 'function') {
removeChannelsBySourceOrigin(barTvSourceName);
}
if (typeof appendM3UContent === 'function') {
appendM3UContent(m3uString, barTvSourceName);
} else {
console.error("appendM3UContent no encontrada. Usando fallback processM3UContent.");
processM3UContent(m3uString, barTvSourceName, true);
}
} catch (error) {
console.error("Error generando M3U de BarTV:", error);
if (typeof showNotification === 'function') showNotification(`Error cargando BarTV: ${error.message}`, 'error');
} finally {
if (headersSetSuccessfully) {
await clearDynamicHeadersBarTv();
}
if (typeof showLoading === 'function') showLoading(false);
}
}

368
channel_ui.js Normal file
View File

@ -0,0 +1,368 @@
function switchFilter(filterType) {
if (currentFilter === filterType) return;
currentFilter = filterType;
currentPage = 1;
$('#groupFilterSidebar').val("").trigger('change');
if (userSettings.persistFilters) {
userSettings.lastSelectedFilterTab = currentFilter;
localStorage.setItem('zenithUserSettings', JSON.stringify(userSettings));
}
updateActiveFilterButton();
filterAndRenderChannels();
}
function updateActiveFilterButton() {
$('.filter-tab-btn').removeClass('active');
if (currentFilter === 'all') $('#showAllChannels').addClass('active');
else if (currentFilter === 'favorites') $('#showFavorites').addClass('active');
else if (currentFilter === 'history') $('#showHistory').addClass('active');
}
function getFilteredChannels() {
const search = $('#searchInput').val().toLowerCase().trim();
const selectedGroup = $('#groupFilterSidebar').val() || "";
let baseChannels;
if (currentFilter === 'favorites') {
baseChannels = favorites.map(url => channels.find(c => c.url === url)).filter(Boolean);
} else if (currentFilter === 'history') {
baseChannels = appHistory.map(url => channels.find(c => c.url === url)).filter(Boolean);
} else {
baseChannels = channels;
}
const filtered = baseChannels.filter(c =>
c && typeof c.name === 'string' && typeof c.url === 'string' &&
c.name.toLowerCase().includes(search) &&
(selectedGroup === "" || c['group-title'] === selectedGroup)
);
return filtered;
}
function getPaginatedChannels() {
const filtered = getFilteredChannels();
const totalItems = filtered.length;
const itemsPerPage = userSettings.channelsPerPage;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
currentPage = Math.min(Math.max(1, currentPage), totalPages === 0 ? 1 : totalPages);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, totalItems);
const paginated = filtered.slice(startIndex, endIndex);
return paginated;
}
function filterAndRenderChannels() {
renderChannels();
updatePaginationControls();
updateGroupSelectors();
checkIfChannelsExist();
if (typeof updateEPGProgressBarOnCards === 'function') {
updateEPGProgressBarOnCards();
}
}
function renderChannels() {
const grid = $('#channelGrid').empty();
const channelsToShow = getPaginatedChannels();
const noChannelsMessageEl = $('#noChannelsMessage');
document.documentElement.style.setProperty('--card-logo-aspect-ratio', userSettings.cardLogoAspectRatio === 'auto' ? '16/9' : userSettings.cardLogoAspectRatio);
$('#channelGridTitle').text("Canales Disponibles");
if (channelsToShow.length > 0) {
noChannelsMessageEl.hide();
grid.show();
const fragment = document.createDocumentFragment();
channelsToShow.forEach(channel => {
const isFavorite = favorites.includes(channel.url);
const card = document.createElement('div');
card.className = 'channel-card';
if (userSettings.compactCardView) {
card.classList.add('compact');
}
card.dataset.url = channel.url;
let logoSrc = '';
const epgIdForLogo = channel.effectiveEpgId || (channel['tvg-id'] || '').toLowerCase().trim();
if (typeof getEpgChannelIcon === 'function' && getEpgChannelIcon(epgIdForLogo)) {
logoSrc = getEpgChannelIcon(epgIdForLogo);
} else if (channel['tvg-logo']) {
logoSrc = channel['tvg-logo'];
}
let epgInfoHtml = '';
let hasCurrentProgramForProgressBar = false;
if (userSettings.cardShowEpg && channel.effectiveEpgId && typeof getEpgDataForChannel === 'function') {
const programsForChannel = getEpgDataForChannel(channel.effectiveEpgId);
const now = new Date();
const currentProgram = programsForChannel.find(p => now >= p.startDt && now < p.stopDt);
const nextProgramIndex = currentProgram ? programsForChannel.indexOf(currentProgram) + 1 : programsForChannel.findIndex(p => p.startDt > now);
const nextProgram = (nextProgramIndex !== -1 && nextProgramIndex < programsForChannel.length) ? programsForChannel[nextProgramIndex] : null;
if (currentProgram) {
hasCurrentProgramForProgressBar = true;
epgInfoHtml += `<div class="epg-current" title="${escapeHtml(currentProgram.title)}">${escapeHtml(currentProgram.title)}</div>`;
}
if (nextProgram && typeof formatEPGTime === 'function') {
epgInfoHtml += `<div class="epg-next" title="${escapeHtml(nextProgram.title)}">Sig: ${escapeHtml(nextProgram.title)} (${formatEPGTime(nextProgram.startDt)})</div>`;
}
}
let progressBarHtml = '';
if (userSettings.cardShowEpg && hasCurrentProgramForProgressBar) {
progressBarHtml = `
<div class="epg-progress-bar-container" style="display: none;">
<div class="epg-progress-bar"></div>
</div>`;
}
const channelNumber = channel.attributes['ch-number'];
const channelNumberHtml = userSettings.cardShowChannelNumber && channelNumber ?
`<span class="channel-number" title="Número ${escapeHtml(channelNumber)}">${escapeHtml(channelNumber)}</span>` : '';
card.innerHTML = `
<div class="channel-logo-container">
<div class="card-video-preview-container"></div>
${logoSrc ? `<img src="${escapeHtml(logoSrc)}" class="channel-logo" alt="${escapeHtml(channel.name)}" loading="lazy">` : ''}
<span class="epg-icon-placeholder"${logoSrc ? ' style="display: none;"' : ''}></span>
${channelNumberHtml}
</div>
<div class="channel-info">
<h3 class="channel-name" title="${escapeHtml(channel.name)}">${escapeHtml(channel.name)}</h3>
${epgInfoHtml ? `<div class="channel-epg-info">${epgInfoHtml}${progressBarHtml}</div>` : ''}
${userSettings.cardShowGroup ? `<p class="channel-group" title="${escapeHtml(channel['group-title'] || 'Sin Grupo')}">${escapeHtml(channel['group-title'] || 'Sin Grupo')}</p>` : ''}
${userSettings.cardShowFavButton ? `<button class="favorite-btn ${isFavorite ? 'favorite' : ''}" data-url="${escapeHtml(channel.url)}" title="${isFavorite ? 'Quitar favorito' : 'Añadir favorito'}"></button>` : ''}
</div>`;
fragment.appendChild(card);
});
grid.append(fragment);
} else {
grid.hide();
noChannelsMessageEl.show();
}
}
function renderXtreamContent(items, title) {
const grid = $('#channelGrid').empty();
const noChannelsMessageEl = $('#noChannelsMessage');
grid.show();
noChannelsMessageEl.hide();
$('#channelGridTitle').text(title);
if (!items || items.length === 0) {
noChannelsMessageEl.text("No se encontraron elementos para mostrar.").show();
grid.hide();
return;
}
const fragment = document.createDocumentFragment();
items.forEach(item => {
const card = document.createElement('div');
card.className = 'channel-card';
if (item.season_number !== undefined) {
card.dataset.seasonData = JSON.stringify(item);
} else {
card.dataset.episodeData = JSON.stringify(item);
}
card.innerHTML = `
<div class="channel-logo-container">
<img src="${escapeHtml(item['tvg-logo'] || 'icons/icon128.png')}" class="channel-logo" alt="${escapeHtml(item.name)}" loading="lazy">
</div>
<div class="channel-info">
<h3 class="channel-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</h3>
<p class="channel-group" title="${escapeHtml(item['group-title'] || '')}">${escapeHtml(item['group-title'] || '')}</p>
</div>`;
fragment.appendChild(card);
});
grid.append(fragment);
$('#paginationControls').hide();
checkIfChannelsExist();
}
function updateGroupSelectors() {
const baseOrder = currentGroupOrder.filter(group => group && group.trim() !== '');
let relevantChannels;
if (currentFilter === 'favorites') {
relevantChannels = channels.filter(c => favorites.includes(c.url));
} else if (currentFilter === 'history') {
relevantChannels = appHistory.map(url => channels.find(c => c.url === url)).filter(Boolean);
} else {
relevantChannels = channels;
}
const groupCounts = {};
relevantChannels.forEach(c => {
const group = c['group-title'] || '';
groupCounts[group] = (groupCounts[group] || 0) + 1;
});
const availableGroupsRaw = relevantChannels.map(c => c['group-title'] || '');
const uniqueSortedGroupsInView = getOrderedUniqueGroups(baseOrder, availableGroupsRaw);
const currentSelectedGroup = $('#groupFilterSidebar').val();
populateGroupFilterDropdown('#groupFilterSidebar', uniqueSortedGroupsInView, '📂 Todos los grupos', groupCounts, currentSelectedGroup);
populateSidebarGroupList('#sidebarGroupList', uniqueSortedGroupsInView, groupCounts, currentSelectedGroup);
}
function getOrderedUniqueGroups(preferredOrder, availableGroups) {
const availableSet = new Set(availableGroups);
const ordered = preferredOrder.filter(group => availableSet.has(group));
const unordered = Array.from(availableSet)
.filter(group => !preferredOrder.includes(group))
.sort((a, b) => {
const aNorm = a === '' ? 'Sin Grupo' : a;
const bNorm = b === '' ? 'Sin Grupo' : b;
return aNorm.localeCompare(bNorm, undefined, { sensitivity: 'base' });
});
return [...new Set([...ordered, ...unordered])];
}
function populateGroupFilterDropdown(selectorId, groups, defaultOptionText, groupCounts = {}, valueToSelect) {
const selector = $(selectorId);
selector.empty().append(`<option value="">${escapeHtml(defaultOptionText)}</option>`);
groups.forEach(group => {
const count = groupCounts[group] || 0;
const displayName = group === '' ? 'Sin Grupo' : group;
selector.append(`<option value="${escapeHtml(group)}">${escapeHtml(displayName)} (${count})</option>`);
});
if (groups.includes(valueToSelect) || valueToSelect === "") {
selector.val(valueToSelect);
} else {
selector.val("");
}
}
function populateSidebarGroupList(listId, groups, groupCounts = {}, valueToSelect) {
const list = $(listId).empty();
const fragment = document.createDocumentFragment();
const allGroupsItem = document.createElement('li');
allGroupsItem.className = 'list-group-item';
allGroupsItem.dataset.groupName = "";
let totalChannelsInView = 0;
if (currentFilter === 'favorites') {
totalChannelsInView = favorites.map(url => channels.find(c => c.url === url)).filter(Boolean).length;
} else if (currentFilter === 'history') {
totalChannelsInView = appHistory.map(url => channels.find(c => c.url === url)).filter(Boolean).length;
} else {
totalChannelsInView = channels.length;
}
if (Object.keys(groupCounts).length > 0 && (currentFilter === 'all' || currentFilter === '')) {
totalChannelsInView = Object.values(groupCounts).reduce((sum, count) => sum + count, 0);
}
allGroupsItem.textContent = `Todos los Grupos (${totalChannelsInView})`;
if (valueToSelect === "") $(allGroupsItem).addClass('active');
fragment.appendChild(allGroupsItem);
groups.forEach(group => {
const item = document.createElement('li');
item.className = 'list-group-item';
item.dataset.groupName = group;
const count = groupCounts[group] || 0;
const displayName = group === '' ? 'Sin Grupo' : group;
item.textContent = `${escapeHtml(displayName)} (${count})`;
if (valueToSelect === group) $(item).addClass('active');
fragment.appendChild(item);
});
list.append(fragment);
}
function updatePaginationControls() {
const filtered = getFilteredChannels();
const totalItems = filtered.length;
const itemsPerPage = userSettings.channelsPerPage;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
currentPage = Math.min(Math.max(1, currentPage), totalPages === 0 ? 1 : totalPages);
$('#pageInfo').text(`Pág ${currentPage} de ${totalPages} (${totalItems})`);
$('#prevPage').prop('disabled', currentPage <= 1);
$('#nextPage').prop('disabled', currentPage >= totalPages || totalPages === 0);
$('#paginationControls').toggle(totalItems > itemsPerPage);
}
function changePage(newPage) {
const filtered = getFilteredChannels();
const itemsPerPage = userSettings.channelsPerPage;
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const targetPage = Math.min(Math.max(1, newPage), totalPages === 0 ? 1 : totalPages);
if (targetPage !== currentPage) {
currentPage = targetPage;
renderChannels();
updatePaginationControls();
const mainContentEl = $('#main-content');
const channelGridEl = $('#channelGrid');
if (channelGridEl.length && mainContentEl.length && channelGridEl.is(":visible")) {
const gridRect = channelGridEl[0].getBoundingClientRect();
const mainContentRect = mainContentEl[0].getBoundingClientRect();
let targetScrollPosition = mainContentEl.scrollTop() + gridRect.top - mainContentRect.top - (parseFloat(mainContentEl.css('padding-top')) || 0);
targetScrollPosition = Math.max(0, targetScrollPosition);
if (currentPage > 1 && (Math.abs(mainContentEl.scrollTop() - targetScrollPosition) > 20 || mainContentEl.scrollTop() > targetScrollPosition) ) {
mainContentEl.animate({ scrollTop: targetScrollPosition }, 300);
} else if (currentPage === 1 && mainContentEl.scrollTop() > 0) {
mainContentEl.animate({ scrollTop: 0 }, 300);
}
}
}
}
function checkIfChannelsExist() {
const hasAnyChannelLoaded = channels.length > 0;
const isMainView = currentView.type === 'main';
const filteredChannelsCount = isMainView ? getFilteredChannels().length : currentView.data?.length || 0;
const noChannelsMsg = $('#noChannelsMessage');
const paginationControls = $('#paginationControls');
const channelGrid = $('#channelGrid');
const channelGridTitleContainer = $('#channelGridTitle').parent();
const filterTabs = $('.filter-tabs-container');
const downloadBtn = $('#downloadM3UBtnHeader');
if (!hasAnyChannelLoaded) {
noChannelsMsg.text(currentM3UContent ? `No se encontraron canales válidos en "${escapeHtml(currentM3UName)}".` : 'Carga una lista M3U (URL o archivo)...').show();
channelGrid.hide();
paginationControls.hide();
channelGridTitleContainer.hide();
filterTabs.hide();
downloadBtn.prop('disabled', true).parent().addClass('disabled');
$('#groupFilterSidebar').prop('disabled', true).val('');
$('#sidebarGroupList').empty().append('<li class="list-group-item text-secondary">Carga una lista M3U</li>');
} else {
filterTabs.toggle(isMainView);
downloadBtn.prop('disabled', false).parent().removeClass('disabled');
$('#groupFilterSidebar').prop('disabled', !isMainView);
if (filteredChannelsCount === 0) {
let message = 'No hay canales que coincidan con los filtros/búsqueda.';
if (isMainView) {
if (currentFilter === 'favorites' && favorites.length === 0) message = 'No tienes canales favoritos. Haz clic en ★ en una tarjeta para añadir.';
if (currentFilter === 'history' && appHistory.length === 0) message = 'El historial de reproducción está vacío.';
} else {
message = "No se encontraron episodios para esta serie.";
}
noChannelsMsg.text(message).show();
channelGrid.hide();
paginationControls.hide();
channelGridTitleContainer.show();
} else {
noChannelsMsg.hide();
channelGrid.show();
channelGridTitleContainer.show();
}
}
}

37
css/base.css Normal file
View File

@ -0,0 +1,37 @@
:root {
--bg-primary: #0D1117; --bg-secondary: #161B22; --bg-tertiary: #010409; --bg-hover: #1F242C;
--bg-element: #21262D; --bg-element-hover: #2D323A; --accent-primary: #10B981;
--accent-secondary: #059669; --accent-hover: #34D399; --accent-primary-transparent: rgba(16, 185, 129, 0.15);
--text-primary: #E6EDF3; --text-secondary: #8B949E; --text-tertiary: #6E7681;
--border-color: #30363D; --border-color-strong: #484F58; --shadow-color: rgba(0, 0, 0, 0.25);
--success: #28A745; --danger: #DC3545; --warning: #FFC107; --info: #17A2B8;
--orange-color: #FF7900; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px;
--font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-heading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--sidebar-width: 260px; --header-height: 65px; --m3u-grid-minmax-size: 180px;
--taskbar-height: 40px;
--rgb-bg-tertiary: 1, 4, 9;
--rgb-accent-primary: 16, 185, 129;
}
body.theme-blue { --accent-primary: #0d6efd; --accent-secondary: #0a58ca; --accent-hover: #3c87fd; --accent-primary-transparent: rgba(13, 110, 253, 0.15); --rgb-accent-primary: 13, 110, 253;}
body.theme-purple { --accent-primary: #6f42c1; --accent-secondary: #59359a; --accent-hover: #8a63d2; --accent-primary-transparent: rgba(111, 66, 193, 0.15); --rgb-accent-primary: 111, 66, 193;}
body.theme-orange { --accent-primary: #fd7e14; --accent-secondary: #d3690f; --accent-hover: #fd933c; --accent-primary-transparent: rgba(253, 126, 20, 0.15); --rgb-accent-primary: 253, 126, 20;}
body.font-type-apple-system { --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --font-heading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";}
body.font-type-sans-serif { --font-main: "Segoe UI", "Helvetica Neue", Arial, sans-serif; --font-heading: "Segoe UI", "Helvetica Neue", Arial, sans-serif;}
body.font-type-serif { --font-main: Georgia, serif; --font-heading: Georgia, serif; }
body.font-type-monospace { --font-main: "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --font-heading: "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
html { scroll-behavior: smooth; }
* { scrollbar-width: thin; scrollbar-color: var(--accent-primary) var(--bg-secondary); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background-color: var(--accent-primary); border-radius: var(--radius-sm); border: 2px solid var(--bg-secondary); }
::-webkit-scrollbar-thumb:hover { background-color: var(--accent-hover); }
body { background-color: var(--bg-primary); color: var(--text-primary); font-family: var(--font-main); overflow-x: hidden; min-height: 100vh; line-height: 1.6; }
#particles-js { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: -1; opacity: var(--particle-opacity, 0.02); pointer-events: none; transition: opacity 0.5s ease-in-out; }
#particles-js.disabled { opacity: 0 !important; }
.d-none { display: none !important; }

80
css/channel_card.css Normal file
View File

@ -0,0 +1,80 @@
.channel-card { background-color: var(--bg-element); border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-color); box-shadow: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -2px var(--shadow-color); transition: transform var(--transition-smooth), box-shadow var(--transition-smooth), border-color var(--transition-smooth), background-color var(--transition-fast); cursor: pointer; position: relative; display: flex; flex-direction: column; will-change: transform, box-shadow; }
.channel-card:hover { transform: translateY(-6px) scale(1.05); box-shadow: 0 12px 22px -6px color-mix(in srgb, var(--accent-primary) 20%, var(--shadow-color)), 0 0 15px 1px color-mix(in srgb, var(--accent-primary) 30%, transparent); border-color: var(--accent-primary); background-color: var(--bg-element-hover); }
.channel-card:hover .channel-logo:not(.error) { transform: scale(1.05); }
.channel-card:active { transform: translateY(-2px) scale(1.01); box-shadow: 0 6px 12px -3px var(--shadow-color), 0 0 8px 0px color-mix(in srgb, var(--accent-primary) 20%, transparent); transition: transform 0.08s ease-out, box-shadow 0.08s ease-out; }
.channel-logo-container { width: 100%; aspect-ratio: var(--card-logo-aspect-ratio, 16/9); background-color: var(--bg-secondary); display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.channel-logo { max-width: 75%; max-height: 75%; object-fit: contain; transition: var(--transition-smooth); z-index: 1; }
.channel-logo-container::before { content: '\1F4FA'; font-family: sans-serif; font-weight: normal; font-size: 2.5rem; color: var(--text-tertiary); position: absolute; opacity: 0.2; z-index: 0; transition: opacity 0.2s ease-in-out; }
.channel-logo-container:has(img.channel-logo[src]:not([src=""]):not(.error))::before { opacity: 0; }
.card-video-preview-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: #000; z-index: 2; display: none; }
.card-video-preview-container video.card-preview-video { width: 100%; height: 100%; object-fit: cover; }
.channel-card.is-playing-preview .channel-logo-container > img.channel-logo,
.channel-card.is-playing-preview .channel-logo-container > .epg-icon-placeholder,
.channel-card.is-playing-preview .channel-logo-container::before { display: none !important; opacity: 0 !important; }
.channel-card.is-playing-preview .card-video-preview-container { display: block; }
.channel-info { padding: 0.75rem; flex-grow: 1; display: flex; flex-direction: column; }
.channel-name { font-size: 0.9rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-number { font-size: 0.7rem; color: var(--text-tertiary); position: absolute; top: 0.5rem; left: 0.5rem; background-color: rgba(var(--rgb-bg-tertiary),0.7); padding: 0.1rem 0.4rem; border-radius: var(--radius-sm); z-index: 3; backdrop-filter: blur(3px); }
.channel-card:hover .channel-number { color: var(--accent-primary); }
.channel-group { font-size: 0.7rem; color: var(--text-tertiary); background: rgba(0, 0, 0, 0.2); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); display: inline-block; margin-top: auto; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-epg-info { font-size: 0.7rem; color: var(--text-secondary); margin-top: 0.2rem; line-height: 1.3; }
.epg-current, .epg-next { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.1rem; }
.epg-current { font-weight: 500; opacity: 0.9; display: flex; align-items: center; }
.epg-current::before { content: "▶"; font-size: 0.8em; color: var(--accent-primary); margin-right: 0.3em; opacity: 0.8; }
.epg-next { opacity: 0.7; }
.epg-progress-bar-container { height: 3px; background-color: var(--border-color); border-radius: 2px; margin-top: 4px; overflow: hidden; }
.epg-progress-bar { height: 100%; background-color: var(--accent-secondary); border-radius: 2px; width: 0%; transition: width 0.5s linear; }
.favorite-btn { position: absolute; top: 0.5rem; right: 0.5rem; background-color: rgba(30, 41, 59, 0.7); border: 1px solid var(--border-color); border-radius: 50%; width: 30px; height: 30px; font-size: 1rem; line-height: 1; color: var(--text-secondary); transition: var(--transition-fast); display: flex; align-items: center; justify-content: center; z-index: 3; backdrop-filter: blur(3px); }
.favorite-btn::before { content: "\2606"; font-family: sans-serif; }
.favorite-btn.favorite::before { content: "\2B50"; color: var(--accent-primary); }
.favorite-btn:hover { background-color: var(--accent-primary-transparent); color: var(--accent-primary); transform: scale(1.1); border-color: var(--accent-primary); }
.favorite-btn.favorite { color: var(--accent-primary); background-color: color-mix(in srgb, var(--accent-primary) 20%, transparent); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.favorite-btn.favorite:hover { color: var(--accent-hover); background-color: color-mix(in srgb, var(--accent-hover) 25%, transparent); border-color: var(--accent-hover); }
.channel-card.compact {
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
}
.channel-card.compact .channel-logo-container {
width: 90px;
height: auto;
aspect-ratio: 16/9;
flex-shrink: 0;
border-radius: var(--radius-sm);
}
.channel-card.compact .channel-logo {
max-width: 80%;
max-height: 80%;
}
.channel-card.compact .channel-info {
padding: 0;
flex: 1;
min-width: 0;
}
.channel-card.compact .channel-name {
margin-bottom: 0.2rem;
font-size: 0.85rem;
}
.channel-card.compact .channel-epg-info {
font-size: 0.65rem;
line-height: 1.2;
}
.channel-card.compact .epg-current::before {
display: none;
}
.channel-card.compact .channel-group {
display: none;
}
.channel-card.compact .favorite-btn {
top: 0.3rem;
right: 0.3rem;
width: 26px;
height: 26px;
font-size: 0.9rem;
}
.channel-card.compact:hover {
transform: translateY(-4px) scale(1.03);
}

18
css/channel_grid.css Normal file
View File

@ -0,0 +1,18 @@
.m3u-load-area { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 1.5rem; border: 1px solid var(--border-color); }
.m3u-load-area h5 { font-family: var(--font-heading); font-size: 1.3rem; color: var(--text-primary); margin-bottom: 1rem; }
.m3u-load-area .form-control, .m3u-load-area .form-select { font-size: 0.9rem; }
.m3u-load-area .btn-control { width: 100%; } /* Consider moving to buttons.css */
.filter-tabs-container { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; }
.filter-tab-btn { background: transparent; border: none; color: var(--text-secondary); padding: 0.6rem 1rem; font-size: 0.95rem; font-weight: 500; border-radius: var(--radius-sm) var(--radius-sm) 0 0; position: relative; transition: var(--transition-fast); }
.filter-tab-btn .icon-placeholder::before { margin-right: 0.5rem; font-family: sans-serif; }
#showAllChannels .icon-placeholder::before { content: "\1F4FA"; }
#showFavorites .icon-placeholder::before { content: "\2B50"; }
#showHistory .icon-placeholder::before { content: "\1F553"; }
.filter-tab-btn:hover { color: var(--text-primary); }
.filter-tab-btn.active { color: var(--accent-primary); }
.filter-tab-btn.active::after { content: ''; position: absolute; bottom: -1px; left: 0; width: 100%; height: 2px; background-color: var(--accent-primary); border-radius: 1px; }
.section-title-main { font-family: var(--font-heading); font-size: 1.8rem; font-weight: 700; color: var(--text-primary); margin-bottom: 1.5rem; display: none; }
.m3u-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--m3u-grid-minmax-size), 1fr)); gap: 1.25rem; }
#noChannelsMessage { grid-column: 1 / -1; text-align: center; margin-top: 3rem; font-size: 1.1rem; color: var(--text-secondary); }
.pagination-controls { display: flex; justify-content: center; align-items: center; gap: 0.75rem; margin-top: 2rem; padding-bottom: 1rem; }
.pagination-controls span { color: var(--text-secondary); font-size: 0.9rem; }

36
css/components.css Normal file
View File

@ -0,0 +1,36 @@
.form-control, .form-select { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: var(--radius-md); padding: 0.6rem 1rem; }
.form-select option { background-color: var(--bg-element); color: var(--text-primary); }
.form-control::placeholder { color: var(--text-tertiary); opacity: 0.8; }
.form-control:focus, .form-select:focus { background-color: var(--bg-element-hover); border-color: var(--accent-primary); box-shadow: 0 0 0 3px var(--accent-primary-transparent); color: var(--text-primary); outline: none; }
.btn-control { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); padding: 0.6rem 1.2rem; border-radius: var(--radius-md); font-weight: 500; transition: var(--transition-fast); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; cursor: pointer; position: relative; overflow: hidden; }
.btn-control .icon-placeholder::before { font-family: sans-serif; }
#prevPage .icon-placeholder::before { content: "\2190"; }
#nextPage .icon-placeholder::before { content: "\2192"; }
#playEpgProgramBtn .icon-placeholder::before { content: "\25B6"; }
#saveSettingsBtn .icon-placeholder::before { content: "\1F4BE"; }
#forceEpgRematchBtn .icon-placeholder::before { content: "\21BB"; }
#exportSettingsBtn .icon-placeholder::before { content: "\1F4E4"; }
#clearCacheBtn .icon-placeholder::before { content: "\1F5D1"; }
#importSettingsInput + label .icon-placeholder::before { content: "\1F4E5"; }
#sendM3UToServerBtn .icon-placeholder::before { content: "\1F4E1"; }
.btn-control:hover:not(:disabled) { background-color: var(--bg-element-hover); border-color: var(--border-color-strong); transform: translateY(-1px); box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
.btn-control:active:not(:disabled) { transform: translateY(0px) scale(0.98); box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.btn-control.primary { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); color: white !important; border: none; }
.btn-control.primary:hover:not(:disabled) { background: linear-gradient(135deg, var(--accent-hover), var(--accent-primary)); transform: translateY(-1px); box-shadow: 0 3px 8px color-mix(in srgb, var(--accent-primary) 40%, transparent); }
.btn-control:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-control.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; border-radius: var(--radius-sm); }
.btn-control.btn-danger { background-color: var(--danger); border-color: var(--danger); color: white !important; }
.btn-control.btn-danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--danger) 85%, black); border-color: color-mix(in srgb, var(--danger) 85%, black); box-shadow: 0 3px 8px color-mix(in srgb, var(--danger) 40%, transparent); }
#loading-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(var(--rgb-bg-tertiary), 0.8); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); display: flex; align-items: center; justify-content: center; z-index: 2000; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; }
#loading-overlay.show { opacity: 1; pointer-events: auto; }
.loader { width: 50px; height: 50px; border: 4px solid var(--accent-primary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
#notification { position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%) translateY(120%); padding: 0.8rem 1.5rem; border-radius: var(--radius-md); box-shadow: 0 5px 15px var(--shadow-color); z-index: 2050; color: white; font-weight: 500; max-width: 90%; text-align: center; opacity: 0; transition: opacity 0.4s cubic-bezier(0.23, 1, 0.32, 1), transform 0.4s cubic-bezier(0.23, 1, 0.32, 1); pointer-events: none; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid transparent; font-size: 0.9rem; }
#notification.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
#notification.success { background-color: rgba(var(--rgb-accent-primary), 0.85); border-color: var(--accent-primary); }
#notification.error { background-color: rgba(220, 53, 69, 0.85); border-color: var(--danger); }
#notification.info { background-color: rgba(23, 162, 184, 0.85); border-color: var(--info); }
#notification.warning { background-color: rgba(255, 193, 7, 0.85); border-color: var(--warning); color: #333; }

121
css/editor.css Normal file
View File

@ -0,0 +1,121 @@
:root {
--editor-primary: var(--accent-primary);
--editor-primary-hover: var(--accent-hover);
--editor-primary-light: var(--accent-primary-transparent);
--editor-danger: var(--danger);
--editor-danger-hover: color-mix(in srgb, var(--danger) 85%, black);
--editor-danger-light: color-mix(in srgb, var(--danger) 15%, transparent);
--editor-success: var(--success);
--editor-success-hover: color-mix(in srgb, var(--success) 85%, black);
--editor-success-light: color-mix(in srgb, var(--success) 15%, transparent);
--editor-info: var(--info);
--editor-info-hover: color-mix(in srgb, var(--info) 85%, black);
--editor-info-light: color-mix(in srgb, var(--info) 15%, transparent);
--editor-warning: var(--warning);
--editor-warning-hover: color-mix(in srgb, var(--warning) 85%, black);
--editor-warning-light: color-mix(in srgb, var(--warning) 15%, transparent);
--editor-text-dark: var(--text-primary);
--editor-text-light: var(--text-secondary);
--editor-light: var(--bg-primary);
--editor-light-alt: var(--bg-secondary);
--editor-border-color: var(--border-color);
--editor-sidebar-bg: var(--bg-tertiary);
--editor-header-bg: var(--bg-tertiary);
--editor-header-text: var(--text-primary);
--editor-sidebar-collapsed-width: 65px;
--editor-sidebar-expanded-width: 240px;
--editor-content-padding: 20px;
--editor-border-radius: var(--radius-md);
--editor-shadow-soft: 0 2px 4px rgba(0, 0, 0, 0.1);
--editor-shadow-medium: 0 5px 15px rgba(0, 0, 0, 0.2);
--editor-shadow-large: 0 10px 30px rgba(0, 0, 0, 0.3);
--editor-transition-speed: 0.3s;
--editor-selected-bg: var(--accent-primary-transparent);
}
#editorModal .content-wrapper { flex-grow: 1; display: flex; overflow: hidden; background-color: var(--bg-primary); height: calc(100vh - 120px); }
#editorModal .list-panel { width: 100%; display: flex; flex-direction: column; border-right: none; overflow: hidden; transition: width var(--editor-transition-speed) ease; }
#editorModal.editor-visible .list-panel { width: 60%; border-right: 1px solid var(--editor-border-color); }
#editorModal .list-toolbar { padding: 10px 15px; border-bottom: 1px solid var(--editor-border-color); display: flex; flex-wrap: wrap; gap: 10px; align-items: center; flex-shrink: 0; background-color: var(--bg-secondary); }
#editorModal #file-name-display { font-size: 0.9rem; font-weight: 500; background-color: var(--bg-element); padding: 4px 10px; border-radius: var(--editor-border-radius); border: 1px solid var(--editor-border-color); color: var(--editor-text-light); margin-right: auto; }
#editorModal #file-name-display.loaded { color: var(--editor-text-dark); border-color: var(--editor-primary); background-color: var(--editor-primary-light); }
#editorModal .list-toolbar .btn, #editorModal .list-toolbar #group-filter { height: 32px; font-size: 0.8rem; padding: 0 12px; background-color: var(--bg-element); border-color: var(--border-color); color: var(--text-primary); border-radius: var(--radius-sm); }
#editorModal .table-container { flex-grow: 1; overflow-y: auto; background: transparent; }
#editorModal table { min-width: 550px; border-collapse: separate; border-spacing: 0; width: 100%; }
#editorModal th, #editorModal td { padding: 10px 12px; white-space: nowrap; font-size: 0.85rem; vertical-align: middle; }
#editorModal td { border-bottom: 1px solid var(--editor-border-color); }
#editorModal th { background-color: var(--bg-tertiary); font-weight: 600; position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--border-color-strong); cursor: default; }
#editorModal th.sortable { cursor: pointer; }
#editorModal .handle-cell, #editorModal .checkbox-cell { width: 40px; text-align: center; }
#editorModal .logo-cell { width: 50px; text-align: center; }
#editorModal .name-cell { min-width: 180px; white-space: normal; font-weight: 500; }
#editorModal .url-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; color: var(--editor-text-light); font-size: 0.8rem; }
#editorModal .epg-cell { width: 120px; overflow: hidden; text-overflow: ellipsis; }
#editorModal .ch-num-cell { width: 60px; text-align: right; color: var(--editor-text-light); }
#editorModal .actions-cell { width: 80px; text-align: center; }
#editorModal .drag-handle { cursor: move; color: var(--editor-text-light); padding: 0 8px 0 0; opacity: 0.6; }
#editorModal tr:hover .drag-handle { opacity: 1; }
#editorModal .logo-preview { max-width: 32px; max-height: 20px; vertical-align: middle; object-fit: contain; background-color: var(--bg-element); border-radius: 3px; }
#editorModal tr.channel-row { cursor: pointer; transition: background-color var(--editor-transition-speed); }
#editorModal tr.channel-row:hover { background-color: var(--bg-hover); }
#editorModal tr.channel-row.selected-row { background-color: var(--editor-selected-bg) !important; }
#editorModal tr.channel-row.selected-row td { border-bottom-color: var(--editor-primary); }
#editorModal .group-header-row { background-color: var(--bg-element); font-weight: 600; cursor: default; transition: background-color var(--editor-transition-speed); user-select: none; }
#editorModal .group-header-row:hover { background-color: var(--bg-element-hover); }
#editorModal .group-header-row td { padding: 8px 12px; border-bottom: 1px solid var(--border-color-strong); }
#editorModal .group-name-text { cursor: text; }
#editorModal .group-channel-count { font-size: 0.8em; color: var(--editor-text-light); margin-left: 8px; font-weight: 400; }
#editorModal .editor-panel { width: 0; padding: 0; border-left: none; display: flex; flex-direction: column; overflow: hidden; background-color: var(--bg-secondary); transition: width var(--editor-transition-speed) ease, padding var(--editor-transition-speed) ease, opacity var(--editor-transition-speed) ease, border-left var(--editor-transition-speed) ease; opacity: 0; flex-shrink: 0; user-select: none; }
#editorModal.editor-visible .editor-panel { width: 40%; opacity: 1; overflow-y: auto; padding: var(--editor-content-padding); border-left: 1px solid var(--editor-border-color); }
#editorModal .editor-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--editor-border-color); flex-shrink: 0; }
#editorModal .editor-panel-header h3 { margin: 0; font-size: 1.1rem; font-weight: 600; display: flex; align-items: center; }
#editorModal .editor-panel-header i { color: var(--editor-primary); margin-right: 8px; }
#editorModal .btn-close-editor { background: none; border: none; font-size: 1.5rem; color: var(--editor-text-light); cursor: pointer; padding: 0 5px; opacity: 0.7; transition: opacity 0.2s; line-height: 1; }
#editorModal .btn-close-editor:hover { opacity: 1; }
#editorModal .editor-panel-content { flex-grow: 1; }
#editorModal .editor-panel .form-group { margin-bottom: 20px; }
#editorModal .editor-panel label { display: block; font-weight: 500; margin-bottom: 8px; font-size: 0.85rem; color: var(--editor-text-dark); }
#editorModal .editor-panel input[type="text"], #editorModal .editor-panel input[type="url"], #editorModal .editor-panel input[type="number"], #editorModal .editor-panel textarea { width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--editor-border-color); border-radius: var(--editor-border-radius); font-size: 0.9rem; background-color: var(--bg-element); color: var(--text-primary); transition: border-color 0.2s, box-shadow 0.2s; line-height: 1.5; }
#editorModal .editor-panel textarea { height: auto; min-height: 76px; padding: 8px 12px; resize: vertical; }
#editorModal .editor-panel input:focus, #editorModal .editor-panel textarea:focus { border-color: var(--editor-primary); outline: none; box-shadow: 0 0 0 3px var(--editor-primary-light); }
#editorModal .editor-panel .input-group { display: flex; gap: 15px; }
#editorModal .editor-panel .input-group .form-group { flex: 1; margin-bottom: 0; }
#editorModal .editor-panel input[type="number"] { padding-left: 25px; }
#editorModal .editor-panel .ch-num-wrapper { position: relative; }
#editorModal .editor-panel .ch-num-wrapper::before { content: "#"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--editor-text-light); font-size: 0.9rem; font-weight: 600; }
#editorModal .editor-logo-preview { max-width: 100px; max-height: 60px; object-fit: contain; margin-bottom: 10px; border: 1px dashed var(--editor-border-color); padding: 5px; display: block; border-radius: var(--editor-border-radius); background-color: var(--bg-primary); min-height: 30px; text-align: center; line-height: 30px; color: var(--editor-text-light); }
#editorModal .editor-logo-preview[src=''] { display: none; }
#editorModal .editor-panel .form-check-group { display: flex; gap: 15px; flex-wrap: wrap; margin-top: 8px; }
#editorModal .editor-panel .form-check { display: flex; align-items: center; gap: 6px; }
#editorModal .editor-panel .form-check input[type="checkbox"] { width: auto; height: auto; flex-shrink: 0; }
#editorModal .editor-panel .form-check label { margin-bottom: 0; font-weight: 400; font-size: 0.85rem; }
#editorModal .editor-panel h6 { font-weight: 600; margin: 25px 0 15px 0; padding-top: 15px; border-top: 1px solid var(--editor-border-color); font-size: 0.9rem; color: var(--editor-primary); }
#editorModal #editor-placeholder { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; color: var(--editor-text-light); text-align: center; padding: 20px; }
#editorModal #editor-placeholder i { font-size: 3em; margin-bottom: 15px; opacity: 0.5; }
#editorModal #editor-placeholder p { font-size: 1rem; }
#editorModal .hidden { display: none !important; }
#editorModal .modal-body { padding: 1rem; }
#editorModal .btn { display: inline-block; font-weight: 400; line-height: 1.5; text-align: center; text-decoration: none; vertical-align: middle; cursor: pointer; user-select: none; border: 1px solid transparent; padding: .375rem .75rem; font-size: 1rem; border-radius: .25rem; background-color: var(--bg-element); color: var(--text-primary); border-color: var(--border-color); }
#editorModal .btn:hover { background-color: var(--bg-element-hover); border-color: var(--border-color-strong); }
#editorModal .btn-sm { padding: .25rem .5rem; font-size: .875rem; border-radius: .2rem; }
#editorModal .btn-outline-danger { color: var(--danger); border-color: var(--danger); }
#editorModal .btn-outline-danger:hover { color: white; background-color: var(--danger); border-color: var(--danger); }
#editorModal .btn-outline-primary { color: var(--accent-primary); border-color: var(--accent-primary); }
#editorModal .btn-outline-primary:hover { color: white; background-color: var(--accent-primary); border-color: var(--accent-primary); }
#editorModal .btn-info { background-color: var(--editor-info); border-color: var(--editor-info); color: white; }
#editorModal .btn-info:hover { background-color: var(--editor-info-hover); border-color: var(--editor-info-hover); }
#editorModal .btn-success { background-color: var(--editor-success); border-color: var(--editor-success); color: white; }
#editorModal .btn-success:hover { background-color: var(--editor-success-hover); border-color: var(--editor-success-hover); }
#editorModal .editor-panel-footer { padding-top: 20px; margin-top: auto; border-top: 1px solid var(--editor-border-color); display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
#editorModal .btn-action { background: none; border: none; color: var(--editor-text-light); opacity: 0.6; cursor: pointer; padding: 2px 4px; transition: opacity 0.2s, color 0.2s; }
#editorModal .btn-action:hover { opacity: 1; color: var(--editor-primary); }
#editorModal .btn-action.play:hover { color: var(--editor-success); }
#editorModal .btn-action i { pointer-events: none; }
#editorModal th i.fas { margin-left: 8px; color: var(--editor-text-light); }
#editorModal th:hover i.fas { color: var(--editor-text-dark); }
#editorModal th .fa-sort-up, #editorModal th .fa-sort-down { color: var(--editor-primary); }
#multiEditModal .multi-edit-field { background-color: var(--bg-element); padding: 15px; border-radius: var(--editor-border-radius); border: 1px solid var(--editor-border-color); margin-bottom: 15px; }
#multiEditModal .multi-edit-field .form-check { margin-bottom: 10px; }
#multiEditModal .multi-edit-field .form-control:disabled, #multiEditModal .multi-edit-field .form-select:disabled { background-color: var(--bg-secondary); opacity: 0.5; }
body.editor-is-dragging { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; cursor: grabbing; }
.sortable-ghost { opacity: 0.4; background-color: var(--accent-primary-transparent); }
.sortable-fallback { display: none; }

33
css/epg_modal.css Normal file
View File

@ -0,0 +1,33 @@
#epgModal .modal-content, #movistarVODModal .modal-content { background-color: var(--bg-primary); }
#epgModal .modal-body { padding: 1rem; height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; }
#epgModal .input-group .form-control { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); }
.epg-timeline { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; background-color: var(--bg-secondary); border-radius: var(--radius-md); border: 1px solid var(--border-color); }
.epg-timeline-header { height: 50px; background-color: rgba(var(--rgb-bg-secondary, 22, 27, 34), 0.7); border-bottom: 1px solid var(--border-color); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); z-index: 2; display: flex; overflow-x: auto; overflow-y: hidden; flex-shrink: 0; }
.epg-timebar { display: flex; height: 100%; position: relative; min-width: 100%; }
.epg-time-slot { min-width: 100px; text-align: center; line-height: 50px; font-size: 0.85rem; color: var(--text-secondary); border-right: 1px solid var(--border-color); flex-shrink: 0; padding: 0 10px; white-space: nowrap; font-weight: 500; }
.epg-timeline-body { display: flex; flex: 1; overflow: hidden; position: relative; }
.epg-channels { width: 230px; overflow-y: auto; overflow-x: hidden; background-color: rgba(var(--rgb-bg-tertiary), 0.5); flex-shrink: 0; border-right: 1px solid var(--border-color); }
.epg-channel-item { padding: 8px 12px; height: 60px; display: flex; align-items: center; border-bottom: 1px solid var(--border-color); cursor: pointer; transition: var(--transition-fast); font-size: 0.9rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; position: relative; }
.epg-channel-item img { width: 40px; height: 40px; margin-right: 10px; object-fit: contain; flex-shrink: 0; background-color: rgba(255, 255, 255, 0.05); border-radius: var(--radius-sm); transition: transform 0.2s ease; }
.epg-channel-item .epg-icon-placeholder { width: 40px; height: 40px; margin-right: 10px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; background-color: rgba(255, 255, 255, 0.03); border-radius: var(--radius-sm); color: var(--text-tertiary); font-size: 1.3rem; transition: transform 0.2s ease; }
.epg-channel-item .epg-icon-placeholder::before { content: "\1F4FA"; font-family: sans-serif; }
.epg-channel-item:hover { background-color: var(--bg-hover); }
.epg-channel-item:hover img, .epg-channel-item:hover .epg-icon-placeholder { transform: scale(1.08); }
.epg-channel-item.active { background-color: var(--accent-primary); color: #fff; }
.epg-channel-item .play-channel-epg-btn { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); color: white; border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 1rem; line-height: 1; display: none; place-items: center; opacity: 0.9; transition: var(--transition-fast), transform 0.15s ease; box-shadow: 0 1px 3px var(--shadow-color); }
.play-channel-epg-btn::before { content: "\25B6"; font-family: sans-serif; }
.epg-channel-item:hover .play-channel-epg-btn { display: grid; }
.play-channel-epg-btn:hover { opacity: 1; transform: translateY(-50%) scale(1.1); box-shadow: 0 0 8px var(--accent-primary); }
.epg-programs-container { flex: 1; overflow: auto; position: relative; }
.epg-programs { position: relative; display: block; background-image: repeating-linear-gradient(to right, transparent, transparent calc(var(--pixelsPerHour, 220px) - 1px), var(--border-color) calc(var(--pixelsPerHour, 220px) - 1px), var(--border-color) var(--pixelsPerHour, 220px)), repeating-linear-gradient(to bottom, transparent, transparent 59px, var(--border-color) 59px, var(--border-color) 60px); background-size: var(--pixelsPerHour, 220px) 60px; }
.epg-program-row { height: 60px; position: relative; }
.epg-program-item { position: absolute; height: calc(100% - 6px); top: 3px; background-color: var(--bg-element); border: 1px solid var(--border-color); border-left: 4px solid var(--accent-secondary); border-radius: var(--radius-sm); padding: 6px 10px; cursor: pointer; transition: var(--transition-fast), transform 0.1s ease-out, box-shadow 0.15s ease-out; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 0.8rem; color: var(--text-primary); z-index: 1; display: flex; align-items: center; will-change: transform, box-shadow; }
.epg-program-item:hover { background-color: var(--bg-element-hover); border-left-color: var(--accent-primary); z-index: 2; box-shadow: 0 3px 10px color-mix(in srgb, var(--accent-primary) 25%, transparent); transform: scale(1.02); }
.epg-program-item.current { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); border-color: transparent; border-left: 4px solid #fff; color: #fff; font-weight: 600; box-shadow: 0 0 15px color-mix(in srgb, var(--accent-primary) 40%, transparent); z-index: 3; }
.epg-current-time-line { position: absolute; top: 0; height: 100%; width: 3px; background-color: var(--danger); box-shadow: 0 0 8px var(--danger); z-index: 4; pointer-events: none; transition: transform 1s linear; opacity: 1; }
.epg-program-modal .modal-content { background-color: var(--bg-element); }
.epg-program-modal .modal-body { padding: 1.5rem; }
.epg-program-modal img { max-width: 150px; border-radius: var(--radius-sm); margin-bottom: 1rem; float: left; margin-right: 1.5rem; border: 1px solid var(--border-color); background-color: var(--bg-secondary); }
.epg-program-modal h5 { font-family: var(--font-heading); color: var(--accent-primary); margin-bottom: 1rem; }
.epg-program-modal p { color: var(--text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem; }
.epg-program-modal p strong { color: var(--text-primary); font-weight: 500; }

8
css/generic_modals.css Normal file
View File

@ -0,0 +1,8 @@
#loadFromDBModal .list-group-item { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); margin-bottom: 0.5rem; border-radius: var(--radius-md); padding: 0.8rem 1.2rem; transition: background-color var(--transition-fast); }
#loadFromDBModal .list-group-item:hover { background-color: var(--bg-element-hover); }
.delete-file-btn { background: transparent !important; border: 1px solid var(--danger) !important; color: var(--danger) !important; opacity: 0.7; border-radius: 50% !important; width: 32px; height: 32px; font-size: 0.9rem !important; padding: 0 !important; transition: background-color 0.15s ease, opacity 0.15s ease, color 0.15s ease; }
.delete-file-btn::before { content: "\1F5D1"; font-family: sans-serif; }
.delete-file-btn:hover { background: rgba(220, 53, 69, 0.15) !important; opacity: 1; color: var(--danger) !important; }
#daznTokenModal.modal {
z-index: 2001;
}

39
css/header.css Normal file
View File

@ -0,0 +1,39 @@
.sidebar-toggle-btn { background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; margin-right: 1rem; padding: 0.5rem; cursor: pointer; }
.sidebar-toggle-btn::before { content: "\2630"; font-family: sans-serif; }
.sidebar-toggle-btn:hover { color: var(--text-primary); }
.header-search-bar { position: relative; max-width: 350px; flex-grow: 1; }
.header-search-input { width: 100%; padding: 0.6rem 1rem 0.6rem 2.8rem; border-radius: var(--radius-md); border: 1px solid var(--border-color); background-color: var(--bg-element); color: var(--text-primary); font-size: 0.9rem; transition: var(--transition-smooth); }
.header-search-input:focus { outline: none; border-color: var(--accent-primary); background-color: var(--bg-element-hover); box-shadow: 0 0 0 3px var(--accent-primary-transparent); }
.header-search-icon { position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text-tertiary); font-size: 0.9rem; }
.header-search-icon::before { content: "\1F50D"; font-family: sans-serif; }
.header-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.header-actions .btn-header-action { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 0.5rem 0.8rem; border-radius: var(--radius-md); font-size: 0.85rem; transition: var(--transition-fast); display: inline-flex; align-items: center; gap: 0.4rem; white-space: nowrap; }
.header-actions .btn-header-action:hover { background-color: var(--bg-element-hover); color: var(--text-primary); border-color: var(--border-color-strong); transform: translateY(-1px); }
.header-actions .btn-header-action:active { transform: translateY(0px) scale(0.98); }
.header-actions .btn-header-action .icon-placeholder { font-size: 1em; font-family: sans-serif; line-height: 1; }
.header-actions .btn-header-action .btn-text { display: none; }
#openEpgModalBtn .icon-placeholder::before { content: "\1F4C5"; }
#openSettingsModalBtn .icon-placeholder::before { content: "\2699"; }
#openXtreamModalBtn .icon-placeholder::before { content: "🚀"; }
.dropdown-item .icon-placeholder { font-size: 0.9em; width: 1.2em; text-align: center; display: inline-block;}
.xtream-info-bar {
background-color: var(--bg-secondary);
color: var(--text-secondary);
padding: 0.5rem 1rem;
font-size: 0.8rem;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
border-bottom: 1px solid var(--border-color);
}
.xtream-info-bar span {
display: flex;
align-items: center;
gap: 0.4rem;
}
.xtream-info-bar .fas {
color: var(--accent-primary);
opacity: 0.8;
}

55
css/layout.css Normal file
View File

@ -0,0 +1,55 @@
#app-container {
display: flex;
min-height: 100vh;
}
#sidebar {
width: var(--sidebar-width);
background-color: var(--bg-tertiary);
border-right: 1px solid var(--border-color);
padding: 1rem 1rem var(--taskbar-height) 1rem;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 1040;
overflow-y: auto;
transition: transform var(--transition-smooth), box-shadow var(--transition-smooth);
transform: translateX(0);
}
#sidebar.collapsed {
transform: translateX(calc(-1 * var(--sidebar-width)));
box-shadow: none;
}
#sidebar:not(.collapsed) {
box-shadow: 5px 0 15px var(--shadow-color);
}
#main-content-wrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
transition: margin-left var(--transition-smooth);
margin-left: var(--sidebar-width);
}
#app-container.sidebar-collapsed #main-content-wrapper {
margin-left: 0;
}
#top-header {
height: var(--header-height);
background-color: rgba(var(--rgb-bg-tertiary), 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
padding: 0 1rem;
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 1030;
box-shadow: 0 2px 10px var(--shadow-color);
}
#main-content {
padding: 1.5rem;
flex-grow: 1;
overflow-y: auto;
padding-bottom: var(--taskbar-height);
}

9
css/modals_general.css Normal file
View File

@ -0,0 +1,9 @@
.modal.fade .modal-dialog { transition: transform .3s ease-out; transform: translateY(-50px); }
.modal.show .modal-dialog { transform: translateY(0); }
.modal-content { background-color: var(--bg-secondary); border-radius: var(--radius-lg); border: 1px solid var(--border-color); box-shadow: 0 10px 30px var(--shadow-color); color: var(--text-primary); }
.modal-header { border-bottom: 1px solid var(--border-color); padding: 1rem 1.5rem; }
.modal-header .modal-title { font-family: var(--font-heading); font-size: 1.4rem; color: var(--text-primary); }
.modal-header .btn-close { filter: invert(0.8) brightness(1.2); opacity: 0.8; transition: opacity 0.2s; }
.modal-header .btn-close:hover { opacity: 1; }
.modal-body { padding: 1.5rem; }
.modal-footer { border-top: 1px solid var(--border-color); padding: 1rem 1.5rem; background-color: var(--bg-tertiary); border-bottom-left-radius: var(--radius-lg); border-bottom-right-radius: var(--radius-lg); }

View File

@ -0,0 +1,25 @@
#movistarVODModal .modal-body { padding: 1rem; height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; }
#movistarVODModal .form-control { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); }
#movistarVODModal-programs { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; padding-top:1rem; }
.movistar-vod-card { background-color: var(--bg-element); border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-color); box-shadow: 0 2px 4px var(--shadow-color); cursor: pointer; display:flex; flex-direction:column; }
.movistar-vod-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 8px var(--accent-primary-transparent); transform: translateY(-2px); }
.movistar-vod-card-img-container { width:100%; aspect-ratio: 16/10; background-color:var(--bg-secondary); overflow:hidden; display:flex; align-items:center; justify-content:center;}
.movistar-vod-card-img-container img { width:100%; height:100%; object-fit:cover;}
.movistar-vod-card-body { padding: 0.75rem; flex-grow:1; }
.movistar-vod-card-title { font-size: 0.95rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.movistar-vod-card-channel, .movistar-vod-card-time, .movistar-vod-card-genre { font-size: 0.75rem; color: var(--text-secondary); margin-bottom:0.1rem; }
#movistarVODModal-filters-container { display:flex; gap:1rem; margin-bottom:1rem; }
#movistarVODModal-filters-container .form-select, #movistarVODModal-filters-container .form-control {font-size:0.85rem;}
#movistarVODModal-channel-filter {flex-basis: 30%;}
#movistarVODModal-genre-filter {flex-basis: 30%;}
#movistarVODModal-search-input {flex-basis: 40%;}
#movistarVODModal-no-results { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 1.1rem; }
#movistarVODModal-pagination-controls { }
#movistarVODProgramDetailsModal .modal-content { background-color: var(--bg-element); }
#movistarVODProgramDetailsModal .modal-body { padding: 1.5rem; max-height: 70vh; overflow-y: auto; }
#movistarVODProgramDetailsModal img { max-width: 100%; height: auto; max-height: 300px; border-radius: var(--radius-sm); margin-bottom: 1rem; border: 1px solid var(--border-color); background-color: var(--bg-secondary); }
#movistarVODProgramDetailsModal h5 { font-family: var(--font-heading); color: var(--accent-primary); margin-bottom: 1rem; font-size: 1.2rem;}
#movistarVODProgramDetailsModal p { color: var(--text-secondary); margin-bottom: 0.5rem; font-size: 0.9rem; }
#movistarVODProgramDetailsModal p strong { color: var(--text-primary); font-weight: 500; }
#movistarVODProgramDetailsModal .btn-control .icon-placeholder { margin-right: 0.3rem; }

363
css/player_modal.css Normal file
View File

@ -0,0 +1,363 @@
.player-window {
position: fixed;
top: 50px;
left: 50px;
width: 60vw;
height: 70vh;
max-width: 1000px;
min-width: 400px;
min-height: 300px;
background-color: rgba(0, 0, 0, var(--player-window-opacity, 1));
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: 0 8px 30px var(--shadow-color);
z-index: 1950;
display: flex;
flex-direction: column;
pointer-events: auto;
resize: none;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.3s ease-in-out;
}
.player-window.active {
border-color: var(--accent-primary);
box-shadow: 0 0 20px var(--accent-primary-transparent);
}
.modal-header-draggable {
cursor: move;
}
.player-window .modal-header {
border-bottom: 1px solid var(--border-color);
padding: 0.5rem 1rem;
background: rgba(var(--rgb-bg-tertiary), calc(0.8 * var(--player-window-opacity, 1)));
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.player-window .modal-header .player-window-title {
font-size: 1rem;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
margin-right: 1rem;
}
.player-window .modal-header .player-window-controls {
display: flex;
gap: 0.5rem;
}
.btn-window-control {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
padding: 0.2rem;
line-height: 1;
transition: color 0.2s, transform 0.2s;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-window-control:hover {
color: var(--text-primary);
transform: scale(1.1);
}
.player-window-minimize-btn::before { content: "\2014"; }
.player-window-close-btn::before { content: "\00D7"; }
.player-window .modal-body {
padding: 0;
flex-grow: 1;
display: flex;
overflow: hidden;
position: relative;
background: transparent;
}
.player-window .player-container {
width: 100%;
height: 100%;
background-color: transparent;
position: relative;
flex-grow: 1;
display: flex;
overflow: hidden;
}
.player-window .player-video {
width: 100%;
height: 100%;
flex-grow: 1;
}
.resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: se-resize;
z-index: 100;
}
.resize-handle::after {
content: '';
position: absolute;
bottom: 2px;
right: 2px;
width: 8px;
height: 8px;
border-bottom: 2px solid rgba(255,255,255,0.4);
border-right: 2px solid rgba(255,255,255,0.4);
}
#player-taskbar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: var(--taskbar-height);
background-color: rgba(var(--rgb-bg-tertiary), 0.9);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border-color);
z-index: 2000;
display: flex;
align-items: center;
padding: 0 0.75rem;
gap: 0.75rem;
overflow-x: auto;
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
}
.taskbar-item {
background-color: var(--bg-element);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition-smooth);
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.taskbar-item-icon-container {
width: 24px;
height: 24px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background-color: rgba(255,255,255,0.05);
overflow: hidden;
}
.taskbar-item-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.taskbar-item-logo-placeholder {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.taskbar-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.taskbar-item:hover {
background-color: var(--bg-element-hover);
color: var(--text-primary);
border-color: var(--border-color-strong);
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow-color);
}
.taskbar-item.active {
color: var(--text-primary);
font-weight: 500;
border-color: var(--accent-primary);
background-color: color-mix(in srgb, var(--accent-primary) 20%, var(--bg-element));
box-shadow: 0 0 12px color-mix(in srgb, var(--accent-primary) 50%, transparent),
inset 0 0 15px color-mix(in srgb, var(--accent-primary) 15%, transparent);
transform: translateY(-2px) scale(1.02);
}
video::-webkit-media-controls { display: none !important; }
video::-webkit-media-controls-enclosure { display: none !important; }
video::-webkit-media-controls-panel { display: none !important; -webkit-appearance: none; }
video::-moz-media-controls { display: none !important; }
.player-infobar { position: absolute; bottom: 15px; left: 20px; right: 20px; background-color: rgba(var(--rgb-bg-tertiary), 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: var(--radius-lg); padding: 1rem 1.5rem; display: flex; align-items: center; gap: 1.25rem; z-index: 100; opacity: 0; transform: translateY(30px) scale(0.95); transition: opacity 0.3s ease-out, transform 0.3s ease-out; pointer-events: none; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
.player-infobar.show { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.infobar-logo { height: 60px; width: auto; max-width: 110px; object-fit: contain; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
.infobar-details { flex-grow: 1; min-width: 0; }
.infobar-channel-name { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin: 0 0 0.25rem 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 1px 1px 3px rgba(0,0,0,0.4); }
.infobar-epg-current { font-size: 1.05rem; font-weight: 500; color: var(--accent-primary); margin-bottom: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 1px 1px 2px rgba(0,0,0,0.3); }
.infobar-epg-next { font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.9; }
.infobar-epg-progress-container { width: 100%; height: 6px; background-color: rgba(255, 255, 255, 0.15); border-radius: 3px; margin-top: 0.6rem; overflow: hidden; }
.infobar-epg-progress { height: 100%; background-color: var(--accent-primary); border-radius: 3px; width: 0%; transition: width 1s linear; box-shadow: 0 0 8px var(--accent-primary-transparent); }
.infobar-time { font-size: 2.2rem; font-weight: 700; color: var(--text-primary); flex-shrink: 0; text-shadow: 1px 1px 4px rgba(0,0,0,0.5); }
.shaka-spinner-path { stroke: var(--accent-primary) !important; stroke-width: 5px !important; }
.shaka-bottom-controls { background: none !important; border-top: none !important; padding: 10px 15px !important; transition: opacity 0.25s ease-in-out !important; }
.shaka-controls-button-panel button>i.material-icons-round { font-size: 26px !important; color: var(--text-primary) !important; opacity: 1 !important; transition: color 0.15s ease, opacity 0.15s ease, transform 0.1s ease; text-shadow: 0px 0px 6px rgba(0, 0, 0, 0.9), 0px 0px 10px rgba(0, 0, 0, 0.8) !important; }
.shaka-controls-button-panel button:hover>i.material-icons-round { opacity: 1 !important; color: var(--accent-primary) !important; transform: scale(1.1); }
.shaka-current-time, .shaka-time-divider, .shaka-duration { color: var(--text-primary) !important; opacity: 0.95 !important; text-shadow: 1px 1px 5px rgba(0, 0, 0, 0.95) !important; font-weight: 500; font-size: 0.9rem; }
input[type=range].shaka-volume-bar, input[type=range].shaka-seek-bar { background: transparent !important; border: none !important; height: 10px !important; }
input[type=range].shaka-volume-bar::-webkit-slider-runnable-track, input[type=range].shaka-seek-bar::-webkit-slider-runnable-track { height: 10px !important; background: transparent !important; }
input[type=range].shaka-volume-bar::-webkit-slider-thumb, input[type=range].shaka-seek-bar::-webkit-slider-thumb { background: var(--accent-primary) !important; border: 2px solid rgba(0,0,0,0.4) !important; box-shadow: 0 0 6px var(--accent-primary), 0 0 4px rgba(0,0,0,0.7); height: 18px !important; width: 18px !important; margin-top: -4px !important; }
input[type=range].shaka-seek-bar { -webkit-appearance: none; appearance: none; width: 100%; height: 10px; cursor: pointer; position: relative; }
.shaka-seek-bar-container { --shaka-bar-color: var(--accent-primary) !important; --shaka-buffer-bar-color: rgba(200, 200, 200, 0.5) !important; --shaka-bg-color: transparent !important; height: 10px; }
.shaka-range-container > div[второй-child], .shaka-seek-bar-container > div:not(.shaka-buffer-bar) { background: transparent !important; }
.shaka-overflow-menu, .shaka-settings-menu { background-color: rgba(var(--rgb-bg-secondary, 22, 27, 34), 0.98) !important; border: 1px solid var(--border-color-strong) !important; border-radius: var(--radius-md) !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important; box-shadow: 0 5px 15px rgba(0,0,0,0.3); }
.shaka-overflow-menu button, .shaka-settings-menu button { color: var(--text-primary) !important; padding: 12px 18px !important; font-size: 0.95rem !important; transition: background-color 0.15s ease, color 0.15s ease !important; }
.shaka-overflow-menu button:hover, .shaka-settings-menu button:hover, .shaka-overflow-menu button[aria-pressed="true"], .shaka-settings-menu button[aria-pressed="true"] { background-color: var(--accent-primary) !important; color: #fff !important; }
.shaka-text-input { background-color: var(--bg-element) !important; color: var(--text-primary) !important; border: 1px solid var(--border-color) !important; }
.player-channel-list-panel {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 280px;
background-color: var(--bg-secondary);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-right: 1px solid var(--border-color);
z-index: 15;
transform: translateX(-100%);
transition: transform var(--transition-smooth), box-shadow var(--transition-smooth);
display: flex;
flex-direction: column;
box-shadow: 2px 0 10px rgba(0,0,0,0.4);
}
.player-channel-list-panel.open {
transform: translateX(0);
box-shadow: 2px 0 15px rgba(0,0,0,0.5);
}
.player-channel-list-header {
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
background-color: var(--bg-hover);
}
.player-channel-list-content {
flex-grow: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.player-channel-list-content::-webkit-scrollbar { width: 6px; height: 6px; }
.player-channel-list-content::-webkit-scrollbar-track { background: transparent; }
.player-channel-list-content::-webkit-scrollbar-thumb { background-color: var(--accent-primary); border-radius: var(--radius-sm); border: 1px solid var(--bg-secondary); }
.player-channel-list-group-header {
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: bold;
color: var(--accent-primary);
background-color: var(--bg-element);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-top: 0.5rem;
}
.player-channel-list-group-header:first-child {
margin-top: 0;
border-top: none;
}
.player-channel-list-item {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--border-color-strong);
}
.player-channel-list-item:last-child {
border-bottom: none;
}
.player-channel-list-item:hover, .player-channel-list-item.active {
background-color: var(--bg-hover);
}
.player-channel-list-item.active {
background-color: color-mix(in srgb, var(--accent-primary) 20%, transparent);
border-left: 3px solid var(--accent-primary);
padding-left: calc(1rem - 3px);
}
.player-channel-list-logo {
width: 40px;
height: 30px;
object-fit: contain;
margin-right: 0.75rem;
flex-shrink: 0;
border-radius: var(--radius-sm);
background-color: rgba(255,255,255,0.05);
}
.player-channel-list-logo-placeholder {
width: 40px;
height: 30px;
margin-right: 0.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: var(--text-tertiary);
background-color: rgba(255,255,255,0.03);
border-radius: var(--radius-sm);
}
.player-channel-list-logo-placeholder::before {
content: "\1F4FA";
font-family: sans-serif;
}
.player-channel-list-info {
display: flex;
flex-direction: column;
overflow: hidden;
flex-grow: 1;
}
.player-channel-list-name {
font-size: 0.85rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.player-channel-list-epg {
font-size: 0.7rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.1rem;
}
.player-channel-list-epg.no-epg {
opacity: 0.6;
font-style: italic;
}
.shaka-channel-list-button > i.material-icons-round {
font-size: 24px !important;
}

71
css/responsive.css Normal file
View File

@ -0,0 +1,71 @@
@media (min-width: 768px) {
.header-actions .btn-header-action .btn-text { display: inline; margin-left: 0.25rem; }
.header-actions .btn-header-action { padding: 0.5rem 1rem; }
}
@media (max-width: 992px) { /* Adjusted from 991.98px for consistency */
:root { --sidebar-width: 240px; --header-height: 60px; --m3u-grid-minmax-size: 160px; }
#sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); box-shadow: none; }
#sidebar.expanded { transform: translateX(0); box-shadow: 5px 0 15px var(--shadow-color); }
#main-content-wrapper { margin-left: 0; }
.sidebar-toggle-btn { display: inline-flex; }
.header-search-bar { max-width: 250px; }
.header-actions .btn-header-action { padding: 0.5rem 0.7rem; gap: 0.3rem; }
.epg-channels { width: 200px; }
#playerModal .modal-header .modal-title { font-size: 1rem; }
#playerModal .modal-header .current-program-player { font-size: 0.75rem; }
#settingsModal .nav-pills.flex-column { flex-direction: row !important; flex-wrap: wrap; }
#settingsModal .nav-pills .nav-link { flex-grow: 1; text-align: center; }
#settingsModal .tab-content { padding-left: 0; border-left: none; margin-top: 1rem; }
}
@media (max-width: 767.98px) {
:root { --header-height: 55px; --m3u-grid-minmax-size: 140px; }
#top-header { padding: 0 0.75rem; }
#main-content { padding: 1rem; }
.m3u-grid { gap: 1rem; }
.channel-name { font-size: 0.85rem; }
.channel-epg-info { font-size: 0.65rem; }
#player-container { height: 60vh; }
#playerModal .modal-dialog { margin: 0.5rem; }
.epg-channels { width: 180px; }
.epg-channel-item { height: 55px; padding: 6px 10px; font-size: 0.85rem; }
.epg-channel-item img, .epg-channel-item .epg-icon-placeholder { width: 35px; height: 35px; margin-right: 8px; }
.epg-programs { background-size: var(--pixelsPerHour, 180px) 55px; }
.epg-program-row { height: 55px; }
#settingsModal .modal-dialog { max-width: 95%; }
#settingsModal .nav-pills .nav-link { padding: 0.5rem 0.75rem; font-size: 0.85rem; }
.header-search-bar { display: none; }
.header-actions { gap: 0.2rem; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; }
.header-actions .btn-header-action { padding: 0.4rem 0.6rem; }
.header-actions .btn-header-action .btn-text { display: none; }
#movistarVODModal-filters-container { flex-direction:column; }
}
@media (max-width: 576px) {
:root { --m3u-grid-minmax-size: 120px; }
#playerModal .modal-dialog { max-width: none; margin: 0; height: 100%; }
#playerModal .modal-content { height: 100%; border-radius: 0; }
#player-container { height: calc(100vh - var(--header-height) - 2px); min-height: 250px; }
.m3u-grid { gap: 0.75rem; }
.channel-card { border-radius: var(--radius-sm); }
.channel-logo-container { aspect-ratio: 4/3; }
.channel-info { padding: 0.6rem; }
.channel-name { font-size: 0.8rem; }
.favorite-btn { width: 26px; height: 26px; font-size: 0.8rem; }
.m3u-load-area { padding: 1rem; }
.m3u-load-area .row>div:not(:last-child) { margin-bottom: 0.75rem; }
.filter-tabs-container { gap: 0.2rem; overflow-x: auto; padding-bottom: 0.1rem; }
.filter-tab-btn { padding: 0.5rem 0.75rem; font-size: 0.9rem; white-space: nowrap; }
.filter-tab-btn.active::after { bottom: 0px; }
.epg-channels { width: 140px; }
.epg-channel-item { height: 50px; padding: 5px 8px; font-size: 0.8rem; }
.epg-channel-item img, .epg-channel-item .epg-icon-placeholder { width: 30px; height: 30px; }
.epg-time-slot { min-width: 80px; font-size: 0.8rem; }
.epg-programs { background-size: var(--pixelsPerHour, 160px) 50px; }
.epg-program-row { height: 50px; }
.epg-program-item { font-size: 0.75rem; padding: 5px 8px; }
#epgProgramModal .modal-body img { float: none; display: block; margin: 0 auto 1rem auto; }
#settingsModal .modal-dialog { max-width: 100%; margin: 0.5rem; }
#movistarVODModal-programs { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}

64
css/settings_modal.css Normal file
View File

@ -0,0 +1,64 @@
#settingsModalLabel .icon-placeholder::before, #settingsModal .settings-group-title .icon-placeholder::before, #settingsModal .nav-link .icon-placeholder::before { content: ""; margin-right: 0.5rem; font-family: sans-serif; font-size: 0.9em; opacity: 0.8; display: inline-block; width: 1.2em; text-align: center; }
#settingsModalLabel .icon-placeholder::before { content: "\2699"; }
#generalUISettingsTab .icon-placeholder::before { content: "\1F3A8"; }
#shakaPlayerSettingsTab .icon-placeholder::before { content: "\1F3A5"; }
#shakaNetworkSettingsTab .icon-placeholder::before { content: "\1F4E1"; }
#epgSettingsTab .icon-placeholder::before { content: "\1F4C5"; }
#xcodecSettingsTab .icon-placeholder::before { content: "⚙️"; font-style: normal; }
#barTvSettingsTab .icon-placeholder::before { content: "🍺"; font-style: normal; }
#orangeTvSettingsTab .icon-placeholder::before { content: "🍊"; font-style: normal; }
#globalNetworkSettingsTab .icon-placeholder::before { content: "\1F310"; }
#daznSettingsTab .icon-placeholder::before { content: "📺"; }
#movistarSettingsTab .icon-placeholder::before { content: "Ⓜ️"; font-style: normal;}
#appDataManagementTab .icon-placeholder::before { content: "\1F4BE"; }
#sendM3uToServerTab .icon-placeholder::before { content: "📡"; }
#settingsModal .form-check-input { background-color: var(--bg-element); border-color: var(--border-color-strong); }
#settingsModal .form-check-input:checked { background-color: var(--accent-primary); border-color: var(--accent-secondary); }
#settingsModal .form-check-input:focus { box-shadow: 0 0 0 0.25rem var(--accent-primary-transparent); }
#settingsModal .form-check-label { margin-left: 0.5em; font-size: 0.9rem; }
.form-switch .form-check-input { width: 2.2em; height: 1.2em; margin-top: 0.2em; }
#settingsModal .form-range { height: 1.3rem; padding: 0; }
#settingsModal .form-range:focus { box-shadow: none; }
#settingsModal .form-range::-webkit-slider-thumb { width: 1.3rem; height: 1.3rem; margin-top: -0.4rem; background-color: var(--accent-primary); border-radius: 50%; border: none; transition: background-color 0.15s ease-in-out; }
#settingsModal .form-range::-webkit-slider-thumb:hover { background-color: var(--accent-hover); }
#settingsModal .form-range::-moz-range-thumb { width: 1.3rem; height: 1.3rem; background-color: var(--accent-primary); border-radius: 50%; border: none; transition: background-color 0.15s ease-in-out; }
#settingsModal .form-range::-moz-range-thumb:hover { background-color: var(--accent-hover); }
#settingsModal .settings-group-title { font-family: var(--font-heading); color: var(--accent-primary); border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; margin-bottom: 1rem; margin-top: 1.5rem; }
#settingsModal .tab-pane > .settings-group-title:first-of-type { margin-top: 0; }
#settingsModal .modal-dialog { max-width: 1100px; }
#settingsModal .nav-pills .nav-link { color: var(--text-secondary); border-radius: var(--radius-md); margin-bottom: 0.25rem; text-align: left; padding: 0.6rem 1rem; }
#settingsModal .nav-pills .nav-link:hover { background-color: var(--bg-element-hover); color: var(--text-primary); }
#settingsModal .nav-pills .nav-link.active { background-color: var(--accent-primary); color: white; font-weight: 600; }
#settingsModal .tab-content { padding-left: 1.5rem; border-left: 1px solid var(--border-color); height: 100%; }
#movistarLongTokensTableBodySettings td, #movistarLongTokensTableBodySettings th {
padding: 0.4rem 0.5rem;
font-size: 0.75rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#movistarLongTokensTableBodySettings td:first-child, #movistarLongTokensTableBodySettings th:first-child { max-width: 120px; }
#movistarLongTokensTableBodySettings td:nth-child(2), #movistarLongTokensTableBodySettings th:nth-child(2) { max-width: 100px; }
#movistarLongTokensTableBodySettings td:nth-child(3), #movistarLongTokensTableBodySettings th:nth-child(3) { max-width: 80px; }
#movistarLongTokensTableBodySettings td:nth-child(4), #movistarLongTokensTableBodySettings th:nth-child(4) { max-width: 130px; }
#movistarLongTokensTableBodySettings td:nth-child(5), #movistarLongTokensTableBodySettings th:nth-child(5) { max-width: 100px; text-align: center;}
#movistarLongTokensTableBodySettings td:last-child, #movistarLongTokensTableBodySettings th:last-child { width: 90px; text-align: center;}
#movistarDevicesListForSettings .list-group-item {
padding: 0.5rem 0.75rem;
font-size: 0.8rem !important;
}
#movistarDevicesListForSettings .form-check-label { font-size: inherit !important; }
#orangeTvGroupSelectionContainer .form-check {
min-width: 150px;
flex-basis: auto;
}
#generatedPhpCode {
font-family: var(--font-monospace);
background-color: var(--bg-primary);
color: #cdd3de;
border-color: var(--border-color-strong);
font-size: 0.8rem;
white-space: pre;
word-wrap: normal;
overflow-x: auto;
}

8
css/sidebar.css Normal file
View File

@ -0,0 +1,8 @@
.sidebar-header { padding-bottom: 1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border-color); }
.sidebar-logo { font-family: var(--font-heading); font-weight: 700; font-size: 1.7rem; color: var(--accent-primary); text-decoration: none; }
.sidebar-logo:hover { color: var(--accent-hover); }
#group-filter-sidebar { width: 100%; margin-bottom: 1rem; } /* Keep with sidebar form elements or move to forms.css */
.group-list-sidebar { list-style: none; padding: 0; margin: 0; }
.group-list-sidebar .list-group-item { background-color: transparent; color: var(--text-secondary); border: none; padding: 0.6rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.9rem; transition: var(--transition-fast); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.group-list-sidebar .list-group-item:hover, .group-list-sidebar .list-group-item.active { background-color: var(--bg-element); color: var(--text-primary); }
.group-list-sidebar .list-group-item.active { font-weight: 600; color: var(--accent-primary); }

74
css/xtream_modal.css Normal file
View File

@ -0,0 +1,74 @@
#xtreamConnectionModal .list-group-item { background-color: var(--bg-element); border: 1px solid var(--border-color); color: var(--text-primary); margin-bottom: 0.5rem; border-radius: var(--radius-md); padding: 0.7rem 1rem; font-size: 0.9rem; transition: background-color var(--transition-fast); cursor: pointer; }
#xtreamConnectionModal .list-group-item:hover { background-color: var(--bg-element-hover); }
#xtreamConnectionModal .list-group-item strong { color: var(--text-primary); font-weight: 500; }
#xtreamConnectionModal .list-group-item small { font-size: 0.8rem; }
.delete-xtream-server-btn { background: transparent !important; border: 1px solid var(--danger) !important; color: var(--danger) !important; opacity: 0.7; border-radius: 50% !important; width: 32px; height: 32px; font-size: 0.9rem !important; padding: 0 !important; transition: background-color 0.15s ease, opacity 0.15s ease, color 0.15s ease; }
.delete-xtream-server-btn::before { content: "\1F5D1"; font-family: sans-serif; }
.delete-xtream-server-btn:hover { background: rgba(220, 53, 69, 0.15) !important; opacity: 1; color: var(--danger) !important; }
#xtreamGroupSelectionModal .xtream-group-list-container {
max-height: 55vh;
overflow-y: auto;
padding: 0.5rem;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
#xtreamGroupSelectionModal .list-group-item {
background-color: var(--bg-element);
border-color: var(--border-color);
color: var(--text-primary);
padding: 0.5rem 1rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--border-color);
}
#xtreamGroupSelectionModal .list-group-item:last-child {
border-bottom: none;
}
#xtreamGroupSelectionModal .form-check-label {
cursor: pointer;
}
#manageXCodecPanelsModal .list-group-item, #xcodecPreviewModal .list-group-item {
background-color: var(--bg-element);
border: 1px solid var(--border-color);
color: var(--text-primary);
margin-bottom: 0.5rem;
border-radius: var(--radius-md);
padding: 0.7rem 1rem;
font-size: 0.9rem;
transition: background-color var(--transition-fast);
}
#manageXCodecPanelsModal .list-group-item:hover, #xcodecPreviewModal .list-group-item:hover:not(.active) {
background-color: var(--bg-element-hover);
}
#manageXCodecPanelsModal .list-group-item strong, #xcodecPreviewModal .list-group-item strong {
color: var(--text-primary);
font-weight: 500;
}
#manageXCodecPanelsModal .list-group-item small, #xcodecPreviewModal .list-group-item small {
font-size: 0.8rem;
}
#manageXCodecPanelsModal .delete-xcodec-panel-btn, #manageXCodecPanelsModal .load-xcodec-panel-btn, #manageXCodecPanelsModal .process-xcodec-panel-direct-btn {
opacity: 0.8;
}
#manageXCodecPanelsModal .delete-xcodec-panel-btn:hover, #manageXCodecPanelsModal .load-xcodec-panel-btn:hover, #manageXCodecPanelsModal .process-xcodec-panel-direct-btn:hover {
opacity: 1;
}
#xcodecPreviewModal .list-group-item.active {
background-color: var(--accent-primary);
color: white;
border-color: var(--accent-secondary);
}
#xcodecPreviewModal .form-check-label {
cursor: pointer;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#xcodecPreviewModal .form-check-input {
margin-top: 0.3em;
}

372
dazn_handler.js Normal file
View File

@ -0,0 +1,372 @@
const DAZN_API_PLAYBACK = 'https://api.playback.indazn.com/v5/Playback';
const DAZN_API_REFRESH_TOKEN = 'https://ott-authz-bff-prod.ar.indazn.com/v5/RefreshAccessToken';
const DAZN_API_RAIL = 'https://rail-router.discovery.indazn.com/eu/v7/Rail';
const DAZN_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
let daznTokenPromiseResolver = null;
function decodeJwtPayload(token) {
if (!token || typeof token !== 'string') return null;
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payloadBase64 = parts[1];
if (!payloadBase64) return null;
const decoded = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded);
} catch (e) {
return null;
}
}
function showDaznTokenModal() {
return new Promise((resolve) => {
daznTokenPromiseResolver = resolve;
try {
const daznModalEl = document.getElementById('daznTokenModal');
if (!daznModalEl) {
resolve({ token: null, remember: false });
daznTokenPromiseResolver = null;
return;
}
const daznModal = bootstrap.Modal.getOrCreateInstance(daznModalEl, { backdrop: 'static', keyboard: false });
$('#daznTokenModalInput').val('');
$('#daznRememberTokenCheck').prop('checked', true);
daznModal.show();
} catch (e) {
resolve({ token: null, remember: false });
daznTokenPromiseResolver = null;
}
});
}
$(document).on('click', '#submitDaznTokenBtn', async () => {
const tokenInput = $('#daznTokenModalInput').val();
const rememberToken = $('#daznRememberTokenCheck').is(':checked');
const currentResolver = daznTokenPromiseResolver;
daznTokenPromiseResolver = null;
if (currentResolver) {
currentResolver({ token: tokenInput, remember: rememberToken });
}
const daznModalEl = document.getElementById('daznTokenModal');
if (daznModalEl) {
const daznModalInstance = bootstrap.Modal.getInstance(daznModalEl);
if (daznModalInstance) {
daznModalInstance.hide();
}
}
});
$(document).on('click', '#cancelDaznTokenBtn', () => {
const currentResolver = daznTokenPromiseResolver;
daznTokenPromiseResolver = null;
if (currentResolver) {
currentResolver({ token: null, remember: false });
}
const daznModalEl = document.getElementById('daznTokenModal');
if (daznModalEl) {
const daznModalInstance = bootstrap.Modal.getInstance(daznModalEl);
if (daznModalInstance) {
daznModalInstance.hide();
}
}
});
async function getDaznTokenFromUserInputOrSettings() {
let token = daznAuthTokenState;
if (!token) {
if (typeof showLoading === 'function') showLoading(true, 'Esperando token DAZN...');
const modalResult = await showDaznTokenModal();
if (typeof showLoading === 'function') showLoading(false);
const userInputToken = modalResult.token;
const remember = modalResult.remember;
if (userInputToken && userInputToken.trim() !== '') {
daznAuthTokenState = userInputToken.trim();
if (remember && typeof saveAppConfigValue === 'function' && typeof DAZN_TOKEN_DB_KEY !== 'undefined') {
try {
await saveAppConfigValue(DAZN_TOKEN_DB_KEY, daznAuthTokenState);
if (typeof showNotification === 'function') showNotification('DAZN: Token guardado.', 'success');
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
} catch (e) {
if(typeof showNotification === 'function') showNotification('DAZN: Error al guardar token en BD.', 'error');
}
} else if (!remember && typeof deleteAppConfigValue === 'function' && typeof DAZN_TOKEN_DB_KEY !== 'undefined') {
try {
const existingToken = await getAppConfigValue(DAZN_TOKEN_DB_KEY);
if (existingToken) {
await deleteAppConfigValue(DAZN_TOKEN_DB_KEY);
}
} catch (e) { console.warn("DAZN: Error revisando/borrando token de BD al no recordar", e); }
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
}
return daznAuthTokenState;
}
if (typeof showNotification === 'function' && !userInputToken) showNotification('DAZN: Operación de token cancelada o token vacío.', 'info');
return null;
}
return token;
}
async function refreshDaznToken(currentToken, specificUserAgent = null) {
if (!currentToken) return null;
if (typeof showLoading === 'function') showLoading(true, 'Refrescando token DAZN...');
const userAgentToUse = specificUserAgent || DAZN_USER_AGENT;
try {
const decoded = decodeJwtPayload(currentToken);
if (!decoded || !decoded.deviceId) {
if (typeof showNotification === 'function') showNotification('DAZN: Token inválido o no se pudo decodificar deviceId para refrescar.', 'error');
daznAuthTokenState = null;
if (typeof deleteAppConfigValue === 'function' && typeof DAZN_TOKEN_DB_KEY !== 'undefined') {
try { await deleteAppConfigValue(DAZN_TOKEN_DB_KEY); } catch (e) { }
}
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
return null;
}
const device_id_suffix = decoded.deviceId.split('-').pop();
if (!device_id_suffix) {
if (typeof showNotification === 'function') showNotification('DAZN: DeviceId con formato inesperado.', 'error');
return null;
}
const response = await fetch(DAZN_API_REFRESH_TOKEN, {
method: 'POST',
headers: {
'Accept': 'application/json, text/plain, */*',
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json',
'User-Agent': userAgentToUse
},
body: JSON.stringify({ DeviceId: device_id_suffix })
});
if (!response.ok) {
const errorText = await response.text().catch(() => `HTTP ${response.status}`);
if (response.status === 401 || response.status === 403) {
daznAuthTokenState = null;
if (typeof deleteAppConfigValue === 'function' && typeof DAZN_TOKEN_DB_KEY !== 'undefined') {
try { await deleteAppConfigValue(DAZN_TOKEN_DB_KEY); } catch (e) { }
}
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
if (typeof showNotification === 'function') showNotification('DAZN: Token inválido/expirado. Se solicitará uno nuevo al intentar la operación.', 'warning');
return null;
}
throw new Error(`Error ${response.status} al refrescar token: ${errorText.substring(0,100)}`);
}
const data = await response.json();
const newToken = data?.AuthToken?.Token;
if (newToken) {
daznAuthTokenState = newToken;
const storedTokenShouldBeRemembered = await getAppConfigValue(DAZN_TOKEN_DB_KEY);
if (storedTokenShouldBeRemembered && typeof saveAppConfigValue === 'function' && typeof DAZN_TOKEN_DB_KEY !== 'undefined') {
try {
await saveAppConfigValue(DAZN_TOKEN_DB_KEY, daznAuthTokenState);
if (typeof showNotification === 'function') showNotification('DAZN: Token refrescado y guardado exitosamente.', 'success');
} catch (e) {
if (typeof showNotification === 'function') showNotification('DAZN: Token refrescado, pero error al guardar en BD.', 'warning');
}
} else if (storedTokenShouldBeRemembered === null) {
if (typeof showNotification === 'function') showNotification('DAZN: Token refrescado (sesión actual).', 'info');
}
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
return newToken;
} else {
throw new Error('Respuesta de refresco de token no contenía un nuevo token.');
}
} catch (error) {
if (typeof showNotification === 'function') showNotification(`DAZN: Error refrescando token: ${error.message}`, 'error');
return null;
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function getActiveDaznToken(forceUserInputIfInvalid = false, specificUserAgent = null) {
let currentToken = daznAuthTokenState;
if (!currentToken) {
currentToken = await getDaznTokenFromUserInputOrSettings();
if (!currentToken) {
return null;
}
return currentToken;
}
const refreshedToken = await refreshDaznToken(currentToken, specificUserAgent);
if (refreshedToken) {
return refreshedToken;
} else {
if (forceUserInputIfInvalid) {
if (typeof showNotification === 'function') showNotification('DAZN: El token actual no pudo ser refrescado. Se solicitará uno nuevo.', 'warning');
daznAuthTokenState = null;
currentToken = await getDaznTokenFromUserInputOrSettings();
if (!currentToken) {
return null;
}
return currentToken;
} else {
if (typeof showNotification === 'function') showNotification('DAZN: El token actual no pudo ser refrescado y no se forzó la entrada de uno nuevo. Usando token anterior si existe.', 'warning');
return currentToken;
}
}
}
async function fetchSingleDaznAssetDetails(authToken, assetId, specificUserAgent = null) {
const decodedToken = decodeJwtPayload(authToken);
if (!decodedToken || !decodedToken.deviceId) {
if (typeof showNotification === 'function') showNotification('DAZN: No se pudo decodificar deviceId del token activo.', 'error');
return null;
}
const userAgentToUse = specificUserAgent || DAZN_USER_AGENT;
const params = new URLSearchParams({
'AppVersion': '0.60.0',
'DrmType': 'WIDEVINE',
'Format': 'MPEG-DASH',
'PlayerId': '@dazn/peng-html5-core/web/web',
'Platform': 'web',
'LanguageCode': 'es',
'Model': 'unknown',
'Secure': 'true',
'Manufacturer': 'microsoft',
'PlayReadyInitiator': 'false',
'Capabilities': 'mta',
'AssetId': assetId
});
try {
const response = await fetch(`${DAZN_API_PLAYBACK}?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'User-Agent': userAgentToUse,
'x-dazn-device': decodedToken.deviceId
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => `HTTP ${response.status}`);
throw new Error(`HTTP ${response.status} para AssetId ${assetId}`);
}
const data = await response.json();
const playbackDetails = data?.PlaybackDetails?.[0];
if (!playbackDetails || !playbackDetails.ManifestUrl || !playbackDetails.CdnToken) {
return null;
}
const manifestUrl = playbackDetails.ManifestUrl;
const cdnToken = playbackDetails.CdnToken;
const linearIdMatch = manifestUrl.match(/dazn-linear-(\d+)/);
const daznLinearId = linearIdMatch ? linearIdMatch[1] : null;
return {
assetId: assetId,
title: null,
baseUrl: manifestUrl,
cdnTokenName: cdnToken.Name,
cdnTokenValue: cdnToken.Value,
daznLinearId: daznLinearId,
streamUserAgent: userAgentToUse
};
} catch (error) {
return null;
}
}
async function fetchDaznRailDataAndChannelDetails(authToken, specificUserAgent = null) {
if (typeof showLoading === 'function') showLoading(true, 'Obteniendo canales DAZN...');
const userAgentToUse = specificUserAgent || DAZN_USER_AGENT;
const params = new URLSearchParams({
'id': 'LiveAndNextNew',
'params': 'PageType:Home;ContentType:None',
'languageCode': 'es',
'country': 'es',
'platform': 'web'
});
try {
const response = await fetch(`${DAZN_API_RAIL}?${params.toString()}`, {
headers: { 'User-Agent': userAgentToUse }
});
if (!response.ok) {
const errorText = await response.text().catch(() => `HTTP ${response.status}`);
throw new Error(`HTTP ${response.status} al obtener Rail data`);
}
const railData = await response.json();
const daznChannelsInfo = [];
const assetPromises = [];
if (railData && railData.Tiles) {
for (const tile of railData.Tiles) {
if (tile.Type === 'Live' && tile.Label === 'Canal en directo' && tile.AssetId) {
assetPromises.push(
fetchSingleDaznAssetDetails(authToken, tile.AssetId, userAgentToUse)
.then(details => {
if (details) {
details.title = tile.Title;
return details;
}
return null;
})
);
}
}
}
const resolvedAssets = await Promise.all(assetPromises);
resolvedAssets.forEach(asset => {
if (asset) {
daznChannelsInfo.push(asset);
}
});
if (daznChannelsInfo.length === 0 && typeof showNotification === 'function') {
showNotification('DAZN: No se encontraron canales en directo desde la API.', 'warning');
}
return daznChannelsInfo;
} catch (error) {
if (typeof showNotification === 'function') showNotification(`DAZN: Error obteniendo lista de canales: ${error.message}`, 'error');
return [];
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function orchestrateDaznUpdate(specificUserAgent = null) {
if (typeof showLoading === 'function') showLoading(true, 'Iniciando actualización DAZN...');
try {
const authToken = await getActiveDaznToken(true, specificUserAgent);
if (!authToken) {
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
return;
}
const daznChannelDetailsList = await fetchDaznRailDataAndChannelDetails(authToken, specificUserAgent);
if (daznChannelDetailsList && daznChannelDetailsList.length > 0) {
if (typeof window.updateM3UWithDaznData === 'function') {
window.updateM3UWithDaznData(daznChannelDetailsList);
} else {
if (typeof showNotification === 'function') showNotification('DAZN: Error interno, no se pudo actualizar M3U.', 'error');
}
} else if (daznChannelDetailsList) {
if (typeof showNotification === 'function') showNotification('DAZN: No se obtuvieron detalles de canales para actualizar.', 'info');
}
} catch (error) {
if (typeof showNotification === 'function') showNotification(`DAZN: Error general durante la actualización: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}

383
db_manager.js Normal file
View File

@ -0,0 +1,383 @@
const dbName = 'ZenithIPTV_DB';
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 5); // Incrementar versión para nuevo objectStore
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'name' });
}
if (!db.objectStoreNames.contains('xtream_servers')) {
const xtreamStore = db.createObjectStore('xtream_servers', { keyPath: 'id', autoIncrement: true });
xtreamStore.createIndex('name', 'name', { unique: false });
}
if (!db.objectStoreNames.contains('app_config')) {
db.createObjectStore('app_config', { keyPath: 'key' });
}
if (!db.objectStoreNames.contains('movistar_vod_cache')) {
const vodCacheStore = db.createObjectStore('movistar_vod_cache', { keyPath: 'dateString' });
vodCacheStore.createIndex('timestamp', 'timestamp', { unique: false });
}
if (!db.objectStoreNames.contains('xcodec_panels')) {
const xcodecStore = db.createObjectStore('xcodec_panels', { keyPath: 'id', autoIncrement: true });
xcodecStore.createIndex('name', 'name', { unique: false });
xcodecStore.createIndex('serverUrl', 'serverUrl', { unique: false });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject('Error al abrir IndexedDB: ' + event.target.error);
});
return dbPromise;
}
async function saveFileToDB(name, content) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(name);
getRequest.onsuccess = () => {
if (getRequest.result && !confirm(`La lista "${name}" ya existe. ¿Quieres reemplazarla?`)) {
reject(new Error('Operación de guardado cancelada por el usuario.'));
return;
}
const putRequest = store.put({ name, content, timestamp: new Date().toISOString(), channelCount: countChannels(content) });
putRequest.onsuccess = () => resolve();
putRequest.onerror = (event) => reject('Error al guardar archivo en IndexedDB: ' + event.target.error);
};
getRequest.onerror = (event) => reject('Error al verificar archivo existente en IndexedDB: ' + event.target.error);
});
}
async function getAllFilesFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readonly');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar archivos de IndexedDB: ' + event.target.error);
});
}
async function getFileFromDB(name) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readonly');
const store = transaction.objectStore('files');
const request = store.get(name);
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar archivo de IndexedDB: ' + event.target.error);
});
}
async function deleteFileFromDB(name) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
const request = store.delete(name);
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al eliminar archivo de IndexedDB: ' + event.target.error);
});
}
async function clearAllFilesFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['files'], 'readwrite');
const store = transaction.objectStore('files');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al limpiar IndexedDB: ' + event.target.error);
});
}
function countChannels(content) {
if (!content) return 0;
const lines = content.split(/\r\n?|\n/);
let count = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('#EXTINF:')) {
for (let j = i + 1; j < lines.length; j++) {
const nextLine = lines[j].trim();
if (nextLine && !nextLine.startsWith('#')) {
count++;
i = j;
break;
}
if (nextLine.startsWith('#EXTINF:') || nextLine.startsWith('#EXTM3U')) {
break;
}
}
}
}
return count;
}
async function saveXtreamServerToDB(serverData) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xtream_servers'], 'readwrite');
const store = transaction.objectStore('xtream_servers');
const dataToSave = { ...serverData };
if (!dataToSave.timestamp) {
dataToSave.timestamp = new Date().toISOString();
}
const request = serverData.id ? store.put(dataToSave) : store.add(dataToSave);
request.onsuccess = () => resolve(request.result || serverData.id);
request.onerror = (event) => reject('Error al guardar servidor Xtream: ' + event.target.error);
});
}
async function getAllXtreamServersFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xtream_servers'], 'readonly');
const store = transaction.objectStore('xtream_servers');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar servidores Xtream: ' + event.target.error);
});
}
async function getXtreamServerFromDB(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xtream_servers'], 'readonly');
const store = transaction.objectStore('xtream_servers');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar servidor Xtream: ' + event.target.error);
});
}
async function deleteXtreamServerFromDB(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xtream_servers'], 'readwrite');
const store = transaction.objectStore('xtream_servers');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al eliminar servidor Xtream: ' + event.target.error);
});
}
async function clearAllXtreamServersFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xtream_servers'], 'readwrite');
const store = transaction.objectStore('xtream_servers');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al limpiar servidores Xtream: ' + event.target.error);
});
}
async function saveXCodecPanelToDB(panelData) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xcodec_panels'], 'readwrite');
const store = transaction.objectStore('xcodec_panels');
const request = panelData.id ? store.put(panelData) : store.add({ ...panelData, timestamp: new Date().toISOString() });
request.onsuccess = () => resolve(request.result || panelData.id);
request.onerror = (event) => reject('Error al guardar panel XCodec: ' + event.target.error);
});
}
async function getAllXCodecPanelsFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xcodec_panels'], 'readonly');
const store = transaction.objectStore('xcodec_panels');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar paneles XCodec: ' + event.target.error);
});
}
async function getXCodecPanelFromDB(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xcodec_panels'], 'readonly');
const store = transaction.objectStore('xcodec_panels');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = (event) => reject('Error al recuperar panel XCodec: ' + event.target.error);
});
}
async function deleteXCodecPanelFromDB(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xcodec_panels'], 'readwrite');
const store = transaction.objectStore('xcodec_panels');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al eliminar panel XCodec: ' + event.target.error);
});
}
async function clearAllXCodecPanelsFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['xcodec_panels'], 'readwrite');
const store = transaction.objectStore('xcodec_panels');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error al limpiar paneles XCodec: ' + event.target.error);
});
}
async function saveAppConfigValue(key, value) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['app_config'], 'readwrite');
const store = transaction.objectStore('app_config');
const request = store.put({ key, value });
request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Error guardando ${key} en IndexedDB: ${event.target.error}`);
});
}
async function getAppConfigValue(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['app_config'], 'readonly');
const store = transaction.objectStore('app_config');
const request = store.get(key);
request.onsuccess = () => resolve(request.result ? request.result.value : null);
request.onerror = (event) => reject(`Error obteniendo ${key} de IndexedDB: ${event.target.error}`);
});
}
async function deleteAppConfigValue(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['app_config'], 'readwrite');
const store = transaction.objectStore('app_config');
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Error eliminando ${key} de IndexedDB: ${event.target.error}`);
});
}
async function clearAppConfigFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['app_config'], 'readwrite');
const store = transaction.objectStore('app_config');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error limpiando app_config de IndexedDB: ' + event.target.error);
});
}
async function getAllAppConfigValues() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['app_config'], 'readonly');
const store = transaction.objectStore('app_config');
const request = store.getAll();
request.onsuccess = () => {
const configObject = {};
if (request.result && Array.isArray(request.result)) {
request.result.forEach(item => {
if (item && typeof item.key === 'string') {
configObject[item.key] = item.value;
}
});
}
resolve(configObject);
};
request.onerror = (event) => reject(`Error obteniendo todos los valores de app_config: ${event.target.error}`);
});
}
async function saveMovistarVodData(dateString, recordData) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['movistar_vod_cache'], 'readwrite');
const store = transaction.objectStore('movistar_vod_cache');
const recordToStore = { dateString: dateString, data: recordData.data, timestamp: recordData.timestamp };
const request = store.put(recordToStore);
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error guardando datos VOD de Movistar: ' + event.target.error);
});
}
async function getMovistarVodData(dateString) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['movistar_vod_cache'], 'readonly');
const store = transaction.objectStore('movistar_vod_cache');
const request = store.get(dateString);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = (event) => reject('Error obteniendo datos VOD de Movistar: ' + event.target.error);
});
}
async function deleteOldMovistarVodData(daysToKeep = 15) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['movistar_vod_cache'], 'readwrite');
const store = transaction.objectStore('movistar_vod_cache');
const threshold = new Date().getTime() - (daysToKeep * 24 * 60 * 60 * 1000);
const index = store.index('timestamp');
const request = index.openCursor(IDBKeyRange.upperBound(threshold));
let deletedCount = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
store.delete(cursor.primaryKey);
deletedCount++;
cursor.continue();
} else {
resolve(deletedCount);
}
};
request.onerror = (event) => reject('Error eliminando datos VOD antiguos de Movistar: ' + event.target.error);
});
}
async function clearMovistarVodCacheFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['movistar_vod_cache'], 'readwrite');
const store = transaction.objectStore('movistar_vod_cache');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = (event) => reject('Error limpiando caché VOD de Movistar de IndexedDB: ' + event.target.error);
});
}
async function getMovistarVodCacheStats() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['movistar_vod_cache'], 'readonly');
const store = transaction.objectStore('movistar_vod_cache');
const request = store.getAll();
request.onsuccess = () => {
const records = request.result || [];
let totalSizeBytes = 0;
records.forEach(record => {
if (record && record.data) {
totalSizeBytes += JSON.stringify(record.data).length;
}
});
resolve({
count: records.length,
totalSizeBytes: totalSizeBytes
});
};
request.onerror = (event) => reject('Error obteniendo estadísticas de caché VOD Movistar: ' + event.target.error);
});
}

76
draggable_modals.js Normal file
View File

@ -0,0 +1,76 @@
function makeWindowsDraggableAndResizable() {
let activeWindow = null;
let action = null;
let offsetX, offsetY, startX, startY, startWidth, startHeight;
function onMouseDown(e) {
const target = e.target;
const draggableWindow = target.closest('.player-window');
if (!draggableWindow) return;
if (typeof setActivePlayer === 'function') {
setActivePlayer(draggableWindow.id);
}
if (target.classList.contains('resize-handle')) {
action = 'resize';
} else if (target.closest('.modal-header-draggable')) {
action = 'drag';
} else {
action = null;
}
if (action) {
e.preventDefault();
activeWindow = draggableWindow;
if (action === 'drag') {
offsetX = e.clientX - activeWindow.getBoundingClientRect().left;
offsetY = e.clientY - activeWindow.getBoundingClientRect().top;
} else if (action === 'resize') {
startX = e.clientX;
startY = e.clientY;
startWidth = parseInt(document.defaultView.getComputedStyle(activeWindow).width, 10);
startHeight = parseInt(document.defaultView.getComputedStyle(activeWindow).height, 10);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
}
function onMouseMove(e) {
if (!activeWindow) return;
if (action === 'drag') {
let x = e.clientX - offsetX;
let y = e.clientY - offsetY;
const container = document.getElementById('app-container');
const containerRect = container.getBoundingClientRect();
const windowRect = activeWindow.getBoundingClientRect();
x = Math.max(containerRect.left, Math.min(x, containerRect.right - windowRect.width));
y = Math.max(containerRect.top, Math.min(y, containerRect.bottom - windowRect.height));
activeWindow.style.left = x + 'px';
activeWindow.style.top = y + 'px';
} else if (action === 'resize') {
const newWidth = startWidth + (e.clientX - startX);
const newHeight = startHeight + (e.clientY - startY);
activeWindow.style.width = Math.max(400, newWidth) + 'px';
activeWindow.style.height = Math.max(300, newHeight) + 'px';
}
}
function onMouseUp() {
activeWindow = null;
action = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousedown', onMouseDown);
}

634
editor_handler.js Normal file
View File

@ -0,0 +1,634 @@
const editorHandler = (() => {
let editorChannels = [];
let selectedChannelId = null;
let selectedRowIds = new Set();
let currentGroupFilter = '';
let currentSort = { column: null, direction: 'asc' };
let sortableInstance = null;
let groupOrder = [];
const dom = {};
function cacheDom() {
const modal = document.getElementById('editorModal');
if (!modal) return false;
dom.modal = modal;
dom.tableContainer = modal.querySelector('#editor-table-container');
dom.tableBody = modal.querySelector('#editor-table-body');
dom.selectAllCheckbox = modal.querySelector('#editor-select-all');
dom.searchInput = modal.querySelector('#editor-search-input');
dom.groupFilterSelect = modal.querySelector('#editor-group-filter');
dom.fileNameDisplay = modal.querySelector('#file-name-display');
dom.editorPanel = modal.querySelector('#editor-panel');
dom.editorPlaceholder = modal.querySelector('#editor-placeholder');
dom.editorFormContent = modal.querySelector('#editor-form-content');
dom.editorChannelNameInput = modal.querySelector('#editor-channel-name');
dom.editorChannelTvgIdInput = modal.querySelector('#editor-channel-tvg-id');
dom.editorChannelChNumInput = modal.querySelector('#editor-channel-ch-num');
dom.editorChannelLogoInput = modal.querySelector('#editor-channel-logo');
dom.editorLogoPreview = modal.querySelector('#editor-logo-preview');
dom.editorChannelUrlInput = modal.querySelector('#editor-channel-url');
dom.editorChannelGroupInput = modal.querySelector('#editor-channel-group');
dom.groupSuggestionsDatalist = modal.querySelector('#group-suggestions');
dom.editorFavCheckbox = modal.querySelector('#editor-fav-channel');
dom.editorHideChannelCheckbox = modal.querySelector('#editor-hide-channel');
dom.editorKodiLicenseTypeInput = modal.querySelector('#editor-kodi-license-type');
dom.editorKodiLicenseKeyInput = modal.querySelector('#editor-kodi-license-key');
dom.editorKodiStreamHeadersInput = modal.querySelector('#editor-kodi-stream-headers');
dom.editorVlcUserAgentInput = modal.querySelector('#editor-vlc-user-agent');
dom.editorSaveBtn = modal.querySelector('#editor-save-btn');
dom.editorPlayBtn = modal.querySelector('#editor-play-btn');
dom.editorDeleteBtn = modal.querySelector('#editor-delete-btn');
dom.closeEditorBtn = modal.querySelector('#close-editor-btn');
dom.multiEditBtn = modal.querySelector('#multi-edit-btn');
dom.deleteSelectedBtn = modal.querySelector('#delete-selected-btn');
dom.clearSelectionBtn = modal.querySelector('#clear-selection-btn');
const multiEditModal = document.getElementById('multiEditModal');
dom.multiEditModal = multiEditModal;
dom.multiEditChannelCount = multiEditModal.querySelector('#multiEditChannelCount');
dom.multiEditEnableGroup = multiEditModal.querySelector('#multiEditEnableGroup');
dom.multiEditGroupInput = multiEditModal.querySelector('#multiEditGroupInput');
dom.multiEditEnableFavorite = multiEditModal.querySelector('#multiEditEnableFavorite');
dom.multiEditFavoriteSelect = multiEditModal.querySelector('#multiEditFavoriteSelect');
dom.multiEditEnableHidden = multiEditModal.querySelector('#multiEditEnableHidden');
dom.multiEditHiddenSelect = multiEditModal.querySelector('#multiEditHiddenSelect');
dom.multiEditEnableUserAgent = multiEditModal.querySelector('#multiEditEnableUserAgent');
dom.multiEditUserAgentInput = multiEditModal.querySelector('#multiEditUserAgentInput');
dom.multiEditEnableStreamHeaders = multiEditModal.querySelector('#multiEditEnableStreamHeaders');
dom.multiEditStreamHeadersInput = multiEditModal.querySelector('#multiEditStreamHeadersInput');
dom.multiEditStreamHeadersMode = multiEditModal.querySelector('#multiEditStreamHeadersMode');
dom.applyMultiEditBtn = multiEditModal.querySelector('#applyMultiEditBtn');
return true;
}
function init(channelsData, fileName) {
if (!cacheDom()) {
console.error("Editor DOM not found. Cannot initialize.");
return;
}
editorChannels = JSON.parse(JSON.stringify(channelsData));
editorChannels.forEach((ch, idx) => {
if (ch) {
ch.editorId = `editor-ch-${idx}-${Date.now()}`;
}
});
dom.fileNameDisplay.textContent = fileName || "Lista Actual";
dom.fileNameDisplay.classList.add('loaded');
updateGroupOrder();
renderTable();
bindEvents();
showEditorPlaceholder();
clearMultiSelection();
}
function bindEvents() {
if (dom.editorSaveBtn.dataset.initialized) return;
dom.editorSaveBtn.addEventListener('click', handleEditorSave);
dom.editorPlayBtn.addEventListener('click', handleEditorPlay);
dom.editorDeleteBtn.addEventListener('click', handleEditorDelete);
dom.closeEditorBtn.addEventListener('click', showEditorPlaceholder);
dom.tableBody.addEventListener('click', handleTableBodyClick);
dom.selectAllCheckbox.addEventListener('change', handleSelectAllVisible);
dom.tableBody.addEventListener('change', handleRowCheckboxChange);
dom.searchInput.addEventListener('input', debounce(() => { currentSort.column = null; renderTable(); }, 300));
dom.groupFilterSelect.addEventListener('change', (e) => { currentGroupFilter = e.target.value; renderTable(); });
dom.deleteSelectedBtn.addEventListener('click', deleteSelectedChannels);
dom.clearSelectionBtn.addEventListener('click', clearMultiSelection);
dom.multiEditBtn.addEventListener('click', openMultiEditModal);
dom.modal.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => handleSort(th.dataset.sort));
});
bindMultiEditEvents();
dom.editorSaveBtn.dataset.initialized = 'true';
}
function bindMultiEditEvents() {
dom.applyMultiEditBtn.addEventListener('click', handleApplyMultiEdit);
const multiEditToggles = [
{ check: dom.multiEditEnableGroup, input: dom.multiEditGroupInput },
{ check: dom.multiEditEnableFavorite, input: dom.multiEditFavoriteSelect },
{ check: dom.multiEditEnableHidden, input: dom.multiEditHiddenSelect },
{ check: dom.multiEditEnableUserAgent, input: dom.multiEditUserAgentInput },
{ check: dom.multiEditEnableStreamHeaders, input: dom.multiEditStreamHeadersInput, extra: dom.multiEditStreamHeadersMode },
];
multiEditToggles.forEach(({check, input, extra}) => {
check.addEventListener('change', (e) => {
input.disabled = !e.target.checked;
if (extra) extra.disabled = !e.target.checked;
});
});
}
function updateGroupOrder() {
const currentGroups = [...new Set(editorChannels.filter(ch => ch).map(ch => ch['group-title'] || ''))];
const newGroupOrder = (groupOrder.length > 0 ? groupOrder : []).filter(group => currentGroups.includes(group));
currentGroups.forEach(group => { if (!newGroupOrder.includes(group)) newGroupOrder.push(group); });
groupOrder = newGroupOrder;
updateGroupFilter();
updateGroupSuggestions();
}
function renderTable() {
const fragment = document.createDocumentFragment();
dom.tableBody.innerHTML = '';
const searchTerm = dom.searchInput.value.toLowerCase().trim();
if (currentGroupFilter === '' && !searchTerm && !currentSort.column) {
const groupCounts = editorChannels.reduce((acc, channel) => {
if (!channel || channel.attributes?.hidden === 'true') return acc;
const groupKey = channel['group-title'] || '';
acc[groupKey] = (acc[groupKey] || 0) + 1;
return acc;
}, {});
groupOrder.forEach(groupKey => {
const count = groupCounts[groupKey] || 0;
if (count > 0) {
fragment.appendChild(createGroupHeaderRow(groupKey, count));
}
});
} else {
const filteredChannels = getFilteredAndSortedChannels();
if (currentGroupFilter) {
if (filteredChannels.length > 0) {
const groupKey = filteredChannels[0]['group-title'] || '';
fragment.appendChild(createGroupHeaderRow(groupKey, filteredChannels.length));
filteredChannels.forEach(channel => fragment.appendChild(createRow(channel)));
}
} else {
filteredChannels.forEach(channel => fragment.appendChild(createRow(channel)));
}
}
dom.tableBody.appendChild(fragment);
updateSortableForCurrentView();
updateSelectAllCheckboxState();
updateSortIcons();
}
function getFilteredAndSortedChannels() {
const searchTerm = dom.searchInput.value.toLowerCase().trim();
let channelsToProcess = editorChannels.filter(ch => {
if (!ch || ch.attributes?.hidden === 'true') {
return false;
}
if (currentGroupFilter && (ch['group-title'] || '') !== currentGroupFilter) {
return false;
}
if (searchTerm) {
if (!(
ch.name?.toLowerCase().includes(searchTerm) ||
ch.url?.toLowerCase().includes(searchTerm) ||
ch['group-title']?.toLowerCase().includes(searchTerm) ||
ch['tvg-id']?.toLowerCase().includes(searchTerm)
)) {
return false;
}
}
return true;
});
if (currentSort.column) {
channelsToProcess.sort((a, b) => {
let valA, valB;
switch (currentSort.column) {
case 'ch-number':
valA = parseInt(a.attributes?.['ch-number'], 10) || Infinity;
valB = parseInt(b.attributes?.['ch-number'], 10) || Infinity;
break;
default:
valA = (a[currentSort.column] || '').toLowerCase();
valB = (b[currentSort.column] || '').toLowerCase();
break;
}
let comparison = valA < valB ? -1 : (valA > valB ? 1 : 0);
return comparison * (currentSort.direction === 'asc' ? 1 : -1);
});
}
return channelsToProcess;
}
function createRow(channel) {
const row = document.createElement('tr');
row.dataset.editorId = channel.editorId;
row.dataset.groupParent = channel['group-title'] || '';
row.className = `channel-row ${channel.editorId === selectedChannelId ? 'selected-row' : ''}`;
const logoHtml = channel['tvg-logo'] ? `<img src="${escapeHtml(channel['tvg-logo'])}" class="logo-preview" loading="lazy" onerror="this.style.opacity=0">` : `<span>-</span>`;
const nameHtml = `${channel.favorite ? '<i class="fas fa-star" style="color:orange;font-size:0.8em;margin-right:4px;"></i>' : ''}${escapeHtml(channel.name || '')}`;
row.innerHTML = `
<td class="checkbox-cell"><input type="checkbox" class="row-checkbox" data-editor-id="${channel.editorId}" ${selectedRowIds.has(channel.editorId) ? 'checked' : ''}></td>
<td class="handle-cell"><i class="fas fa-grip-lines drag-handle"></i></td>
<td class="logo-cell">${logoHtml}</td>
<td class="name-cell" title="${escapeHtml(channel.name || '')}">${nameHtml}</td>
<td class="url-cell" title="${escapeHtml(channel.url || '')}">${escapeHtml(channel.url || '')}</td>
<td class="epg-cell" title="${escapeHtml(channel['tvg-id'] || '')}">${escapeHtml(channel['tvg-id'] || '-')}</td>
<td class="ch-num-cell">${escapeHtml(channel.attributes?.['ch-number'] || '-')}</td>
<td class="actions-cell">
<button class="btn-action play" title="Probar Canal"><i class="fas fa-play"></i></button>
</td>
`;
return row;
}
function createGroupHeaderRow(group, count) {
const headerRow = document.createElement('tr');
headerRow.className = `group-header-row`;
headerRow.dataset.group = group;
const displayName = group === '' ? '(Sin Grupo)' : group;
headerRow.innerHTML = `
<td class="checkbox-cell"></td>
<td class="handle-cell"><i class="fas fa-grip-lines drag-handle"></i></td>
<td colspan="5">
<span class="group-name-text">${escapeHtml(displayName)}</span>
<span class="group-channel-count">(${count})</span>
<button class="btn-action rename" title="Renombrar Grupo"><i class="fas fa-pencil-alt"></i></button>
</td>
<td class="actions-cell"></td>
`;
return headerRow;
}
function handleTableBodyClick(e) {
const btn = e.target.closest('.btn-action');
if (btn) {
const row = e.target.closest('tr');
if (btn.classList.contains('play')) {
handleEditorPlay(row.dataset.editorId);
} else if (btn.classList.contains('rename')) {
handleRenameGroup(row.dataset.group);
}
return;
}
if (e.target.closest('.row-checkbox, .drag-handle, .group-header-row')) return;
const row = e.target.closest('tr.channel-row');
if (row && row.dataset.editorId) {
displayChannelInEditor(row.dataset.editorId);
}
}
function displayChannelInEditor(editorId) {
const channel = editorChannels.find(ch => ch && ch.editorId === editorId);
if (!channel) { showEditorPlaceholder(); return; }
selectedChannelId = editorId;
dom.editorFormContent.classList.remove('hidden');
dom.editorPlaceholder.classList.add('hidden');
dom.modal.classList.add('editor-visible');
if (!dom.editorChannelIdInput) {
dom.editorChannelIdInput = document.createElement('input');
dom.editorChannelIdInput.type = 'hidden';
dom.editorChannelIdInput.id = 'editor-channel-id';
dom.editorFormContent.insertBefore(dom.editorChannelIdInput, dom.editorFormContent.firstChild);
}
dom.editorChannelIdInput.value = editorId;
dom.editorChannelNameInput.value = channel.name || '';
dom.editorChannelTvgIdInput.value = channel['tvg-id'] || '';
dom.editorChannelChNumInput.value = channel.attributes?.['ch-number'] || '';
dom.editorChannelLogoInput.value = channel['tvg-logo'] || '';
dom.editorLogoPreview.src = channel['tvg-logo'] || '';
dom.editorLogoPreview.style.display = channel['tvg-logo'] ? 'block' : 'none';
dom.editorChannelUrlInput.value = channel.url || '';
dom.editorChannelGroupInput.value = channel['group-title'] || '';
dom.editorFavCheckbox.checked = channel.favorite || false;
dom.editorHideChannelCheckbox.checked = channel.attributes?.hidden === 'true';
const kodiProps = channel.kodiProps || {};
dom.editorKodiLicenseTypeInput.value = kodiProps['inputstream.adaptive.license_type'] || '';
dom.editorKodiLicenseKeyInput.value = kodiProps['inputstream.adaptive.license_key'] || '';
dom.editorKodiStreamHeadersInput.value = kodiProps['inputstream.adaptive.stream_headers'] || '';
dom.editorVlcUserAgentInput.value = (channel.vlcOptions || {})['http-user-agent'] || '';
renderTable();
}
function showEditorPlaceholder() {
selectedChannelId = null;
dom.editorFormContent.classList.add('hidden');
dom.editorPlaceholder.classList.remove('hidden');
dom.modal.classList.remove('editor-visible');
if (dom.tableBody) dom.tableBody.querySelectorAll('.selected-row').forEach(r => r.classList.remove('selected-row'));
}
function handleEditorSave() {
if (!selectedChannelId) return;
const index = editorChannels.findIndex(ch => ch && ch.editorId === selectedChannelId);
if (index === -1) return;
const channelData = editorChannels[index];
const oldGroup = channelData['group-title'];
channelData.name = dom.editorChannelNameInput.value.trim();
channelData.url = dom.editorChannelUrlInput.value.trim();
channelData['group-title'] = dom.editorChannelGroupInput.value.trim();
channelData['tvg-logo'] = dom.editorChannelLogoInput.value.trim();
channelData['tvg-id'] = dom.editorChannelTvgIdInput.value.trim();
if (!channelData.attributes) channelData.attributes = {};
channelData.attributes['ch-number'] = dom.editorChannelChNumInput.value.trim();
channelData.favorite = dom.editorFavCheckbox.checked;
channelData.attributes['hidden'] = dom.editorHideChannelCheckbox.checked ? 'true' : 'false';
channelData.kodiProps = channelData.kodiProps || {};
channelData.kodiProps['inputstream.adaptive.license_type'] = dom.editorKodiLicenseTypeInput.value.trim();
channelData.kodiProps['inputstream.adaptive.license_key'] = dom.editorKodiLicenseKeyInput.value.trim();
channelData.kodiProps['inputstream.adaptive.stream_headers'] = dom.editorKodiStreamHeadersInput.value.trim();
channelData.vlcOptions = channelData.vlcOptions || {};
channelData.vlcOptions['http-user-agent'] = dom.editorVlcUserAgentInput.value.trim();
if (oldGroup !== channelData['group-title']) {
updateGroupOrder();
}
renderTable();
showToast('Canal guardado.', 'success');
}
function handleEditorPlay(id) {
const editorId = id || selectedChannelId;
if (!editorId) return;
const channel = editorChannels.find(ch => ch.editorId === editorId);
if (channel && typeof createPlayerWindow === 'function') {
createPlayerWindow(channel);
}
}
function handleEditorDelete() {
if(!selectedChannelId) return;
const index = editorChannels.findIndex(ch => ch && ch.editorId === selectedChannelId);
if (index > -1) {
editorChannels.splice(index, 1);
showEditorPlaceholder();
renderTable();
}
}
function deleteSelectedChannels() {
if (selectedRowIds.size === 0) return;
editorChannels = editorChannels.filter(ch => !selectedRowIds.has(ch.editorId));
selectedRowIds.clear();
showEditorPlaceholder();
renderTable();
updateGroupOrder();
}
function handleRenameGroup(oldGroupName) {
const newGroupName = prompt(`Renombrar grupo "${escapeHtml(oldGroupName || '(Sin Grupo)')}":`, oldGroupName);
if (newGroupName === null || newGroupName === oldGroupName) return;
editorChannels.forEach(ch => {
if ((ch['group-title'] || '') === oldGroupName) {
ch['group-title'] = newGroupName;
}
});
const groupIndex = groupOrder.indexOf(oldGroupName);
if (groupIndex > -1) {
groupOrder[groupIndex] = newGroupName;
}
updateGroupOrder();
renderTable();
showToast('Grupo renombrado.', 'success');
}
function updateGroupFilter() {
const currentFilterValue = dom.groupFilterSelect.value;
dom.groupFilterSelect.innerHTML = '<option value="">Todos los Grupos</option>';
groupOrder.forEach(group => {
const displayName = group === '' ? '(Sin Grupo)' : group;
dom.groupFilterSelect.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(group)}">${escapeHtml(displayName)}</option>`);
});
dom.groupFilterSelect.value = currentFilterValue;
}
function updateSortableForCurrentView() {
if (sortableInstance) {
sortableInstance.destroy();
}
sortableInstance = new Sortable(dom.tableBody, {
animation: 150,
handle: '.drag-handle',
draggable: currentGroupFilter === '' ? '.group-header-row' : '.channel-row',
forceFallback: true,
ghostClass: 'sortable-ghost',
fallbackClass: 'sortable-fallback',
onStart: () => {
document.body.classList.add('editor-is-dragging');
},
onEnd: (evt) => {
document.body.classList.remove('editor-is-dragging');
const { oldIndex, newIndex, item } = evt;
if (oldIndex === newIndex) return;
if (item.classList.contains('group-header-row')) {
const [movedGroup] = groupOrder.splice(oldIndex, 1);
groupOrder.splice(newIndex, 0, movedGroup);
}
else if (item.classList.contains('channel-row') && currentGroupFilter !== '') {
const allVisibleIdsInOrder = Array.from(dom.tableBody.querySelectorAll('.channel-row')).map(row => row.dataset.editorId);
const channelsInCurrentGroup = editorChannels.filter(ch => (ch['group-title'] || '') === currentGroupFilter);
const channelsInOtherGroups = editorChannels.filter(ch => (ch['group-title'] || '') !== currentGroupFilter);
const channelMap = new Map(channelsInCurrentGroup.map(ch => [ch.editorId, ch]));
const reorderedGroup = allVisibleIdsInOrder.map(id => channelMap.get(id));
editorChannels = [...channelsInOtherGroups, ...reorderedGroup];
}
currentSort.column = null;
renderTable();
}
});
}
function handleSelectAllVisible(e) {
const isChecked = e.target.checked;
dom.tableBody.querySelectorAll('tr:not(.hidden) .row-checkbox').forEach(cb => {
const editorId = cb.dataset.editorId;
if (isChecked) selectedRowIds.add(editorId); else selectedRowIds.delete(editorId);
cb.checked = isChecked;
});
updateMultiEditButtonState();
}
function handleRowCheckboxChange(e) {
if (!e.target.classList.contains('row-checkbox')) return;
const editorId = e.target.dataset.editorId;
if (e.target.checked) selectedRowIds.add(editorId); else selectedRowIds.delete(editorId);
updateSelectAllCheckboxState();
updateMultiEditButtonState();
}
function updateSelectAllCheckboxState() {
const visibleCheckboxes = Array.from(dom.tableBody.querySelectorAll('tr:not(.hidden) .row-checkbox'));
if (visibleCheckboxes.length === 0) {
dom.selectAllCheckbox.checked = false; dom.selectAllCheckbox.indeterminate = false; return;
}
const numSelectedVisible = visibleCheckboxes.filter(cb => cb.checked).length;
dom.selectAllCheckbox.checked = numSelectedVisible > 0 && numSelectedVisible === visibleCheckboxes.length;
dom.selectAllCheckbox.indeterminate = numSelectedVisible > 0 && numSelectedVisible < visibleCheckboxes.length;
}
function updateMultiEditButtonState() {
const hasSelection = selectedRowIds.size > 0;
dom.multiEditBtn.disabled = !hasSelection;
dom.deleteSelectedBtn.disabled = !hasSelection;
}
function clearMultiSelection() {
selectedRowIds.clear();
if (dom.tableBody) {
dom.tableBody.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = false);
}
updateSelectAllCheckboxState();
updateMultiEditButtonState();
}
function showToast(message, type = 'info', duration = 3000) {
if (typeof window.showNotification === 'function') {
window.showNotification(message, type, duration);
}
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function updateGroupSuggestions() {
if (!dom.groupSuggestionsDatalist) return;
dom.groupSuggestionsDatalist.innerHTML = '';
groupOrder.forEach(group => {
if (group) {
dom.groupSuggestionsDatalist.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(group)}"></option>`);
}
});
}
function handleSort(column) {
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
renderTable();
}
function updateSortIcons() {
dom.modal.querySelectorAll('th.sortable i').forEach(icon => icon.className = 'fas fa-sort');
if (currentSort.column) {
const th = dom.modal.querySelector(`th[data-sort="${currentSort.column}"]`);
if (th) th.querySelector('i').className = `fas fa-sort-${currentSort.direction === 'asc' ? 'up' : 'down'}`;
}
}
function openMultiEditModal() {
dom.multiEditChannelCount.textContent = selectedRowIds.size;
const modal = new bootstrap.Modal(dom.multiEditModal);
modal.show();
}
function handleApplyMultiEdit() {
const changes = {};
if (dom.multiEditEnableGroup.checked) changes.group = dom.multiEditGroupInput.value.trim();
if (dom.multiEditEnableFavorite.checked) changes.favorite = dom.multiEditFavoriteSelect.value;
if (dom.multiEditEnableHidden.checked) changes.hidden = dom.multiEditHiddenSelect.value;
if (dom.multiEditEnableUserAgent.checked) changes.userAgent = dom.multiEditUserAgentInput.value.trim();
if (dom.multiEditEnableStreamHeaders.checked) {
changes.streamHeaders = dom.multiEditStreamHeadersInput.value.trim();
changes.streamHeadersMode = dom.multiEditStreamHeadersMode.value;
}
selectedRowIds.forEach(id => {
const channel = editorChannels.find(ch => ch.editorId === id);
if (!channel) return;
if (changes.group !== undefined) channel['group-title'] = changes.group;
if (changes.favorite !== undefined) channel.favorite = changes.favorite === 'add';
if (changes.hidden !== undefined) {
if (!channel.attributes) channel.attributes = {};
channel.attributes.hidden = changes.hidden === 'hide' ? 'true' : 'false';
}
if (changes.userAgent !== undefined) {
if (!channel.vlcOptions) channel.vlcOptions = {};
channel.vlcOptions['http-user-agent'] = changes.userAgent;
}
if (changes.streamHeaders !== undefined) {
if (!channel.kodiProps) channel.kodiProps = {};
if (changes.streamHeadersMode === 'replace' || !channel.kodiProps['inputstream.adaptive.stream_headers']) {
channel.kodiProps['inputstream.adaptive.stream_headers'] = changes.streamHeaders;
} else {
const existingHeaders = new Map(channel.kodiProps['inputstream.adaptive.stream_headers'].split('|').map(h => { const p = h.split('='); return [p[0], p.slice(1).join('=')]; }));
changes.streamHeaders.split('|').forEach(h => { const p = h.split('='); if(p[0]) existingHeaders.set(p[0], p.slice(1).join('=')); });
channel.kodiProps['inputstream.adaptive.stream_headers'] = Array.from(existingHeaders).map(([k,v]) => `${k}=${v}`).join('|');
}
}
});
updateGroupOrder();
renderTable();
showToast(`${selectedRowIds.size} canales actualizados.`, 'success');
const modal = bootstrap.Modal.getInstance(dom.multiEditModal);
modal.hide();
}
function getFinalData() {
const groupIndexMap = new Map(groupOrder.map((group, index) => [group, index]));
const channelsByGroup = {};
editorChannels.forEach(ch => {
const group = ch['group-title'] || '';
if (!channelsByGroup[group]) channelsByGroup[group] = [];
channelsByGroup[group].push(ch);
});
const finalOrderedChannels = [];
groupOrder.forEach(group => {
if (channelsByGroup[group]) {
finalOrderedChannels.push(...channelsByGroup[group]);
}
});
const remainingChannels = editorChannels.filter(ch => !groupIndexMap.has(ch['group-title'] || ''));
finalOrderedChannels.push(...remainingChannels);
return {
channels: finalOrderedChannels,
groupOrder: groupOrder
};
}
return {
init: init,
getFinalData: getFinalData
};
})();

1035
epg.js Normal file

File diff suppressed because it is too large Load Diff

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

2
libs/Sortable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
libs/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
libs/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

54
libs/controls.css Normal file

File diff suppressed because one or more lines are too long

9
libs/fontawesome/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
libs/jquery-3.7.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

8
libs/particles.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

2072
libs/shaka-player.ui.js Normal file

File diff suppressed because it is too large Load Diff

319
m3u_operations.js Normal file
View File

@ -0,0 +1,319 @@
async function loadUrl(url, sourceOrigin = null) {
showLoading(true, 'Cargando lista desde URL...');
if (typeof hideXtreamInfoBar === 'function') hideXtreamInfoBar();
if (!sourceOrigin) {
channels = [];
currentGroupOrder = [];
currentM3UName = null;
}
try {
const response = await fetch(url);
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status} - ${response.statusText}${errorBody ? ': ' + errorBody.substring(0,100)+'...' : ''}`);
}
const content = await response.text();
if (!content || content.trim() === '') throw new Error('Lista vacía o inaccesible.');
const effectiveSourceName = sourceOrigin || url;
processM3UContent(content, effectiveSourceName, !sourceOrigin);
if(userSettings.autoSaveM3U && !sourceOrigin) {
await saveAppConfigValue('lastM3UUrl', url);
await deleteAppConfigValue('lastM3UFileContent');
await deleteAppConfigValue('lastM3UFileName');
await deleteAppConfigValue('currentXtreamServerInfo');
}
showNotification(`Lista cargada desde URL (${channels.length} canales).`, 'success');
} catch (err) {
showNotification(`Error cargando URL: ${err.message}`, 'error');
if (!sourceOrigin) {
channels = []; currentM3UContent = null; currentM3UName = null; currentGroupOrder = [];
filterAndRenderChannels();
}
} finally { showLoading(false); }
}
function loadFile(event) {
const file = event.target.files[0]; if (!file) return;
showLoading(true, `Leyendo archivo "${escapeHtml(file.name)}"...`);
if (typeof hideXtreamInfoBar === 'function') hideXtreamInfoBar();
channels = [];
currentGroupOrder = [];
currentM3UName = null;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target.result;
if (!content || content.trim() === '') throw new Error('Archivo vacío.');
processM3UContent(content, file.name, true);
if (userSettings.autoSaveM3U) {
if (content.length < 4 * 1024 * 1024) {
await saveAppConfigValue('lastM3UFileContent', content);
await saveAppConfigValue('lastM3UFileName', currentM3UName);
await deleteAppConfigValue('lastM3UUrl');
await deleteAppConfigValue('currentXtreamServerInfo');
} else {
showNotification('Archivo local grande (>4MB), no se guardará para recarga automática.', 'info');
await deleteAppConfigValue('lastM3UFileContent');
await deleteAppConfigValue('lastM3UFileName');
await deleteAppConfigValue('lastM3UUrl');
await deleteAppConfigValue('currentXtreamServerInfo');
}
}
showNotification(`Lista "${escapeHtml(file.name)}" cargada (${channels.length} canales).`, 'success');
} catch (err) {
showNotification(`Error procesando archivo: ${err.message}`, 'error');
channels = []; currentM3UContent = null; currentM3UName = null; currentGroupOrder = [];
filterAndRenderChannels();
} finally { showLoading(false); $('#fileInput').val(''); }
};
reader.onerror = (e) => {
showNotification('Error al leer archivo: ' + e.target.error, 'error');
showLoading(false); $('#fileInput').val('');
};
reader.readAsText(file);
}
function processM3UContent(content, sourceName, isFullLoad = false) {
currentM3UContent = content;
if (isFullLoad) {
if (sourceName.startsWith('http')) {
try {
const url = new URL(sourceName);
currentM3UName = url.pathname.split('/').pop() || url.search.substring(1) || url.hostname || 'lista_url';
currentM3UName = decodeURIComponent(currentM3UName).replace(/\.(m3u8?|txt|pls|m3uplus)$/i, '').replace(/[\/\\]/g,'_');
if (!currentM3UName || currentM3UName.length > 50) currentM3UName = currentM3UName.substring(0, 47) + '...';
if(currentM3UName.length === 0) currentM3UName = 'lista_remota';
} catch(e) { currentM3UName = 'lista_url_malformada'; }
} else {
currentM3UName = sourceName.replace(/\.(m3u8?|txt|pls|m3uplus)$/i, '').replace(/[\/\\]/g,'_');
if (!currentM3UName || currentM3UName.length > 50) currentM3UName = currentM3UName.substring(0, 47) + '...';
if(currentM3UName.length === 0) currentM3UName = 'lista_local';
}
if (channels.length > 0 || currentGroupOrder.length > 0) {
channels = [];
currentGroupOrder = [];
}
}
const parseResult = typeof parseM3U === 'function' ? parseM3U(content, sourceName) : { channels: [], groupOrder: [] };
channels.push(...parseResult.channels);
const existingGroupsSet = new Set(currentGroupOrder);
parseResult.groupOrder.forEach(group => {
if (!existingGroupsSet.has(group)) {
currentGroupOrder.push(group);
}
});
const allCurrentGroups = new Set(channels.map(c => c['group-title']).filter(Boolean));
currentGroupOrder = currentGroupOrder.filter(g => allCurrentGroups.has(g));
allCurrentGroups.forEach(g => {
if (!currentGroupOrder.includes(g)) currentGroupOrder.push(g);
});
currentPage = 1;
if (typeof matchChannelsWithEpg === 'function') {
matchChannelsWithEpg();
}
let initialGroupToSelect = "";
if (userSettings.persistFilters && userSettings.lastSelectedGroup && currentGroupOrder.includes(userSettings.lastSelectedGroup)) {
initialGroupToSelect = userSettings.lastSelectedGroup;
}
$('#groupFilterSidebar').val(initialGroupToSelect);
filterAndRenderChannels();
if (channels.length === 0) {
showNotification(`No se encontraron canales válidos en "${escapeHtml(currentM3UName || sourceName)}". Revisa el formato del M3U.`, 'warning');
}
}
function removeChannelsBySourceOrigin(originToRemove) {
if (!originToRemove) return;
const originalChannelCount = channels.length;
channels = channels.filter(channel => channel.sourceOrigin !== originToRemove);
const channelsRemovedCount = originalChannelCount - channels.length;
if (channelsRemovedCount > 0) {
if (channels.length > 0) {
regenerateCurrentM3UContentFromString();
} else {
currentM3UContent = null;
currentM3UName = null;
}
const activeGroups = new Set(channels.map(ch => ch['group-title']));
currentGroupOrder = currentGroupOrder.filter(group => activeGroups.has(group));
}
}
async function appendM3UContent(newM3UString, sourceNameForAppend) {
showLoading(true, `Agregando canales de ${escapeHtml(sourceNameForAppend)}...`);
const parseResult = typeof parseM3U === 'function' ? parseM3U(newM3UString, sourceNameForAppend) : { channels: [], groupOrder: [] };
const newChannelsFromAppend = parseResult.channels;
const newGroupOrderFromAppend = parseResult.groupOrder;
const wasChannelsEmpty = channels.length === 0;
if (newChannelsFromAppend.length === 0) {
showNotification(`No se encontraron canales válidos en ${escapeHtml(sourceNameForAppend)} para agregar.`, 'warning');
showLoading(false);
if (wasChannelsEmpty) {
currentM3UName = null;
currentM3UContent = null;
currentGroupOrder = [];
if (typeof filterAndRenderChannels === 'function') filterAndRenderChannels();
}
return;
}
if (wasChannelsEmpty) {
channels = newChannelsFromAppend;
currentGroupOrder = newGroupOrderFromAppend;
currentM3UContent = newM3UString;
currentM3UName = sourceNameForAppend;
} else {
channels.push(...newChannelsFromAppend);
const existingGroupsSet = new Set(currentGroupOrder);
newGroupOrderFromAppend.forEach(group => {
if (!existingGroupsSet.has(group)) {
currentGroupOrder.push(group);
}
});
const allCurrentGroups = new Set(channels.map(c => c['group-title']).filter(Boolean));
currentGroupOrder = currentGroupOrder.filter(g => allCurrentGroups.has(g));
allCurrentGroups.forEach(g => {
if (!currentGroupOrder.includes(g)) currentGroupOrder.push(g);
});
await regenerateCurrentM3UContentFromString();
}
currentPage = 1;
if (typeof matchChannelsWithEpg === 'function') {
matchChannelsWithEpg();
}
filterAndRenderChannels();
let notificationMessage;
const addedOrLoaded = wasChannelsEmpty ? 'cargados' : 'agregados/actualizados';
notificationMessage = `${newChannelsFromAppend.length} canales de ${escapeHtml(sourceNameForAppend)} ${addedOrLoaded}.`;
if (userSettings.autoSaveM3U) {
if (currentM3UContent && currentM3UContent.length < 4 * 1024 * 1024) {
await saveAppConfigValue('lastM3UFileContent', currentM3UContent);
await saveAppConfigValue('lastM3UFileName', currentM3UName);
await deleteAppConfigValue('lastM3UUrl');
if (currentM3UName && !currentM3UName.startsWith('Xtream:')) {
await deleteAppConfigValue('currentXtreamServerInfo');
}
else if (!sourceNameForAppend.startsWith('Xtream:') && await getAppConfigValue('currentXtreamServerInfo')) {
await deleteAppConfigValue('currentXtreamServerInfo');
}
notificationMessage += " Lista actual guardada para recarga automática.";
} else if (currentM3UContent) {
await deleteAppConfigValue('lastM3UFileContent');
await deleteAppConfigValue('lastM3UFileName');
await deleteAppConfigValue('lastM3UUrl');
await deleteAppConfigValue('currentXtreamServerInfo');
notificationMessage += " Lista actual demasiado grande, no se guardó para recarga automática.";
}
}
showNotification(notificationMessage, 'success');
showLoading(false);
}
async function regenerateCurrentM3UContentFromString() {
if (!channels || channels.length === 0) {
currentM3UContent = null;
return;
}
let newM3U = "#EXTM3U\n";
channels.forEach(ch => {
let extinfLine = `#EXTINF:${ch.attributes?.duration || -1}`;
const tempAttrs = {...ch.attributes};
delete tempAttrs.duration;
if (ch['tvg-id']) tempAttrs['tvg-id'] = ch['tvg-id'];
if (ch['tvg-name']) tempAttrs['tvg-name'] = ch['tvg-name'];
if (ch['tvg-logo']) tempAttrs['tvg-logo'] = ch['tvg-logo'];
if (ch['group-title']) tempAttrs['group-title'] = ch['group-title'];
if (ch.attributes && ch.attributes['ch-number']) tempAttrs['ch-number'] = ch.attributes['ch-number'];
if (ch.sourceOrigin) tempAttrs['source-origin'] = ch.sourceOrigin;
for (const key in tempAttrs) {
if (tempAttrs[key] || typeof tempAttrs[key] === 'number') {
extinfLine += ` ${key}="${tempAttrs[key]}"`;
}
}
extinfLine += `,${ch.name}\n`;
newM3U += extinfLine;
if (ch.kodiProps) {
Object.entries(ch.kodiProps).forEach(([key, value]) => {
newM3U += `#KODIPROP:${key}=${value}\n`;
});
}
if (ch.vlcOptions) {
Object.entries(ch.vlcOptions).forEach(([key, value]) => {
if (key === 'description' && value) {
newM3U += `#EXTVLCOPT:description=${value.replace(/[\n\r]+/g, ' ').replace(/"/g, "'")}\n`;
} else {
newM3U += `#EXTVLCOPT:${key}=${value}\n`;
}
});
}
if (ch.extHttp && Object.keys(ch.extHttp).length > 0) {
newM3U += `#EXTHTTP:${JSON.stringify(ch.extHttp)}\n`;
}
newM3U += `${ch.url}\n`;
});
currentM3UContent = newM3U;
if (userSettings.autoSaveM3U && currentM3UContent && currentM3UName) {
if (currentM3UContent.length < 4 * 1024 * 1024) {
await saveAppConfigValue('lastM3UFileContent', currentM3UContent);
await saveAppConfigValue('lastM3UFileName', currentM3UName);
await deleteAppConfigValue('lastM3UUrl');
if (currentM3UName && !currentM3UName.startsWith('Xtream:')) {
await deleteAppConfigValue('currentXtreamServerInfo');
}
} else {
showNotification("Lista M3U actualizada es muy grande (>4MB), no se guardará para recarga automática.", "warning");
await deleteAppConfigValue('lastM3UFileContent');
await deleteAppConfigValue('lastM3UFileName');
await deleteAppConfigValue('lastM3UUrl');
await deleteAppConfigValue('currentXtreamServerInfo');
}
}
}
function downloadCurrentM3U() {
if (!currentM3UContent) {
showNotification('No hay lista M3U cargada para descargar.', 'info');
return;
}
const fileName = (currentM3UName ? currentM3UName.replace(/\.\.\.$/, '') : 'lista_player') + '.m3u';
const blob = new Blob([currentM3UContent], { type: 'audio/mpegurl;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Descargando lista como "${escapeHtml(fileName)}"`, 'success');
}

45
m3u_sender.js Normal file
View File

@ -0,0 +1,45 @@
async function sendM3UToServer(targetUrlOverride = null) {
if (typeof showLoading !== 'function' || typeof currentM3UContent === 'undefined' || typeof userSettings === 'undefined' || typeof currentM3UName === 'undefined' || typeof showNotification !== 'function' || typeof escapeHtml !== 'function') {
console.error("M3U Sender: Funciones o variables esenciales no están disponibles.");
if(typeof showNotification === 'function') showNotification("Error interno al intentar enviar M3U.", "error");
return;
}
if (!currentM3UContent) {
showNotification('No hay lista M3U cargada para enviar.', 'info');
return;
}
const effectiveUrl = targetUrlOverride || userSettings.m3uUploadServerUrl;
if (!effectiveUrl || !effectiveUrl.trim().startsWith('http')) {
showNotification('La URL del servidor para enviar M3U no está configurada o es inválida. Configúrala en Ajustes (guarda si es necesario) o introduce una URL válida en la pestaña "Enviar M3U" y pulsa el botón allí.', 'warning');
return;
}
showLoading(true, 'Enviando lista M3U al servidor...');
try {
const formData = new FormData();
formData.append('m3u_content', currentM3UContent);
formData.append('m3u_name', currentM3UName || 'lista_player_desconocida');
const response = await fetch(effectiveUrl, {
method: 'POST',
body: formData,
});
const responseData = await response.json();
if (response.ok && responseData.success) {
showNotification(`M3U enviado con éxito al servidor. Guardado como: ${escapeHtml(responseData.filename || 'nombre_desconocido')}`, 'success');
} else {
throw new Error(responseData.message || `Error del servidor: ${response.status}`);
}
} catch (error) {
console.error("Error enviando M3U al servidor:", error);
showNotification(`Error al enviar M3U: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}

286
m3u_utils.js Normal file
View File

@ -0,0 +1,286 @@
function parseM3U(content, sourceOrigin = null) {
const lines = content.split(/\r\n?|\n/).map(line => line.trim()).filter(Boolean);
const parsedChannels = [];
let currentChannel = null;
const seenGroups = new Set();
const orderedGroups = [];
if (lines.length > 0 && !lines[0].startsWith('#EXTM3U')) {
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF:')) {
if (currentChannel && !currentChannel.url) {
}
currentChannel = {
name: `Canal ${parsedChannels.length + 1}`,
url: null,
attributes: {},
kodiProps: {},
vlcOptions: {},
extHttp: {},
effectiveEpgId: null,
sourceOrigin: sourceOrigin
};
try {
const extinfMatch = line.match(/^#EXTINF:(-?\d*(?:\.\d+)?)([^,]*),(.*)$/);
if (extinfMatch) {
currentChannel.attributes.duration = extinfMatch[1];
const attrString = extinfMatch[2].trim();
const channelName = extinfMatch[3].trim();
currentChannel.name = channelName || `Canal ${parsedChannels.length + 1}`;
const attributeMatchRegex = /([a-zA-Z0-9_-]+)=("([^"]*)"|'([^']*)'|([^"\s',]+))/g;
let attrMatch;
while ((attrMatch = attributeMatchRegex.exec(attrString)) !== null) {
const attrName = attrMatch[1].toLowerCase();
const attrValue = attrMatch[3] || attrMatch[4] || attrMatch[5] || '';
currentChannel.attributes[attrName] = attrValue.trim();
}
currentChannel['tvg-id'] = currentChannel.attributes['tvg-id'] || '';
currentChannel['tvg-name'] = currentChannel.attributes['tvg-name'] || '';
currentChannel['tvg-logo'] = currentChannel.attributes['tvg-logo'] || '';
currentChannel['group-title'] = currentChannel.attributes['group-title'] || '';
currentChannel.attributes['ch-number'] = currentChannel.attributes['ch-number'] || currentChannel.attributes['tvg-chno'] || '';
if (currentChannel.attributes['source-origin']) {
currentChannel.sourceOrigin = currentChannel.attributes['source-origin'];
}
const groupTitle = currentChannel['group-title'];
if (groupTitle && groupTitle.trim() !== '' && !seenGroups.has(groupTitle)) {
seenGroups.add(groupTitle); orderedGroups.push(groupTitle);
}
} else {
const commaIndex = line.indexOf(',');
if (commaIndex !== -1) {
currentChannel.name = line.substring(commaIndex + 1).trim() || `Canal ${parsedChannels.length + 1}`;
currentChannel.attributes.duration = line.substring("#EXTINF:".length, commaIndex).trim();
} else { currentChannel = null; }
}
} catch (e) { console.warn("Error parsing #EXTINF line:", line, e); currentChannel = null; }
} else if (currentChannel && line.startsWith('#KODIPROP:')) {
const propMatch = line.match(/^#KODIPROP:([^=]+)=(.*)$/);
if (propMatch && propMatch[1] && typeof propMatch[2] === 'string') {
currentChannel.kodiProps[propMatch[1].trim()] = propMatch[2].trim();
}
} else if (currentChannel && line.startsWith('#EXTVLCOPT:')) {
const propMatch = line.match(/^#EXTVLCOPT:([^=]+)=(.*)$/);
if (propMatch && propMatch[1] && typeof propMatch[2] === 'string') {
const key = propMatch[1].trim();
let value = propMatch[2].trim();
if (key === 'http-user-agent' && value.includes('&Referer=')) {
const parts = value.split('&Referer=');
currentChannel.vlcOptions['http-user-agent'] = parts[0];
if (parts.length > 1 && parts[1]) {
currentChannel.vlcOptions['http-referrer'] = parts[1];
}
} else if (key === 'http-user-agent' && value.includes('&referer=')) {
const parts = value.split('&referer=');
currentChannel.vlcOptions['http-user-agent'] = parts[0];
if (parts.length > 1 && parts[1]) {
currentChannel.vlcOptions['http-referrer'] = parts[1];
}
}
else {
currentChannel.vlcOptions[key] = value;
}
}
} else if (currentChannel && line.startsWith('#EXTHTTP:')) {
try {
const httpJson = line.substring('#EXTHTTP:'.length).trim();
if (httpJson) { currentChannel.extHttp = JSON.parse(httpJson); }
} catch (e) { console.warn("Error parsing #EXTHTTP JSON:", line, e); }
} else if (currentChannel && line.startsWith('#EXTGRP:')) {
const groupName = line.substring('#EXTGRP:'.length).trim();
if (!currentChannel['group-title'] && groupName) {
currentChannel['group-title'] = groupName;
if (!seenGroups.has(groupName)) { seenGroups.add(groupName); orderedGroups.push(groupName); }
} else if (groupName && groupName.trim() !== '' && !seenGroups.has(groupName)) {
seenGroups.add(groupName); orderedGroups.push(groupName);
}
} else if (!line.startsWith('#') && currentChannel && !currentChannel.url) {
const url = line.trim();
if (url) {
currentChannel.url = url;
if (!currentChannel.attributes['source-origin']) {
if (url.includes('atres-live.atresmedia.com')) {
currentChannel.sourceOrigin = 'Atresplayer';
} else if (url.includes('orangetv.orange.es')) {
currentChannel.sourceOrigin = 'OrangeTV';
} else if (url.toLowerCase().includes('dazn')) {
currentChannel.sourceOrigin = 'DAZN';
} else if (url.toLowerCase().includes('telefonica.com') || url.toLowerCase().includes('movistarplus.es')) {
currentChannel.sourceOrigin = 'Movistar+';
}
}
if (currentChannel.attributes['source-origin']) {
currentChannel.sourceOrigin = currentChannel.attributes['source-origin'];
}
parsedChannels.push(currentChannel);
currentChannel = null;
} else {
currentChannel = null;
}
} else if (!line.startsWith('#') && !currentChannel) {
}
}
if (currentChannel && !currentChannel.url) {
}
const finalOrderedGroups = Array.from(new Set(orderedGroups));
return { channels: parsedChannels, groupOrder: finalOrderedGroups };
}
function normalizeStringForComparison(str) {
if (typeof str !== 'string') return '';
return str.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[hd|sd|fhd|uhd|4k|8k|(\(\d+p\))|[,.:;\-_\s()\[\]&+'!¡¿?]/g, '')
.replace(/\s+/g, '');
}
function getStringSimilarity(str1, str2) {
const s1 = normalizeStringForComparison(str1);
const s2 = normalizeStringForComparison(str2);
if (s1 === s2) return 1.0;
if (s1.length < 2 || s2.length < 2) return 0.0;
const profile1 = {};
for (let i = 0; i < s1.length - 1; i++) {
const bigram = s1.substring(i, i + 2);
profile1[bigram] = (profile1[bigram] || 0) + 1;
}
const profile2 = {};
for (let i = 0; i < s2.length - 1; i++) {
const bigram = s2.substring(i, i + 2);
profile2[bigram] = (profile2[bigram] || 0) + 1;
}
const union = new Set([...Object.keys(profile1), ...Object.keys(profile2)]);
let intersectionSize = 0;
for (const bigram of union) {
if (profile1[bigram] && profile2[bigram]) {
intersectionSize += Math.min(profile1[bigram], profile2[bigram]);
}
}
return (2.0 * intersectionSize) / (s1.length - 1 + s2.length - 1);
}
function base64ToHex(base64) {
try {
const b64Str = String(base64 || '');
const binary = atob(b64Str.replace(/-/g, '+').replace(/_/g, '/'));
let hex = '';
for (let i = 0; i < binary.length; i++) {
const byte = binary.charCodeAt(i).toString(16).padStart(2, '0');
hex += byte;
}
return hex.toLowerCase();
} catch (e) {
return null;
}
}
function parseClearKey(keyString) {
if (!keyString || typeof keyString !== 'string') {
return null;
}
keyString = keyString.trim();
const clearKeys = {};
try {
if (keyString.startsWith('{') && keyString.endsWith('}')) {
try {
const parsed = JSON.parse(keyString);
if (parsed.keys && Array.isArray(parsed.keys)) {
for (const keyObj of parsed.keys) {
if (keyObj.kty !== 'oct') { continue; }
if (!keyObj.k || !keyObj.kid) { continue; }
const kidHex = base64ToHex(keyObj.kid);
const keyHex = base64ToHex(keyObj.k);
if (kidHex && keyHex && /^[0-9a-f]{32}$/.test(kidHex) && /^[0-9a-f]{32}$/.test(keyHex)) {
clearKeys[kidHex] = keyHex;
}
}
} else {
for (const kid_orig in parsed) {
if (Object.prototype.hasOwnProperty.call(parsed, kid_orig)) {
const key_orig = parsed[kid_orig];
if (typeof kid_orig !== 'string' || typeof key_orig !== 'string') {
continue;
}
let kidHexStr, keyHexStr;
if (!/^[0-9a-fA-F]{32}$/.test(kid_orig)) {
const converted = base64ToHex(kid_orig);
kidHexStr = converted ? converted : '';
} else {
kidHexStr = kid_orig.toLowerCase();
}
if (!/^[0-9a-fA-F]{32}$/.test(key_orig)) {
const converted = base64ToHex(key_orig);
keyHexStr = converted ? converted : '';
} else {
keyHexStr = key_orig.toLowerCase();
}
if (/^[0-9a-f]{32}$/.test(kidHexStr) && /^[0-9a-f]{32}$/.test(keyHexStr)) {
clearKeys[kidHexStr] = keyHexStr;
}
}
}
}
} catch (jsonParseError) {
const compactObjectMatch = keyString.match(/^\{([0-9a-fA-F]{32}):([0-9a-fA-F]{32})\}$/);
if (compactObjectMatch) {
clearKeys[compactObjectMatch[1].toLowerCase()] = compactObjectMatch[2].toLowerCase();
}
}
}
if (Object.keys(clearKeys).length === 0) {
const simpleHexMatch = keyString.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
if (simpleHexMatch) {
clearKeys[simpleHexMatch[1].toLowerCase()] = simpleHexMatch[2].toLowerCase();
return clearKeys;
}
const simpleBase64Match = keyString.match(/^([A-Za-z0-9+/_-]+={0,2}):([A-Za-z0-9+/_-]+={0,2})$/);
if (simpleBase64Match) {
const kidHex = base64ToHex(simpleBase64Match[1]);
const keyHex = base64ToHex(simpleBase64Match[2]);
if (kidHex && keyHex && /^[0-9a-f]{32}$/.test(kidHex) && /^[0-9a-f]{32}$/.test(keyHex)) {
clearKeys[kidHex] = keyHex;
return clearKeys;
}
}
}
if (Object.keys(clearKeys).length === 0) {
return null;
}
return clearKeys;
} catch (e) {
console.error("Error parsing clearkey string:", e, keyString);
return null;
}
}
function safeParseInt(value, defaultValue = 0) {
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
function detectMimeType(url) {
if (typeof url !== 'string') return '';
const u = url.toLowerCase();
const urlWithoutQuery = u.split('?')[0];
if (urlWithoutQuery.endsWith('.m3u8')) return 'application/x-mpegURL';
if (urlWithoutQuery.endsWith('.mpd')) return 'application/dash+xml';
return '';
}

91
manifest.json Normal file
View File

@ -0,0 +1,91 @@
{
"manifest_version": 3,
"name": "DRM Player Avanzado",
"version": "2",
"description": "Reproductor avanzado de M3U/M3U8 con soporte para EPG y DRM (KodiProps), y carga de OrangeTV.",
"default_locale": "es",
"permissions": [
"storage",
"declarativeNetRequest",
"cookies"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Abrir DRM Player",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"web_accessible_resources": [
{
"resources": [
"libs/bootstrap.min.css",
"libs/controls.css",
"libs/jquery-3.7.0.min.js",
"libs/bootstrap.bundle.min.js",
"libs/particles.min.js",
"libs/shaka-player.compiled.js",
"libs/shaka-player.ui.js",
"libs/Sortable.min.js",
"player.js",
"ui_actions.js",
"m3u_operations.js",
"channel_ui.js",
"player_interaction.js",
"user_session.js",
"movistar_vod_ui.js",
"epg.js",
"orange_tv_client.js",
"settings_manager.js",
"db_manager.js",
"m3u_utils.js",
"shaka_handler.js",
"xtream_handler.js",
"xcodec_handler.js",
"dazn_handler.js",
"movistar_handler.js",
"atresplayer_handler.js",
"bartv_handler.js",
"m3u_sender.js",
"php_handler.js",
"draggable_modals.js",
"editor_handler.js",
"_locales/es/messages.json",
"_locales/en/messages.json",
"css/base.css",
"css/layout.css",
"css/sidebar.css",
"css/header.css",
"css/channel_grid.css",
"css/channel_card.css",
"css/modals_general.css",
"css/player_modal.css",
"css/epg_modal.css",
"css/movistar_vod_modal.css",
"css/settings_modal.css",
"css/xtream_modal.css",
"css/generic_modals.css",
"css/components.css",
"css/responsive.css",
"css/editor.css"
],
"matches": [
"chrome-extension://*/*"
]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; object-src 'self'; media-src * blob: data:; connect-src * blob: data: https://*.orange.es https://*.dof6.com https://*.atresplayer.com https://*.bartv.es;"
}
}

648
movistar_handler.js Normal file
View File

@ -0,0 +1,648 @@
const MOVISTAR_API_BASE = 'https://auth.dof6.com';
const MOVISTAR_API_CLIENTSERVICES = 'https://clientservices.dof6.com';
const MOVISTAR_API_IDSERVER = 'https://idserver.dof6.com';
const MOVISTAR_UI_VERSION = '2.45.20';
const MOVISTAR_API_DEVICES_ENDPOINT = `${MOVISTAR_API_CLIENTSERVICES}/movistarplus/accounts/{ACCOUNTNUMBER}/devices?qspVersion=ssp`;
const MOVISTAR_API_REGISTER_DEVICE_ENDPOINT = `${MOVISTAR_API_BASE}/movistarplus/android.tv/accounts/{ACCOUNTNUMBER}/devices/?qspVersion=ssp`;
const M_SHORT_TOKEN_KEY = 'movistar_shortToken';
const M_SHORT_TOKEN_EXPIRY_KEY = 'movistar_shortTokenExpiry';
const M_LONG_TOKEN_PREFIX = 'movistar_longToken_';
const M_LAST_USED_TOKEN_ID_KEY = 'movistar_lastUsedLongTokenId';
const M_LAST_ROTATION_DATE_KEY = 'movistar_lastRotationDate';
const M_REFRESH_LONG_TOKEN_WITHIN_DAYS = 2;
let movistarLogCallback = (message, type = 'info') => { console.log(`[MovistarHandler Log|${type}]: ${message}`); };
function setMovistarLogCallback(callback) {
if (typeof callback === 'function') {
movistarLogCallback = callback;
}
}
function _log(message, type = 'info') {
movistarLogCallback(message, type);
}
function _parseJwtPayload(token) {
if (!token || typeof token !== 'string') return null;
try {
const base64Url = token.split('.')[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const paddedBase64 = base64 + '==='.slice((base64.length + 3) % 4);
const jsonPayload = decodeURIComponent(atob(paddedBase64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
_log(`Error decodificando JWT: ${e.message}`, 'error');
return null;
}
}
async function _getAllLongTokensFromDB() {
_log('Obteniendo todos los tokens largos de la DB...');
if (typeof getAllAppConfigValues !== 'function') {
_log('Función getAllAppConfigValues no disponible en db_manager.js. No se pueden listar tokens largos.', 'error');
return [];
}
try {
const allConfig = await getAllAppConfigValues();
const longTokens = [];
for (const key in allConfig) {
if (key.startsWith(M_LONG_TOKEN_PREFIX) && allConfig[key] && typeof allConfig[key] === 'object') {
longTokens.push(allConfig[key]);
}
}
_log(`Se encontraron ${longTokens.length} tokens largos en la DB.`);
return longTokens;
} catch (error) {
_log(`Error cargando todos los tokens largos: ${error.message}`, 'error');
return [];
}
}
async function _saveLongTokenToDB(tokenData) {
if (!tokenData || !tokenData.id || !tokenData.id.startsWith(M_LONG_TOKEN_PREFIX)) {
_log(`Intento de guardar token largo con ID inválido o faltante: ${JSON.stringify(tokenData)}`, 'error');
throw new Error("ID de token largo inválido o faltante.");
}
_log(`Guardando token largo ID: ${tokenData.id.slice(-12)}`);
return saveAppConfigValue(tokenData.id, tokenData);
}
async function _deleteLongTokenFromDB(tokenId) {
_log(`Eliminando token largo ID: ${tokenId.slice(-12)}`);
return deleteAppConfigValue(tokenId);
}
async function _getOrCreateFunctionalDeviceId(longTokenData) {
_log("Buscando/Creando Device ID funcional...", 'info');
if (!longTokenData || !longTokenData.login_token || !longTokenData.account_nbr) {
throw new Error("Datos de token insuficientes para buscar/crear Device ID.");
}
const url = MOVISTAR_API_DEVICES_ENDPOINT.replace('{ACCOUNTNUMBER}', longTokenData.account_nbr);
const headers = {
'Authorization': `Bearer ${longTokenData.login_token}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01', 'x-movistarplus-ui': MOVISTAR_UI_VERSION,
'x-movistarplus-os': 'Windows10', 'Origin': 'https://ver.movistarplus.es', 'Referer': 'https://ver.movistarplus.es/'
};
try {
_log("Consultando /devices...", 'info');
const response = await fetch(url, { method: 'GET', headers: headers });
const responseText = await response.text();
_log(`Respuesta /devices: ${response.status}`, 'debug');
if (!response.ok) { throw new Error(`Fallo consulta /devices: ${response.status} ${responseText.substring(0,100)}`); }
const devices = JSON.parse(responseText);
if (!Array.isArray(devices)) throw new Error("Respuesta /devices no es array.");
const validDevices = devices.filter(d => d && d.Id && d.Id !== '-');
_log(`Encontrados ${validDevices.length} dispositivos válidos.`, 'info');
const preferredTypes = ["WP_DASH", "ANTV"];
for (const type of preferredTypes) {
const device = validDevices.find(d => d.DeviceTypeCode === type);
if (device) { _log(`Reutilizando device tipo ${type}: ...${device.Id.slice(-6)}`, 'info'); return device.Id; }
}
if (validDevices.length > 0) { _log(`Reutilizando primer device válido (tipo ${validDevices[0].DeviceTypeCode}): ...${validDevices[0].Id.slice(-6)}`, 'info'); return validDevices[0].Id; }
_log("No hay devices válidos, registrando nuevo...", 'info');
const registerUrl = MOVISTAR_API_REGISTER_DEVICE_ENDPOINT.replace('{ACCOUNTNUMBER}', longTokenData.account_nbr);
const registerHeaders = { ...headers, 'Content-Type': 'application/json' };
delete registerHeaders.Origin; delete registerHeaders.Referer;
const registerResponse = await fetch(registerUrl, { method: 'POST', headers: registerHeaders });
const newDeviceIdText = await registerResponse.text();
const newDeviceId = newDeviceIdText.trim().replace(/^"|"$/g, '');
_log(`Respuesta registro: ${registerResponse.status}`, 'debug');
if (!registerResponse.ok || !newDeviceId || newDeviceId.length < 10) {
let errorMsg = `Fallo registro: ${registerResponse.status}`;
if (newDeviceIdText.length < 200 && !newDeviceIdText.includes('<')) errorMsg += ` - ${newDeviceIdText}`;
if (registerResponse.status === 403 || newDeviceIdText.toLowerCase().includes('limit')) errorMsg = "Límite de dispositivos alcanzado.";
throw new Error(errorMsg);
}
_log(`Nuevo device registrado: ...${newDeviceId.slice(-6)}`, 'success');
return newDeviceId;
} catch (error) {
_log(`Error en flujo Device ID: ${error.message}`, 'error'); throw error;
}
}
async function _refreshMovistarLongToken(currentTokenData) {
_log(`Intentando renovar token largo ID: ${currentTokenData?.id?.slice(-12)}`, 'info');
if (!currentTokenData?.login_token || !currentTokenData?.account_nbr || !currentTokenData?.device_id) {
_log("Datos insuficientes para renovación.", 'error'); return null;
}
const { login_token, account_nbr, device_id } = currentTokenData;
try {
const sdpUrl = `${MOVISTAR_API_CLIENTSERVICES}/movistarplus/android.tv/sdp/mediaPlayers/${device_id}/initData?qspVersion=ssp&version=8&status=login`;
const sdpHeaders = {
'x-movistarplus-ui': MOVISTAR_UI_VERSION, 'Authorization': `Bearer ${login_token}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 'Content-Type': 'application/json', 'Accept': 'application/json, text/javascript, */*; q=0.01',
'x-movistarplus-deviceid': device_id, 'x-movistarplus-os': 'Windows10'
};
const sdpPayload = { 'accountNumber': account_nbr, 'sessionUserProfile': 0, 'streamMiscellanea': 'HTTPS', 'deviceType': 'WP_OTT', 'deviceManufacturerProduct': 'Chrome', 'streamDRM': 'Widevine', 'streamFormat': 'DASH' };
_log("Solicitando initData para refrescar token largo...", 'info');
const sdpResponse = await fetch(sdpUrl, { method: 'POST', headers: sdpHeaders, body: JSON.stringify(sdpPayload) });
const sdpRespJson = await sdpResponse.json();
if (!sdpResponse.ok || !sdpRespJson.accessToken) { throw new Error(`Fallo SDP (refresh long): ${sdpRespJson.message || sdpResponse.status}`); }
const refreshed_login_token = sdpRespJson.accessToken;
const newJwtPayload = _parseJwtPayload(refreshed_login_token);
if (!newJwtPayload || !newJwtPayload.exp) { throw new Error("Token largo refrescado inválido."); }
_log("Token largo renovado con éxito.", 'success');
return { ...currentTokenData, login_token: refreshed_login_token, expiry_tstamp: newJwtPayload.exp };
} catch (error) {
_log(`Error renovando token largo: ${error.message}`, 'error'); return null;
}
}
async function _getValidLongTokenForCdnGeneration() {
_log("Buscando token largo válido para generar CDN...", 'info');
const now = Math.floor(Date.now() / 1000);
const currentDateStr = new Date().toISOString().slice(0, 10);
const allLongTokens = await _getAllLongTokensFromDB();
const validFunctionalTokens = allLongTokens.filter(t => t.expiry_tstamp > now && t.device_id);
if (validFunctionalTokens.length === 0) {
_log("No se encontraron tokens largos válidos CON Device ID.", 'error');
return null;
}
_log(`Encontrados ${validFunctionalTokens.length} tokens largos funcionales.`, 'info');
const lastUsedId = await getAppConfigValue(M_LAST_USED_TOKEN_ID_KEY);
const lastRotationDate = await getAppConfigValue(M_LAST_ROTATION_DATE_KEY);
let selectedToken = null;
let needsRotation = false;
if (!lastRotationDate || currentDateStr > lastRotationDate || !lastUsedId) {
needsRotation = true;
_log("Necesita rotación (fecha o último ID no encontrado).", 'info');
} else {
selectedToken = validFunctionalTokens.find(t => t.id === lastUsedId);
if (!selectedToken) {
needsRotation = true;
_log("Necesita rotación (último ID usado ya no es válido/funcional).", 'info');
}
}
if (needsRotation) {
let nextTokenIndex = 0;
if (lastUsedId) {
const lastOriginalIndex = allLongTokens.findIndex(t => t.id === lastUsedId);
if (lastOriginalIndex !== -1) {
let foundNextValid = false;
for (let i = 1; i <= allLongTokens.length; i++) {
const potentialNextOriginalIndex = (lastOriginalIndex + i) % allLongTokens.length;
const potentialTokenId = allLongTokens[potentialNextOriginalIndex]?.id;
if(potentialTokenId) {
const validIndex = validFunctionalTokens.findIndex(vt => vt.id === potentialTokenId);
if (validIndex !== -1) {
nextTokenIndex = validIndex;
foundNextValid = true;
break;
}
}
}
if (!foundNextValid) nextTokenIndex = 0;
}
}
selectedToken = validFunctionalTokens[nextTokenIndex % validFunctionalTokens.length];
_log(`Token rotado a: ${selectedToken.id.slice(-12)}`, 'info');
await saveAppConfigValue(M_LAST_USED_TOKEN_ID_KEY, selectedToken.id);
await saveAppConfigValue(M_LAST_ROTATION_DATE_KEY, currentDateStr);
} else {
_log(`Reutilizando último token largo usado: ${selectedToken.id.slice(-12)}`, 'info');
}
const refreshThreshold = now + (M_REFRESH_LONG_TOKEN_WITHIN_DAYS * 24 * 60 * 60);
if (selectedToken.expiry_tstamp < refreshThreshold) {
_log(`Token ${selectedToken.id.slice(-12)} cerca de expirar, intentando refresco...`, 'info');
try {
const refreshedData = await _refreshMovistarLongToken(selectedToken);
if (refreshedData) {
_log(`Refresco de token largo ${selectedToken.id.slice(-12)} exitoso.`, 'success');
await _saveLongTokenToDB(refreshedData);
selectedToken = refreshedData;
} else {
_log(`Refresco de token largo ${selectedToken.id.slice(-12)} fallido. Usando el actual.`, 'warning');
}
} catch (refreshError) {
_log(`Error durante el refresco oportunista: ${refreshError.message}`, 'error');
}
}
return selectedToken;
}
async function doMovistarLoginAndGetTokens(username, password) {
_log(`LOGIN: Iniciando para usuario ${username}...`, 'info');
let result = { success: false, message: "Error desconocido", shortToken: null, shortTokenExpiry: 0, longTokenData: null };
if (!username || !password) {
result.message = "Usuario o contraseña vacíos.";
_log(result.message, 'error');
return result;
}
try {
_log(`Realizando login para usuario: ${username}...`, 'info');
const loginUrl = `${MOVISTAR_API_BASE}/auth/oauth2/token?deviceClass=android.tv`;
const loginHeaders = {
'x-movistarplus-ui': MOVISTAR_UI_VERSION, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json, text/javascript, */*; q=0.01',
'x-movistarplus-os': 'Windows10'
};
const loginBody = new URLSearchParams({'grant_type': 'password', 'deviceClass': 'android.tv', 'username': username, 'password': password });
const response = await fetch(loginUrl, { method: 'POST', headers: loginHeaders, body: loginBody.toString() });
const respJson = await response.json();
if (!response.ok || !respJson.access_token) {
throw new Error(`Fallo en login: ${respJson.error_description || respJson.message || `Error ${response.status}`}`);
}
const new_login_token = respJson.access_token;
const jwtPayload = _parseJwtPayload(new_login_token);
if (!jwtPayload || !jwtPayload.accountNumber || !jwtPayload.exp) {
throw new Error('Token de login inválido o incompleto.');
}
const loggedInAccountNumber = jwtPayload.accountNumber;
const loggedInExpiry = jwtPayload.exp;
_log(`Login OK para cuenta: ${loggedInAccountNumber}`, 'success');
const functional_device_id = await _getOrCreateFunctionalDeviceId({ login_token: new_login_token, account_nbr: loggedInAccountNumber });
if (!functional_device_id) throw new Error("No se pudo obtener/registrar Device ID funcional.");
_log(`Device ID funcional: ...${functional_device_id.slice(-6)}`, 'info');
let existingTokenId = `${M_LONG_TOKEN_PREFIX}${Date.now()}_login_${Math.random().toString(16).slice(2,8)}`;
const allExistingTokens = await _getAllLongTokensFromDB();
const existingTokenForAccount = allExistingTokens.find(t => t.account_nbr === loggedInAccountNumber);
if (existingTokenForAccount) {
existingTokenId = existingTokenForAccount.id;
_log(`Token existente encontrado para ${loggedInAccountNumber} (ID: ...${existingTokenId.slice(-12)}). Se actualizará.`, 'info');
} else {
_log(`Creando nuevo token para ${loggedInAccountNumber} (ID: ...${existingTokenId.slice(-12)}).`, 'info');
}
result.longTokenData = {
id: existingTokenId, login_token: new_login_token, account_nbr: loggedInAccountNumber,
expiry_tstamp: loggedInExpiry, device_id: functional_device_id
};
await _saveLongTokenToDB(result.longTokenData);
await saveAppConfigValue(M_LAST_USED_TOKEN_ID_KEY, result.longTokenData.id);
await saveAppConfigValue(M_LAST_ROTATION_DATE_KEY, new Date().toISOString().slice(0, 10));
_log(`Token largo ${existingTokenForAccount ? 'actualizado' : 'guardado'} en DB.`, 'info');
_log('Generando token CDN...', 'info');
const sdpUrl = `${MOVISTAR_API_CLIENTSERVICES}/movistarplus/android.tv/sdp/mediaPlayers/${result.longTokenData.device_id}/initData?qspVersion=ssp&version=8&status=login`;
const sdpHeaders = {
'x-movistarplus-ui': MOVISTAR_UI_VERSION, 'Authorization': `Bearer ${result.longTokenData.login_token}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 'Content-Type': 'application/json', 'Accept': 'application/json, text/javascript, */*; q=0.01',
'x-movistarplus-deviceid': result.longTokenData.device_id, 'x-movistarplus-os': 'Windows10'
};
const sdpPayload = {
'accountNumber': result.longTokenData.account_nbr, 'sessionUserProfile': 0, 'streamMiscellanea': 'HTTPS', 'deviceType': 'WP_OTT',
'deviceManufacturerProduct': 'Chrome', 'streamDRM': 'Widevine', 'streamFormat': 'DASH'
};
const responseSDP = await fetch(sdpUrl, { method: 'POST', headers: sdpHeaders, body: JSON.stringify(sdpPayload) });
const respJsonSDP = await responseSDP.json();
if (!responseSDP.ok || !respJsonSDP.accessToken || !respJsonSDP.token) { throw new Error(`Fallo al obtener SDP init data: ${respJsonSDP.message || `Error ${responseSDP.status}`}.`); }
const sdp_access_token = respJsonSDP.accessToken;
const hzid_token = respJsonSDP.token;
const cdnTokenUrl = `${MOVISTAR_API_IDSERVER}/${result.longTokenData.account_nbr}/devices/android.tv/cdn/token/refresh`;
const cdnHeaders = {
'Authorization': `Bearer ${sdp_access_token}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Content-Type': 'application/json', 'Accept': 'application/vnd.miviewtv.v1+json', 'X-HZId': hzid_token
};
const responseCdn = await fetch(cdnTokenUrl, { method: 'POST', headers: cdnHeaders });
const responseCdnText = await responseCdn.text();
if (!responseCdn.ok) { throw new Error(`Fallo al obtener Token CDN: Error ${responseCdn.status} - ${responseCdnText.substring(0, 100)}`); }
let respJsonCdn;
try { respJsonCdn = JSON.parse(responseCdnText); } catch (e) { throw new Error("Respuesta CDN OK pero no JSON."); }
if (!respJsonCdn || !respJsonCdn.access_token) { throw new Error(`Fallo al obtener Token CDN: ${respJsonCdn?.message || 'No access_token'}`); }
result.shortToken = respJsonCdn.access_token;
const cdnPayload = _parseJwtPayload(result.shortToken);
result.shortTokenExpiry = (cdnPayload && cdnPayload.exp) ? cdnPayload.exp : 0;
await saveAppConfigValue(M_SHORT_TOKEN_KEY, result.shortToken);
await saveAppConfigValue(M_SHORT_TOKEN_EXPIRY_KEY, result.shortTokenExpiry);
_log(`Nuevo Token CDN obtenido (expira: ${new Date(result.shortTokenExpiry * 1000).toLocaleString()}) y guardado.`, 'success');
result.success = true;
result.message = "Login y obtención de tokens completados con éxito.";
} catch (error) {
result.message = error.message;
_log(`Error en Login Movistar: ${error.message}`, 'error');
}
return result;
}
async function refreshMovistarCdnToken(forceNew = false) {
_log("REFRESH CDN: Iniciando...", 'info');
let result = { success: false, message: "Error desconocido al refrescar CDN", shortToken: null, shortTokenExpiry: 0 };
const nowSeconds = Math.floor(Date.now() / 1000);
const bufferSeconds = 60; // 1 minuto de buffer
if (!forceNew) {
try {
const cachedToken = await getAppConfigValue(M_SHORT_TOKEN_KEY);
let cachedExpiry = await getAppConfigValue(M_SHORT_TOKEN_EXPIRY_KEY) || 0;
if (typeof cachedExpiry !== 'number') cachedExpiry = 0;
if (cachedToken && cachedExpiry > (nowSeconds + bufferSeconds)) {
_log(`Usando token CDN cacheado (expira: ${new Date(cachedExpiry * 1000).toLocaleString()})`, 'info');
result.shortToken = cachedToken;
result.shortTokenExpiry = cachedExpiry;
result.success = true;
result.message = "Token CDN obtenido de la caché.";
return result;
} else {
_log("Token CDN cacheado no válido o expirado. Procediendo a generar uno nuevo.", 'info');
await deleteAppConfigValue(M_SHORT_TOKEN_KEY);
await deleteAppConfigValue(M_SHORT_TOKEN_EXPIRY_KEY);
}
} catch (cacheError) {
_log(`Error leyendo caché de token CDN: ${cacheError.message}. Generando nuevo.`, 'warning');
}
} else {
_log("Forzando generación de nuevo token CDN.", 'info');
}
try {
const longTokenToUse = await _getValidLongTokenForCdnGeneration();
if (!longTokenToUse) {
throw new Error("No se encontró token largo válido y funcional para generar CDN.");
}
_log(`Usando Token Largo ID: ...${longTokenToUse.id.slice(-12)} (Exp: ${new Date(longTokenToUse.expiry_tstamp * 1000).toLocaleDateString()})`, 'info');
_log(`Con Device ID: ...${longTokenToUse.device_id.slice(-6)}`, 'info');
_log('Generando nuevo token CDN...', 'info');
const sdpUrl = `${MOVISTAR_API_CLIENTSERVICES}/movistarplus/android.tv/sdp/mediaPlayers/${longTokenToUse.device_id}/initData?qspVersion=ssp&version=8&status=login`;
const sdpHeaders = {
'x-movistarplus-ui': MOVISTAR_UI_VERSION, 'Authorization': `Bearer ${longTokenToUse.login_token}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 'Content-Type': 'application/json', 'Accept': 'application/json, text/javascript, */*; q=0.01',
'x-movistarplus-deviceid': longTokenToUse.device_id, 'x-movistarplus-os': 'Windows10'
};
const sdpPayload = {
'accountNumber': longTokenToUse.account_nbr, 'sessionUserProfile': 0, 'streamMiscellanea': 'HTTPS', 'deviceType': 'WP_OTT',
'deviceManufacturerProduct': 'Chrome', 'streamDRM': 'Widevine', 'streamFormat': 'DASH'
};
const responseSDP = await fetch(sdpUrl, { method: 'POST', headers: sdpHeaders, body: JSON.stringify(sdpPayload) });
const respJsonSDP = await responseSDP.json();
if (!responseSDP.ok || !respJsonSDP.accessToken || !respJsonSDP.token) { throw new Error(`Fallo al obtener SDP init data (refresh): ${respJsonSDP.message || `Error ${responseSDP.status}`}.`); }
const sdp_access_token = respJsonSDP.accessToken;
const hzid_token = respJsonSDP.token;
const cdnTokenUrl = `${MOVISTAR_API_IDSERVER}/${longTokenToUse.account_nbr}/devices/android.tv/cdn/token/refresh`;
const cdnHeaders = {
'Authorization': `Bearer ${sdp_access_token}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Content-Type': 'application/json', 'Accept': 'application/vnd.miviewtv.v1+json', 'X-HZId': hzid_token
};
const responseCdn = await fetch(cdnTokenUrl, { method: 'POST', headers: cdnHeaders });
const responseCdnText = await responseCdn.text();
if (!responseCdn.ok) { throw new Error(`Fallo al obtener Token CDN (refresh): Error ${responseCdn.status} - ${responseCdnText.substring(0, 100)}`); }
let respJsonCdn;
try { respJsonCdn = JSON.parse(responseCdnText); } catch (e) { throw new Error("Respuesta CDN OK pero no JSON (refresh)."); }
if (!respJsonCdn || !respJsonCdn.access_token) { throw new Error(`Fallo al obtener Token CDN (refresh): ${respJsonCdn?.message || 'No access_token'}`); }
result.shortToken = respJsonCdn.access_token;
const cdnPayload = _parseJwtPayload(result.shortToken);
result.shortTokenExpiry = (cdnPayload && cdnPayload.exp) ? cdnPayload.exp : 0;
if (result.shortTokenExpiry <= Math.floor(Date.now() / 1000)) {
throw new Error("Token CDN generado (refresh) ya ha expirado.");
}
await saveAppConfigValue(M_SHORT_TOKEN_KEY, result.shortToken);
await saveAppConfigValue(M_SHORT_TOKEN_EXPIRY_KEY, result.shortTokenExpiry);
_log(`Nuevo Token CDN obtenido vía refresh (expira: ${new Date(result.shortTokenExpiry * 1000).toLocaleString()}) y guardado.`, 'success');
result.success = true;
result.message = "Token CDN refrescado y guardado con éxito.";
} catch (error) {
result.message = error.message;
_log(`Error refrescando token CDN: ${error.message}`, 'error');
}
return result;
}
async function getAllLongTokens() {
return _getAllLongTokensFromDB();
}
async function deleteLongToken(tokenId) {
_log(`Eliminando token largo (handler): ${tokenId.slice(-12)}`, 'info');
await _deleteLongTokenFromDB(tokenId);
const lastUsedId = await getAppConfigValue(M_LAST_USED_TOKEN_ID_KEY);
if (lastUsedId === tokenId) {
await deleteAppConfigValue(M_LAST_USED_TOKEN_ID_KEY);
_log("Referencia a último token usado eliminada.", 'info');
}
}
async function validateAllLongTokens() {
_log("Validando todos los tokens largos...", 'info');
const nowSeconds = Math.floor(Date.now() / 1000);
const refreshThresholdSeconds = M_REFRESH_LONG_TOKEN_WITHIN_DAYS * 24 * 60 * 60;
let report = { validated: 0, functional: 0, expired: 0, refreshed: 0, refreshErrors: 0, noDeviceId: 0 };
const tokens = await _getAllLongTokensFromDB();
report.validated = tokens.length;
for (const token of tokens) {
if (!token || !token.expiry_tstamp) continue;
if (token.expiry_tstamp < nowSeconds) {
report.expired++;
} else {
if (!token.device_id) {
report.noDeviceId++;
} else {
report.functional++;
if (token.expiry_tstamp < (nowSeconds + refreshThresholdSeconds)) {
_log(`Token ${token.id.slice(-12)} cerca de expirar. Intentando refresco...`, 'info');
try {
const refreshedData = await _refreshMovistarLongToken(token);
if (refreshedData) {
await _saveLongTokenToDB(refreshedData);
report.refreshed++;
_log(`Token ${token.id.slice(-12)} refrescado.`, 'success');
} else {
report.refreshErrors++;
_log(`Fallo al refrescar token ${token.id.slice(-12)}.`, 'warning');
}
} catch (e) {
report.refreshErrors++;
_log(`Error crítico al refrescar ${token.id.slice(-12)}: ${e.message}`, 'error');
}
}
}
}
}
_log(`Validación completa: ${JSON.stringify(report)}`, 'info');
return report;
}
async function deleteExpiredLongTokens() {
_log("Eliminando tokens largos expirados...", 'info');
const tokens = await _getAllLongTokensFromDB();
const nowSeconds = Math.floor(Date.now() / 1000);
const expiredTokens = tokens.filter(t => !t || !t.expiry_tstamp || t.expiry_tstamp < nowSeconds);
let deletedCount = 0;
if (expiredTokens.length === 0) {
_log("No hay tokens expirados para eliminar.", 'info');
return 0;
}
for (const token of expiredTokens) {
if (token && token.id) {
try {
await _deleteLongTokenFromDB(token.id);
const lastUsedId = await getAppConfigValue(M_LAST_USED_TOKEN_ID_KEY);
if(lastUsedId === token.id) await deleteAppConfigValue(M_LAST_USED_TOKEN_ID_KEY);
deletedCount++;
} catch (e) {
_log(`Error eliminando token expirado ${token.id.slice(-12)}: ${e.message}`, 'error');
}
}
}
_log(`${deletedCount} tokens expirados eliminados.`, 'info');
return deletedCount;
}
async function addLongTokenManually(jwtTokenString, deviceId = null) {
_log(`Añadiendo token manualmente: ${jwtTokenString.substring(0,20)}...`, 'info');
const payload = _parseJwtPayload(jwtTokenString);
if (!payload || !payload.accountNumber || !payload.exp) {
throw new Error('Token JWT inválido o no contiene accountNumber/exp.');
}
let deviceIdToUse = deviceId;
if (!deviceIdToUse) {
_log("No se proveyó Device ID, intentando obtener/registrar uno...", 'info');
deviceIdToUse = await _getOrCreateFunctionalDeviceId({
login_token: jwtTokenString,
account_nbr: payload.accountNumber
});
if (!deviceIdToUse) throw new Error("Fallo al obtener/registrar Device ID automáticamente.");
_log(`Device ID obtenido/registrado: ...${deviceIdToUse.slice(-6)}`, 'info');
}
const newTokenData = {
id: `${M_LONG_TOKEN_PREFIX}${Date.now()}_manual_${Math.random().toString(16).slice(2)}`,
login_token: jwtTokenString,
account_nbr: payload.accountNumber,
expiry_tstamp: payload.exp,
device_id: deviceIdToUse
};
await _saveLongTokenToDB(newTokenData);
_log(`Token manual guardado con ID: ${newTokenData.id.slice(-12)}`, 'success');
return newTokenData;
}
async function getMovistarDevicesForToken(longTokenId) {
_log(`Obteniendo dispositivos para token ID ${longTokenId.slice(-12)}...`, 'info');
const tokenData = await getAppConfigValue(longTokenId);
if (!tokenData || !tokenData.login_token || !tokenData.account_nbr) {
throw new Error("Token largo no encontrado o inválido para obtener dispositivos.");
}
const url = MOVISTAR_API_DEVICES_ENDPOINT.replace('{ACCOUNTNUMBER}', tokenData.account_nbr);
const headers = {
'Authorization': `Bearer ${tokenData.login_token}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01', 'x-movistarplus-ui': MOVISTAR_UI_VERSION,
'x-movistarplus-os': 'Windows10', 'Origin': 'https://ver.movistarplus.es', 'Referer': 'https://ver.movistarplus.es/'
};
const response = await fetch(url, { method: 'GET', headers: headers });
const responseText = await response.text();
if (!response.ok) { throw new Error(`Fallo al obtener dispositivos: ${response.status} ${responseText.substring(0,100)}`); }
const devicesApi = JSON.parse(responseText);
if (!Array.isArray(devicesApi)) { throw new Error("Respuesta de API de dispositivos inesperada."); }
return devicesApi.filter(d => d && d.Id && d.Id !== '-').map(d => ({
id: d.Id,
name: d.Name || `Dispositivo ${d.DeviceTypeCode || '?'}`,
type: d.DeviceTypeCode || '?',
reg_date: d.RegistrationDate ? new Date(d.RegistrationDate).toLocaleDateString() : 'N/D',
is_associated: d.Id === tokenData.device_id
}));
}
async function associateDeviceToLongToken(longTokenId, deviceIdToAssociate) {
_log(`Asociando Device ID ${deviceIdToAssociate.slice(-6)} a Token ID ${longTokenId.slice(-12)}...`, 'info');
const tokenData = await getAppConfigValue(longTokenId);
if (!tokenData) throw new Error("Token largo no encontrado para asociar dispositivo.");
if (tokenData.device_id === deviceIdToAssociate) {
_log("El dispositivo ya está asociado a este token.", 'info');
return tokenData;
}
tokenData.device_id = deviceIdToAssociate;
await _saveLongTokenToDB(tokenData);
_log("Device ID asociado y token guardado.", 'success');
return tokenData;
}
async function registerAndAssociateNewDevice(longTokenId) {
_log(`Registrando nuevo dispositivo para Token ID ${longTokenId.slice(-12)}...`, 'info');
const tokenData = await getAppConfigValue(longTokenId);
if (!tokenData || !tokenData.login_token || !tokenData.account_nbr) {
throw new Error("Token largo no encontrado o inválido para registrar nuevo dispositivo.");
}
const url = MOVISTAR_API_REGISTER_DEVICE_ENDPOINT.replace('{ACCOUNTNUMBER}', tokenData.account_nbr);
const headers = {
'Authorization': `Bearer ${tokenData.login_token}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01', 'x-movistarplus-ui': MOVISTAR_UI_VERSION,
'x-movistarplus-os': 'Windows10', 'Content-Type': 'application/json'
};
const response = await fetch(url, { method: 'POST', headers: headers });
const newDeviceIdText = await response.text();
const newDeviceId = newDeviceIdText.trim().replace(/^"|"$/g, '');
if (!response.ok || !newDeviceId || newDeviceId.length < 10) {
let errorMsg = `Fallo registro: ${response.status}`;
if (newDeviceIdText.length < 200 && !newDeviceIdText.includes('<html')) errorMsg += ` - ${newDeviceIdText}`;
if (response.status === 403 || newDeviceIdText.toLowerCase().includes('limit')) errorMsg = "Límite de dispositivos alcanzado.";
throw new Error(errorMsg);
}
_log(`Nuevo Device ID registrado: ...${newDeviceId.slice(-6)}`, 'success');
tokenData.device_id = newDeviceId;
await _saveLongTokenToDB(tokenData);
_log("Nuevo Device ID asociado y token guardado.", 'success');
return tokenData;
}
async function getMovistarShortTokenStatus() {
const token = await getAppConfigValue(M_SHORT_TOKEN_KEY);
const expiry = await getAppConfigValue(M_SHORT_TOKEN_EXPIRY_KEY) || 0;
return { token, expiry: Number(expiry) || 0 };
}
window.MovistarTokenHandler = {
setLogCallback: setMovistarLogCallback,
loginAndGetTokens: doMovistarLoginAndGetTokens,
refreshCdnToken: refreshMovistarCdnToken,
getAllLongTokens: getAllLongTokens,
deleteLongToken: deleteLongToken,
validateAllLongTokens: validateAllLongTokens,
deleteExpiredLongTokens: deleteExpiredLongTokens,
addLongTokenManually: addLongTokenManually,
getDevicesForToken: getMovistarDevicesForToken,
associateDeviceToToken: associateDeviceToLongToken,
registerAndAssociateNewDeviceToToken: registerAndAssociateNewDevice,
getShortTokenStatus: getMovistarShortTokenStatus,
};
_log("Movistar Handler (para Extensión v1.0) inicializado.", 'info');

532
movistar_vod_ui.js Normal file
View File

@ -0,0 +1,532 @@
const MOVISTAR_VOD_API_BASE_URL = 'https://ottcache.dof6.com/movistarplus/webplayer/OTT/epg';
const MOVISTAR_VOD_CACHE_MAX_AGE_MS = 12 * 60 * 60 * 1000;
const MOVISTAR_VOD_ITEMS_PER_PAGE = 48;
let movistarVodData = [];
let movistarVodSelectedDate = new Date();
let movistarVodChannelMap = {};
let movistarVodOrderedChannels = [];
let movistarVodGenreMap = {};
let movistarVodSelectedChannelId = '';
let movistarVodSelectedGenre = '';
let movistarVodSearchTerm = '';
let movistarVodCurrentPage = 1;
let movistarVodFilteredPrograms = [];
function openMovistarVODModal() {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
$('#movistarVODDateInput').val(`${yyyy}-${mm}-${dd}`);
movistarVodSelectedDate = today;
$('#movistarVODModal-search-input').val('');
movistarVodSearchTerm = '';
movistarVodCurrentPage = 1;
$('#movistarVODModal').modal('show');
loadMovistarVODData();
}
async function loadMovistarVODData() {
showLoading(true, "Cargando EPG de Movistar VOD...");
const programsContainer = $('#movistarVODModal-programs').empty();
const noResultsP = $('#movistarVODModal-no-results');
noResultsP.addClass('d-none');
programsContainer.html('<div class="w-100 text-center p-3"><i class="fas fa-spinner fa-spin fa-2x"></i></div>');
const yyyy = movistarVodSelectedDate.getFullYear();
const mm = String(movistarVodSelectedDate.getMonth() + 1).padStart(2, '0');
const dd = String(movistarVodSelectedDate.getDate()).padStart(2, '0');
const dateString = `${yyyy}-${mm}-${dd}`;
let jsonDataFromCache = null;
try {
const cachedRecord = await getMovistarVodData(dateString);
if (cachedRecord && cachedRecord.data && cachedRecord.timestamp) {
if ((new Date().getTime() - cachedRecord.timestamp) < MOVISTAR_VOD_CACHE_MAX_AGE_MS) {
jsonDataFromCache = cachedRecord.data;
showNotification("Datos VOD cargados desde caché local.", "info");
} else {
showNotification("Datos VOD en caché expirados, obteniendo nuevos...", "info");
}
}
} catch (e) {
console.warn("Error al cargar VOD desde caché:", e);
}
try {
let processedDataForDisplay;
if (jsonDataFromCache) {
processedDataForDisplay = jsonDataFromCache;
} else {
const apiUrl = `${MOVISTAR_VOD_API_BASE_URL}?from=${dateString}T06:00:00&span=1&channel=&network=movistarplus&version=8.2&mdrm=true&tlsstream=true&demarcation=1`;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`Error HTTP ${response.status} al cargar VOD.`);
}
const rawJsonData = await response.json();
let processedProgramsToCache = [];
if (Array.isArray(rawJsonData)) {
rawJsonData.forEach(channelProgramArray => {
if (Array.isArray(channelProgramArray)) {
channelProgramArray.forEach(prog => {
processedProgramsToCache.push({
Titulo: prog.Titulo,
CanalNombre: prog.Canal?.Nombre,
CanalServiceUid2: prog.Canal?.ServiceUid2,
FechaHoraInicio: prog.FechaHoraInicio,
FechaHoraFin: prog.FechaHoraFin,
Duracion: prog.Duracion,
GeneroComAntena: prog.GeneroComAntena,
Ficha: prog.Ficha,
IdPrograma: prog.IdPrograma,
ImagenMiniatura: prog.ImagenMiniatura
});
});
}
});
}
processedDataForDisplay = processedProgramsToCache;
try {
await saveMovistarVodData(dateString, { data: processedProgramsToCache, timestamp: new Date().getTime() });
const deletedOldCount = await deleteOldMovistarVodData(userSettings.movistarVodCacheDaysToKeep);
if (deletedOldCount > 0) {
console.log(`Se eliminaron ${deletedOldCount} registros VOD antiguos de la caché.`);
if (typeof updateMovistarVodCacheStatsUI === 'function') {
updateMovistarVodCacheStatsUI();
}
}
} catch(dbError) {
console.warn("Error guardando/limpiando VOD en DB:", dbError);
showNotification("Error guardando datos VOD en caché local.", "warning");
}
}
movistarVodData = Array.isArray(processedDataForDisplay) ? processedDataForDisplay : [];
movistarVodChannelMap = {};
movistarVodGenreMap = {};
const seenChannelIds = new Set();
movistarVodOrderedChannels = [];
if (movistarVodData.length === 0) {
noResultsP.removeClass('d-none');
programsContainer.empty();
} else {
movistarVodData.forEach(prog => {
if (prog.CanalServiceUid2 && prog.CanalNombre) {
if (!seenChannelIds.has(prog.CanalServiceUid2)) {
movistarVodOrderedChannels.push({ id: prog.CanalServiceUid2, name: prog.CanalNombre });
seenChannelIds.add(prog.CanalServiceUid2);
}
movistarVodChannelMap[prog.CanalServiceUid2] = prog.CanalNombre;
}
if (prog.GeneroComAntena && !movistarVodGenreMap[prog.GeneroComAntena]) {
movistarVodGenreMap[prog.GeneroComAntena] = prog.GeneroComAntena;
}
});
}
if (userSettings.useMovistarVodAsEpg && typeof updateEpgWithMovistarVodData === 'function') {
await updateEpgWithMovistarVodData(dateString, movistarVodData);
}
movistarVodCurrentPage = 1;
populateMovistarVODFilters();
renderMovistarVODPrograms();
} catch (error) {
console.error("Error al cargar Movistar VOD data:", error);
showNotification(`Error cargando EPG VOD: ${error.message}`, 'error');
programsContainer.empty();
noResultsP.removeClass('d-none');
movistarVodData = [];
movistarVodChannelMap = {};
movistarVodGenreMap = {};
populateMovistarVODFilters();
} finally {
showLoading(false);
}
}
function populateMovistarVODFilters() {
const channelFilter = $('#movistarVODModal-channel-filter').empty().append('<option value="">Todos los canales</option>');
const genreFilter = $('#movistarVODModal-genre-filter').empty().append('<option value="">Todos los géneros</option>');
movistarVodOrderedChannels.forEach(ch => {
channelFilter.append(`<option value="${escapeHtml(ch.id)}">${escapeHtml(ch.name)}</option>`);
});
if (movistarVodSelectedChannelId && movistarVodChannelMap[movistarVodSelectedChannelId]) {
channelFilter.val(movistarVodSelectedChannelId);
}
const sortedGenres = Object.keys(movistarVodGenreMap).sort((a,b) => a.localeCompare(b));
sortedGenres.forEach(genre => {
genreFilter.append(`<option value="${escapeHtml(genre)}">${escapeHtml(genre)}</option>`);
});
if (movistarVodSelectedGenre && movistarVodGenreMap[movistarVodSelectedGenre]) {
genreFilter.val(movistarVodSelectedGenre);
}
}
function renderMovistarVODPrograms() {
movistarVodSelectedChannelId = $('#movistarVODModal-channel-filter').val();
movistarVodSelectedGenre = $('#movistarVODModal-genre-filter').val();
movistarVodSearchTerm = $('#movistarVODModal-search-input').val().toLowerCase().trim();
movistarVodFilteredPrograms = movistarVodData.filter(prog => {
if (movistarVodSelectedChannelId && prog.CanalServiceUid2 !== movistarVodSelectedChannelId) return false;
if (movistarVodSelectedGenre && prog.GeneroComAntena !== movistarVodSelectedGenre) return false;
if (movistarVodSearchTerm && !prog.Titulo?.toLowerCase().includes(movistarVodSearchTerm)) return false;
return true;
});
movistarVodCurrentPage = 1;
displayCurrentMovistarVODPage();
updateMovistarVODPaginationControls();
}
async function displayCurrentMovistarVODPage() {
const programsContainer = $('#movistarVODModal-programs').empty();
const noResultsP = $('#movistarVODModal-no-results');
const startIndex = (movistarVodCurrentPage - 1) * MOVISTAR_VOD_ITEMS_PER_PAGE;
const endIndex = startIndex + MOVISTAR_VOD_ITEMS_PER_PAGE;
const programsToDisplay = movistarVodFilteredPrograms.slice(startIndex, endIndex);
if (programsToDisplay.length > 0) {
noResultsP.addClass('d-none');
const fragment = document.createDocumentFragment();
const imageFetchPromises = programsToDisplay.map(async (prog) => {
let finalImageUrl = prog.ImagenMiniatura || 'icons/icon128.png';
if (prog.Ficha) {
try {
const response = await fetch(prog.Ficha);
if (response.ok) {
const fichaData = await response.json();
if (fichaData && fichaData.Imagen) {
finalImageUrl = fichaData.Imagen;
}
}
} catch (e) {
console.error(`Error en fetch a ${prog.Ficha} para ${prog.Titulo}: ${e}`);
}
}
return finalImageUrl;
});
const imageUrls = await Promise.all(imageFetchPromises);
programsToDisplay.forEach((prog, index) => {
const imageUrl = imageUrls[index];
const card = document.createElement('div');
card.className = 'movistar-vod-card';
card.dataset.programArrayIndex = startIndex + index;
const startTime = new Date(parseInt(prog.FechaHoraInicio)).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const endTime = new Date(parseInt(prog.FechaHoraFin)).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
card.innerHTML = `
<div class="movistar-vod-card-img-container">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(prog.Titulo || '')}" loading="lazy" onerror="this.onerror=null;this.src='icons/icon128.png';">
</div>
<div class="movistar-vod-card-body">
<h5 class="movistar-vod-card-title" title="${escapeHtml(prog.Titulo || '')}">${escapeHtml(prog.Titulo || 'Sin título')}</h5>
<p class="movistar-vod-card-channel">${escapeHtml(prog.CanalNombre || 'Desconocido')}</p>
<p class="movistar-vod-card-time">${startTime} - ${endTime} (${prog.Duracion} min)</p>
${prog.GeneroComAntena ? `<p class="movistar-vod-card-genre">${escapeHtml(prog.GeneroComAntena)}</p>` : ''}
</div>
`;
fragment.appendChild(card);
});
programsContainer.append(fragment);
} else {
noResultsP.removeClass('d-none');
}
}
function updateMovistarVODPaginationControls() {
const totalItems = movistarVodFilteredPrograms.length;
const totalPages = Math.max(1, Math.ceil(totalItems / MOVISTAR_VOD_ITEMS_PER_PAGE));
const controlsContainer = $('#movistarVODModal-pagination-controls');
const pageInfoSpan = $('#movistarVODModal-page-info');
const prevButton = $('#movistarVODModal-prev-page');
const nextButton = $('#movistarVODModal-next-page');
if (totalPages <= 1) {
controlsContainer.hide();
return;
}
controlsContainer.show();
pageInfoSpan.text(`Página ${movistarVodCurrentPage} de ${totalPages} (${totalItems} resultados)`);
prevButton.prop('disabled', movistarVodCurrentPage === 1);
nextButton.prop('disabled', movistarVodCurrentPage === totalPages);
}
function handleMovistarVODProgramClick(programData) {
showMovistarVODProgramDetailsModal(programData);
}
async function showMovistarVODProgramDetailsModal(programData) {
const modalBody = $('#movistarVODProgramDetailsBody').empty();
const modalLabel = $('#movistarVODProgramDetailsModalLabel');
const playButton = $('#playMovistarVODProgramFromDetailsBtn').off('click');
const addButton = $('#addMovistarVODToM3UFromDetailsBtn').off('click');
modalLabel.text(escapeHtml(programData.Titulo || 'Detalles del Programa'));
let imageUrl = programData.ImagenMiniatura || 'icons/icon128.png';
let fichaData = null;
if (programData.Ficha) {
try {
showLoading(true, "Cargando detalles...");
const response = await fetch(programData.Ficha);
if (response.ok) {
fichaData = await response.json();
if (fichaData && fichaData.Imagen) {
imageUrl = fichaData.Imagen;
}
}
} catch (e) {
console.error(`Error obteniendo ficha para ${programData.Titulo}: ${e}`);
showNotification("Error cargando detalles adicionales del programa.", "warning");
} finally {
showLoading(false);
}
}
let detailsHtml = `<div class="row"><div class="col-md-4 text-center"><img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(programData.Titulo)}" class="img-fluid rounded mb-3" style="max-height: 300px; object-fit: contain; background-color: var(--bg-tertiary);"></div><div class="col-md-8">`;
detailsHtml += `<h5>${escapeHtml(programData.Titulo || 'Sin título')}</h5>`;
detailsHtml += `<p><strong>Canal:</strong> ${escapeHtml(programData.CanalNombre || 'Desconocido')}</p>`;
detailsHtml += `<p><strong>Duración:</strong> ${escapeHtml(formatVodDuration(programData.Duracion))}</p>`;
if (fichaData?.Anno) detailsHtml += `<p><strong>Año:</strong> ${escapeHtml(fichaData.Anno)}</p>`;
if (fichaData?.Nacionalidad) detailsHtml += `<p><strong>Nacionalidad:</strong> ${escapeHtml(fichaData.Nacionalidad)}</p>`;
const description = fichaData?.Descripcion || fichaData?.Sinopsis;
if (description) detailsHtml += `<p class="text-break"><strong>Descripción:</strong> ${escapeHtml(description)}</p>`;
if (fichaData?.Actores) detailsHtml += `<p class="text-break"><strong>Actores:</strong> ${escapeHtml(fichaData.Actores)}</p>`;
if (fichaData?.Directores) detailsHtml += `<p class="text-break"><strong>Directores:</strong> ${escapeHtml(fichaData.Directores)}</p>`;
if (fichaData?.Valoracion?.Valoracion) {
detailsHtml += `<p><strong>Valoración:</strong> ${escapeHtml(fichaData.Valoracion.Valoracion.toFixed(1))}⭐ (${escapeHtml(fichaData.Valoracion.Valoraciones)} votos)</p>`;
}
detailsHtml += `</div></div>`;
modalBody.html(detailsHtml);
playButton.on('click', () => {
handlePlayCatchup(null, programData);
$('#movistarVODProgramDetailsModal').modal('hide');
});
addButton.on('click', () => {
addMovistarVODToM3U(programData, fichaData);
});
$('#movistarVODProgramDetailsModal').modal('show');
}
function formatVodDuration(minutes) {
if (isNaN(minutes) || minutes <= 0) return 'N/D';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
let str = '';
if (h > 0) str += `${h}h `;
if (m > 0) str += `${m}min`;
return str.trim() || `${minutes} min`;
}
async function addMovistarVODToM3U(programData, fichaData) {
if (!channels || channels.length === 0) {
showNotification("Debes tener una lista M3U de Movistar+ cargada para añadir contenido VOD/Catchup.", "warning");
return;
}
const serviceUid2 = programData.CanalServiceUid2;
if (!serviceUid2) {
showNotification("El programa seleccionado no tiene un ServiceUid2 válido para buscar el canal M3U base.", "error");
return;
}
const m3uChannelBase = channels.find(ch => {
if (ch.url && (ch.url.includes(`/${serviceUid2}/`) || ch.url.includes(`/CVXCH${serviceUid2}/`))) return true;
const tvgIdServiceUid = ch['tvg-id'] ? ch['tvg-id'].split('.').pop() : null;
if (tvgIdServiceUid === serviceUid2) return true;
if (ch.attributes && ch.attributes['ch-number'] && ch.attributes['ch-number'] === serviceUid2) return true;
return false;
});
if (!m3uChannelBase) {
showNotification(`No se encontró el canal M3U base (${programData.CanalNombre || serviceUid2}) en tu lista actual para añadir el VOD.`, "warning");
return;
}
const programStartTime = new Date(parseInt(programData.FechaHoraInicio));
const programEndTime = new Date(parseInt(programData.FechaHoraFin));
const catchupUrl = buildMovistarCatchupUrl(m3uChannelBase, programStartTime, programEndTime);
if (!catchupUrl) {
showNotification("No se pudo generar la URL de catchup para este programa/canal.", "error");
return;
}
const newVodChannelObject = {
name: `${programData.Titulo || 'Programa VOD'} (${m3uChannelBase.name})`,
url: catchupUrl,
'tvg-id': `vod.${programData.IdPrograma}_${serviceUid2}`,
'tvg-logo': fichaData?.Imagen || programData.ImagenMiniatura || m3uChannelBase['tvg-logo'] || '',
'group-title': `VOD - ${m3uChannelBase['group-title'] || programData.CanalNombre || 'Movistar'}`,
attributes: {
'tvg-id': `vod.${programData.IdPrograma}_${serviceUid2}`,
'tvg-logo': fichaData?.Imagen || programData.ImagenMiniatura || m3uChannelBase['tvg-logo'] || '',
'group-title': `VOD - ${m3uChannelBase['group-title'] || programData.CanalNombre || 'Movistar'}`,
duration: programData.Duracion || -1,
},
kodiProps: { ...m3uChannelBase.kodiProps, 'inputstream.adaptive.play_timeshift_buffer': 'true' },
vlcOptions: { ...m3uChannelBase.vlcOptions },
extHttp: { ...m3uChannelBase.extHttp },
sourceOrigin: m3uChannelBase.sourceOrigin || `movistar-vod-${serviceUid2}`
};
channels.push(newVodChannelObject);
const newGroup = newVodChannelObject['group-title'];
if (currentGroupOrder && !currentGroupOrder.includes(newGroup)) {
currentGroupOrder.push(newGroup);
}
if (typeof regenerateCurrentM3UContentFromString === 'function') regenerateCurrentM3UContentFromString();
if (typeof filterAndRenderChannels === 'function') filterAndRenderChannels();
showNotification(`"${escapeHtml(programData.Titulo)}" añadido a la lista M3U.`, "success");
$('#movistarVODProgramDetailsModal').modal('hide');
}
function toISOUTCString(date) {
if (!(date instanceof Date) || isNaN(date.getTime())) return null;
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
}
function buildMovistarCatchupUrl(originalM3UChannel, programStartDt, programEndDt) {
if (!originalM3UChannel || !originalM3UChannel.url || !programStartDt || !programEndDt) {
console.error("buildMovistarCatchupUrl: Parámetros inválidos.");
return null;
}
const originalUrlStr = originalM3UChannel.url;
if (!originalUrlStr.toLowerCase().includes('.cdn.telefonica.com/') && !originalUrlStr.toLowerCase().includes('.movistarplus.es/')) {
console.warn("buildMovistarCatchupUrl: La URL no parece ser de Movistar CDN:", originalUrlStr);
return null;
}
let serviceIdFromM3U = null;
const serviceIdRegexes = [
/\/(\d{3,6})\/vxfmt=dp\//i,
/\/CVXCH(\d{3,6})\//i,
/\/([A-Za-z0-9_-]+)\.MS\/vxfmt=dp/i
];
let serviceIdFromUrl = null;
for (const regex of serviceIdRegexes) {
const match = originalUrlStr.match(regex);
if (match && match[1]) {
serviceIdFromUrl = match[1];
break;
}
}
if (originalM3UChannel['tvg-id']) {
const tvgId = String(originalM3UChannel['tvg-id']);
const idParts = tvgId.split('.');
const potentialIdFromTvg = idParts[idParts.length - 1];
if (/^\d+$/.test(potentialIdFromTvg) || potentialIdFromTvg.includes('.MS')) {
serviceIdFromM3U = potentialIdFromTvg;
} else if (originalM3UChannel.attributes && originalM3UChannel.attributes['ch-number'] && /^\d+$/.test(originalM3UChannel.attributes['ch-number'])) {
serviceIdFromM3U = originalM3UChannel.attributes['ch-number'];
}
} else if (originalM3UChannel.attributes && originalM3UChannel.attributes['ch-number'] && /^\d+$/.test(originalM3UChannel.attributes['ch-number'])) {
serviceIdFromM3U = originalM3UChannel.attributes['ch-number'];
}
const effectiveServiceIdForPath = serviceIdFromUrl || serviceIdFromM3U;
if (!effectiveServiceIdForPath) {
console.warn("buildMovistarCatchupUrl: No se pudo extraer un Service ID válido de la URL del canal o del M3U:", originalUrlStr, "tvg-id:", originalM3UChannel['tvg-id']);
return null;
}
const domainMatch = originalUrlStr.match(/https?:\/\/([^/]+)/);
if (!domainMatch || !domainMatch[1]) {
console.warn("buildMovistarCatchupUrl: No se pudo extraer el dominio de la URL del canal:", originalUrlStr);
return null;
}
const domain = domainMatch[1];
const startTimeStr = toISOUTCString(programStartDt);
const endTimeStr = toISOUTCString(programEndDt);
if (!startTimeStr || !endTimeStr) {
console.warn("buildMovistarCatchupUrl: Fechas de inicio o fin del programa inválidas para catchup.");
return null;
}
let originalUrlObj;
try {
originalUrlObj = new URL(originalUrlStr);
} catch (e) {
console.error("buildMovistarCatchupUrl: URL original inválida:", originalUrlStr, e);
return null;
}
let basePathForCatchup;
const originalPath = originalUrlObj.pathname;
const liveStreamSuffix = "/vxfmt=dp/Manifest.mpd";
const indexOfLiveSuffix = originalPath.lastIndexOf(liveStreamSuffix);
if (indexOfLiveSuffix !== -1) {
const channelPathPrefix = originalPath.substring(0, indexOfLiveSuffix);
basePathForCatchup = `${channelPathPrefix}${liveStreamSuffix}`;
} else {
basePathForCatchup = `/${effectiveServiceIdForPath}${liveStreamSuffix}`;
}
basePathForCatchup = basePathForCatchup.replace(/\/\//g, '/');
if (!basePathForCatchup.startsWith('/')) {
basePathForCatchup = '/' + basePathForCatchup;
}
const queryParamsToEncode = new URLSearchParams();
originalUrlObj.searchParams.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if (lowerKey !== 'start_time' && lowerKey !== 'end_time' && lowerKey !== 'token') {
queryParamsToEncode.set(key, value);
}
});
if (!queryParamsToEncode.has('device_profile')) {
queryParamsToEncode.set('device_profile', 'DASH_TV_WIDEVINE');
}
let encodedQueryPart = queryParamsToEncode.toString();
let timeParamsStringPart = `start_time=${startTimeStr}&end_time=${endTimeStr}`;
let finalQueryString;
if (encodedQueryPart) {
finalQueryString = `${encodedQueryPart}&${timeParamsStringPart}`;
} else {
finalQueryString = timeParamsStringPart;
}
const finalCatchupUrl = `https://${domain}${basePathForCatchup}?${finalQueryString}`;
return finalCatchupUrl;
}

675
orange_tv_client.js Normal file
View File

@ -0,0 +1,675 @@
const ORANGE_IDENTITY_KEY = "orangeTvIdentityCookie";
const URL_BASE_API_MOB_JS = 'https://android.orangetv.orange.es/mob/api/rtv/v1';
const URL_BASE_API_PC_JS = 'https://pc.orangetv.orange.es/pc/api/rtv/v1';
const URL_BASE_IMAGES_PC_JS = `${URL_BASE_API_PC_JS}/images`;
const MODEL_EXTERNAL_IDS_FOR_TERMINALS_JS = ['AKS19', 'HUM18', 'SAG22'];
const BOUQUET_ID_FOR_CHANNELS_PC_JS = '1';
const MODEL_ID_FOR_CHANNELS_PC_JS = 'PC';
const MAX_WORKERS_CHANNELS_JS = 10;
const ORANGE_TV_API_HOST_MOB = "android.orangetv.orange.es";
const ORANGE_TV_API_HOST_PC = "pc.orangetv.orange.es";
const CHANNEL_SPECIFIC_CLEARKEYS_JS = {
"r11_la1": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_la2": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_antena3": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_cuatro": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_telecinco": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_lasexta": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_selekt": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_starchannel": {"kid": "MD7N7urJAQtF5oTryeF1lA", "k": "BxIdiu1Rx7vYBGGUUXw/Eg"},
"r11_amc": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_tnt": {"kid": "VypQBxqrMWM4PmRW+hcBdQ", "k": "hj7H6C6K0HjZ4ICxwLpi0g"},
"r11_axn": {"kid": "DhwwYB1i4nchlODT7uz1Xw", "k": "D314t8hAHWaGkMSTCwhh+Q"},
"r11_comedy": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r11_calle13": {"kid": "MW3cMBCG06kSHBrb1nIXyA", "k": "J6EXwkkHdU+L1+iTe3IQxg"},
"r11_xtrm": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_scifi": {"kid": "MW3cMBCG06kSHBrb1nIXyA", "k": "J6EXwkkHdU+L1+iTe3IQxg"},
"r11_cosmo": {"kid": "6XvuzxKLh3MNzpOFl2+PAQ", "k": "loe9Tcqr+hlepdH88g7nKg"},
"r13_enfamilia": {"kid": "F2gY9e6GDV4KP0kErIgBKg", "k": "BZEt/FNCoTx8YSZS72Tgig"},
"r13_fdf": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_neox": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_energy": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_atreseries": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_divinity": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_nova": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_hollywood": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_axnwhite": {"kid": "DhwwYB1i4nchlODT7uz1Xw", "k": "D314t8hAHWaGkMSTCwhh+Q"},
"r11_somos": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r13_bomcine": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_squirrel": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r11_tcm": {"kid": "VypQBxqrMWM4PmRW+hcBdQ", "k": "hj7H6C6K0HjZ4ICxwLpi0g"},
"r11_sundance": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_dark": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_paramount": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r11_bemad": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_historia": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_nat_geo": {"kid": "MD7N7urJAQtF5oTryeF1lA", "k": "BxIdiu1Rx7vYBGGUUXw/Eg"},
"r11_blaze": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_odisea": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_discovery": {"kid": "vB3XOgolNBAnPnx3RaJhCQ", "k": "jkxJcB1cWDLDKMmdIoLdqQ"},
"r11_natgeowild": {"kid": "MD7N7urJAQtF5oTryeF1lA", "k": "BxIdiu1Rx7vYBGGUUXw/Eg"},
"r11_crimeninvestigacion": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_cocina": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_decasahd": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r13_solmusica": {"kid": "27o7zoN8t7LH7Jq5VycTMg", "k": "+fxpwwObxHptQxQms4C+LA"},
"r11_mega": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_dmax": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_ten": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_disneychan": {"kid": "j1CZ9MDi/2+moKofzwo2TA", "k": "ImOtSX+6rDn7Ca1RSCS3GA"},
"r11_disney_jr": {"kid": "j1CZ9MDi/2+moKofzwo2TA", "k": "ImOtSX+6rDn7Ca1RSCS3GA"},
"r11_nick": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r11_nickjr": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r11_dreamworks": {"kid": "MW3cMBCG06kSHBrb1nIXyA", "k": "J6EXwkkHdU+L1+iTe3IQxg"},
"r11_boing": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r11_clanhd": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r12_eurosport": {"kid": "vB3XOgolNBAnPnx3RaJhCQ", "k": "jkxJcB1cWDLDKMmdIoLdqQ"},
"r12_eurosport2": {"kid": "vB3XOgolNBAnPnx3RaJhCQ", "k": "jkxJcB1cWDLDKMmdIoLdqQ"},
"r11_tdphd": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r12_daznlaliga": {"kid": "Yemhb9f6RnnLUcgfyqhynw", "k": "kpmfl/9O5uxSpg1JD7PxTA"},
"r14ll_mlaliga": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mlaliga": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_daznlaliga2": {"kid": "Yemhb9f6RnnLUcgfyqhynw", "k": "kpmfl/9O5uxSpg1JD7PxTA"},
"r12_mlaliga2": {"kid": "Yemhb9f6RnnLUcgfyqhynw", "k": "kpmfl/9O5uxSpg1JD7PxTA"},
"r12_mlaliga3": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mlaliga4": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones7": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mlaliga6": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones5": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones6": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones4": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r14ll_mcampeones": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones-hdr": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones2-hdr": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones2": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones3": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_laligasmartbank": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_laligasmartbank2": {"kid": "AiSxNfL5UCr/+cszVRwIgQ", "k": "URcFiCvQispOGhKKfZuoEw"},
"r12_laligasmartbank3": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_laligaplus": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r13_nautical": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r12_gol": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r13_realmadridconti": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r12_betis": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_motoadv": {"kid": "iukRwhaDykixDta5JRJyGA", "k": "IPzNiyCJIrIMclkEWZCKVg"},
"r11_mtv": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r13_ubeat": {"kid": "JjJYKDacD2UbHQKAMWcWeA", "k": "2oVeLpuI43GyrGj4W5VgaQ"},
"r13_gametoon": {"kid": "JjJYKDacD2UbHQKAMWcWeA", "k": "2oVeLpuI43GyrGj4W5VgaQ"},
"r13_dkiss": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_myzen": {"kid": "3J1au8Je3Q/LRcXw3k/p5A", "k": "tVbv93kAFfDl+F8zk+zOqg"},
"r13_outtv": {"kid": "3J1au8Je3Q/LRcXw3k/p5A", "k": "tVbv93kAFfDl+F8zk+zOqg"},
"r11_mtvlive": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r11_vh1": {"kid": "Z8AqZy5+h+KXz6dQPeew4g", "k": "YK2xLXIvkk7cGhow+MNJ1Q"},
"r13_mezzo": {"kid": "6di3sjutuhXFL8S3Uqiw8Q", "k": "1sFtN5OtzTjtxLuRRc4gIA"},
"r13_tr3ce": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_intereconomia": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r13_ewtn": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_andalucia": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r12_realmadrid": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_tv3i": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r13_tvgi": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r13_eitb": {"kid": "IsjmUI4McB6wmNGYoR8rEA", "k": "Mzk+KGJVxi5ho9Xtk40vHg"},
"r11_24h": {"kid": "9nV6vfcUs6Qjf/R3niFDXA", "k": "+Hf9yEn6OF2o4ornglSp0g"},
"r13_euronews": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_bbc": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_11internacional": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_aljazeera": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_caracoltv": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_protv": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r13_tv5": {"kid": "WwxMD+1K/NItX7qazKZ4ig", "k": "NmFTcN1vRo26EkWPoOOq/Q"},
"r12_daznlaliga3": {"kid": "Yemhb9f6RnnLUcgfyqhynw", "k": "kpmfl/9O5uxSpg1JD7PxTA"},
"r12_mcampeones8": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones9": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones10": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones11": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones12": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones13": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones14": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones15": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones16": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones17": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones18": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"},
"r12_mcampeones19": {"kid": "hQwaQwOWzkJV85Ar8fTolw", "k": "KVsduXf5Tfu6ASBzkBoBmw"}
};
class NotAuthenticatedError extends Error {
constructor(message) {
super(message);
this.name = "NotAuthenticatedError";
}
}
async function setDynamicHeaders(headersArray, targetHost = null) {
if (!chrome.runtime?.id) {
return;
}
try {
const message = {
cmd: "updateHeadersRules",
requestHeaders: headersArray
};
if (targetHost) {
message.urlFilter = `*://${targetHost}/*`;
} else {
message.urlFilter = `*://${ORANGE_TV_API_HOST_MOB}/*,*://${ORANGE_TV_API_HOST_PC}/*`;
}
const response = await chrome.runtime.sendMessage(message);
if (!response || !response.success) {
console.error("Error al configurar cabeceras dinámicas:", response?.error || "Respuesta no exitosa.");
showNotification("Error crítico configurando cabeceras de red.", "error");
}
} catch (e) {
console.error("Excepción al enviar mensaje para configurar cabeceras dinámicas:", e);
showNotification("Excepción configurando cabeceras de red.", "error");
}
}
async function clearAllDynamicHeaders() {
if (!chrome.runtime?.id) return;
try {
await chrome.runtime.sendMessage({ cmd: "clearAllDnrHeaders" });
} catch (e) {
console.error("Excepción al limpiar cabeceras dinámicas:", e);
}
}
async function loginOrangeMob() {
console.log("Paso 1: Intentando iniciar sesión (Mob API)...");
showNotification("Iniciando sesión en OrangeTV (Mob API)...", "info");
await clearAllDynamicHeaders();
const orangeUsername = userSettings.orangeTvUsername;
const orangePassword = userSettings.orangeTvPassword;
const fetchHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'okhttp/4.10.0'
};
const loginUrl = `${URL_BASE_API_MOB_JS}/Login?username=${orangeUsername}`;
const loginDataStr = `client=json&username=${orangeUsername}&password=${orangePassword}`;
try {
const response = await fetch(loginUrl, {
method: 'POST',
headers: fetchHeaders,
body: loginDataStr,
credentials: 'omit'
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Paso 1: HTTP error en Login (Mob API): ${response.status}`, errorText.substring(0,500));
showNotification(`Error en login (Mob): ${response.status}`, "error");
return null;
}
const responseJson = await response.json();
if (responseJson?.response?.status === 'SUCCESS' && responseJson?.response?.message?.startsWith('identity=')) {
const identityCookieStr = responseJson.response.message;
console.log(`Paso 1: ¡Login (Mob API) exitoso! Cookie: ${identityCookieStr.substring(0, 20)}...`);
showNotification("Login (Mob API) exitoso.", "success");
await saveAppConfigValue(ORANGE_IDENTITY_KEY, identityCookieStr);
return identityCookieStr;
} else {
console.error("Paso 1: Login (Mob API) fallido o formato inesperado.", responseJson);
showNotification("Login (Mob API) fallido. Revisa las credenciales.", "error");
return null;
}
} catch (e) {
console.error("Error de red o JSON en Login (Mob API):", e);
showNotification("Error de red en login (Mob).", "error");
return null;
}
}
async function loadIdentityFromDB() {
try {
const identityStr = await getAppConfigValue(ORANGE_IDENTITY_KEY);
if (identityStr && identityStr.startsWith("identity=")) {
console.log(`Cookie '${identityStr.substring(0,20)}...' cargada desde IndexedDB.`);
return identityStr;
}
} catch (e) {
console.error("Error al cargar cookie desde IndexedDB:", e);
}
return null;
}
async function getIdentityCookie() {
let identity = await loadIdentityFromDB();
if (identity) {
return identity;
}
console.warn("Cookie no válida/inexistente. Iniciando sesión (Mob API)...");
showNotification("Cookie de OrangeTV no encontrada, intentando nuevo login...", "info");
return await loginOrangeMob();
}
async function apiRequestMob(method, endpoint, identityCookieStr, params = null, bodyData = null, includeHouseholdId = false) {
let url = `${URL_BASE_API_MOB_JS}${endpoint}`;
if (params) {
url += `?${new URLSearchParams(params).toString()}`;
}
const dnrHeadersToSet = [
{ header: 'User-Agent', value: 'okhttp/4.10.0' },
{ header: 'Cookie', value: identityCookieStr }
];
if (includeHouseholdId) {
dnrHeadersToSet.push({ header: 'HouseholdID', value: '1' });
}
await setDynamicHeaders(dnrHeadersToSet, ORANGE_TV_API_HOST_MOB);
const fetchOptions = {
method: method,
headers: {},
credentials: 'omit'
};
if (bodyData && (method === 'POST' || method === 'PUT')) {
fetchOptions.body = bodyData;
if (typeof bodyData === 'string' && bodyData.includes('=')) {
fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
try {
await new Promise(resolve => setTimeout(resolve, 150));
const response = await fetch(url, fetchOptions);
if (response.status === 401) {
throw new NotAuthenticatedError("401 Auth Error (Mob API). Cookie expirada?");
}
if (!response.ok) {
const errorText = await response.text();
console.error(`Error en API Mob (${endpoint}): ${response.status}`, errorText.substring(0,200));
return null;
}
if (response.status === 204 || response.headers.get("content-length") === "0") {
return { success_no_content: true, status: response.status };
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return await response.json();
} else {
console.warn(`Respuesta de API Mob (${endpoint}) no es JSON. Tipo: ${contentType}`);
return { raw_text: await response.text(), status_code: response.status };
}
} catch (e) {
if (e instanceof NotAuthenticatedError) throw e;
console.error(`Excepción en API Mob (${endpoint}):`, e);
return null;
}
}
async function getSerialAndModelMob(identityCookieStr) {
if (!identityCookieStr) return { serial: null, model: null };
console.log("Paso 2: Obteniendo terminales (Mob API)...");
showNotification("Obteniendo información de terminales (Mob)...", "info");
const responseData = await apiRequestMob('GET', '/GetTerminalList?client=json', identityCookieStr, null, null, false);
if (responseData?.response?.terminals) {
const terminals = responseData.response.terminals;
if (terminals && terminals.length > 0) {
for (const modelIdFilter of MODEL_EXTERNAL_IDS_FOR_TERMINALS_JS) {
for (const t of terminals) {
if (t?.model?.externalId === modelIdFilter) {
const serial = t.serialNumber;
console.log(`Paso 2: ¡Terminal encontrado! Modelo: ${modelIdFilter}, Serial: ${serial}`);
showNotification("Terminal (Mob) encontrado.", "success");
return { serial: serial, model: modelIdFilter };
}
}
}
console.warn(`Paso 2: No se encontró descodificador con modelos: ${MODEL_EXTERNAL_IDS_FOR_TERMINALS_JS.join(', ')}`);
showNotification("No se encontró terminal compatible (Mob).", "warning");
} else {
console.warn("Paso 2: No se encontraron terminales.");
showNotification("No hay terminales registrados (Mob).", "warning");
}
} else {
console.error("Paso 2: Fallo al obtener terminales (Mob API) o formato inesperado.");
if (responseData) console.error("Respuesta GetTerminalList:", responseData);
showNotification("Error obteniendo terminales (Mob).", "error");
}
return { serial: null, model: null };
}
async function getChannelListPc(identityCookieMobStr) {
if (!identityCookieMobStr) return null;
console.log(`Paso 3.1: Obteniendo canales (PC API) para modelo ${MODEL_ID_FOR_CHANNELS_PC_JS}...`);
showNotification("Obteniendo lista de canales (PC API)...", "info");
const dnrHeadersForPc = [
{ header: 'User-Agent', value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36' },
{ header: 'Cookie', value: identityCookieMobStr },
{ header: 'Accept', value: 'application/json, text/plain, */*'}
];
await setDynamicHeaders(dnrHeadersForPc, ORANGE_TV_API_HOST_PC);
const params = {
'bouquet_id': BOUQUET_ID_FOR_CHANNELS_PC_JS,
'model_external_id': MODEL_ID_FOR_CHANNELS_PC_JS,
'filter_unsupported_channels': 'false',
'client': 'json'
};
const urlPcChannelList = `${URL_BASE_API_PC_JS}/GetChannelList?${new URLSearchParams(params).toString()}`;
try {
await new Promise(resolve => setTimeout(resolve, 150));
const response = await fetch(urlPcChannelList, {
method: 'GET',
headers: {},
credentials: 'omit'
});
if (response.status === 401) {
console.error(`Paso 3.1: Error de autenticación (401) en GetChannelList (PC API).`);
showNotification("Autenticación fallida para PC API (canales).", "error");
return null;
}
if (!response.ok) {
const errorText = await response.text();
console.error(`Paso 3.1: HTTP error en GetChannelList (PC API): ${response.status}`, errorText.substring(0,500));
showNotification(`Error obteniendo canales (PC API): ${response.status}`, "error");
return null;
}
const responseJson = await response.json();
if (responseJson && Array.isArray(responseJson.response)) {
const channelsData = responseJson.response;
console.log(`Paso 3.1: ¡${channelsData.length} canales (PC API) obtenidos!`);
showNotification(`${channelsData.length} canales (PC API) obtenidos.`, "success");
return channelsData;
} else {
console.error("Paso 3.1: Fallo al obtener canales (PC API) o formato inesperado.", responseJson);
showNotification("Formato de respuesta de canales (PC API) inesperado.", "error");
return null;
}
} catch (e) {
console.error("Error de red o JSON en GetChannelList (PC API):", e);
showNotification("Error de red obteniendo canales (PC API).", "error");
return null;
}
}
async function getLivePlayingInfoMob(identityCookieStr, serialNumber, channelExternalId) {
if (!identityCookieStr || !serialNumber || !channelExternalId) return null;
const params = {
'client': 'json',
'serial_number': serialNumber,
'include_cas_token': 'true',
'channel_external_id': channelExternalId
};
const responseData = await apiRequestMob('GET', '/GetLivePlayingInfo', identityCookieStr, params, null, true);
if (responseData?.response?.playingUrl) {
return responseData.response;
}
console.warn(`No se obtuvo playingUrl para ${channelExternalId}. Respuesta:`, responseData);
return null;
}
function extractStreamIdentifier(mpdUrl) {
if (!mpdUrl || typeof mpdUrl !== 'string') return null;
const regex = /\/([a-zA-Z0-9_.-]+)\/dash_(?:high|medium|low)\.mpd/i;
let match = mpdUrl.match(regex);
if (match && match[1]) {
const candidate = match[1];
if (/^r\d{1,2}_/i.test(candidate)) return candidate;
}
const pathParts = mpdUrl.split('/');
for (let i = pathParts.length - 2; i >= 0; i--) {
const part = pathParts[i];
if (part.toLowerCase() === 'cmaf' || part.toLowerCase() === 'std' || part.includes('.')) continue;
if (part && /^r\d{1,2}_/i.test(part)) return part;
}
if (match && match[1] && (match[1].toLowerCase() !== 'cmaf' && match[1].toLowerCase() !== 'std')) return match[1];
return null;
}
async function processSingleChannel(channelDataPc, serialNumberMob, identityCookieMob) {
const name = channelDataPc.name || 'Nombre Desconocido';
const externalId = channelDataPc.externalChannelId;
const category = channelDataPc.category || 'Desconocido';
const number = channelDataPc.number || '';
const attachments = channelDataPc.attachments || [];
const encodingType = channelDataPc.encoding;
const sourceType = channelDataPc.sourceType;
const channelUrlField = channelDataPc.url;
if (!externalId) {
console.warn("Canal sin externalChannelId:", channelDataPc);
return null;
}
if (userSettings.orangeTvSelectedGroups && userSettings.orangeTvSelectedGroups.length > 0) {
const normalizedCategoryApi = category.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
const selectedGroupsNormalized = userSettings.orangeTvSelectedGroups.map(g => g.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase());
let groupMatch = selectedGroupsNormalized.includes(normalizedCategoryApi);
if (!groupMatch) {
if ((normalizedCategoryApi === "general" || normalizedCategoryApi === "generalistas") && selectedGroupsNormalized.includes("generalista")) {
groupMatch = true;
} else if ((normalizedCategoryApi === "noticias") && selectedGroupsNormalized.includes("informacion")) {
groupMatch = true;
} else if ((normalizedCategoryApi === "infanti") && selectedGroupsNormalized.includes("infantil")) {
groupMatch = true;
}
}
if (!groupMatch) {
return null;
}
}
let streamUrl = null;
let isExternalHls = false;
let kodiPropsArray = [];
if (encodingType === "EXTERNAL" && sourceType === "HLS" && channelUrlField === "externalURL") {
const extrafields = channelDataPc.extrafields || [];
const extStreamField = extrafields.find(ef => ef.name === "externalStreamingUrl");
if (extStreamField && extStreamField.value) {
try {
const externalStreamingData = JSON.parse(extStreamField.value);
const hlsUrl = externalStreamingData.externalURL;
if (hlsUrl) {
streamUrl = hlsUrl;
isExternalHls = true;
}
} catch (e) {
console.warn(`Error parseando externalStreamingUrl para ${name}: ${e}`);
}
}
if (!streamUrl) isExternalHls = false;
}
if (!isExternalHls) {
const playingInfoMob = await getLivePlayingInfoMob(identityCookieMob, serialNumberMob, externalId);
if (playingInfoMob && playingInfoMob.playingUrl) {
let tempUrl = playingInfoMob.playingUrl;
if (!tempUrl.endsWith('/externalURL')) {
streamUrl = tempUrl;
if (streamUrl.toLowerCase().endsWith(".mpd")) {
streamUrl = streamUrl.replace(/dash_medium\.mpd/i, "dash_high.mpd").replace(/dash_low\.mpd/i, "dash_high.mpd");
kodiPropsArray.push("inputstream.adaptive.manifest_type=mpd");
const streamIdForClearkey = extractStreamIdentifier(streamUrl);
if (streamIdForClearkey && CHANNEL_SPECIFIC_CLEARKEYS_JS[streamIdForClearkey]) {
const keys = CHANNEL_SPECIFIC_CLEARKEYS_JS[streamIdForClearkey];
if (keys.k && keys.kid) {
const licenseKeyJsonObj = { keys: [{ kty: "oct", k: keys.k, kid: keys.kid }], type: "temporary" };
const licenseKeyJsonStr = JSON.stringify(licenseKeyJsonObj);
kodiPropsArray.push(`inputstream.adaptive.license_type=clearkey`);
kodiPropsArray.push(`inputstream.adaptive.license_key=${licenseKeyJsonStr}`);
}
}
}
}
}
}
if (streamUrl) {
let m3uEntry = "";
const logoAttachment = attachments.find(att => att.name === "LOGO" && att.value);
const logoPath = logoAttachment ? `${URL_BASE_IMAGES_PC_JS}${logoAttachment.value}` : "";
m3uEntry += `#EXTINF:-1 tvg-id="${externalId}" ch-number="${number}" tvg-name="${name}" tvg-logo="${logoPath}" group-title="OrangeTV | ${category}",${name}\n`;
kodiPropsArray.forEach(prop => {
m3uEntry += `#KODIPROP:${prop}\n`;
});
m3uEntry += `${streamUrl}\n`;
return m3uEntry;
}
return null;
}
async function generateM3uOrangeTv() {
showLoading(true, "Iniciando proceso OrangeTV...");
console.log("--- Iniciando generación M3U OrangeTV (JS) ---");
const orangeTvSourceName = "OrangeTV";
let identityCookieMob = null;
let serialNumberMob = null;
try {
identityCookieMob = await getIdentityCookie();
if (!identityCookieMob) {
throw new Error("CRÍTICO: No se pudo obtener cookie (Mob API).");
}
const terminalInfo = await getSerialAndModelMob(identityCookieMob);
serialNumberMob = terminalInfo.serial;
if (!serialNumberMob) {
console.warn("Fallo al obtener terminales (Mob API). La cookie podría haber expirado. Re-intentando login...");
showNotification("Información de terminal no obtenida, reintentando login...", "warning");
await deleteAppConfigValue(ORANGE_IDENTITY_KEY);
identityCookieMob = await loginOrangeMob();
if (!identityCookieMob) {
throw new Error("CRÍTICO: No se pudo obtener cookie (Mob API) tras re-login.");
}
const newTerminalInfo = await getSerialAndModelMob(identityCookieMob);
serialNumberMob = newTerminalInfo.serial;
if (!serialNumberMob) {
throw new Error("CRÍTICO: No se pudo obtener serial (Mob API) tras re-login.");
}
}
const listaCanalesPcApi = await getChannelListPc(identityCookieMob);
if (!listaCanalesPcApi || listaCanalesPcApi.length === 0) {
throw new Error("CRÍTICO: No se pudo obtener la lista de canales (PC API).");
}
showNotification(`Procesando ${listaCanalesPcApi.length} canales... Esto puede tardar.`, "info", 10000);
let m3uLinesForFile = ["#EXTM3U"];
let canalesExitosos = 0;
let canalesConError = 0;
const resultsInOrder = new Array(listaCanalesPcApi.length).fill(null);
let processedCount = 0;
for (let i = 0; i < listaCanalesPcApi.length; i += MAX_WORKERS_CHANNELS_JS) {
const batch = listaCanalesPcApi.slice(i, i + MAX_WORKERS_CHANNELS_JS);
const promises = batch.map((channelPc, indexInBatch) =>
processSingleChannel(channelPc, serialNumberMob, identityCookieMob)
.then(result => ({ status: 'fulfilled', value: result, originalIndex: i + indexInBatch }))
.catch(error => ({ status: 'rejected', reason: error, originalIndex: i + indexInBatch }))
);
const settledResults = await Promise.all(promises);
for (const result of settledResults) {
if (result.status === 'fulfilled' && result.value) {
resultsInOrder[result.originalIndex] = result.value;
canalesExitosos++;
} else {
canalesConError++;
if (result.status === 'rejected') {
const channelNameForError = listaCanalesPcApi[result.originalIndex]?.name || `Índice ${result.originalIndex}`;
console.error(`Error procesando canal '${channelNameForError}':`, result.reason);
}
}
processedCount++;
if (processedCount % 10 === 0 || processedCount === listaCanalesPcApi.length) {
showNotification(`Procesados ${processedCount}/${listaCanalesPcApi.length} canales...`, "info", 3000);
}
}
if (i + MAX_WORKERS_CHANNELS_JS < listaCanalesPcApi.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
resultsInOrder.forEach(entry => {
if (entry) {
m3uLinesForFile.push(entry);
}
});
console.log("Proceso de URLs (concurrente) finalizado.");
console.log(`Canales con URL exitosa: ${canalesExitosos}`);
console.log(`Canales con error/omitidos: ${canalesConError}`);
showNotification(`Proceso completado. Éxito: ${canalesExitosos}, Fallos/Omitidos: ${canalesConError}`, "info");
if (canalesExitosos > 0) {
let finalM3uContent = m3uLinesForFile.join("\n");
if (!finalM3uContent.endsWith("\n\n") && finalM3uContent.split('\n').length > 1) {
finalM3uContent += "\n";
}
console.log("M3U Generado (primeros 500 caracteres):", finalM3uContent.substring(0,500));
if (typeof removeChannelsBySourceOrigin === 'function') {
removeChannelsBySourceOrigin(orangeTvSourceName);
}
if (typeof appendM3UContent === 'function') {
appendM3UContent(finalM3uContent, orangeTvSourceName);
} else {
console.error("appendM3UContent no disponible. Usando fallback processM3UContent.");
processM3UContent(finalM3uContent, orangeTvSourceName, channels.length === 0);
}
return finalM3uContent;
} else {
showNotification("No se generó M3U (no se obtuvieron URLs o no coincidieron con grupos seleccionados).", "warning");
return "#EXTM3U\n#EXTINF:-1,No se pudieron obtener canales\nerror.ts";
}
} catch (e) {
console.error("Error en generateM3uOrangeTv:", e.message, e);
if (e instanceof NotAuthenticatedError || e.message.toLowerCase().includes("cookie")) {
showNotification("Error de autenticación con OrangeTV. Intenta de nuevo.", "error");
await deleteAppConfigValue(ORANGE_IDENTITY_KEY);
} else {
showNotification(`Error generando lista OrangeTV: ${e.message.substring(0,100)}`, "error");
}
return "#EXTM3U\n#EXTINF:-1,Error general en el proceso\nerror.ts";
} finally {
showLoading(false);
await clearAllDynamicHeaders();
console.log("--- Proceso OrangeTV (JS) Finalizado ---");
}
}

212
php_handler.js Normal file
View File

@ -0,0 +1,212 @@
const phpGenerator = (() => {
let dom = {};
function cacheDom() {
const settingsModal = document.getElementById('settingsModal');
if (!settingsModal) return false;
dom.secretKeyCheck = settingsModal.querySelector('#phpSecretKeyCheck');
dom.secretKey = settingsModal.querySelector('#phpSecretKey');
dom.restrictExtIdCheck = settingsModal.querySelector('#phpRestrictToExtensionIdCheck');
dom.savePath = settingsModal.querySelector('#phpSavePath');
dom.filenameOriginalRadio = settingsModal.querySelector('#phpFilenameOriginal');
dom.filenameFixedRadio = settingsModal.querySelector('#phpFilenameFixed');
dom.fixedFilename = settingsModal.querySelector('#phpFixedFilename');
dom.addTimestampCheck = settingsModal.querySelector('#phpAddTimestamp');
dom.overwriteCheck = settingsModal.querySelector('#phpOverwrite');
dom.generatedCode = settingsModal.querySelector('#generatedPhpCode');
dom.generateBtn = settingsModal.querySelector('#generatePhpScriptBtn');
dom.copyBtn = settingsModal.querySelector('#copyPhpScriptBtn');
return dom.generateBtn && dom.copyBtn;
}
function init() {
if (!cacheDom()) {
console.error("No se pudieron cachear los elementos del DOM para el generador PHP.");
return;
}
dom.generateBtn.addEventListener('click', generatePhpScript);
dom.copyBtn.addEventListener('click', copyScript);
}
function generatePhpScript() {
const useSecretKey = dom.secretKeyCheck.checked;
const secretKey = dom.secretKey.value.trim();
const useExtensionIdCheck = dom.restrictExtIdCheck.checked;
const extensionId = chrome.runtime.id;
const savePath = dom.savePath.value.trim();
const useFixedFilename = dom.filenameFixedRadio.checked;
const fixedFilename = dom.fixedFilename.value.trim();
const addTimestamp = dom.addTimestampCheck.checked;
const allowOverwrite = dom.overwriteCheck.checked;
let script = `<?php
// Script generado por DRM Player Avanzado para recibir listas M3U
// Versión: 1.0
// --- Cabeceras ---
// Permiten que la extensión se comunique con este script desde cualquier origen.
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Origin');
// Manejo de la solicitud pre-vuelo (preflight) de CORS
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
exit(0);
}
// --- Configuración ---
`;
if (useSecretKey) {
script += `define('SECRET_KEY', '${secretKey.replace(/'/g, "\\'")}'); // Clave secreta para validar la solicitud\n`;
}
if (useExtensionIdCheck) {
script += `define('ALLOWED_EXTENSION_ID', '${extensionId}'); // ID de la extensión de Chrome permitida\n`;
}
script += `define('TARGET_DIRECTORY', '${savePath.replace(/'/g, "\\'")}'); // Directorio donde se guardarán las listas. Dejar vacío para el mismo directorio del script.\n`;
script += `define('USE_FIXED_FILENAME', ${useFixedFilename ? 'true' : 'false'}); // true para usar un nombre de archivo fijo, false para usar el original.\n`;
script += `define('FIXED_FILENAME', '${fixedFilename.replace(/'/g, "\\'")}'); // Nombre de archivo a usar si USE_FIXED_FILENAME es true.\n`;
script += `define('ADD_TIMESTAMP', ${addTimestamp ? 'true' : 'false'}); // Añadir fecha y hora al nombre del archivo para evitar sobrescrituras.\n`;
script += `define('ALLOW_OVERWRITE', ${allowOverwrite ? 'true' : 'false'}); // Permitir sobrescribir archivos si ya existen (ignorado si ADD_TIMESTAMP es true).\n`;
script += `
// --- Lógica del Script ---
$response = ['success' => false, 'message' => '', 'filename' => ''];
// Verificar el método de la solicitud
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$response['message'] = 'Error: Método no permitido. Solo se acepta POST.';
http_response_code(405);
echo json_encode($response);
exit;
}
// Verificar la clave secreta si está configurada
`;
if (useSecretKey) {
script += `
if (defined('SECRET_KEY') && SECRET_KEY !== '') {
$submittedKey = isset($_POST['secret']) ? $_POST['secret'] : '';
if ($submittedKey !== SECRET_KEY) {
$response['message'] = 'Error: Clave secreta inválida.';
http_response_code(403);
echo json_encode($response);
exit;
}
}
`;
}
if (useExtensionIdCheck) {
script += `
// Verificar el origen de la extensión de Chrome si está configurado
if (defined('ALLOWED_EXTENSION_ID') && ALLOWED_EXTENSION_ID !== '') {
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
if ($origin !== 'chrome-extension://' . ALLOWED_EXTENSION_ID) {
$response['message'] = 'Error: Solicitud desde un origen no permitido.';
http_response_code(403);
echo json_encode($response);
exit;
}
}
`;
}
script += `
// Obtener datos del POST
$m3uContent = isset($_POST['m3u_content']) ? $_POST['m3u_content'] : null;
$originalM3uName = isset($_POST['m3u_name']) ? $_POST['m3u_name'] : 'lista_subida.m3u';
if (empty($m3uContent)) {
$response['message'] = 'Error: No se recibió contenido M3U (m3u_content).';
http_response_code(400);
echo json_encode($response);
exit;
}
// Determinar el directorio de destino
$targetDir = TARGET_DIRECTORY !== '' ? rtrim(TARGET_DIRECTORY, '/\\\\') : __DIR__;
if (!is_dir($targetDir) || !is_writable($targetDir)) {
$response['message'] = 'Error: El directorio de destino no existe o no tiene permisos de escritura.';
http_response_code(500);
echo json_encode($response);
exit;
}
// Determinar el nombre del archivo final
$filename = '';
if (USE_FIXED_FILENAME && FIXED_FILENAME !== '') {
$filename = FIXED_FILENAME;
} else {
$filename = empty(trim($originalM3uName)) ? 'lista_sin_nombre.m3u' : $originalM3uName;
}
// Asegurarse de que el nombre del archivo tiene la extensión .m3u
if (strtolower(substr($filename, -4)) !== '.m3u') {
$filename .= '.m3u';
}
// Sanitizar el nombre del archivo para seguridad
$baseFilename = basename($filename);
$safeFilename = preg_replace('/[^\w\s._-]/', '_', $baseFilename);
$safeFilename = preg_replace('/\s+/', '_', $safeFilename);
// Añadir timestamp si está configurado
if (ADD_TIMESTAMP) {
$nameWithoutExt = pathinfo($safeFilename, PATHINFO_FILENAME);
$extension = pathinfo($safeFilename, PATHINFO_EXTENSION);
$timestamp = date('Ymd_His');
$safeFilename = "{$nameWithoutExt}_{$timestamp}.{$extension}";
}
$targetFilePath = $targetDir . DIRECTORY_SEPARATOR . $safeFilename;
// Comprobar si se permite sobrescribir (solo si no se añade timestamp)
if (!ADD_TIMESTAMP && !ALLOW_OVERWRITE && file_exists($targetFilePath)) {
$response['message'] = 'Error: El archivo ya existe y no está permitida la sobrescritura.';
http_response_code(409);
echo json_encode($response);
exit;
}
// Guardar el archivo
if (file_put_contents($targetFilePath, $m3uContent) !== false) {
$response['success'] = true;
$response['message'] = 'Archivo M3U guardado correctamente en el servidor.';
$response['filename'] = $safeFilename;
http_response_code(200);
} else {
$response['message'] = 'Error: No se pudo escribir el archivo en el servidor.';
http_response_code(500);
}
// Enviar la respuesta final
echo json_encode($response);
?>
`;
dom.generatedCode.value = script;
showNotification("Script PHP generado.", "success");
}
function copyScript() {
if (!dom.generatedCode.value || dom.generatedCode.value.startsWith('Configura')) {
showNotification("Primero genera un script para poder copiarlo.", "warning");
return;
}
navigator.clipboard.writeText(dom.generatedCode.value).then(() => {
showNotification("Script PHP copiado al portapapeles.", "success");
}).catch(err => {
showNotification("Error al copiar el script. Revisa la consola.", "error");
console.error('Error al copiar: ', err);
});
}
return {
init: init
};
})();

1267
player.html Normal file

File diff suppressed because it is too large Load Diff

1458
player.js Normal file

File diff suppressed because it is too large Load Diff

324
player_interaction.js Normal file
View File

@ -0,0 +1,324 @@
class ChannelListButton extends shaka.ui.Element {
constructor(parent, controls, windowId) {
super(parent, controls);
this.windowId = windowId;
this.button_ = document.createElement('button');
this.button_.classList.add('shaka-channel-list-button');
this.button_.classList.add('shaka-tooltip');
this.button_.setAttribute('aria-label', 'Lista de Canales');
this.button_.setAttribute('data-tooltip-text', 'Lista de Canales');
const icon = document.createElement('i');
icon.classList.add('material-icons-round');
icon.textContent = 'video_library';
this.button_.appendChild(icon);
this.parent.appendChild(this.button_);
this.eventManager.listen(this.button_, 'click', () => {
togglePlayerChannelList(this.windowId);
});
}
destroy() {
this.eventManager.release();
super.destroy();
}
}
class ChannelListButtonFactory {
constructor(windowId) {
this.windowId = windowId;
}
create(rootElement, controls) {
return new ChannelListButton(rootElement, controls, this.windowId);
}
}
function createPlayerWindow(channel) {
const template = document.getElementById('playerWindowTemplate');
if (!template) {
showNotification("Error: No se encuentra la plantilla del reproductor.", "error");
return;
}
const newWindow = template.content.firstElementChild.cloneNode(true);
const uniqueId = `player-window-${Date.now()}`;
newWindow.id = uniqueId;
const titleEl = newWindow.querySelector('.player-window-title');
titleEl.textContent = channel.name || 'Reproductor';
titleEl.title = channel.name || 'Reproductor';
const videoElement = newWindow.querySelector('.player-video');
const containerElement = newWindow.querySelector('.player-container');
const channelListPanel = newWindow.querySelector('.player-channel-list-panel');
containerElement.appendChild(channelListPanel);
const numWindows = Object.keys(playerInstances).length;
const baseTop = 50;
const baseLeft = 50;
const offset = (numWindows % 10) * 30;
newWindow.style.top = `${baseTop + offset}px`;
newWindow.style.left = `${baseLeft + offset}px`;
document.getElementById('player-windows-container').appendChild(newWindow);
const playerInstance = new shaka.Player();
const uiInstance = new shaka.ui.Overlay(playerInstance, containerElement, videoElement);
const factory = new ChannelListButtonFactory(uniqueId);
shaka.ui.Controls.registerElement('channel_list', factory);
uiInstance.configure({
controlPanelElements: ['play_pause', 'time_and_duration', 'volume', 'live_display', 'spacer', 'channel_list', 'quality', 'language', 'captions', 'fullscreen'],
overflowMenuButtons: ['cast', 'picture_in_picture', 'playback_rate'],
addSeekBar: true,
addBigPlayButton: true,
enableTooltips: true,
fadeDelay: userSettings.persistentControls ? Infinity : 0,
seekBarColors: { base: 'rgba(255, 255, 255, 0.3)', played: 'var(--accent-primary)', buffered: 'rgba(200, 200, 200, 0.6)' },
volumeBarColors: { base: 'rgba(255, 255, 255, 0.3)', level: 'var(--accent-primary)' },
customContextMenu: true
});
playerInstances[uniqueId] = {
player: playerInstance,
ui: uiInstance,
videoElement: videoElement,
container: newWindow,
channel: channel,
infobarInterval: null,
isChannelListVisible: false,
channelListPanelElement: channelListPanel
};
setActivePlayer(uniqueId);
playerInstance.attach(videoElement).then(() => {
playChannelInShaka(channel, uniqueId);
}).catch(e => {
console.error("Error al adjuntar Shaka Player a la nueva ventana:", e);
showNotification("Error al crear la ventana del reproductor.", "error");
destroyPlayerWindow(uniqueId);
});
createTaskbarItem(uniqueId, channel);
newWindow.querySelector('.player-window-close-btn').addEventListener('click', () => destroyPlayerWindow(uniqueId));
newWindow.querySelector('.player-window-minimize-btn').addEventListener('click', () => minimizePlayerWindow(uniqueId));
startPlayerInfobarUpdate(uniqueId);
}
function destroyPlayerWindow(id) {
const instance = playerInstances[id];
if (instance) {
if (instance.infobarInterval) clearInterval(instance.infobarInterval);
if (instance.player) {
instance.player.destroy().catch(e => {});
}
if (instance.container) {
instance.container.remove();
}
delete playerInstances[id];
const taskbarItem = document.getElementById(`taskbar-item-${id}`);
if (taskbarItem) taskbarItem.remove();
if (activePlayerId === id) {
const remainingIds = Object.keys(playerInstances);
setActivePlayer(remainingIds.length > 0 ? remainingIds[0] : null);
}
}
if (Object.keys(playerInstances).length === 0) {
applyHttpHeaders([]);
}
}
function handleFavoriteButtonClick(event) {
event.stopPropagation();
const url = $(this).data('url');
if (!url) { return; }
toggleFavorite(url);
}
function showPlayerInfobar(channel, infobarElement) {
if (!infobarElement || !channel) return;
if (infobarElement.hideTimeout) clearTimeout(infobarElement.hideTimeout);
updatePlayerInfobar(channel, infobarElement);
$(infobarElement).addClass('show');
infobarElement.hideTimeout = setTimeout(() => {
$(infobarElement).removeClass('show');
}, 7000);
}
function createTaskbarItem(windowId, channel) {
const taskbar = document.getElementById('player-taskbar');
const item = document.createElement('button');
item.className = 'taskbar-item';
item.id = `taskbar-item-${windowId}`;
item.title = channel.name;
item.dataset.windowId = windowId;
const logoSrc = channel['tvg-logo'] || '';
let iconHtml;
if (logoSrc) {
iconHtml = `<img src="${escapeHtml(logoSrc)}" class="taskbar-item-logo" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-flex';">
<span class="taskbar-item-logo-placeholder" style="display: none;">${escapeHtml(channel.name.charAt(0))}</span>`;
} else {
iconHtml = `<span class="taskbar-item-logo-placeholder">${escapeHtml(channel.name.charAt(0))}</span>`;
}
item.innerHTML = `
<div class="taskbar-item-icon-container">${iconHtml}</div>
<span class="taskbar-item-text">${escapeHtml(channel.name)}</span>
`;
taskbar.appendChild(item);
}
function minimizePlayerWindow(windowId) {
const instance = playerInstances[windowId];
if (instance) {
instance.container.style.display = 'none';
$(`#taskbar-item-${windowId}`).removeClass('active');
if (activePlayerId === windowId) {
activePlayerId = null;
}
}
}
function togglePlayerChannelList(windowId) {
const instance = playerInstances[windowId];
if (!instance || !instance.channelListPanelElement) return;
instance.isChannelListVisible = !instance.isChannelListVisible;
instance.channelListPanelElement.classList.toggle('open', instance.isChannelListVisible);
if (instance.isChannelListVisible) {
populatePlayerChannelList(windowId);
}
}
function populatePlayerChannelList(windowId) {
const instance = playerInstances[windowId];
if (!instance || !instance.channelListPanelElement || !instance.channel) return;
const listContentElement = instance.channelListPanelElement.querySelector('.player-channel-list-content');
if (!listContentElement) return;
listContentElement.innerHTML = '';
const currentPlayingChannel = instance.channel;
const currentGroup = currentPlayingChannel['group-title'] || 'Sin Grupo';
const channelsInGroup = channels.filter(ch => (ch['group-title'] || 'Sin Grupo') === currentGroup);
const fragment = document.createDocumentFragment();
if (channelsInGroup.length > 0) {
const groupHeader = document.createElement('div');
groupHeader.className = 'player-channel-list-group-header';
groupHeader.textContent = escapeHtml(currentGroup);
fragment.appendChild(groupHeader);
channelsInGroup.forEach(channel => {
fragment.appendChild(createPlayerChannelListItem(channel, windowId));
});
} else {
const noChannelsMessage = document.createElement('p');
noChannelsMessage.className = 'p-2 text-secondary text-center';
noChannelsMessage.textContent = 'No hay canales en este grupo.';
fragment.appendChild(noChannelsMessage);
}
listContentElement.appendChild(fragment);
highlightCurrentChannelInList(windowId);
}
function createPlayerChannelListItem(channel, windowId) {
const itemElement = document.createElement('div');
itemElement.className = 'player-channel-list-item';
itemElement.dataset.channelUrl = channel.url;
let logoSrc = channel['tvg-logo'];
if (!logoSrc && typeof getEpgChannelIcon === 'function' && channel.effectiveEpgId) {
logoSrc = getEpgChannelIcon(channel.effectiveEpgId);
}
let logoHtml;
if (logoSrc) {
logoHtml = `<img src="${escapeHtml(logoSrc)}" class="player-channel-list-logo" alt="${escapeHtml(channel.name)}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="player-channel-list-logo-placeholder" style="display: none;"></div>`;
} else {
logoHtml = `<div class="player-channel-list-logo-placeholder"></div>`;
}
let epgText = 'EPG no disponible';
let epgClass = 'no-epg';
if (channel.effectiveEpgId && typeof getEpgDataForChannel === 'function') {
const programs = getEpgDataForChannel(channel.effectiveEpgId);
const now = new Date();
const currentProgram = programs.find(p => now >= p.startDt && now < p.stopDt);
if (currentProgram) {
epgText = `Ahora: ${currentProgram.title}`;
epgClass = '';
}
}
itemElement.innerHTML = `
${logoHtml}
<div class="player-channel-list-info">
<span class="player-channel-list-name">${escapeHtml(channel.name)}</span>
<span class="player-channel-list-epg ${epgClass}">${escapeHtml(epgText)}</span>
</div>
`;
itemElement.addEventListener('click', () => {
const targetChannel = channels.find(ch => ch.url === channel.url);
if (targetChannel) {
const instance = playerInstances[windowId];
if (instance) {
playChannelInShaka(targetChannel, windowId);
const titleEl = instance.container.querySelector('.player-window-title');
if (titleEl) {
titleEl.textContent = targetChannel.name;
titleEl.title = targetChannel.name;
}
highlightCurrentChannelInList(windowId);
}
}
});
return itemElement;
}
function highlightCurrentChannelInList(windowId) {
const instance = playerInstances[windowId];
if (!instance || !instance.channelListPanelElement || !instance.channel) return;
const listContentElement = instance.channelListPanelElement.querySelector('.player-channel-list-content');
if (!listContentElement) return;
listContentElement.querySelectorAll('.player-channel-list-item.active').forEach(activeItem => {
activeItem.classList.remove('active');
});
const currentChannelUrl = instance.channel.url;
const currentItemInList = listContentElement.querySelector(`.player-channel-list-item[data-channel-url="${CSS.escape(currentChannelUrl)}"]`);
if (currentItemInList) {
currentItemInList.classList.add('active');
requestAnimationFrame(() => {
if (currentItemInList.offsetParent) {
currentItemInList.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
}

455
settings_manager.js Normal file
View File

@ -0,0 +1,455 @@
let userSettings = {
language: 'en',
enableEpgNameMatching: false,
epgNameMatchThreshold: 0.80,
sidebarCollapsed: window.innerWidth < 992,
persistFilters: true,
lastSelectedGroup: "",
lastSelectedFilterTab: "all",
playerBuffer: 30,
preferredAudioLanguage: 'es',
preferredTextLanguage: 'off',
lowLatencyMode: true,
liveCatchUpMode: false,
globalUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
globalReferrer: '',
additionalGlobalHeaders: '{}',
channelCardSize: 180,
persistentControls: false,
maxVideoHeight: 0,
autoSaveM3U: true,
defaultEpgUrl: 'https://raw.githubusercontent.com/davidmuma/EPG_dobleM/refs/heads/master/EPG_dobleM.xml',
lastEpgUrl: '',
abrEnabled: true,
abrDefaultBandwidthEstimate: 1000,
streamingJumpLargeGaps: false,
manifestRetryMaxAttempts: 2,
manifestRetryTimeout: 15000,
segmentRetryMaxAttempts: 2,
segmentRetryTimeout: 15000,
shakaDefaultPresentationDelay: 5,
shakaAudioVideoSyncThreshold: 0.25,
appTheme: 'default-green',
appFont: 'system',
particlesEnabled: true,
particleOpacity: 0.02,
channelsPerPage: 48,
epgDensity: 200,
cardShowGroup: true,
cardShowEpg: true,
cardShowFavButton: true,
cardShowChannelNumber: false,
cardLogoAspectRatio: "16/9",
m3uUploadServerUrl: "",
orangeTvUsername: "",
orangeTvPassword: "",
orangeTvSelectedGroups: [],
barTvEmail: "",
barTvPassword: "",
movistarVodCacheDaysToKeep: 15,
useMovistarVodAsEpg: true,
xcodecCorsProxyUrl: "",
xcodecIgnorePanelsOverStreams: 0,
xcodecDefaultBatchSize: 15,
xcodecDefaultTimeout: 8000,
playerWindowOpacity: 1,
compactCardView: false,
enableHoverPreview: true
};
let daznAuthTokenState = null;
const DAZN_TOKEN_DB_KEY = 'daznAuthTokenKey';
const ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS = [
"Generalista", "Series", "Cine", "Documentales", "Lifestyle", "Juvenil",
"Infantil", "Deportes", "Motor", "Anime", "Música", "Información",
"Autonómicos", "Internacionales", "Adulto"
];
const availableLanguages = [
{code: 'es', name: 'Español'}, {code: 'en', name: 'Inglés'},
{code: 'pt', name: 'Portugués'}, {code: 'fr', name: 'Francés'},
{code: 'de', name: 'Alemán'}, {code: 'it', name: 'Italiano'},
{code: 'ja', name: 'Japonés'}, {code: 'qaa', name: 'Original'},
{code: 'und', name: 'Indefinido (und)'}
];
const availableTextLanguages = [
{code: 'off', name: 'Desactivado'}, ...availableLanguages
];
async function loadUserSettings() {
const storedSettings = await getAppConfigValue('userSettings');
const defaultSettingsCopy = JSON.parse(JSON.stringify(userSettings));
if (!defaultSettingsCopy.orangeTvSelectedGroups || defaultSettingsCopy.orangeTvSelectedGroups.length === 0) {
defaultSettingsCopy.orangeTvSelectedGroups = ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS.slice();
}
if (storedSettings) {
Object.assign(userSettings, defaultSettingsCopy, storedSettings);
if(typeof userSettings.additionalGlobalHeaders !== 'string'){
userSettings.additionalGlobalHeaders = JSON.stringify(userSettings.additionalGlobalHeaders || {});
}
if (!userSettings.orangeTvSelectedGroups || !Array.isArray(userSettings.orangeTvSelectedGroups)) {
userSettings.orangeTvSelectedGroups = defaultSettingsCopy.orangeTvSelectedGroups;
}
} else {
userSettings.orangeTvSelectedGroups = defaultSettingsCopy.orangeTvSelectedGroups;
}
favorites = await getAppConfigValue('favorites') || [];
appHistory = await getAppConfigValue('history') || [];
daznAuthTokenState = await getAppConfigValue(DAZN_TOKEN_DB_KEY) || null;
}
async function saveUserSettings() {
userSettings.language = $('#appLanguageSelect').val();
userSettings.enableEpgNameMatching = $('#enableEpgNameMatchingCheck').is(':checked');
userSettings.epgNameMatchThreshold = parseFloat($('#epgNameMatchThreshold').val()) / 100;
userSettings.sidebarCollapsed = $('#sidebar').hasClass('collapsed');
userSettings.persistFilters = $('#persistFiltersCheck').is(':checked');
userSettings.playerBuffer = parseInt($('#playerBufferInput').val(), 10);
userSettings.preferredAudioLanguage = $('#preferredAudioLanguageInput').val();
userSettings.preferredTextLanguage = $('#preferredTextLanguageInput').val();
userSettings.lowLatencyMode = $('#lowLatencyModeCheck').is(':checked');
userSettings.liveCatchUpMode = $('#liveCatchUpModeCheck').is(':checked');
userSettings.abrEnabled = $('#abrEnabledCheck').is(':checked');
userSettings.abrDefaultBandwidthEstimate = parseInt($('#abrDefaultBandwidthEstimateInput').val(), 10);
userSettings.streamingJumpLargeGaps = $('#streamingJumpLargeGapsCheck').is(':checked');
userSettings.shakaDefaultPresentationDelay = parseFloat($('#shakaDefaultPresentationDelayInput').val());
userSettings.shakaAudioVideoSyncThreshold = parseFloat($('#shakaAudioVideoSyncThresholdInput').val());
userSettings.manifestRetryMaxAttempts = parseInt($('#manifestRetryMaxAttemptsInput').val(), 10);
userSettings.manifestRetryTimeout = parseInt($('#manifestRetryTimeoutInput').val(), 10);
userSettings.segmentRetryMaxAttempts = parseInt($('#segmentRetryMaxAttemptsInput').val(), 10);
userSettings.segmentRetryTimeout = parseInt($('#segmentRetryTimeoutInput').val(), 10);
userSettings.globalUserAgent = $('#globalUserAgentInput').val().trim();
userSettings.globalReferrer = $('#globalReferrerInput').val().trim();
try {
JSON.parse($('#additionalGlobalHeadersInput').val());
userSettings.additionalGlobalHeaders = $('#additionalGlobalHeadersInput').val();
} catch(e) {
userSettings.additionalGlobalHeaders = '{}';
if (typeof showNotification === 'function') showNotification('Cabeceras Globales Adicionales no es un JSON válido. No se guardó.', 'warning');
}
userSettings.channelCardSize = parseInt($('#channelCardSizeInput').val(), 10);
userSettings.channelsPerPage = parseInt($('#channelsPerPageInput').val(), 10);
userSettings.persistentControls = $('#persistentControlsCheck').is(':checked');
userSettings.maxVideoHeight = parseInt($('#maxVideoHeight').val(), 10);
userSettings.autoSaveM3U = $('#autoSaveM3UCheck').is(':checked');
userSettings.defaultEpgUrl = $('#defaultEpgUrlInput').val().trim();
userSettings.appTheme = $('#appThemeSelect').val();
userSettings.appFont = $('#appFontSelect').val();
userSettings.particlesEnabled = $('#particlesEnabledCheck').is(':checked');
userSettings.particleOpacity = parseFloat($('#particleOpacityInput').val()) / 100;
userSettings.epgDensity = parseInt($('#epgDensityInput').val(), 10);
userSettings.cardShowGroup = $('#cardShowGroupCheck').is(':checked');
userSettings.cardShowEpg = $('#cardShowEpgCheck').is(':checked');
userSettings.cardShowFavButton = $('#cardShowFavButtonCheck').is(':checked');
userSettings.cardShowChannelNumber = $('#cardShowChannelNumberCheck').is(':checked');
userSettings.cardLogoAspectRatio = $('#cardLogoAspectRatioSelect').val();
userSettings.m3uUploadServerUrl = $('#m3uUploadServerUrlInput').val().trim();
userSettings.orangeTvUsername = $('#orangeTvUsernameInput').val().trim() || "";
userSettings.orangeTvPassword = $('#orangeTvPasswordInput').val() || "";
userSettings.orangeTvSelectedGroups = [];
$('#orangeTvGroupSelectionContainer .form-check-input:checked').each(function() {
userSettings.orangeTvSelectedGroups.push($(this).val());
});
userSettings.barTvEmail = $('#barTvEmailInput').val().trim();
userSettings.barTvPassword = $('#barTvPasswordInput').val();
const oldUseMovistarVodAsEpg = userSettings.useMovistarVodAsEpg;
userSettings.useMovistarVodAsEpg = $('#useMovistarVodAsEpgCheck').is(':checked');
const oldMovistarVodCacheDays = userSettings.movistarVodCacheDaysToKeep;
userSettings.movistarVodCacheDaysToKeep = parseInt($('#movistarVodCacheDaysToKeepInput').val(), 10) || 15;
if (userSettings.movistarVodCacheDaysToKeep < 1) userSettings.movistarVodCacheDaysToKeep = 1;
if (userSettings.movistarVodCacheDaysToKeep > 90) userSettings.movistarVodCacheDaysToKeep = 90;
userSettings.xcodecCorsProxyUrl = $('#xcodecCorsProxyUrlInput').val().trim();
userSettings.xcodecIgnorePanelsOverStreams = parseInt($('#xcodecIgnorePanelsOverStreamsInput').val(), 10) || 0;
userSettings.xcodecDefaultBatchSize = parseInt($('#xcodecDefaultBatchSizeInput').val(), 10) || 15;
userSettings.xcodecDefaultTimeout = parseInt($('#xcodecDefaultTimeoutInput').val(), 10) || 8000;
userSettings.playerWindowOpacity = parseFloat($('#playerWindowOpacityInput').val());
userSettings.compactCardView = $('#compactCardViewCheck').is(':checked');
userSettings.enableHoverPreview = $('#enableHoverPreviewCheck').is(':checked');
await saveAppConfigValue('userSettings', userSettings);
const daznTokenFromInput = $('#daznAuthTokenSettingsInput').val().trim();
const currentTokenInDb = daznAuthTokenState;
if (daznTokenFromInput && daznTokenFromInput !== currentTokenInDb) {
daznAuthTokenState = daznTokenFromInput;
await saveAppConfigValue(DAZN_TOKEN_DB_KEY, daznAuthTokenState);
if (typeof showNotification === 'function') showNotification('Token DAZN guardado.', 'success');
} else if (!daznTokenFromInput && currentTokenInDb) {
daznAuthTokenState = null;
await deleteAppConfigValue(DAZN_TOKEN_DB_KEY);
if (typeof showNotification === 'function') showNotification('Token DAZN eliminado.', 'info');
}
if (oldMovistarVodCacheDays !== userSettings.movistarVodCacheDaysToKeep) {
if (typeof deleteOldMovistarVodData === 'function') {
try {
const deletedCount = await deleteOldMovistarVodData(userSettings.movistarVodCacheDaysToKeep);
if (typeof showNotification === 'function') showNotification(`Política de caché VOD actualizada. ${deletedCount} registros antiguos eliminados.`, 'info');
updateMovistarVodCacheStatsUI();
} catch (e) {
if (typeof showNotification === 'function') showNotification(`Error aplicando nueva política de caché VOD: ${e.message}`, 'warning');
}
}
}
if (oldUseMovistarVodAsEpg !== userSettings.useMovistarVodAsEpg) {
if (typeof matchChannelsWithEpg === 'function') await matchChannelsWithEpg(true);
if (userSettings.useMovistarVodAsEpg && typeof updateEpgWithMovistarVodData === 'function') {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
await updateEpgWithMovistarVodData(`${yyyy}-${mm}-${dd}`);
} else if (!userSettings.useMovistarVodAsEpg && typeof epgDataByChannelId !== 'undefined') {
for (const key in epgDataByChannelId) {
if (key.startsWith('movistar.')) {
delete epgDataByChannelId[key];
}
}
if (typeof filterAndRenderChannels === 'function') filterAndRenderChannels();
if (typeof updateEPGProgressBarOnCards === 'function') updateEPGProgressBarOnCards();
}
}
if (typeof applyUISettings === 'function') applyUISettings();
if (typeof showNotification === 'function' && !daznTokenFromInput && !currentTokenInDb) {
showNotification('Ajustes guardados y aplicados.', 'success');
} else if (typeof showNotification === 'function' && daznTokenFromInput && daznTokenFromInput === currentTokenInDb) {
showNotification('Ajustes guardados y aplicados (Token DAZN sin cambios).', 'success');
}
}
function populateUserSettingsForm() {
populateLanguageSelects();
$('#appLanguageSelect').val(userSettings.language);
$('#enableEpgNameMatchingCheck').prop('checked', userSettings.enableEpgNameMatching);
$('#epgNameMatchThreshold').val(userSettings.epgNameMatchThreshold * 100);
$('#epgNameMatchThresholdValue').text((userSettings.epgNameMatchThreshold * 100).toFixed(0) + '%');
$('#persistFiltersCheck').prop('checked', userSettings.persistFilters);
$('#playerBufferInput').val(userSettings.playerBuffer);
$('#playerBufferValue').text(userSettings.playerBuffer + 's');
$('#preferredAudioLanguageInput').val(userSettings.preferredAudioLanguage);
$('#preferredTextLanguageInput').val(userSettings.preferredTextLanguage);
$('#lowLatencyModeCheck').prop('checked', userSettings.lowLatencyMode);
$('#liveCatchUpModeCheck').prop('checked', userSettings.liveCatchUpMode);
$('#abrEnabledCheck').prop('checked', userSettings.abrEnabled);
$('#abrDefaultBandwidthEstimateInput').val(userSettings.abrDefaultBandwidthEstimate);
$('#abrDefaultBandwidthEstimateValue').text(userSettings.abrDefaultBandwidthEstimate + ' Kbps');
$('#streamingJumpLargeGapsCheck').prop('checked', userSettings.streamingJumpLargeGaps);
$('#shakaDefaultPresentationDelayInput').val(userSettings.shakaDefaultPresentationDelay);
$('#shakaDefaultPresentationDelayValue').text(userSettings.shakaDefaultPresentationDelay.toFixed(userSettings.shakaDefaultPresentationDelay % 1 === 0 ? 0 : 1) + 's');
$('#shakaAudioVideoSyncThresholdInput').val(userSettings.shakaAudioVideoSyncThreshold);
$('#shakaAudioVideoSyncThresholdValue').text(userSettings.shakaAudioVideoSyncThreshold.toFixed(userSettings.shakaAudioVideoSyncThreshold % 1 === 0 ? 0 : 2) + 's');
$('#manifestRetryMaxAttemptsInput').val(userSettings.manifestRetryMaxAttempts);
$('#manifestRetryMaxAttemptsValue').text(userSettings.manifestRetryMaxAttempts);
$('#manifestRetryTimeoutInput').val(userSettings.manifestRetryTimeout);
$('#manifestRetryTimeoutValue').text(userSettings.manifestRetryTimeout);
$('#segmentRetryMaxAttemptsInput').val(userSettings.segmentRetryMaxAttempts);
$('#segmentRetryMaxAttemptsValue').text(userSettings.segmentRetryMaxAttempts);
$('#segmentRetryTimeoutInput').val(userSettings.segmentRetryTimeout);
$('#segmentRetryTimeoutValue').text(userSettings.segmentRetryTimeout);
$('#globalUserAgentInput').val(userSettings.globalUserAgent);
$('#globalReferrerInput').val(userSettings.globalReferrer);
try {
const parsedHeaders = JSON.parse(userSettings.additionalGlobalHeaders || '{}');
$('#additionalGlobalHeadersInput').val(JSON.stringify(parsedHeaders, null, 2));
} catch (e) {
$('#additionalGlobalHeadersInput').val('{}');
}
$('#channelCardSizeInput').val(userSettings.channelCardSize);
$('#channelCardSizeValue').text(userSettings.channelCardSize + 'px');
$('#channelsPerPageInput').val(userSettings.channelsPerPage);
$('#channelsPerPageValue').text(userSettings.channelsPerPage);
$('#persistentControlsCheck').prop('checked', userSettings.persistentControls);
$('#maxVideoHeight').val(userSettings.maxVideoHeight);
$('#autoSaveM3UCheck').prop('checked', userSettings.autoSaveM3U);
$('#defaultEpgUrlInput').val(userSettings.defaultEpgUrl);
$('#appThemeSelect').val(userSettings.appTheme);
$('#appFontSelect').val(userSettings.appFont);
$('#particlesEnabledCheck').prop('checked', userSettings.particlesEnabled);
$('#particleOpacityInput').val(userSettings.particleOpacity * 100);
$('#particleOpacityValue').text((userSettings.particleOpacity * 100).toFixed(0) + '%');
$('#epgDensityInput').val(userSettings.epgDensity);
$('#epgDensityValue').text(userSettings.epgDensity + 'px/h');
$('#cardShowGroupCheck').prop('checked', userSettings.cardShowGroup);
$('#cardShowEpgCheck').prop('checked', userSettings.cardShowEpg);
$('#cardShowFavButtonCheck').prop('checked', userSettings.cardShowFavButton);
$('#cardShowChannelNumberCheck').prop('checked', userSettings.cardShowChannelNumber);
$('#cardLogoAspectRatioSelect').val(userSettings.cardLogoAspectRatio);
$('#m3uUploadServerUrlInput').val(userSettings.m3uUploadServerUrl);
$('#orangeTvUsernameInput').val(userSettings.orangeTvUsername);
$('#orangeTvPasswordInput').val(userSettings.orangeTvPassword);
const orangeTvGroupContainer = $('#orangeTvGroupSelectionContainer');
orangeTvGroupContainer.empty();
const currentSelectedOrangeGroups = Array.isArray(userSettings.orangeTvSelectedGroups) ? userSettings.orangeTvSelectedGroups : ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS.slice();
ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS.forEach(group => {
const isChecked = currentSelectedOrangeGroups.includes(group);
const checkboxHtml = `
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="orangeTvGroup_${group.replace(/\s+/g, '')}" value="${group}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label" for="orangeTvGroup_${group.replace(/\s+/g, '')}">${group}</label>
</div>`;
orangeTvGroupContainer.append(checkboxHtml);
});
$('#barTvEmailInput').val(userSettings.barTvEmail);
$('#barTvPasswordInput').val(userSettings.barTvPassword);
$('#daznAuthTokenSettingsInput').val(daznAuthTokenState || '');
$('#movistarVodCacheDaysToKeepInput').val(userSettings.movistarVodCacheDaysToKeep);
$('#useMovistarVodAsEpgCheck').prop('checked', userSettings.useMovistarVodAsEpg);
$('#xcodecCorsProxyUrlInput').val(userSettings.xcodecCorsProxyUrl);
$('#xcodecIgnorePanelsOverStreamsInput').val(userSettings.xcodecIgnorePanelsOverStreams);
$('#xcodecDefaultBatchSizeInput').val(userSettings.xcodecDefaultBatchSize);
$('#xcodecDefaultTimeoutInput').val(userSettings.xcodecDefaultTimeout);
$('#playerWindowOpacityInput').val(userSettings.playerWindowOpacity);
$('#playerWindowOpacityValue').text((userSettings.playerWindowOpacity * 100).toFixed(0) + '%');
$('#compactCardViewCheck').prop('checked', userSettings.compactCardView);
$('#enableHoverPreviewCheck').prop('checked', userSettings.enableHoverPreview);
updateMovistarVodCacheStatsUI();
}
function applyThemeAndFont() {
document.body.className = '';
document.body.classList.add(`theme-${userSettings.appTheme.replace('default-','')}`);
document.body.classList.add(`font-type-${userSettings.appFont.replace('system','apple-system')}`);
$('#particles-js').toggleClass('disabled', !userSettings.particlesEnabled);
document.documentElement.style.setProperty('--particle-opacity', userSettings.particlesEnabled ? String(userSettings.particleOpacity) : '0');
if(typeof particlesJS !== 'undefined' && document.getElementById('particles-js').dataset.initialized === 'true' && userSettings.particlesEnabled) {
const pJS = pJSDom[0]?.pJS;
if (pJS) {
const particleColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-secondary').trim();
const particleLineColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim();
pJS.particles.color.value = particleColor;
pJS.particles.line_linked.color = particleLineColor;
pJS.fn.particlesRefresh();
}
} else if (typeof initParticles === 'function') {
initParticles();
}
}
function populateLanguageSelects() {
const audioSelect = $('#preferredAudioLanguageInput');
const textSelect = $('#preferredTextLanguageInput');
audioSelect.empty(); textSelect.empty();
availableLanguages.forEach(lang => {
audioSelect.append(new Option(lang.name + ` (${lang.code})`, lang.code));
});
availableTextLanguages.forEach(lang => {
textSelect.append(new Option(lang.name + (lang.code !== 'off' ? ` (${lang.code})` : ''), lang.code));
});
}
function exportSettings() {
const settingsToExport = { ...userSettings };
const settingsString = JSON.stringify(settingsToExport, null, 2);
const blob = new Blob([settingsString], {type: "application/json;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'zenith_player_settings.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof showNotification === 'function') showNotification('Ajustes (sin token DAZN) exportados.', 'success');
}
async function importSettings(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const imported = JSON.parse(e.target.result);
const defaultSettingsCopy = JSON.parse(JSON.stringify(userSettings));
Object.keys(defaultSettingsCopy).forEach(key => {
if (imported[key] !== undefined && (typeof imported[key] === typeof defaultSettingsCopy[key] || defaultSettingsCopy[key] === null)) {
userSettings[key] = imported[key];
}
});
if(typeof userSettings.additionalGlobalHeaders !== 'string'){
userSettings.additionalGlobalHeaders = JSON.stringify(userSettings.additionalGlobalHeaders || '{}');
}
if (!Number.isInteger(userSettings.channelsPerPage) || userSettings.channelsPerPage < 12 || userSettings.channelsPerPage > 120) {
userSettings.channelsPerPage = defaultSettingsCopy.channelsPerPage;
}
if (!Number.isInteger(userSettings.movistarVodCacheDaysToKeep) || userSettings.movistarVodCacheDaysToKeep < 1 || userSettings.movistarVodCacheDaysToKeep > 90) {
userSettings.movistarVodCacheDaysToKeep = defaultSettingsCopy.movistarVodCacheDaysToKeep;
}
if (!Array.isArray(userSettings.orangeTvSelectedGroups) || userSettings.orangeTvSelectedGroups.some(g => !ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS.includes(g))) {
userSettings.orangeTvSelectedGroups = ORANGE_TV_AVAILABLE_GROUPS_FOR_SETTINGS.slice();
}
let importedDaznToken = imported.daznAuthToken || imported.daznAuthTokenState;
if (importedDaznToken && typeof importedDaznToken === 'string') {
daznAuthTokenState = importedDaznToken;
await saveAppConfigValue(DAZN_TOKEN_DB_KEY, daznAuthTokenState);
}
await saveAppConfigValue('userSettings', userSettings);
if (typeof applyUISettings === 'function') applyUISettings();
if (typeof showNotification === 'function') showNotification('Ajustes importados y aplicados correctamente.', 'success');
$('#settingsModal').modal('hide');
if (typeof populateUserSettingsForm === 'function') populateUserSettingsForm();
} catch (err) {
if (typeof showNotification === 'function') showNotification('Error importando ajustes: Archivo JSON inválido o corrupto. ' + err.message, 'error');
} finally {
$('#importSettingsInput').val('');
}
};
reader.readAsText(file);
}
async function updateMovistarVodCacheStatsUI() {
const daysSpan = $('#movistarVodCacheCurrentDaysSpan');
const sizeSpan = $('#movistarVodCacheSizeSpan');
daysSpan.text('Calculando...');
sizeSpan.text('Calculando...');
if (typeof getMovistarVodCacheStats === 'function') {
try {
const stats = await getMovistarVodCacheStats();
daysSpan.text(`${stats.count} día(s)`);
const sizeMB = (stats.totalSizeBytes / (1024 * 1024)).toFixed(2);
sizeSpan.text(`${sizeMB} MB`);
} catch (e) {
daysSpan.text('Error');
sizeSpan.text('Error');
if (typeof showNotification === 'function') showNotification(`Error al obtener info de caché VOD: ${e.message}`, 'warning');
}
} else {
daysSpan.text('N/A');
sizeSpan.text('N/A');
}
}

380
shaka_handler.js Normal file
View File

@ -0,0 +1,380 @@
let player;
let ui;
const DEBUG_MODE = false;
function debugLog(...args) {
if (DEBUG_MODE) console.debug("[ShakaDebug]", ...args);
}
async function resolveFinalUrl(originalUrl, requestHeaders = {}) {
debugLog(`Resolviendo URL final para: ${originalUrl}`);
try {
const response = await fetch(originalUrl, {
method: 'GET',
headers: requestHeaders,
redirect: 'follow'
});
const finalUrl = response.url;
debugLog(`URL resuelta a: ${finalUrl}`);
const contentType = response.headers.get("content-type") || "";
const isStreamContentType = contentType.includes("application/dash+xml") || contentType.includes("application/vnd.apple.mpegurl") || contentType.includes("octet-stream");
if (!isStreamContentType && !detectMimeType(finalUrl)) {
debugLog(`URL resuelta ${finalUrl} no parece ser un stream (Content-Type: ${contentType}). Se usará de todas formas.`);
}
return finalUrl;
} catch (error) {
console.error("Error al resolver URL final:", error);
return originalUrl;
}
}
function formatShakaError(error, channelName = 'el canal') {
let message = `Error en "${escapeHtml(channelName)}": `;
if (error.code) message += `(Cód: ${error.code}) `;
if (error.message) message += error.message;
if (error.data && error.data.length > 0) message += ` Detalles: ${error.data.join(', ')}`;
if (error.category) message += ` (Categoría: ${error.category})`;
switch (error.code) {
case shaka.util.Error.Code.LOAD_FAILED:
case shaka.util.Error.Code.HTTP_ERROR:
message += " Posibles causas: URL incorrecta, red, CORS, o servidor no responde.";
break;
default:
if (error.category === shaka.util.Error.Category.DRM) {
message += " Problema DRM: licencia inválida, servidor inaccesible o contenido protegido.";
}
}
return message.length > 300 ? message.slice(0, 300) + '...' : message;
}
function validateShakaConfig(config) {
if (!config || !config.streaming || !config.manifest) {
throw new Error("Configuración de Shaka inválida: faltan claves esenciales (streaming, manifest).");
}
return true;
}
async function applyHttpHeaders(headersObject, urlFilter = null, initiatorDomain = null) {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
const dnrHeaders = [];
for (const key in headersObject) {
if (Object.hasOwnProperty.call(headersObject, key)) {
dnrHeaders.push({ header: key, operation: 'set', value: String(headersObject[key]) });
}
}
try {
await new Promise((resolve, reject) => {
const messagePayload = {
cmd: "updateHeadersRules",
requestHeaders: dnrHeaders
};
if (urlFilter) {
messagePayload.urlFilter = urlFilter;
}
if (initiatorDomain) {
messagePayload.initiatorDomain = initiatorDomain;
}
chrome.runtime.sendMessage(messagePayload, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (response && response.success) {
resolve(response);
} else {
reject(response ? response.error : 'Respuesta desconocida o fallida del background script');
}
});
});
debugLog("Cabeceras DNR aplicadas:", dnrHeaders);
} catch (error) {
console.error("[Shaka ApplyHeaders] Error al actualizar las reglas de cabecera:", error);
if (typeof showNotification === 'function') showNotification("Error al aplicar cabeceras de red: " + (error.message || error), "error");
}
} else {
console.warn("[Shaka ApplyHeaders] API de Chrome runtime no disponible. Las cabeceras no serán aplicadas por la extensión.");
}
}
function buildShakaConfig(channel, isPreview = false) {
const kodiProps = channel.kodiProps || {};
let config = {
drm: {
servers: {},
clearKeys: {},
advanced: {
'com.widevine.alpha': { videoRobustness: ['SW_SECURE_CRYPTO'], audioRobustness: ['SW_SECURE_CRYPTO'] },
'com.microsoft.playready': { videoRobustness: ['SW'], audioRobustness: ['SW'] }
}
},
manifest: {
retryParameters: {
maxAttempts: isPreview ? 1 : safeParseInt(userSettings.manifestRetryMaxAttempts, 2),
timeout: isPreview ? 5000 : safeParseInt(userSettings.manifestRetryTimeout, 15000)
},
dash: { defaultPresentationDelay: parseFloat(userSettings.shakaDefaultPresentationDelay) },
hls: { ignoreTextStreamFailures: true }
},
streaming: {
retryParameters: {
maxAttempts: isPreview ? 1 : safeParseInt(userSettings.segmentRetryMaxAttempts, 2),
timeout: isPreview ? 5000 : safeParseInt(userSettings.segmentRetryTimeout, 15000)
},
lowLatencyMode: userSettings.lowLatencyMode,
liveSync: { enabled: userSettings.liveCatchUpMode },
ignoreTextStreamFailures: true
},
abr: {
enabled: !isPreview && userSettings.abrEnabled,
defaultBandwidthEstimate: isPreview ? 500000 : safeParseInt(userSettings.abrDefaultBandwidthEstimate, 1000) * 1000,
restrictions: {}
},
preferredAudioLanguage: userSettings.preferredAudioLanguage,
preferredTextLanguage: userSettings.preferredTextLanguage,
};
if (isPreview) {
config.abr.restrictions.maxHeight = 480;
config.streaming.bufferingGoal = 5;
} else {
const channelBuffer = channel.attributes ? parseFloat(channel.attributes['player-buffer']) : NaN;
config.streaming.bufferingGoal = !isNaN(channelBuffer) && channelBuffer >= 0 ? channelBuffer : safeParseInt(userSettings.playerBuffer, 30);
const maxVideoHeight = safeParseInt(userSettings.maxVideoHeight, 0);
if (maxVideoHeight > 0) {
config.abr.restrictions.maxHeight = maxVideoHeight;
}
}
if(Object.keys(config.abr.restrictions).length === 0) {
delete config.abr.restrictions;
}
const licenseType = kodiProps['inputstream.adaptive.license_type']?.toLowerCase().trim();
const licenseKey = kodiProps['inputstream.adaptive.license_key']?.trim();
const serverCertB64 = kodiProps['inputstream.adaptive.server_certificate']?.trim();
if (licenseType && licenseKey) {
if (licenseType.includes('clearkey')) {
const parsedClearKeys = parseClearKey(licenseKey);
if (parsedClearKeys) config.drm.clearKeys = parsedClearKeys;
} else if (licenseType.includes('widevine') || licenseType.includes('playready')) {
const drmSystem = licenseType.includes('widevine') ? 'com.widevine.alpha' : 'com.microsoft.playready';
if (licenseKey.match(/^https?:\/\//)) {
config.drm.servers[drmSystem] = licenseKey;
if (serverCertB64 && config.drm.advanced[drmSystem]) {
try {
config.drm.advanced[drmSystem].serverCertificate = shaka.util.Uint8ArrayUtils.fromBase64(serverCertB64);
} catch (e) { console.error(`[Shaka Play] Error parseando certificado ${drmSystem} (Base64): ${e.message}`); }
}
}
}
}
if (Object.keys(config.drm.servers).length === 0 && Object.keys(config.drm.clearKeys).length === 0) {
delete config.drm;
}
debugLog("Configuración de Shaka generada:", config);
return config;
}
function updatePlayerConfigFromSettings(playerInstance) {
if (!playerInstance) return;
const channel = playerInstances[activePlayerId]?.channel || {};
const config = buildShakaConfig(channel, false);
validateShakaConfig(config);
playerInstance.configure(config);
const ui = playerInstances[activePlayerId]?.ui;
if (ui) {
ui.configure({
fadeDelay: userSettings.persistentControls ? Infinity : 0
});
}
}
function getChannelHeaders(channel) {
const requestHeaders = {};
const kodiProps = channel.kodiProps || {};
const vlcOpts = channel.vlcOptions || {};
const extHttpHeaders = channel.extHttp || {};
if (channel.sourceOrigin && channel.sourceOrigin.toLowerCase().startsWith('xtream')) {
requestHeaders['User-Agent'] = 'VLC/3.0.20 (Linux; x86_64)';
}
if (vlcOpts['http-user-agent']) requestHeaders['User-Agent'] = vlcOpts['http-user-agent'];
else if (userSettings.globalUserAgent && !requestHeaders['User-Agent']) requestHeaders['User-Agent'] = userSettings.globalUserAgent;
if (vlcOpts['http-referrer']) requestHeaders['Referer'] = vlcOpts['http-referrer'];
else if (userSettings.globalReferrer) requestHeaders['Referer'] = userSettings.globalReferrer;
if (vlcOpts['http-origin']) requestHeaders['Origin'] = vlcOpts['http-origin'];
try {
const globalExtra = JSON.parse(userSettings.additionalGlobalHeaders || '{}');
Object.assign(requestHeaders, globalExtra);
} catch (e) { console.warn("Error parsing global headers", e); }
Object.assign(requestHeaders, extHttpHeaders);
const kodiStreamHeadersRaw = kodiProps['inputstream.adaptive.stream_headers'];
if (kodiStreamHeadersRaw) {
kodiStreamHeadersRaw.split('|').forEach(part => {
const eqIndex = part.indexOf('=');
if (eqIndex > 0) requestHeaders[part.substring(0, eqIndex).trim()] = part.substring(eqIndex + 1).trim();
});
}
return requestHeaders;
}
async function playChannelInShaka(channel, windowId) {
const instance = playerInstances[windowId];
if (!instance || !instance.player) {
if (typeof showNotification === 'function') showNotification('Instancia de reproductor no encontrada.', 'error');
return;
}
if (!channel || typeof channel.url !== 'string') {
console.warn("Canal inválido o sin URL:", channel);
if (typeof showNotification === 'function') showNotification('Datos del canal inválidos.', 'error');
return;
}
instance.channel = channel;
const player = instance.player;
const videoElement = instance.videoElement;
videoElement.poster = channel['tvg-logo'] || '';
if (typeof showLoading === 'function') showLoading(true, `Cargando ${escapeHtml(channel.name)}...`);
try {
if (player.getMediaElement()) {
await player.unload(true);
videoElement.src = '';
videoElement.load();
}
const requestHeadersForDNR = getChannelHeaders(channel);
await applyHttpHeaders(requestHeadersForDNR, "*://*/*", chrome.runtime.id);
const playerConfig = buildShakaConfig(channel, false);
validateShakaConfig(playerConfig);
player.configure(playerConfig);
if (instance.errorHandler) {
player.removeEventListener('error', instance.errorHandler);
}
const newErrorHandler = (e) => onShakaPlayerError(e, windowId);
player.addEventListener('error', newErrorHandler);
instance.errorHandler = newErrorHandler;
const resolvedUrl = await resolveFinalUrl(channel.url, requestHeadersForDNR);
if (!resolvedUrl) {
throw new Error("No se pudo resolver la URL final del stream.");
}
channel.resolvedUrl = resolvedUrl;
const mimeType = detectMimeType(resolvedUrl);
await player.load(resolvedUrl, null, mimeType);
if (typeof showPlayerInfobar === 'function') {
showPlayerInfobar(channel, instance.container.querySelector('.player-infobar'));
}
if (typeof highlightCurrentChannelInList === 'function' && instance.isChannelListVisible) {
highlightCurrentChannelInList(windowId);
}
if (typeof addToHistory === 'function') {
addToHistory(channel);
}
} catch (e) {
onShakaPlayerError({ detail: e, channelName: channel.name }, windowId);
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
function onShakaPlayerError(event, windowId) {
const instance = playerInstances[windowId];
const channelName = instance ? instance.channel.name : 'el canal';
const error = event.detail;
const finalMessage = formatShakaError(error, channelName);
console.error("Player Error Event:", finalMessage, "Full error object:", event.detail);
if (typeof showNotification === 'function') showNotification(finalMessage, 'error');
if (typeof showLoading === 'function') showLoading(false);
}
async function playChannelInCardPreview(channel, videoContainerElement) {
if (activeCardPreviewPlayer) {
await destroyActiveCardPreviewPlayer();
}
if (!channel || !channel.url || !videoContainerElement) {
return;
}
const videoElement = document.createElement('video');
videoElement.className = 'card-preview-video';
videoElement.muted = true;
videoElement.autoplay = true;
videoElement.playsInline = true;
videoContainerElement.innerHTML = '';
videoContainerElement.appendChild(videoElement);
activeCardPreviewPlayer = new shaka.Player();
try {
await activeCardPreviewPlayer.attach(videoElement);
const requestHeadersForDNR = getChannelHeaders(channel);
await applyHttpHeaders(requestHeadersForDNR, "*://*/*", chrome.runtime.id);
const previewConfig = buildShakaConfig(channel, true);
validateShakaConfig(previewConfig);
await activeCardPreviewPlayer.configure(previewConfig);
const resolvedUrl = await resolveFinalUrl(channel.url, requestHeadersForDNR);
if (!resolvedUrl) {
throw new Error("No se pudo resolver la URL final del stream para la previsualización.");
}
const mimeType = detectMimeType(resolvedUrl);
await activeCardPreviewPlayer.load(resolvedUrl, null, mimeType);
videoElement.play().catch(e => {
console.warn("Error al iniciar previsualización automática:", e);
destroyActiveCardPreviewPlayer();
});
} catch (error) {
console.error("Error al cargar previsualización:", error);
if (activeCardPreviewElement) activeCardPreviewElement.removeClass('is-playing-preview');
activeCardPreviewElement = null;
destroyActiveCardPreviewPlayer();
}
}
async function destroyActiveCardPreviewPlayer() {
if (activeCardPreviewPlayer) {
try {
await activeCardPreviewPlayer.destroy();
} catch (e) {
console.warn("Error destruyendo reproductor de previsualización:", e);
}
activeCardPreviewPlayer = null;
}
if (activeCardPreviewElement) {
activeCardPreviewElement.removeClass('is-playing-preview');
const previewContainer = activeCardPreviewElement.find('.card-video-preview-container');
if(previewContainer.length) previewContainer.empty();
activeCardPreviewElement = null;
}
await applyHttpHeaders([], "*://*/*", null);
}

101
ui_actions.js Normal file
View File

@ -0,0 +1,101 @@
function showLoading(show, message = 'Cargando...') {
const overlay = $('#loading-overlay');
if (show) {
overlay.find('.loader').next('span').remove();
overlay.find('.loader').after(`<span class="ms-2 text-light">${escapeHtml(message)}</span>`);
overlay.addClass('show');
} else {
overlay.removeClass('show');
}
}
function showNotification(message, type = 'info', duration) {
if (notificationTimeout) clearTimeout(notificationTimeout);
const notification = $('#notification');
notification.text(message).removeClass('success error info warning').addClass(type).addClass('show');
let effectiveDuration = duration;
if (effectiveDuration === undefined) {
effectiveDuration = type === 'error' ? 8000 : type === 'warning' ? 6000 : 4000;
}
notificationTimeout = setTimeout(() => {
notification.removeClass('show');
notificationTimeout = null;
}, effectiveDuration);
}
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') {
return '';
}
return unsafe.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"'
})[m]);
}
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
async function showConfirmationModal(message, title = "Confirmación", confirmText = "Confirmar", confirmClass = "btn-primary") {
return new Promise((resolve) => {
const modalId = 'genericConfirmationModal';
let modalElement = document.getElementById(modalId);
if (!modalElement) {
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div class="modal fade" id="${modalId}" tabindex="-1" aria-labelledby="${modalId}Label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="${modalId}Label">${escapeHtml(title)}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>${escapeHtml(message)}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn ${escapeHtml(confirmClass)}" id="${modalId}ConfirmBtn">${escapeHtml(confirmText)}</button>
</div>
</div>
</div>
</div>
`;
modalElement = wrapper.firstElementChild;
document.body.appendChild(modalElement);
} else {
$(modalElement).find('.modal-title').text(title);
$(modalElement).find('.modal-body p').text(message);
$(modalElement).find(`#${modalId}ConfirmBtn`).text(confirmText).attr('class', `btn ${confirmClass}`);
}
const confirmBtn = document.getElementById(`${modalId}ConfirmBtn`);
const modalInstance = bootstrap.Modal.getOrCreateInstance(document.getElementById(modalId));
const confirmHandler = () => {
confirmBtn.removeEventListener('click', confirmHandler);
modalInstance.hide();
resolve(true);
};
$(modalElement).off('hidden.bs.modal.confirm').one('hidden.bs.modal.confirm', () => {
confirmBtn.removeEventListener('click', confirmHandler);
resolve(false);
});
$(confirmBtn).off('click.confirm').one('click.confirm', confirmHandler);
modalInstance.show();
});
}

178
user_session.js Normal file
View File

@ -0,0 +1,178 @@
async function loadLastM3U() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('url') || urlParams.has('name')) { return; }
if (!userSettings.autoSaveM3U) {
checkIfChannelsExist();
return;
}
const lastUrl = await getAppConfigValue('lastM3UUrl');
const lastFileContent = await getAppConfigValue('lastM3UFileContent');
const lastXtreamInfoStr = await getAppConfigValue('currentXtreamServerInfo');
if (lastXtreamInfoStr) {
try {
const lastXtreamInfo = JSON.parse(lastXtreamInfoStr);
if (lastXtreamInfo && lastXtreamInfo.host && lastXtreamInfo.username && lastXtreamInfo.password) {
showNotification(`Recargando última conexión Xtream: ${escapeHtml(lastXtreamInfo.name || lastXtreamInfo.host)}...`, 'info');
if(typeof handleConnectXtreamServer === 'function') {
$('#xtreamServerNameInput').val(lastXtreamInfo.name || '');
$('#xtreamHostInput').val(lastXtreamInfo.host);
$('#xtreamUsernameInput').val(lastXtreamInfo.username);
$('#xtreamPasswordInput').val(lastXtreamInfo.password);
$('#xtreamOutputTypeSelect').val(lastXtreamInfo.outputType || 'm3u_plus');
$('#xtreamFetchEpgCheck').prop('checked', typeof lastXtreamInfo.fetchEpg === 'boolean' ? lastXtreamInfo.fetchEpg : true);
handleConnectXtreamServer();
} else {
checkIfChannelsExist();
}
return;
} else {
await deleteAppConfigValue('currentXtreamServerInfo');
}
} catch (e) {
await deleteAppConfigValue('currentXtreamServerInfo');
}
}
if (lastUrl) {
showNotification('Cargando última lista URL...', 'info');
loadUrl(lastUrl)
.catch(async () => {
await deleteAppConfigValue('lastM3UUrl');
const lastFileContentAfterFail = await getAppConfigValue('lastM3UFileContent');
if (lastFileContentAfterFail) loadLastM3U();
else checkIfChannelsExist();
});
} else if (lastFileContent) {
showNotification('Cargando última lista local guardada...', 'info');
try {
const m3uName = await getAppConfigValue('lastM3UFileName') || 'lista_local_guardada.m3u';
processM3UContent(lastFileContent, m3uName, true);
} catch (err) {
showNotification(`Error recargando lista local: ${err.message}`, 'error');
await deleteAppConfigValue('lastM3UFileContent');
await deleteAppConfigValue('lastM3UFileName');
channels = []; currentM3UContent = null; currentM3UName = null; currentGroupOrder = [];
filterAndRenderChannels();
}
} else {
checkIfChannelsExist();
let initialGroupToSelect = "";
if (userSettings.persistFilters && userSettings.lastSelectedGroup) {
initialGroupToSelect = userSettings.lastSelectedGroup;
}
$('#groupFilterSidebar').val(initialGroupToSelect);
filterAndRenderChannels();
}
}
async function toggleFavorite(url) {
const index = favorites.indexOf(url);
const $button = $(`.favorite-btn[data-url="${escapeHtml(url)}"]`);
if (index > -1) {
favorites.splice(index, 1);
$button.removeClass('favorite').attr('title', 'Añadir favorito');
showNotification('Quitado de favoritos.', 'info');
} else {
favorites.push(url);
$button.addClass('favorite').attr('title', 'Quitar favorito');
showNotification('Añadido a favoritos.', 'success');
}
await saveAppConfigValue('favorites', favorites);
if (currentFilter === 'favorites') {
currentPage = 1;
filterAndRenderChannels();
} else {
updateGroupSelectors();
}
}
async function addToHistory(channel) {
if (!channel || !channel.url) { return; }
appHistory = appHistory.filter(hUrl => hUrl !== channel.url);
appHistory.unshift(channel.url);
appHistory = appHistory.slice(0, 50);
await saveAppConfigValue('history', appHistory);
if (currentFilter === 'history') {
currentPage = 1;
filterAndRenderChannels();
} else {
updateGroupSelectors();
}
}
async function clearCacheAndReload() {
const confirmed = await showConfirmationModal(
"¿Estás seguro de que quieres borrar TODOS los datos locales (historial, favoritos, listas guardadas, servidores Xtream, EPG, ajustes y tokens)? La página se recargará.",
"Confirmar Limpieza Completa",
"Sí, Borrar Todo",
"btn-danger"
);
if (!confirmed) {
showNotification("Operación cancelada.", "info");
return;
}
showLoading(true, "Limpiando datos...");
try {
if (typeof dbPromise !== 'undefined' && dbPromise) {
const db = await dbPromise;
if (db) {
db.close();
dbPromise = null;
}
}
await new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(dbName);
deleteRequest.onsuccess = () => {
showNotification("Base de datos eliminada. La página se recargará.", "success");
setTimeout(() => window.location.reload(), 1500);
resolve();
};
deleteRequest.onerror = (event) => {
reject(event.target.error);
};
deleteRequest.onblocked = () => {
showNotification("Borrado de BD bloqueado. Cierra otras pestañas de la extensión y reintenta.", "warning");
reject(new Error("Database deletion blocked"));
};
});
} catch (error) {
showLoading(false);
showNotification("Error limpiando datos: " + error.message, "error");
}
}
function handleSaveToDB() {
const nameInput = $('#saveM3UNameInput').val();
if (!nameInput || !nameInput.trim()) {
showNotification('Nombre de lista inválido o vacío. Guardado cancelado.', 'info');
return;
}
const finalName = nameInput.trim();
const saveModalInstance = bootstrap.Modal.getInstance(document.getElementById('saveM3UModal'));
if(saveModalInstance) saveModalInstance.hide();
showLoading(true, `Guardando "${escapeHtml(finalName)}"...`);
if (typeof saveFileToDB === 'function') {
saveFileToDB(finalName, currentM3UContent)
.then(() => showNotification(`Lista "${escapeHtml(finalName)}" guardada (${typeof countChannels === 'function' ? countChannels(currentM3UContent) : 0} canales).`, 'success'))
.catch(err => {
if (err.message.includes('cancelada')) {
showNotification('Guardado cancelado por el usuario.', 'info');
} else {
showNotification(`Error al guardar "${escapeHtml(finalName)}": ${err.message}`, 'error');
}
})
.finally(() => showLoading(false));
} else {
showLoading(false);
}
}

819
xcodec_handler.js Normal file
View File

@ -0,0 +1,819 @@
const PRESET_XCODEC_PANELS = [
{ name: "Orange", serverUrl: "http://213.220.3.165/", apiToken: "iM4iIpjCWwNiOoL4EPEZV1xD" },
];
let xcodecUi = {
manageModal: null,
panelNameInput: null,
panelServerUrlInput: null,
panelApiTokenInput: null,
editingPanelIdInput: null,
savePanelBtn: null,
clearFormBtn: null,
processPanelBtn: null,
processAllPanelsBtn: null,
importPresetBtn: null,
savedPanelsList: null,
status: null,
progressContainer: null,
progressBar: null,
previewModal: null,
previewModalLabel: null,
previewStats: null,
previewGroupList: null,
previewChannelList: null,
previewSelectAllGroupsBtn: null,
previewSelectAllChannelsInGroupBtn: null,
addSelectedBtn: null,
addAllValidBtn: null
};
let xcodecTotalApiCallsExpected = 0;
let xcodecApiCallsCompleted = 0;
let currentXCodecPanelDataForPreview = null;
let xcodecProcessedStreamsForPreview = [];
function initXCodecPanelManagement() {
xcodecUi.manageModal = document.getElementById('manageXCodecPanelsModal');
xcodecUi.panelNameInput = document.getElementById('xcodecPanelNameInput');
xcodecUi.panelServerUrlInput = document.getElementById('xcodecPanelServerUrlInput');
xcodecUi.panelApiTokenInput = document.getElementById('xcodecPanelApiTokenInput');
xcodecUi.editingPanelIdInput = document.getElementById('xcodecEditingPanelIdInput');
xcodecUi.savePanelBtn = document.getElementById('xcodecSavePanelBtn');
xcodecUi.clearFormBtn = document.getElementById('xcodecClearFormBtn');
xcodecUi.processPanelBtn = document.getElementById('xcodecProcessPanelBtn');
xcodecUi.processAllPanelsBtn = document.getElementById('xcodecProcessAllPanelsBtn');
xcodecUi.importPresetBtn = document.getElementById('xcodecImportPresetPanelsBtn');
xcodecUi.savedPanelsList = document.getElementById('savedXCodecPanelsList');
xcodecUi.status = document.getElementById('xcodecStatus');
xcodecUi.progressContainer = document.getElementById('xcodecProgressContainer');
xcodecUi.progressBar = document.getElementById('xcodecProgressBar');
xcodecUi.previewModal = document.getElementById('xcodecPreviewModal');
xcodecUi.previewModalLabel = document.getElementById('xcodecPreviewModalLabel');
xcodecUi.previewStats = document.getElementById('xcodecPreviewStats');
xcodecUi.previewGroupList = document.getElementById('xcodecPreviewGroupList');
xcodecUi.previewChannelList = document.getElementById('xcodecPreviewChannelList');
xcodecUi.previewSelectAllGroupsBtn = document.getElementById('xcodecPreviewSelectAllGroupsBtn');
xcodecUi.previewSelectAllChannelsInGroupBtn = document.getElementById('xcodecPreviewSelectAllChannelsInGroupBtn');
xcodecUi.addSelectedBtn = document.getElementById('xcodecAddSelectedBtn');
xcodecUi.addAllValidBtn = document.getElementById('xcodecAddAllValidBtn');
if (xcodecUi.savePanelBtn) xcodecUi.savePanelBtn.addEventListener('click', handleSaveXCodecPanel);
if (xcodecUi.clearFormBtn) xcodecUi.clearFormBtn.addEventListener('click', clearXCodecPanelForm);
if (xcodecUi.processPanelBtn) xcodecUi.processPanelBtn.addEventListener('click', () => processPanelFromForm(false));
if (xcodecUi.processAllPanelsBtn) xcodecUi.processAllPanelsBtn.addEventListener('click', processAllSavedXCodecPanels);
if (xcodecUi.importPresetBtn) xcodecUi.importPresetBtn.addEventListener('click', importPresetXCodecPanels);
if (xcodecUi.previewSelectAllGroupsBtn) xcodecUi.previewSelectAllGroupsBtn.addEventListener('click', toggleAllGroupsInPreview);
if (xcodecUi.previewSelectAllChannelsInGroupBtn) xcodecUi.previewSelectAllChannelsInGroupBtn.addEventListener('click', toggleAllChannelsInCurrentPreviewGroup);
if (xcodecUi.addSelectedBtn) xcodecUi.addSelectedBtn.addEventListener('click', addSelectedXCodecStreamsToM3U);
if (xcodecUi.addAllValidBtn) xcodecUi.addAllValidBtn.addEventListener('click', addAllValidXCodecStreamsToM3U);
if (xcodecUi.savedPanelsList) {
xcodecUi.savedPanelsList.addEventListener('click', (event) => {
const target = event.target.closest('button');
if (!target) return;
const panelId = parseInt(target.dataset.id, 10);
if (target.classList.contains('load-xcodec-panel-btn')) {
loadXCodecPanelToForm(panelId);
} else if (target.classList.contains('delete-xcodec-panel-btn')) {
handleDeleteXCodecPanel(panelId);
} else if (target.classList.contains('process-xcodec-panel-direct-btn')) {
loadXCodecPanelToForm(panelId).then(() => processPanelFromForm(true));
}
});
}
if (xcodecUi.previewGroupList) {
xcodecUi.previewGroupList.addEventListener('click', (event) => {
const groupItem = event.target.closest('.list-group-item');
if (groupItem && groupItem.dataset.groupName) {
const groupName = groupItem.dataset.groupName;
renderXCodecPreviewChannels(groupName);
xcodecUi.previewGroupList.querySelectorAll('.list-group-item').forEach(item => item.classList.remove('active'));
groupItem.classList.add('active');
xcodecUi.previewSelectAllChannelsInGroupBtn.disabled = false;
}
});
}
loadSavedXCodecPanels();
}
function xcodecUpdateStatus(message, type = 'info', modal = 'manage') {
const statusEl = modal === 'manage' ? xcodecUi.status : xcodecUi.previewStats;
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = 'alert mt-2';
statusEl.style.display = 'block';
if (type) statusEl.classList.add(`alert-${type}`);
}
function xcodecResetProgress(expectedCalls = 0) {
if (!xcodecUi) return;
xcodecApiCallsCompleted = 0;
xcodecTotalApiCallsExpected = expectedCalls;
xcodecUi.progressBar.style.width = '0%';
xcodecUi.progressBar.textContent = '0%';
xcodecUi.progressContainer.style.display = expectedCalls > 0 ? 'block' : 'none';
}
function xcodecUpdateProgress() {
if (!xcodecUi || xcodecTotalApiCallsExpected === 0) return;
xcodecApiCallsCompleted++;
const percentage = Math.min(100, Math.max(0, (xcodecApiCallsCompleted / xcodecTotalApiCallsExpected) * 100));
xcodecUi.progressBar.style.width = percentage + '%';
xcodecUi.progressBar.textContent = Math.round(percentage) + '%';
if (percentage >= 100 && xcodecUi.progressContainer) {
setTimeout(() => { if (xcodecUi.progressContainer) xcodecUi.progressContainer.style.display = 'none'; }, 1500);
}
}
function xcodecSetControlsDisabled(disabled, modal = 'manage') {
if (!xcodecUi) return;
if (modal === 'manage') {
xcodecUi.processPanelBtn.disabled = disabled;
if (xcodecUi.processAllPanelsBtn) xcodecUi.processAllPanelsBtn.disabled = disabled;
xcodecUi.panelServerUrlInput.disabled = disabled;
xcodecUi.panelApiTokenInput.disabled = disabled;
xcodecUi.savePanelBtn.disabled = disabled;
xcodecUi.clearFormBtn.disabled = disabled;
xcodecUi.importPresetBtn.disabled = disabled;
const processBtnIcon = xcodecUi.processPanelBtn.querySelector('i');
if (processBtnIcon) processBtnIcon.className = disabled ? 'fas fa-spinner fa-spin me-1' : 'fas fa-cogs me-1';
const processAllBtnIcon = xcodecUi.processAllPanelsBtn ? xcodecUi.processAllPanelsBtn.querySelector('i') : null;
if (processAllBtnIcon) processAllBtnIcon.className = disabled ? 'fas fa-spinner fa-spin me-1' : 'fas fa-tasks me-1';
} else if (modal === 'preview') {
xcodecUi.addSelectedBtn.disabled = disabled;
xcodecUi.addAllValidBtn.disabled = disabled;
}
}
function xcodecCleanUrl(url) {
try {
const urlObj = new URL(url);
urlObj.searchParams.delete('decryption_key');
return urlObj.toString();
} catch (e) {
return url.replace(/[?&]decryption_key=[^&]+/gi, '');
}
}
function getXCodecProxiedApiEndpoint(targetServerBaseUrl, apiPath) {
let base = targetServerBaseUrl.trim();
if (!base.endsWith('/')) base += '/';
let path = apiPath.trim();
if (path.startsWith('/')) path = path.substring(1);
const proxy = userSettings.xcodecCorsProxyUrl ? userSettings.xcodecCorsProxyUrl.trim() : '';
if (proxy) {
return proxy + base + path;
}
return base + path;
}
async function fetchXCodecWithTimeout(resource, options = {}, timeout) {
const effectiveTimeout = timeout || userSettings.xcodecDefaultTimeout || 8000;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), effectiveTimeout);
const response = await fetch(resource, { ...options, signal: controller.signal });
clearTimeout(id);
return response;
}
async function getXCodecStreamStats(targetServerUrl, apiToken) {
const apiUrl = getXCodecProxiedApiEndpoint(targetServerUrl, 'api/stream/stats');
xcodecUpdateProgress();
const headers = {};
if (apiToken) headers['Authorization'] = `Token ${apiToken}`;
try {
const response = await fetchXCodecWithTimeout(apiUrl, { headers });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const stats = await response.json();
if (!Array.isArray(stats)) throw new Error("La respuesta de estadísticas no es un array.");
return stats;
} catch (error) {
throw error;
}
}
async function processXCodecStreamConfig(targetServerUrl, apiToken, streamId, streamNameFallback, serverHostForGroupTitle) {
const apiUrl = getXCodecProxiedApiEndpoint(targetServerUrl, `api/stream/${streamId}/config`);
const headers = {};
if (apiToken) headers['Authorization'] = `Token ${apiToken}`;
const DEFAULT_KID_FOR_JSON_SINGLE_KEY = "00000000000000000000000000000000";
try {
const response = await fetchXCodecWithTimeout(apiUrl, { headers });
if (!response.ok) throw new Error(`HTTP ${response.status} para config ${streamId}`);
const config = await response.json();
const streamName = config?.name || streamNameFallback || `Stream ${streamId}`;
if (!config?.input_urls?.length) {
return { error: `Stream ${streamId} (${streamName}) sin input_urls.` };
}
let kodiProps = {
'inputstreamaddon': 'inputstream.adaptive',
'inputstream.adaptive.manifest_type': 'mpd'
};
let vlcOpts = {};
const urlWithKey = config.input_urls.find(u => /[?&]decryption_key=([^&]+)/i.test(u));
if (urlWithKey) {
const keyMatch = urlWithKey.match(/[?&]decryption_key=([^&]+)/i);
if (keyMatch && keyMatch[1]) {
const allKeyEntriesString = keyMatch[1];
const keyEntriesArray = allKeyEntriesString.split(',');
let licenseKeyStringForKodi = '';
if (keyEntriesArray.length === 1) {
const singleEntry = keyEntriesArray[0].trim();
if (singleEntry.indexOf(':') === -1 && singleEntry.length === 32 && /^[0-9a-fA-F]{32}$/.test(singleEntry)) {
licenseKeyStringForKodi = singleEntry;
}
}
if (!licenseKeyStringForKodi) {
const licenseKeysObject = {};
let foundValidKeysForJson = false;
for (const entryStr of keyEntriesArray) {
const trimmedEntry = entryStr.trim();
if (!trimmedEntry) continue;
const parts = trimmedEntry.split(':');
if (parts.length === 2 && parts[0].trim() && parts[1].trim()) {
const kid = parts[0].trim();
const key = parts[1].trim();
if (kid.length === 32 && key.length === 32 && /^[0-9a-fA-F]+$/.test(kid) && /^[0-9a-fA-F]+$/.test(key)) {
licenseKeysObject[kid] = key;
foundValidKeysForJson = true;
}
} else if (parts.length === 1 && parts[0].trim()) {
const potentialKey = parts[0].trim();
if (potentialKey.length === 32 && /^[0-9a-fA-F]{32}$/.test(potentialKey)) {
licenseKeysObject[DEFAULT_KID_FOR_JSON_SINGLE_KEY] = potentialKey;
foundValidKeysForJson = true;
}
}
}
if (foundValidKeysForJson) {
licenseKeyStringForKodi = JSON.stringify(licenseKeysObject);
}
}
if (licenseKeyStringForKodi) {
kodiProps['inputstream.adaptive.license_type'] = 'clearkey';
kodiProps['inputstream.adaptive.license_key'] = licenseKeyStringForKodi;
}
}
}
if (config.headers) {
try {
const formattedHeaders = config.headers.split('&').map(p => {
const eq = p.indexOf('=');
return eq > -1 ? `${p.substring(0, eq).trim()}=${encodeURIComponent(p.substring(eq + 1).trim())}` : p.trim();
}).join('&');
kodiProps['inputstream.adaptive.stream_headers'] = formattedHeaders;
} catch (e) {
}
}
return {
name: streamName,
url: xcodecCleanUrl(config.input_urls[0]),
'tvg-id': config.epg_id || `xcodec.${streamId}`,
'tvg-logo': config.logo || '',
'group-title': config.category_name || serverHostForGroupTitle || 'XCodec Streams',
attributes: { duration: -1 },
kodiProps: kodiProps,
vlcOptions: vlcOpts,
sourceOrigin: `XCodec: ${serverHostForGroupTitle}`
};
} catch (error) {
return { error: `Fallo config Stream ${streamId} de ${targetServerUrl}: ${error.message}` };
}
}
async function processSingleXCodecPanelLogic(panelData, directAdd, isPartOfBatchOperation) {
let serverHostForGroupTitle;
try {
const urlObj = new URL(panelData.serverUrl);
serverHostForGroupTitle = panelData.name || urlObj.hostname;
} catch(e) {
serverHostForGroupTitle = panelData.name || panelData.serverUrl;
}
const serverBaseUrl = panelData.serverUrl.endsWith('/') ? panelData.serverUrl : panelData.serverUrl + '/';
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(`Iniciando panel: ${escapeHtml(panelData.name || panelData.serverUrl)}...`, 'info', 'manage');
}
xcodecResetProgress(1);
let streamStats;
try {
streamStats = await getXCodecStreamStats(serverBaseUrl, panelData.apiToken);
} catch (error) {
const errorMsg = `Error obteniendo estadísticas de ${serverHostForGroupTitle}: ${error.message}`;
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(errorMsg, 'danger', 'manage');
xcodecSetControlsDisabled(false, 'manage');
xcodecResetProgress();
}
return { success: false, name: serverHostForGroupTitle, error: errorMsg, added: 0, errors: 1 };
}
if (!streamStats) {
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(`No se obtuvieron estadísticas de ${serverHostForGroupTitle}.`, 'warning', 'manage');
xcodecSetControlsDisabled(false, 'manage');
xcodecResetProgress();
}
return { success: false, name: serverHostForGroupTitle, error: "No stats returned", added: 0, errors: 1 };
}
if (streamStats.length === 0) {
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(`No se encontraron streams activos en ${serverHostForGroupTitle}.`, 'info', 'manage');
xcodecSetControlsDisabled(false, 'manage');
xcodecResetProgress();
}
return { success: true, name: serverHostForGroupTitle, added: 0, errors: 0, message: "No active streams" };
}
if (directAdd && userSettings.xcodecIgnorePanelsOverStreams > 0 && streamStats.length > userSettings.xcodecIgnorePanelsOverStreams) {
const ignoreMsg = `Panel ${serverHostForGroupTitle} ignorado: ${streamStats.length} streams (límite ${userSettings.xcodecIgnorePanelsOverStreams}).`;
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(ignoreMsg, 'warning', 'manage');
xcodecSetControlsDisabled(false, 'manage');
xcodecResetProgress();
}
return { success: true, name: serverHostForGroupTitle, added: 0, errors: 0, message: ignoreMsg };
}
xcodecTotalApiCallsExpected = 1 + streamStats.length;
if (!isPartOfBatchOperation) {
xcodecUi.progressBar.textContent = Math.round((1 / xcodecTotalApiCallsExpected) * 100) + '%';
}
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(`Procesando ${streamStats.length} streams de ${serverHostForGroupTitle}...`, 'info', 'manage');
}
if (streamStats.length > 0) xcodecUi.progressContainer.style.display = 'block';
const batchSize = userSettings.xcodecDefaultBatchSize || 15;
let processedStreams = [];
let streamsWithErrors = 0;
for (let j = 0; j < streamStats.length; j += batchSize) {
const batch = streamStats.slice(j, j + batchSize);
const configPromises = batch.map(s =>
processXCodecStreamConfig(serverBaseUrl, panelData.apiToken, s.id, s.name, serverHostForGroupTitle)
.finally(() => xcodecUpdateProgress())
);
const batchResults = await Promise.allSettled(configPromises);
batchResults.forEach(r => {
if (r.status === 'fulfilled' && r.value && !r.value.error) {
processedStreams.push(r.value);
} else {
streamsWithErrors++;
}
});
}
currentXCodecPanelDataForPreview = panelData;
xcodecProcessedStreamsForPreview = processedStreams;
if (directAdd) {
if (processedStreams.length > 0) {
const m3uString = streamsToM3U(processedStreams, serverHostForGroupTitle);
const sourceName = `XCodec: ${serverHostForGroupTitle}`;
if (typeof removeChannelsBySourceOrigin === 'function') removeChannelsBySourceOrigin(sourceName);
appendM3UContent(m3uString, sourceName);
if (!isPartOfBatchOperation) {
showNotification(`${processedStreams.length} canales de "${escapeHtml(serverHostForGroupTitle)}" añadidos/actualizados.`, 'success');
}
}
if (!isPartOfBatchOperation) {
xcodecUpdateStatus(`Proceso completado. Streams OK: ${processedStreams.length}. Errores: ${streamsWithErrors}.`, 'success', 'manage');
const manageModalInstance = bootstrap.Modal.getInstance(xcodecUi.manageModal);
if (manageModalInstance) manageModalInstance.hide();
}
} else {
if (!isPartOfBatchOperation) {
openXCodecPreviewModal(serverHostForGroupTitle, processedStreams.length, streamsWithErrors);
}
}
if (!isPartOfBatchOperation) {
xcodecSetControlsDisabled(false, 'manage');
}
return { success: true, name: serverHostForGroupTitle, added: processedStreams.length, errors: streamsWithErrors };
}
async function processPanelFromForm(directAdd = false) {
if (!xcodecUi) return;
const panelData = {
id: xcodecUi.editingPanelIdInput.value ? parseInt(xcodecUi.editingPanelIdInput.value, 10) : null,
name: xcodecUi.panelNameInput.value.trim(),
serverUrl: xcodecUi.panelServerUrlInput.value.trim(),
apiToken: xcodecUi.panelApiTokenInput.value.trim()
};
if (!panelData.serverUrl) {
xcodecUpdateStatus('Por favor, introduce la URL del servidor X-UI/XC.', 'warning', 'manage');
return;
}
try {
new URL(panelData.serverUrl);
} catch(e){
xcodecUpdateStatus('La URL del servidor no es válida.', 'warning', 'manage');
return;
}
if (!panelData.name) panelData.name = new URL(panelData.serverUrl).hostname;
xcodecSetControlsDisabled(true, 'manage');
try {
await processSingleXCodecPanelLogic(panelData, directAdd, false);
} catch (error) {
xcodecUpdateStatus(`Error procesando el panel ${escapeHtml(panelData.name)}: ${error.message}`, 'danger', 'manage');
xcodecSetControlsDisabled(false, 'manage');
xcodecResetProgress();
}
}
async function processAllSavedXCodecPanels() {
if (!xcodecUi) return;
const userConfirmed = await showConfirmationModal(
"Esto procesará TODOS los paneles XCodec guardados y añadirá sus streams directamente a la lista M3U actual. Esta operación puede tardar y añadir muchos canales. ¿Continuar?",
"Confirmar Procesamiento Masivo de Paneles",
"Sí, Procesar Todos",
"btn-primary"
);
if (!userConfirmed) {
xcodecUpdateStatus('Procesamiento masivo cancelado por el usuario.', 'info', 'manage');
return;
}
xcodecSetControlsDisabled(true, 'manage');
xcodecUpdateStatus('Iniciando procesamiento de todos los paneles guardados...', 'info', 'manage');
let savedPanels;
try {
savedPanels = await getAllXCodecPanelsFromDB();
} catch (error) {
xcodecUpdateStatus(`Error al obtener paneles guardados: ${error.message}`, 'danger', 'manage');
xcodecSetControlsDisabled(false, 'manage');
return;
}
if (!savedPanels || savedPanels.length === 0) {
xcodecUpdateStatus('No hay paneles guardados para procesar.', 'info', 'manage');
xcodecSetControlsDisabled(false, 'manage');
return;
}
let totalPanels = savedPanels.length;
let panelsProcessedCount = 0;
let totalStreamsAdded = 0;
let totalErrorsAcrossPanels = 0;
let panelsWithErrorsCount = 0;
xcodecUi.progressContainer.style.display = 'block';
for (const panel of savedPanels) {
panelsProcessedCount++;
const panelDisplayName = panel.name || panel.serverUrl;
const overallPercentage = (panelsProcessedCount / totalPanels) * 100;
xcodecUi.progressBar.style.width = overallPercentage + '%';
xcodecUi.progressBar.textContent = `Panel ${panelsProcessedCount}/${totalPanels}`;
xcodecUpdateStatus(`Procesando panel ${panelsProcessedCount} de ${totalPanels}: "${escapeHtml(panelDisplayName)}"`, 'info', 'manage');
try {
const result = await processSingleXCodecPanelLogic(panel, true, true);
if (result) {
totalStreamsAdded += result.added || 0;
if (!result.success || (result.errors || 0) > 0) {
panelsWithErrorsCount++;
totalErrorsAcrossPanels += result.errors || 0;
}
}
} catch (error) {
xcodecUpdateStatus(`Error crítico procesando panel "${escapeHtml(panelDisplayName)}": ${error.message}. Saltando al siguiente.`, 'warning', 'manage');
panelsWithErrorsCount++;
totalErrorsAcrossPanels++;
}
}
xcodecUi.progressBar.style.width = '100%';
xcodecUi.progressBar.textContent = `Completado ${totalPanels}/${totalPanels}`;
setTimeout(() => {
if (xcodecUi.progressContainer) xcodecUi.progressContainer.style.display = 'none';
xcodecUi.progressBar.style.width = '0%';
xcodecUi.progressBar.textContent = '0%';
}, 3000);
const summaryMessage = `Procesamiento masivo completado. ${panelsProcessedCount} paneles procesados. Total streams añadidos: ${totalStreamsAdded}. Paneles con errores: ${panelsWithErrorsCount}. Total errores individuales: ${totalErrorsAcrossPanels}.`;
xcodecUpdateStatus(summaryMessage, 'success', 'manage');
showNotification(summaryMessage, 'success', 10000);
xcodecSetControlsDisabled(false, 'manage');
}
function streamsToM3U(streamsArray, panelName) {
let m3u = '#EXTM3U\n';
m3u += `# ----- Inicio Panel: ${panelName} -----\n\n`;
streamsArray.forEach(ch => {
m3u += `#EXTINF:-1 tvg-id="${ch['tvg-id']}" tvg-logo="${ch['tvg-logo']}" group-title="${ch['group-title']}",${ch.name}\n`;
if (ch.kodiProps) {
for (const key in ch.kodiProps) {
m3u += `#KODIPROP:${key}=${ch.kodiProps[key]}\n`;
}
}
m3u += `${ch.url}\n\n`;
});
m3u += `# ----- Fin Panel: ${panelName} -----\n\n`;
return m3u;
}
function openXCodecPreviewModal(panelName, validCount, errorCount) {
xcodecUi.previewModalLabel.textContent = `Previsualización Panel: ${escapeHtml(panelName)}`;
xcodecUpdateStatus(`Streams válidos: ${validCount}. Con errores: ${errorCount}.`, 'info', 'preview');
const groups = {};
xcodecProcessedStreamsForPreview.forEach(stream => {
const group = stream['group-title'] || 'Sin Grupo';
if (!groups[group]) groups[group] = [];
groups[group].push(stream);
});
xcodecUi.previewGroupList.innerHTML = '';
const sortedGroupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b));
sortedGroupNames.forEach(groupName => {
const groupItem = document.createElement('li');
groupItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
groupItem.dataset.groupName = groupName;
groupItem.style.cursor = 'pointer';
groupItem.innerHTML = `
<div class="form-check">
<input class="form-check-input xcodec-group-checkbox" type="checkbox" value="${escapeHtml(groupName)}" id="groupCheck_${escapeHtml(groupName.replace(/\s+/g, ''))}" checked>
<label class="form-check-label" for="groupCheck_${escapeHtml(groupName.replace(/\s+/g, ''))}">
${escapeHtml(groupName)}
</label>
</div>
<span class="badge bg-secondary rounded-pill">${groups[groupName].length}</span>
`;
xcodecUi.previewGroupList.appendChild(groupItem);
});
xcodecUi.previewChannelList.innerHTML = '<li class="list-group-item text-secondary text-center">Selecciona un grupo para ver los canales.</li>';
xcodecUi.addSelectedBtn.disabled = false;
xcodecUi.addAllValidBtn.disabled = validCount === 0;
xcodecUi.previewSelectAllChannelsInGroupBtn.disabled = true;
const previewModalInstance = bootstrap.Modal.getOrCreateInstance(xcodecUi.previewModal);
previewModalInstance.show();
const manageModalInstance = bootstrap.Modal.getInstance(xcodecUi.manageModal);
if (manageModalInstance) manageModalInstance.hide();
}
function renderXCodecPreviewChannels(groupName) {
xcodecUi.previewChannelList.innerHTML = '';
const streamsInGroup = xcodecProcessedStreamsForPreview.filter(s => (s['group-title'] || 'Sin Grupo') === groupName);
if (streamsInGroup.length === 0) {
xcodecUi.previewChannelList.innerHTML = '<li class="list-group-item text-secondary text-center">No hay canales en este grupo.</li>';
return;
}
streamsInGroup.forEach(stream => {
const channelItem = document.createElement('li');
channelItem.className = 'list-group-item';
channelItem.innerHTML = `
<div class="form-check">
<input class="form-check-input xcodec-channel-checkbox" type="checkbox" value="${escapeHtml(stream.url)}" id="channelCheck_${escapeHtml(stream['tvg-id'].replace(/[^a-zA-Z0-9]/g, ''))}" checked data-group="${escapeHtml(groupName)}">
<label class="form-check-label" for="channelCheck_${escapeHtml(stream['tvg-id'].replace(/[^a-zA-Z0-9]/g, ''))}" title="${escapeHtml(stream.name)} - ${escapeHtml(stream.url)}">
${escapeHtml(stream.name)}
</label>
</div>
`;
xcodecUi.previewChannelList.appendChild(channelItem);
});
}
function toggleAllGroupsInPreview() {
const firstCheckbox = xcodecUi.previewGroupList.querySelector('.xcodec-group-checkbox');
if (!firstCheckbox) return;
const currentlyChecked = firstCheckbox.checked;
xcodecUi.previewGroupList.querySelectorAll('.xcodec-group-checkbox').forEach(cb => cb.checked = !currentlyChecked);
xcodecUi.previewChannelList.querySelectorAll('.xcodec-channel-checkbox').forEach(cb => cb.checked = !currentlyChecked);
}
function toggleAllChannelsInCurrentPreviewGroup() {
const activeGroupItem = xcodecUi.previewGroupList.querySelector('.list-group-item.active');
if (!activeGroupItem) return;
const groupName = activeGroupItem.dataset.groupName;
const firstChannelCheckboxInGroup = xcodecUi.previewChannelList.querySelector(`.xcodec-channel-checkbox[data-group="${escapeHtml(groupName)}"]`);
if (!firstChannelCheckboxInGroup) return;
const currentlyChecked = firstChannelCheckboxInGroup.checked;
xcodecUi.previewChannelList.querySelectorAll(`.xcodec-channel-checkbox[data-group="${escapeHtml(groupName)}"]`).forEach(cb => {
cb.checked = !currentlyChecked;
});
}
function addSelectedXCodecStreamsToM3U() {
const selectedStreams = [];
const selectedGroupCheckboxes = xcodecUi.previewGroupList.querySelectorAll('.xcodec-group-checkbox:checked');
const selectedGroups = Array.from(selectedGroupCheckboxes).map(cb => cb.value);
xcodecUi.previewChannelList.querySelectorAll('.xcodec-channel-checkbox:checked').forEach(cb => {
const streamUrl = cb.value;
const streamGroup = cb.dataset.group;
if (selectedGroups.includes(streamGroup)) {
const streamData = xcodecProcessedStreamsForPreview.find(s => s.url === streamUrl && (s['group-title'] || 'Sin Grupo') === streamGroup);
if (streamData) selectedStreams.push(streamData);
}
});
if (selectedStreams.length > 0) {
const panelName = currentXCodecPanelDataForPreview.name || new URL(currentXCodecPanelDataForPreview.serverUrl).hostname;
const m3uString = streamsToM3U(selectedStreams, panelName);
const sourceName = `XCodec: ${panelName}`;
if (typeof removeChannelsBySourceOrigin === 'function') removeChannelsBySourceOrigin(sourceName);
appendM3UContent(m3uString, sourceName);
showNotification(`${selectedStreams.length} canales de "${escapeHtml(panelName)}" seleccionados y añadidos.`, 'success');
} else {
showNotification('No se seleccionaron canales para añadir.', 'info');
}
const previewModalInstance = bootstrap.Modal.getInstance(xcodecUi.previewModal);
if (previewModalInstance) previewModalInstance.hide();
}
function addAllValidXCodecStreamsToM3U() {
if (xcodecProcessedStreamsForPreview.length > 0) {
const panelName = currentXCodecPanelDataForPreview.name || new URL(currentXCodecPanelDataForPreview.serverUrl).hostname;
const m3uString = streamsToM3U(xcodecProcessedStreamsForPreview, panelName);
const sourceName = `XCodec: ${panelName}`;
if (typeof removeChannelsBySourceOrigin === 'function') removeChannelsBySourceOrigin(sourceName);
appendM3UContent(m3uString, sourceName);
showNotification(`${xcodecProcessedStreamsForPreview.length} canales válidos de "${escapeHtml(panelName)}" añadidos.`, 'success');
} else {
showNotification('No hay canales válidos para añadir.', 'info');
}
const previewModalInstance = bootstrap.Modal.getInstance(xcodecUi.previewModal);
if (previewModalInstance) previewModalInstance.hide();
}
async function loadSavedXCodecPanels() {
if (typeof showLoading === 'function') showLoading(true, 'Cargando paneles XCodec...');
try {
const panels = typeof getAllXCodecPanelsFromDB === 'function' ? await getAllXCodecPanelsFromDB() : [];
xcodecUi.savedPanelsList.innerHTML = '';
if (!panels || panels.length === 0) {
xcodecUi.savedPanelsList.innerHTML = '<li class="list-group-item text-secondary text-center">No hay paneles guardados.</li>';
} else {
panels.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
panels.forEach(panel => {
const panelDisplayName = panel.name || panel.serverUrl;
const item = document.createElement('li');
item.className = 'list-group-item d-flex justify-content-between align-items-center';
item.innerHTML = `
<div style="flex-grow: 1; margin-right: 0.5rem; overflow: hidden;">
<strong title="${escapeHtml(panelDisplayName)}">${escapeHtml(panelDisplayName)}</strong>
<small class="text-secondary d-block" style="font-size:0.75rem;">${escapeHtml(panel.serverUrl)}</small>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary load-xcodec-panel-btn" data-id="${panel.id}" title="Cargar en formulario"><i class="fas fa-edit"></i></button>
<button class="btn btn-outline-primary process-xcodec-panel-direct-btn" data-id="${panel.id}" title="Procesar y Añadir Todo Directamente"><i class="fas fa-bolt"></i></button>
<button class="btn btn-outline-danger delete-xcodec-panel-btn" data-id="${panel.id}" title="Eliminar panel"><i class="fas fa-trash"></i></button>
</div>
`;
xcodecUi.savedPanelsList.appendChild(item);
});
}
} catch (error) {
showNotification(`Error cargando paneles XCodec: ${error.message}`, 'error');
xcodecUi.savedPanelsList.innerHTML = '<li class="list-group-item text-danger text-center">Error al cargar paneles.</li>';
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function handleSaveXCodecPanel() {
const panelData = {
id: xcodecUi.editingPanelIdInput.value ? parseInt(xcodecUi.editingPanelIdInput.value, 10) : null,
name: xcodecUi.panelNameInput.value.trim(),
serverUrl: xcodecUi.panelServerUrlInput.value.trim(),
apiToken: xcodecUi.panelApiTokenInput.value.trim()
};
if (!panelData.serverUrl) {
showNotification('La URL del Servidor es obligatoria.', 'warning');
return;
}
try {
new URL(panelData.serverUrl);
} catch(e){
showNotification('La URL del servidor no es válida.', 'warning');
return;
}
if (!panelData.name) panelData.name = new URL(panelData.serverUrl).hostname;
if (typeof showLoading === 'function') showLoading(true, `Guardando panel XCodec: ${escapeHtml(panelData.name)}...`);
try {
await saveXCodecPanelToDB(panelData);
showNotification(`Panel XCodec "${escapeHtml(panelData.name)}" guardado.`, 'success');
loadSavedXCodecPanels();
clearXCodecPanelForm();
} catch (error) {
showNotification(`Error al guardar panel: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
function clearXCodecPanelForm() {
xcodecUi.editingPanelIdInput.value = '';
xcodecUi.panelNameInput.value = '';
xcodecUi.panelServerUrlInput.value = '';
xcodecUi.panelApiTokenInput.value = '';
xcodecUi.panelNameInput.focus();
}
async function loadXCodecPanelToForm(id) {
if (typeof showLoading === 'function') showLoading(true, "Cargando datos del panel...");
try {
const panel = await getXCodecPanelFromDB(id);
if (panel) {
xcodecUi.editingPanelIdInput.value = panel.id;
xcodecUi.panelNameInput.value = panel.name || '';
xcodecUi.panelServerUrlInput.value = panel.serverUrl || '';
xcodecUi.panelApiTokenInput.value = panel.apiToken || '';
showNotification(`Datos del panel "${escapeHtml(panel.name || panel.serverUrl)}" cargados.`, 'info');
} else {
showNotification('Panel no encontrado.', 'error');
}
} catch (error) {
showNotification(`Error al cargar panel: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function handleDeleteXCodecPanel(id) {
const panelToDelete = await getXCodecPanelFromDB(id);
const panelName = panelToDelete ? (panelToDelete.name || panelToDelete.serverUrl) : 'este panel';
if (!confirm(`¿Seguro que quieres eliminar el panel XCodec "${escapeHtml(panelName)}"?`)) return;
if (typeof showLoading === 'function') showLoading(true, `Eliminando panel "${escapeHtml(panelName)}"...`);
try {
await deleteXCodecPanelFromDB(id);
showNotification(`Panel XCodec "${escapeHtml(panelName)}" eliminado.`, 'success');
loadSavedXCodecPanels();
if (xcodecUi.editingPanelIdInput.value === String(id)) {
clearXCodecPanelForm();
}
} catch (error) {
showNotification(`Error al eliminar panel: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function importPresetXCodecPanels() {
if (!confirm(`¿Quieres importar ${PRESET_XCODEC_PANELS.length} paneles predefinidos a tu lista? Esto no sobrescribirá los existentes con la misma URL.`)) return;
if (typeof showLoading === 'function') showLoading(true, "Importando paneles predefinidos...");
let importedCount = 0;
let skippedCount = 0;
try {
const existingPanels = await getAllXCodecPanelsFromDB();
const existingUrls = new Set(existingPanels.map(p => p.serverUrl));
for (const preset of PRESET_XCODEC_PANELS) {
if (!existingUrls.has(preset.serverUrl)) {
await saveXCodecPanelToDB(preset);
importedCount++;
} else {
skippedCount++;
}
}
showNotification(`Importación completada: ${importedCount} paneles añadidos, ${skippedCount} omitidos (ya existían).`, 'success');
loadSavedXCodecPanels();
} catch (error) {
showNotification(`Error importando paneles: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}

569
xtream_handler.js Normal file
View File

@ -0,0 +1,569 @@
const XTREAM_USER_AGENT = 'VLC/3.0.20 (Linux; x86_64)';
let currentXtreamServerInfo = null;
let xtreamData = { live: [], vod: [], series: [] };
let xtreamGroupSelectionResolver = null;
function isXtreamUrl(url) {
try {
const parsedUrl = new URL(url);
return parsedUrl.pathname.endsWith('/get.php') &&
parsedUrl.searchParams.has('username') &&
parsedUrl.searchParams.has('password');
} catch (e) {
return false;
}
}
function handleXtreamUrl(url) {
try {
const parsedUrl = new URL(url);
const host = `${parsedUrl.protocol}//${parsedUrl.hostname}${parsedUrl.port ? ':' + parsedUrl.port : ''}`;
const username = parsedUrl.searchParams.get('username');
const password = parsedUrl.searchParams.get('password');
let outputType = 'm3u_plus';
if (parsedUrl.searchParams.has('type')) {
const typeParam = parsedUrl.searchParams.get('type');
if (typeParam === 'm3u_plus') outputType = 'm3u_plus';
}
if (parsedUrl.searchParams.has('output')) {
const outputParam = parsedUrl.searchParams.get('output');
if (outputParam === 'ts') outputType = 'ts';
else if (outputParam === 'hls' || outputParam === 'm3u8') outputType = 'hls';
}
$('#xtreamHostInput').val(host);
$('#xtreamUsernameInput').val(username);
$('#xtreamPasswordInput').val(password);
$('#xtreamOutputTypeSelect').val(outputType);
$('#xtreamServerNameInput').val('');
$('#xtreamFetchEpgCheck').prop('checked', true);
showXtreamConnectionModal();
if (typeof showNotification === 'function') showNotification("Datos de URL Xtream precargados en el modal.", "info");
} catch (e) {
if (typeof showNotification === 'function') showNotification("URL Xtream inválida.", "error");
console.error("Error parsing Xtream URL:", e);
}
}
async function showXtreamConnectionModal() {
if (typeof dbPromise === 'undefined' || !dbPromise) {
if (typeof showLoading === 'function') showLoading(true, 'Iniciando base de datos local...');
try { if (typeof openDB === 'function') await openDB(); } catch (error) { if (typeof showNotification === 'function') showNotification(`Error DB: ${error.message}`, 'error'); if (typeof showLoading === 'function') showLoading(false); return; }
finally { if (typeof showLoading === 'function') showLoading(false); }
}
$('#xtreamConnectionModal').modal('show');
loadSavedXtreamServers();
}
async function loadSavedXtreamServers() {
if (typeof showLoading === 'function') showLoading(true, 'Cargando servidores Xtream guardados...');
try {
const servers = typeof getAllXtreamServersFromDB === 'function' ? await getAllXtreamServersFromDB() : [];
const $list = $('#savedXtreamServersList').empty();
if (!servers || servers.length === 0) {
$list.append('<li class="list-group-item text-secondary text-center">No hay servidores guardados.</li>');
} else {
servers.sort((a,b) => (b.timestamp || 0) - (a.timestamp || 0));
servers.forEach(server => {
const serverDisplayName = server.name || server.host;
$list.append(`
<li class="list-group-item d-flex justify-content-between align-items-center">
<div style="flex-grow: 1; margin-right: 1rem; overflow: hidden; cursor:pointer;" class="load-xtream-server-btn" data-id="${server.id}">
<strong title="${escapeHtml(serverDisplayName)}">${escapeHtml(serverDisplayName)}</strong>
<small class="text-secondary d-block">${escapeHtml(server.host)}</small>
</div>
<button class="btn-control btn-sm delete-xtream-server-btn" data-id="${server.id}" title="Eliminar servidor"></button>
</li>`);
});
}
} catch (error) {
if (typeof showNotification === 'function') showNotification(`Error cargando servidores Xtream: ${error.message}`, 'error');
$('#savedXtreamServersList').empty().append('<li class="list-group-item text-danger text-center">Error al cargar servidores.</li>');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function fetchXtreamData(action = null, params = {}, currentServer = null) {
const serverToUse = currentServer || currentXtreamServerInfo;
if (!serverToUse || !serverToUse.host || !serverToUse.username || !serverToUse.password) {
throw new Error("Datos del servidor Xtream no configurados.");
}
let url = `${serverToUse.host.replace(/\/$/, '')}/player_api.php?username=${encodeURIComponent(serverToUse.username)}&password=${encodeURIComponent(serverToUse.password)}`;
if (action) {
url += `&action=${action}`;
}
if (params) {
for (const key in params) {
url += `&${key}=${encodeURIComponent(params[key])}`;
}
}
const response = await fetch(url, { headers: { 'User-Agent': XTREAM_USER_AGENT }});
if (!response.ok) {
throw new Error(`Error API Xtream (${action || 'base'}): ${response.status}`);
}
const data = await response.json();
if (data && data.user_info && data.user_info.auth === 0) {
throw new Error(`Autenticación fallida con el servidor Xtream: ${data.user_info.status || 'Error desconocido'}`);
}
return data;
}
function buildM3UFromString(items) {
let m3uString = "#EXTM3U\n";
items.forEach(ch => {
let attributesString = `tvg-id="${ch['tvg-id'] || ''}" tvg-logo="${ch['tvg-logo'] || ''}" group-title="${ch['group-title'] || ''}"`;
if (ch.attributes) {
for (const key in ch.attributes) {
attributesString += ` ${key}="${ch.attributes[key]}"`;
}
}
m3uString += `#EXTINF:-1 ${attributesString},${ch.name || ''}\n${ch.url || ''}\n`;
});
return m3uString;
}
function showXtreamGroupSelectionModal(categories) {
return new Promise((resolve) => {
xtreamGroupSelectionResolver = resolve;
const { live, vod, series } = categories;
const liveCol = $('#xtreamLiveGroupsCol').hide();
const vodCol = $('#xtreamVodGroupsCol').hide();
const seriesCol = $('#xtreamSeriesGroupsCol').hide();
const setupGroup = (col, listEl, btnSelect, btnDeselect, cats, type) => {
listEl.empty();
if (cats && cats.length > 0) {
cats.forEach(cat => listEl.append(`<li class="list-group-item"><div class="form-check"><input class="form-check-input" type="checkbox" value="${cat.category_id}" id="xtream_${type}_${cat.category_id}" checked><label class="form-check-label" for="xtream_${type}_${cat.category_id}">${escapeHtml(cat.category_name)}</label></div></li>`));
btnSelect.off('click').on('click', () => listEl.find('input[type="checkbox"]').prop('checked', true));
btnDeselect.off('click').on('click', () => listEl.find('input[type="checkbox"]').prop('checked', false));
col.show();
} else {
listEl.append('<li class="list-group-item text-secondary">No disponible</li>');
if(cats) col.show();
}
};
setupGroup(liveCol, $('#xtreamLiveGroupList'), $('#xtreamSelectAllLive'), $('#xtreamDeselectAllLive'), live, 'live');
setupGroup(vodCol, $('#xtreamVodGroupList'), $('#xtreamSelectAllVod'), $('#xtreamDeselectAllVod'), vod, 'vod');
setupGroup(seriesCol, $('#xtreamSeriesGroupList'), $('#xtreamSelectAllSeries'), $('#xtreamDeselectAllSeries'), series, 'series');
const groupSelectionModal = new bootstrap.Modal(document.getElementById('xtreamGroupSelectionModal'));
groupSelectionModal.show();
});
}
function handleXtreamGroupSelection() {
const selectedGroups = { live: [], vod: [], series: [] };
$('#xtreamLiveGroupList input:checked').each(function() { selectedGroups.live.push($(this).val()); });
$('#xtreamVodGroupList input:checked').each(function() { selectedGroups.vod.push($(this).val()); });
$('#xtreamSeriesGroupList input:checked').each(function() { selectedGroups.series.push($(this).val()); });
if (xtreamGroupSelectionResolver) {
xtreamGroupSelectionResolver(selectedGroups);
xtreamGroupSelectionResolver = null;
}
const groupSelectionModal = bootstrap.Modal.getInstance(document.getElementById('xtreamGroupSelectionModal'));
if (groupSelectionModal) {
groupSelectionModal.hide();
}
}
async function handleConnectXtreamServer() {
const host = $('#xtreamHostInput').val().trim();
const username = $('#xtreamUsernameInput').val().trim();
const password = $('#xtreamPasswordInput').val();
const outputType = $('#xtreamOutputTypeSelect').val();
const fetchEpgFlag = $('#xtreamFetchEpgCheck').is(':checked');
const forceGroupSelection = $('#xtreamForceGroupSelectionCheck').is(':checked');
const loadLive = $('#xtreamLoadLive').is(':checked');
const loadVod = $('#xtreamLoadVod').is(':checked');
const loadSeries = $('#xtreamLoadSeries').is(':checked');
const serverName = $('#xtreamServerNameInput').val().trim() || host;
if (!host || !username || !password) {
showNotification('Host, usuario y contraseña son obligatorios.', 'warning');
return;
}
if (!loadLive && !loadVod && !loadSeries) {
showNotification('Debes seleccionar al menos un tipo de contenido para cargar.', 'warning');
return;
}
currentXtreamServerInfo = { host, username, password, outputType, name: serverName, fetchEpg: fetchEpgFlag };
showLoading(true, `Conectando a Xtream: ${escapeHtml(serverName)}...`);
try {
const playerApiData = await fetchXtreamData();
displayXtreamInfoBar(playerApiData);
const existingServer = (await getAllXtreamServersFromDB()).find(s => s.host === host && s.username === username);
let selectedGroupIds;
if (existingServer && existingServer.id) {
currentXtreamServerInfo.id = existingServer.id;
}
if (existingServer && existingServer.selectedGroups && !forceGroupSelection) {
selectedGroupIds = existingServer.selectedGroups;
showNotification('Usando selección de grupos guardada para este servidor.', 'info');
} else {
showLoading(true, 'Obteniendo categorías...');
let categoryPromises = [];
if (loadLive) categoryPromises.push(fetchXtreamData('get_live_categories').catch(e => { console.error("Error fetching live categories:", e); return null; }));
else categoryPromises.push(Promise.resolve(null));
if (loadVod) categoryPromises.push(fetchXtreamData('get_vod_categories').catch(e => { console.error("Error fetching vod categories:", e); return null; }));
else categoryPromises.push(Promise.resolve(null));
if (loadSeries) categoryPromises.push(fetchXtreamData('get_series_categories').catch(e => { console.error("Error fetching series categories:", e); return null; }));
else categoryPromises.push(Promise.resolve(null));
const [liveCategories, vodCategories, seriesCategories] = await Promise.all(categoryPromises);
$('#xtreamConnectionModal').modal('hide');
showLoading(false);
selectedGroupIds = await showXtreamGroupSelectionModal({ live: liveCategories, vod: vodCategories, series: seriesCategories });
currentXtreamServerInfo.selectedGroups = selectedGroupIds;
await saveXtreamServerToDB(currentXtreamServerInfo);
}
showLoading(true, `Cargando streams seleccionados de Xtream...`);
let streamPromises = [];
if (loadLive && selectedGroupIds.live.length > 0) streamPromises.push(fetchXtreamData('get_live_streams').catch(e => [])); else streamPromises.push(Promise.resolve([]));
if (loadVod && selectedGroupIds.vod.length > 0) streamPromises.push(fetchXtreamData('get_vod_streams').catch(e => [])); else streamPromises.push(Promise.resolve([]));
if (loadSeries && selectedGroupIds.series.length > 0) streamPromises.push(fetchXtreamData('get_series').catch(e => [])); else streamPromises.push(Promise.resolve([]));
let [liveStreams, vodStreams, seriesStreams] = await Promise.all(streamPromises);
const allCategories = await Promise.all([
loadLive ? fetchXtreamData('get_live_categories') : Promise.resolve([]),
loadVod ? fetchXtreamData('get_vod_categories') : Promise.resolve([]),
loadSeries ? fetchXtreamData('get_series_categories') : Promise.resolve([])
]).then(([live, vod, series]) => [...(live||[]), ...(vod||[]), ...(series||[])]);
const categoryMap = {};
allCategories.forEach(cat => categoryMap[cat.category_id] = cat.category_name);
xtreamData.live = transformXtreamItems(liveStreams, 'live', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.live.includes(item.attributes['category_id']));
xtreamData.vod = transformXtreamItems(vodStreams, 'vod', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.vod.includes(item.attributes['category_id']));
xtreamData.series = transformXtreamItems(seriesStreams, 'series', currentXtreamServerInfo, categoryMap).filter(item => selectedGroupIds.series.includes(item.attributes['category_id']));
channels = [...xtreamData.live, ...xtreamData.vod, ...xtreamData.series];
currentM3UContent = buildM3UFromString(channels);
currentM3UName = `Xtream: ${serverName}`;
currentGroupOrder = [...new Set(channels.map(c => c['group-title']))].sort();
if(userSettings.autoSaveM3U) {
localStorage.setItem('currentXtreamServerInfo', JSON.stringify(currentXtreamServerInfo));
localStorage.removeItem('lastM3UUrl');
localStorage.removeItem('lastM3UFileContent');
localStorage.removeItem('lastM3UFileName');
}
$('#xtreamConnectionModal').modal('hide');
displayXtreamRootView();
if (fetchEpgFlag) {
const epgUrl = `${currentXtreamServerInfo.host.replace(/\/$/, '')}/xmltv.php?username=${encodeURIComponent(currentXtreamServerInfo.username)}&password=${encodeURIComponent(currentXtreamServerInfo.password)}`;
if (typeof loadEpgFromUrl === 'function') {
loadEpgFromUrl(epgUrl).catch(err => {
console.error("Error cargando EPG de Xtream en segundo plano:", err);
if (typeof showNotification === 'function') {
showNotification('Fallo al cargar EPG de Xtream: ' + err.message, 'error');
}
});
}
}
} catch (error) {
showNotification(`Error conectando a Xtream: ${error.message}`, 'error');
hideXtreamInfoBar();
} finally {
showLoading(false);
}
}
function displayXtreamRootView() {
navigationHistory = [];
currentView = { type: 'main' };
renderCurrentView();
showNotification(`Xtream: Canales cargados. Live: ${xtreamData.live.length}, VOD: ${xtreamData.vod.length}, Series: ${xtreamData.series.length}`, "success");
}
function transformXtreamItems(items, type, serverInfo, categoryMap) {
if (!Array.isArray(items)) return [];
return items.map(item => {
let baseObject = {
'group-title': categoryMap[item.category_id] || `Xtream ${type}`,
attributes: {'category_id': item.category_id},
kodiProps: {}, vlcOptions: {}, extHttp: {},
sourceOrigin: `xtream-${serverInfo.name || serverInfo.host}`
};
if (type === 'live') {
let streamUrl;
const serverHost = serverInfo.host.replace(/\/$/, '');
const ds = item.direct_source ? item.direct_source.trim() : '';
if (ds) {
try {
new URL(ds);
streamUrl = ds;
} catch (e) {
streamUrl = `${serverHost}${ds.startsWith('/') ? '' : '/'}${ds}`;
}
} else {
let extension;
switch (serverInfo.outputType) {
case 'ts':
extension = 'ts';
break;
case 'hls':
case 'm3u_plus':
default:
extension = 'm3u8';
break;
}
streamUrl = `${serverHost}/live/${serverInfo.username}/${serverInfo.password}/${item.stream_id}.${extension}`;
}
return {
...baseObject,
name: item.name,
url: streamUrl,
'tvg-id': item.epg_channel_id || `xtream.${item.stream_id}`,
'tvg-logo': item.stream_icon || '',
attributes: { ...baseObject.attributes, 'xtream-type': 'live', 'stream-id': item.stream_id }
};
}
if (type === 'vod') {
const vodInfo = item.info || {};
return {
...baseObject,
name: item.name,
url: `${serverInfo.host.replace(/\/$/, '')}/movie/${serverInfo.username}/${serverInfo.password}/${item.stream_id}.${item.container_extension || 'mp4'}`,
'tvg-id': `vod.${item.stream_id}`,
'tvg-logo': item.stream_icon || vodInfo.movie_image || '',
attributes: { ...baseObject.attributes, 'xtream-type': 'vod', 'stream-id': item.stream_id, 'xtream-info': JSON.stringify(vodInfo) }
};
}
if (type === 'series') {
return {
...baseObject,
name: item.name,
url: `#xtream-series-${item.series_id}`,
'tvg-id': `series.${item.series_id}`,
'tvg-logo': item.cover || (item.backdrop_path && item.backdrop_path[0]) || '',
attributes: { ...baseObject.attributes, 'xtream-type': 'series', 'xtream-series-id': item.series_id, 'xtream-info': JSON.stringify(item) }
};
}
return null;
}).filter(Boolean);
}
async function loadXtreamSeasons(seriesId, seriesName) {
if (!currentXtreamServerInfo) {
showNotification("No hay servidor Xtream activo para cargar las temporadas.", "warning");
return null;
}
showLoading(true, `Cargando temporadas para: ${escapeHtml(seriesName)}`);
try {
const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId });
const seasons = [];
if (seriesData && seriesData.episodes) {
const seriesInfo = seriesData.info || {};
const sortedSeasonKeys = Object.keys(seriesData.episodes).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
for (const seasonNumber of sortedSeasonKeys) {
seasons.push({
name: `Temporada ${seasonNumber}`,
'tvg-logo': seriesInfo.cover || '',
'group-title': seriesName,
season_number: seasonNumber,
series_id: seriesId
});
}
}
return seasons;
} catch (error) {
showNotification(`Error cargando temporadas: ${error.message}`, 'error');
return null;
} finally {
showLoading(false);
}
}
async function loadXtreamSeasonEpisodes(seriesId, seasonNumber) {
if (!currentXtreamServerInfo) {
showNotification("No hay servidor Xtream activo para cargar los episodios.", "warning");
return null;
}
showLoading(true, `Cargando episodios para la temporada ${seasonNumber}...`);
try {
const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId });
const episodes = [];
const seriesInfo = seriesData.info || {};
if (seriesData && seriesData.episodes && seriesData.episodes[seasonNumber]) {
const episodesInSeason = seriesData.episodes[seasonNumber];
episodesInSeason.sort((a,b) => (a.episode_num || 0) - (b.episode_num || 0));
episodesInSeason.forEach(ep => {
const episodeNum = ep.episode_num || 0;
const episodeInfo = ep.info || {};
const containerExtension = ep.container_extension || 'mp4';
episodes.push({
name: `${ep.title || 'Episodio ' + episodeNum} (T${seasonNumber}E${episodeNum})`,
url: `${currentXtreamServerInfo.host.replace(/\/$/, '')}/series/${currentXtreamServerInfo.username}/${currentXtreamServerInfo.password}/${ep.id}.${containerExtension}`,
'tvg-id': `series.ep.${ep.id}`,
'tvg-logo': episodeInfo.movie_image || seriesInfo.cover || '',
'group-title': `${seriesInfo.name} - Temporada ${seasonNumber}`,
attributes: { 'xtream-type': 'episode', 'stream-id': ep.id },
kodiProps: {}, vlcOptions: {}, extHttp: {},
sourceOrigin: `xtream-${currentXtreamServerInfo.name || currentXtreamServerInfo.host}`
});
});
}
return episodes;
} catch (error) {
showNotification(`Error cargando episodios: ${error.message}`, 'error');
return null;
} finally {
showLoading(false);
}
}
async function loadXtreamSeriesEpisodes(seriesId, seriesName) {
if (!currentXtreamServerInfo) {
showNotification("No hay servidor Xtream activo para cargar episodios.", "warning");
return;
}
showLoading(true, `Cargando episodios para: ${escapeHtml(seriesName)}`);
try {
const seriesData = await fetchXtreamData('get_series_info', { series_id: seriesId });
let episodesForGrid = [];
const seriesInfo = seriesData.info || {};
if (seriesData && seriesData.episodes && typeof seriesData.episodes === 'object') {
const seasons = seriesData.episodes;
const sortedSeasonKeys = Object.keys(seasons).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
for (const seasonNumber of sortedSeasonKeys) {
const episodesInSeason = seasons[seasonNumber];
if (Array.isArray(episodesInSeason)) {
episodesInSeason.sort((a,b) => (a.episode_num || a.episode_number || 0) - (b.episode_num || b.episode_number || 0));
episodesInSeason.forEach(ep => {
const episodeNum = ep.episode_num || ep.episode_number;
const episodeInfo = ep.info || {};
const containerExtension = ep.container_extension || 'mp4';
episodesForGrid.push({
name: `${ep.title || 'Episodio ' + episodeNum} (T${ep.season || seasonNumber}E${episodeNum})`,
url: `${currentXtreamServerInfo.host.replace(/\/$/, '')}/series/${currentXtreamServerInfo.username}/${currentXtreamServerInfo.password}/${ep.id}.${containerExtension}`,
'tvg-id': `series.ep.${ep.id}`,
'tvg-logo': episodeInfo.movie_image || seriesInfo.cover || '',
'group-title': `${seriesName} - Temporada ${ep.season || seasonNumber}`,
attributes: { 'xtream-type': 'episode', 'stream-id': ep.id },
kodiProps: {}, vlcOptions: {}, extHttp: {},
sourceOrigin: `xtream-${currentXtreamServerInfo.name || currentXtreamServerInfo.host}`
});
});
}
}
}
if (episodesForGrid.length > 0) {
pushNavigationState();
currentView = { type: 'episode_list', data: episodesForGrid, title: seriesName };
renderCurrentView();
showNotification(`${episodesForGrid.length} episodios cargados.`, 'success');
} else {
showNotification(`No se encontraron episodios para ${escapeHtml(seriesName)}.`, 'info');
}
} catch (error) {
showNotification(`Error cargando episodios: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
async function handleSaveXtreamServer() {
const serverName = $('#xtreamServerNameInput').val().trim();
const host = $('#xtreamHostInput').val().trim();
const username = $('#xtreamUsernameInput').val().trim();
const password = $('#xtreamPasswordInput').val();
const outputType = $('#xtreamOutputTypeSelect').val();
const fetchEpg = $('#xtreamFetchEpgCheck').is(':checked');
if (!host || !username || !password) {
if (typeof showNotification === 'function') showNotification('Host, usuario y contraseña son obligatorios para guardar.', 'warning');
return;
}
const serverData = { name: serverName || host, host, username, password, outputType, fetchEpg };
if (typeof showLoading === 'function') showLoading(true, `Guardando servidor Xtream: ${escapeHtml(serverData.name)}...`);
try {
await saveXtreamServerToDB(serverData);
if (typeof showNotification === 'function') showNotification(`Servidor Xtream "${escapeHtml(serverData.name)}" guardado.`, 'success');
loadSavedXtreamServers();
$('#xtreamServerNameInput, #xtreamHostInput, #xtreamUsernameInput, #xtreamPasswordInput').val('');
} catch (error) {
if (typeof showNotification === 'function') showNotification(`Error al guardar servidor: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function loadXtreamServerToForm(id) {
if (typeof showLoading === 'function') showLoading(true, "Cargando datos del servidor...");
try {
const server = await getXtreamServerFromDB(id);
if (server) {
$('#xtreamServerNameInput').val(server.name || '');
$('#xtreamHostInput').val(server.host || '');
$('#xtreamUsernameInput').val(server.username || '');
$('#xtreamPasswordInput').val(server.password || '');
$('#xtreamOutputTypeSelect').val(server.outputType || 'm3u_plus');
$('#xtreamFetchEpgCheck').prop('checked', typeof server.fetchEpg === 'boolean' ? server.fetchEpg : true);
if (typeof showNotification === 'function') showNotification(`Datos del servidor "${escapeHtml(server.name || server.host)}" cargados.`, 'info');
} else {
if (typeof showNotification === 'function') showNotification('Servidor no encontrado.', 'error');
}
} catch (error) {
if (typeof showNotification === 'function') showNotification(`Error al cargar servidor: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}
async function handleDeleteXtreamServer(id) {
const serverToDelete = await getXtreamServerFromDB(id);
const serverName = serverToDelete ? (serverToDelete.name || serverToDelete.host) : 'este servidor';
if (!confirm(`¿Estás seguro de eliminar el servidor Xtream "${escapeHtml(serverName)}"?`)) return;
if (typeof showLoading === 'function') showLoading(true, `Eliminando servidor "${escapeHtml(serverName)}"...`);
try {
await deleteXtreamServerFromDB(id);
if (typeof showNotification === 'function') showNotification(`Servidor Xtream "${escapeHtml(serverName)}" eliminado.`, 'success');
loadSavedXtreamServers();
} catch (error) {
if (typeof showNotification === 'function') showNotification(`Error al eliminar servidor: ${error.message}`, 'error');
} finally {
if (typeof showLoading === 'function') showLoading(false);
}
}