Subida SOLO de DRMPlayer
This commit is contained in:
commit
22d35b2718
334
_locales/en/messages.json
Normal file
334
_locales/en/messages.json
Normal 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
334
_locales/es/messages.json
Normal 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
189
atresplayer_handler.js
Normal 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
115
background.js
Normal 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
234
bartv_handler.js
Normal 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
368
channel_ui.js
Normal 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
37
css/base.css
Normal 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
80
css/channel_card.css
Normal 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
18
css/channel_grid.css
Normal 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
36
css/components.css
Normal 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
121
css/editor.css
Normal 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
33
css/epg_modal.css
Normal 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
8
css/generic_modals.css
Normal 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
39
css/header.css
Normal 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
55
css/layout.css
Normal 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
9
css/modals_general.css
Normal 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); }
|
25
css/movistar_vod_modal.css
Normal file
25
css/movistar_vod_modal.css
Normal 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
363
css/player_modal.css
Normal 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
71
css/responsive.css
Normal 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
64
css/settings_modal.css
Normal 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
8
css/sidebar.css
Normal 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
74
css/xtream_modal.css
Normal 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
372
dazn_handler.js
Normal 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
383
db_manager.js
Normal 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
76
draggable_modals.js
Normal 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
634
editor_handler.js
Normal 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
|
||||
};
|
||||
})();
|
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
2
libs/Sortable.min.js
vendored
Normal file
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
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
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
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
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
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
8
libs/particles.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1738
libs/shaka-player.compiled.js
Normal file
1738
libs/shaka-player.compiled.js
Normal file
File diff suppressed because it is too large
Load Diff
2072
libs/shaka-player.ui.js
Normal file
2072
libs/shaka-player.ui.js
Normal file
File diff suppressed because it is too large
Load Diff
319
m3u_operations.js
Normal file
319
m3u_operations.js
Normal 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
45
m3u_sender.js
Normal 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
286
m3u_utils.js
Normal 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
91
manifest.json
Normal 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
648
movistar_handler.js
Normal 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
532
movistar_vod_ui.js
Normal 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
675
orange_tv_client.js
Normal 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
212
php_handler.js
Normal 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
1267
player.html
Normal file
File diff suppressed because it is too large
Load Diff
324
player_interaction.js
Normal file
324
player_interaction.js
Normal 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
455
settings_manager.js
Normal 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
380
shaka_handler.js
Normal 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
101
ui_actions.js
Normal 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
178
user_session.js
Normal 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
819
xcodec_handler.js
Normal 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
569
xtream_handler.js
Normal 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);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user