diff --git a/README.md b/README.md index 48d1009..c33866a 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ services: container_name: ultimate-backend restart: unless-stopped ports: - - 7777:7777 + - "7777:7777" environment: - ULTIMATE_PORT=7777 - ULTIMATE_DEBUG=false diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index c635a2c..db331a5 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional from .provider import StreamingProvider from .models import StreamingChannel, DRMSystem +from .models import UserSubscription from .drm import DRMPluginManager from .epg import EPGManager from .utils.logger import logger @@ -712,6 +713,124 @@ class ProviderManager: return capabilities + # ============================================================================ + # SUBSCRIPTION METHODS (NEW) + # ============================================================================ + + def get_subscription_status(self, provider_name: str, **kwargs) -> Optional[UserSubscription]: + """ + Get subscription status for a specific provider. + + Args: + provider_name: Name of the provider + **kwargs: Additional arguments passed to provider + + Returns: + UserSubscription object or None if not supported/available + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found") + + try: + subscription = provider.get_subscription_status(**kwargs) + if subscription: + logger.debug(f"Got subscription status for {provider_name}: " + f"{subscription.package_count} packages, " + f"{subscription.accessible_channel_count} channels") + else: + logger.debug(f"No subscription info available for {provider_name}") + + return subscription + except Exception as e: + logger.warning(f"Error getting subscription status for {provider_name}: {e}") + return None + + def get_subscribed_channels(self, provider_name: str, **kwargs) -> List[StreamingChannel]: + """ + Get subscribed channels for a specific provider. + + Args: + provider_name: Name of the provider + **kwargs: Additional arguments passed to provider + + Returns: + List of StreamingChannel objects the user can access + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found") + + try: + channels = provider.get_subscribed_channels(**kwargs) + logger.info(f"Got {len(channels)} subscribed channels from {provider_name}") + return channels + except Exception as e: + logger.error(f"Error getting subscribed channels for {provider_name}: {e}") + # Fallback to all channels on error + return provider.get_channels(**kwargs) + + def get_available_packages(self, provider_name: str, **kwargs) -> List[SubscriptionPackage]: + """ + Get available subscription packages for a provider. + + Args: + provider_name: Name of the provider + **kwargs: Additional arguments passed to provider + + Returns: + List of available SubscriptionPackage objects + Empty list if not supported/available + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found") + + try: + packages = provider.get_available_packages(**kwargs) + logger.debug(f"Got {len(packages)} available packages from {provider_name}") + return packages + except Exception as e: + logger.warning(f"Error getting available packages for {provider_name}: {e}") + return [] + + def is_channel_accessible(self, provider_name: str, channel_id: str, **kwargs) -> bool: + """ + Check if a channel is accessible with current subscription. + + Args: + provider_name: Name of the provider + channel_id: ID of the channel to check + **kwargs: Additional arguments passed to provider + + Returns: + True if accessible, False otherwise + True if subscription checking not supported + + Raises: + ValueError: If provider not found + """ + provider = self.get_provider(provider_name) + if not provider: + raise ValueError(f"Provider '{provider_name}' not found") + + try: + accessible = provider.is_channel_accessible(channel_id, **kwargs) + logger.debug(f"Channel {channel_id} accessible for {provider_name}: {accessible}") + return accessible + except Exception as e: + logger.warning(f"Error checking channel accessibility for {provider_name}/{channel_id}: {e}") + return True # Assume accessible on error (backward compatibility) + def _extract_pssh_from_manifest(self, manifest_url: str) -> List: """ Extract PSSH data from a manifest URL. diff --git a/lib/streaming_providers/base/models/__init__.py b/lib/streaming_providers/base/models/__init__.py index e2f9cfd..21c5c12 100644 --- a/lib/streaming_providers/base/models/__init__.py +++ b/lib/streaming_providers/base/models/__init__.py @@ -1,5 +1,7 @@ # streaming_providers/base/models/__init__.py from .streaming_channel import StreamingChannel from .drm_models import DRMConfig, LicenseConfig, DRMSystem, LicenseUnwrapperParams +from .subscription import SubscriptionPackage, UserSubscription -__all__ = ['StreamingChannel', 'DRMConfig', 'LicenseConfig', 'LicenseUnwrapperParams', 'DRMSystem'] + +__all__ = ['StreamingChannel', 'DRMConfig', 'LicenseConfig', 'LicenseUnwrapperParams', 'DRMSystem', 'SubscriptionPackage', 'UserSubscription'] diff --git a/lib/streaming_providers/base/models/subscription.py b/lib/streaming_providers/base/models/subscription.py new file mode 100644 index 0000000..82e06f4 --- /dev/null +++ b/lib/streaming_providers/base/models/subscription.py @@ -0,0 +1,132 @@ +""" +Subscription models for provider packages and user entitlements. +""" +from typing import Dict, List, Optional, Set +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class SubscriptionPackage: + """Represents a subscription package with provider-specific naming""" + + package_id: str + """Internal ID (e.g., 'sports_package_2024', 'joyn_plus')""" + + name: str + """Display name (e.g., 'Sports Package', 'Sky Sport', 'Joyn Plus')""" + + description: Optional[str] = None + """Optional description of the package""" + + price_info: Optional[str] = None + """Optional price information (e.g., '€9.99/month', 'included')""" + + channel_ids: List[str] = field(default_factory=list) + """List of channel IDs included in this package""" + + metadata: Dict[str, any] = field(default_factory=dict) + """Provider-specific metadata (e.g., {'sky_id': 'SPORT1', 'category': 'sports'})""" + + @property + def channel_count(self) -> int: + """Number of channels in this package""" + return len(self.channel_ids) + + +@dataclass +class UserSubscription: + """User's subscription status for a provider""" + + provider: str + """Provider name (e.g., 'joyn', 'magenta')""" + + country: str + """Country code (e.g., 'DE', 'AT')""" + + active: bool = False + """Whether the subscription is currently active""" + + packages: List[SubscriptionPackage] = field(default_factory=list) + """All subscription packages the user has access to""" + + accessible_channel_ids: Set[str] = field(default_factory=set) + """Set of all channel IDs the user can access (derived from packages)""" + + valid_from: Optional[datetime] = None + """When the subscription becomes/starts valid""" + + valid_until: Optional[datetime] = None + """When the subscription expires""" + + status_message: Optional[str] = None + """Human-readable status message (e.g., 'Active until 2024-12-31')""" + + billing_status: Optional[str] = None + """Billing status (e.g., 'paid', 'trial', 'expired', 'cancelled')""" + + def __post_init__(self): + """Populate derived fields after initialization""" + self._update_derived_fields() + + def _update_derived_fields(self): + """Update derived fields like accessible_channel_ids""" + self.accessible_channel_ids.clear() + for package in self.packages: + self.accessible_channel_ids.update(package.channel_ids) + + @property + def has_packages(self) -> bool: + """Check if user has any packages""" + return len(self.packages) > 0 + + @property + def package_count(self) -> int: + """Number of packages""" + return len(self.packages) + + @property + def accessible_channel_count(self) -> int: + """Number of accessible channels""" + return len(self.accessible_channel_ids) + + @property + def package_names(self) -> List[str]: + """List of package names for display""" + return [pkg.name for pkg in self.packages] + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization""" + return { + 'provider': self.provider, + 'country': self.country, + 'active': self.active, + 'has_packages': self.has_packages, + 'package_count': self.package_count, + 'package_names': self.package_names, + 'accessible_channel_count': self.accessible_channel_count, + 'valid_from': self.valid_from.isoformat() if self.valid_from else None, + 'valid_until': self.valid_until.isoformat() if self.valid_until else None, + 'status_message': self.status_message, + 'billing_status': self.billing_status, + 'packages': [ + { + 'package_id': pkg.package_id, + 'name': pkg.name, + 'description': pkg.description, + 'price_info': pkg.price_info, + 'channel_count': pkg.channel_count, + 'metadata': pkg.metadata + } + for pkg in self.packages + ] + } + + def add_package(self, package: SubscriptionPackage): + """Add a package and update derived fields""" + self.packages.append(package) + self._update_derived_fields() + + def can_access_channel(self, channel_id: str) -> bool: + """Check if user can access a specific channel""" + return channel_id in self.accessible_channel_ids \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 0e9b7ef..caf1ef3 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -77,6 +77,7 @@ from datetime import datetime from .models.streaming_channel import StreamingChannel from .models.drm_models import DRMConfig from .models.proxy_models import ProxyConfig +from .models.subscription import UserSubscription, SubscriptionPackage from .network import HTTPManagerFactory, HTTPManager from .utils.logger import logger from ..providers.auth import AuthContext, AuthStatus @@ -760,6 +761,107 @@ class StreamingProvider(ABC): separator = '&' if '?' in base_url else '?' return f"{base_url}{separator}start={start_time}&end={end_time}" + # ============================================================================ + # SUBSCRIPTION METHODS (NEW) + # ============================================================================ + + def get_subscription_status(self, **kwargs) -> Optional[UserSubscription]: + """ + Get user's subscription status for this provider. + + Returns: + UserSubscription object with subscription details, or + None if provider doesn't support subscription queries or + subscription info isn't available. + + Default implementation returns None (subscription not supported). + Override in provider plugins that support subscription checking. + + Example usage in providers: + # Query provider API for user entitlements + # Parse response into SubscriptionPackage objects + # Return UserSubscription with packages and accessible channels + """ + return None + + def get_subscribed_channels(self, **kwargs) -> List[StreamingChannel]: + """ + Get channels the current user is subscribed to. + + This method: + 1. Gets subscription status (if supported) + 2. Filters all channels based on accessible channel IDs + 3. Returns filtered list or all channels as fallback + + Returns: + List of StreamingChannel objects that the user can access + + Note: + Override get_subscription_status() in provider plugins + to enable subscription filtering. + """ + # Get all channels first + all_channels = self.get_channels(**kwargs) + + # Try to get subscription status + subscription = self.get_subscription_status(**kwargs) + + # If no subscription info or not active, return all channels + if not subscription or not subscription.active: + return all_channels + + # Filter channels based on accessible channel IDs + if subscription.accessible_channel_ids: + return [ + channel for channel in all_channels + if channel.channel_id in subscription.accessible_channel_ids + ] + + # No filtering possible, return all channels + return all_channels + + def get_available_packages(self, **kwargs) -> List[SubscriptionPackage]: + """ + Get all subscription packages available from this provider. + + Useful for: + - Displaying upgrade options in UI + - Showing package comparison + - Subscription management interface + + Returns: + List of available SubscriptionPackage objects + Empty list if not implemented or no packages available + + Default implementation returns empty list. + Override in provider plugins that have package information. + """ + return [] + + def is_channel_accessible(self, channel_id: str, **kwargs) -> bool: + """ + Check if a specific channel is accessible with current subscription. + + Args: + channel_id: ID of the channel to check + **kwargs: Additional arguments passed to get_subscription_status() + + Returns: + True if channel is accessible, False otherwise. + Returns True if subscription checking is not supported. + + Note: + This is a convenience method for quick checks. + """ + subscription = self.get_subscription_status(**kwargs) + + # If no subscription info, assume accessible (backward compatibility) + if not subscription or not subscription.active: + return True + + # Check if channel is in accessible set + return subscription.can_access_channel(channel_id) + @classmethod def get_supported_countries(cls) -> List[str]: """ diff --git a/resources/web/config.css b/resources/web/config.css index eed62cf..e7381ad 100644 --- a/resources/web/config.css +++ b/resources/web/config.css @@ -446,36 +446,6 @@ body { margin-top: 20px; } -@media (max-width: 768px) { - .providers-grid { - grid-template-columns: 1fr; - } - - .header h1 { - font-size: 2rem; - } - - .tabs { - flex-direction: column; - } - - .tab { - text-align: center; - } - - .btn-group { - flex-direction: column; - } - - .btn { - width: 100%; - } - - .modal-buttons { - flex-direction: column; - } -} - .token-expiration { font-size: 0.875rem; color: #666; @@ -510,4 +480,37 @@ body { .provider-info .token-expiration + .token-expiration { margin-top: 6px; +} + +/* EPG Mapping specific styles - moved to separate file */ +/* .epg-mapping-container etc. */ + +@media (max-width: 768px) { + .providers-grid { + grid-template-columns: 1fr; + } + + .header h1 { + font-size: 2rem; + } + + .tabs { + flex-direction: column; + } + + .tab { + text-align: center; + } + + .btn-group { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .modal-buttons { + flex-direction: column; + } } \ No newline at end of file diff --git a/resources/web/config.html b/resources/web/config.html index 0dabf6a..0d24af2 100644 --- a/resources/web/config.html +++ b/resources/web/config.html @@ -74,9 +74,9 @@
-
- -

Select a provider from the credentials tab to configure proxy settings

+
+ +

Loading proxy configurations...

diff --git a/resources/web/config.js b/resources/web/config.js index 0bef407..5d826a0 100644 --- a/resources/web/config.js +++ b/resources/web/config.js @@ -8,7 +8,6 @@ const API_BASE = window.location.origin; const tabs = document.querySelectorAll('.tab'); const tabContents = document.querySelectorAll('.tab-content'); const credentialsContainer = document.getElementById('credentials-container'); -const proxyContainer = document.getElementById('proxy-container'); const jsonEditor = document.getElementById('json-editor'); const importModal = document.getElementById('import-modal'); const importFile = document.getElementById('import-file'); @@ -41,11 +40,12 @@ function switchTab(tabId) { currentTab = tabId; // Load proxy data if switching to proxy tab - if (tabId === 'proxy') { - loadProxyForms(); + if (tabId === 'proxy' && window.proxyManager) { + window.proxyManager.init(providers); + window.proxyManager.loadProxyForms(); } - // Load EPG mappings if switching to that tab + // Load EPG mappings if switching to that tab if (tabId === 'epg-mapping' && window.epgMappingManager) { window.epgMappingManager.loadProviders(); } @@ -427,108 +427,6 @@ async function renderCredentialsForms() { } } -// Load proxy forms -async function loadProxyForms() { - proxyContainer.innerHTML = await Promise.all(providers.map(async (provider) => { - try { - // Try to load existing proxy config - const proxyResponse = await fetch(`${API_BASE}/api/providers/${provider.name}/proxy`); - let existingProxy = null; - - if (proxyResponse.ok) { - const proxyData = await proxyResponse.json(); - if (proxyData.proxy_config) { - existingProxy = proxyData.proxy_config; - } - } - - return ` -
-
- ${provider.logo ? `` : ''} -
-

${provider.label}

-
Proxy Configuration
-
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - ${existingProxy ? ` - - ` : ''} -
-
- `; - } catch (error) { - console.error(`Error loading proxy for ${provider.name}:`, error); - return ''; // Return empty string on error - } - })).then(htmls => htmls.join('')); -} - // Helper functions function getStatusIcon(status) { if (!status) return ''; @@ -831,73 +729,6 @@ async function testAuth(providerName) { } } -async function saveProxy(providerName) { - const host = document.getElementById(`proxy-host-${providerName}`).value; - const port = document.getElementById(`proxy-port-${providerName}`).value; - const type = document.getElementById(`proxy-type-${providerName}`).value; - const user = document.getElementById(`proxy-user-${providerName}`).value; - const pass = document.getElementById(`proxy-pass-${providerName}`).value; - - if (!host || !port) { - showAlert('error', 'Please fill in at least Host and Port fields'); - return; - } - - const proxyConfig = { - host, - port: parseInt(port), - proxy_type: type - }; - - if (user) proxyConfig.username = user; - if (pass) proxyConfig.password = pass; - - try { - const response = await fetch(`${API_BASE}/api/providers/${providerName}/proxy`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(proxyConfig) - }); - - if (response.ok) { - showAlert('success', `Proxy configuration saved for ${providerName}`); - // Clear password field - document.getElementById(`proxy-pass-${providerName}`).value = ''; - } else { - const result = await response.json(); - showAlert('error', result.error || `Failed to save proxy for ${providerName}`); - } - } catch (error) { - showAlert('error', `Network error: ${error.message}`); - console.error('Proxy save error:', error); - } -} - -async function deleteProxy(providerName) { - if (!confirm(`Delete proxy configuration for ${providerName}?`)) return; - - try { - const response = await fetch(`${API_BASE}/api/providers/${providerName}/proxy`, { - method: 'DELETE' - }); - - if (response.ok) { - showAlert('success', `Proxy configuration deleted for ${providerName}`); - // Clear fields - document.getElementById(`proxy-host-${providerName}`).value = ''; - document.getElementById(`proxy-port-${providerName}`).value = ''; - document.getElementById(`proxy-user-${providerName}`).value = ''; - document.getElementById(`proxy-pass-${providerName}`).value = ''; - } else { - const result = await response.json(); - showAlert('error', result.error || `Failed to delete proxy for ${providerName}`); - } - } catch (error) { - showAlert('error', `Network error: ${error.message}`); - console.error('Proxy delete error:', error); - } -} - // UI Helper Functions function showAlert(type, message) { const alert = document.createElement('div'); @@ -986,7 +817,6 @@ async function importConfig() { // Reload providers await loadProviders(); - loadProxyForms(); } else { const result = await response.json(); showAlert('error', result.error || 'Import failed'); @@ -1018,7 +848,6 @@ async function applyJsonConfig() { // Reload providers await loadProviders(); - loadProxyForms(); } else { const result = await response.json(); showAlert('error', result.error || 'Failed to apply configuration'); @@ -1064,6 +893,7 @@ async function clearAllConfigurations() { }); } + // Delete proxy configurations await fetch(`${API_BASE}/api/providers/${provider.name}/proxy`, { method: 'DELETE' }); @@ -1073,7 +903,12 @@ async function clearAllConfigurations() { // Reload providers await loadProviders(); - loadProxyForms(); + + // Reload proxy forms if proxy manager exists and we're on proxy tab + if (window.proxyManager && currentTab === 'proxy') { + window.proxyManager.init(data.providers); + window.proxyManager.loadProxyForms(); + } } catch (error) { showAlert('error', `Failed to clear configurations: ${error.message}`); } @@ -1082,6 +917,4 @@ async function clearAllConfigurations() { // Make functions available globally window.saveCredentials = saveCredentials; window.deleteCredentials = deleteCredentials; -window.testAuth = testAuth; -window.saveProxy = saveProxy; -window.deleteProxy = deleteProxy; \ No newline at end of file +window.testAuth = testAuth; \ No newline at end of file diff --git a/resources/web/proxy.css b/resources/web/proxy.css new file mode 100644 index 0000000..9c878f0 --- /dev/null +++ b/resources/web/proxy.css @@ -0,0 +1,263 @@ +/* Proxy-specific styles */ + +.proxy-card { + background: var(--light-color); + border-radius: var(--border-radius); + padding: 20px; + border: 1px solid #dee2e6; + transition: var(--transition); + position: relative; +} + +.proxy-card:hover { + border-color: var(--primary-color); + transform: translateY(-5px); +} + +.proxy-header { + display: flex; + align-items: center; + margin-bottom: 20px; + gap: 15px; +} + +.proxy-logo { + width: 60px; + height: 60px; + border-radius: 50%; + object-fit: contain; + background: white; + padding: 8px; + border: 2px solid var(--primary-color); + box-sizing: content-box; +} + +.proxy-info h3 { + font-size: 1.3rem; + margin-bottom: 5px; +} + +.proxy-info .proxy-id { + color: var(--gray-color); + font-size: 0.9rem; +} + +/* Proxy form specific styles */ +.proxy-form-group { + margin-bottom: 20px; +} + +.proxy-form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--dark-color); + align-items: center; + gap: 8px; +} + +.proxy-form-control { + width: 100%; + padding: 12px 15px; + border: 2px solid #dee2e6; + border-radius: var(--border-radius); + font-size: 1rem; + transition: var(--transition); +} + +.proxy-form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.2); +} + +.proxy-select { + width: 100%; + padding: 12px 15px; + border: 2px solid #dee2e6; + border-radius: var(--border-radius); + font-size: 1rem; + background-color: white; + cursor: pointer; + transition: var(--transition); +} + +.proxy-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.2); +} + +.proxy-btn-group { + display: flex; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; +} + +/* Proxy type indicators */ +.proxy-type-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 15px; + font-size: 0.8rem; + font-weight: 600; + margin-left: 10px; +} + +.proxy-type-http { + background: rgba(67, 97, 238, 0.1); + color: var(--primary-color); + border: 1px solid rgba(67, 97, 238, 0.3); +} + +.proxy-type-https { + background: rgba(76, 201, 240, 0.1); + color: var(--success-color); + border: 1px solid rgba(76, 201, 240, 0.3); +} + +.proxy-type-socks4 { + background: rgba(255, 158, 0, 0.1); + color: var(--warning-color); + border: 1px solid rgba(255, 158, 0, 0.3); +} + +.proxy-type-socks5 { + background: rgba(247, 37, 133, 0.1); + color: var(--danger-color); + border: 1px solid rgba(247, 37, 133, 0.3); +} + +/* Proxy status */ +.proxy-status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + margin-top: 10px; +} + +.proxy-status-active { + background: rgba(76, 201, 240, 0.2); + color: var(--success-color); +} + +.proxy-status-inactive { + background: rgba(108, 117, 125, 0.2); + color: var(--gray-color); +} + +.proxy-status-error { + background: rgba(247, 37, 133, 0.2); + color: var(--danger-color); +} + +/* Proxy scope settings */ +.proxy-scope-settings { + background: rgba(0, 0, 0, 0.02); + border-radius: var(--border-radius); + padding: 15px; + margin-top: 15px; + border-left: 3px solid var(--primary-color); +} + +.proxy-scope-settings h4 { + margin-bottom: 10px; + font-size: 1rem; + color: var(--dark-color); +} + +.scope-checkboxes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.scope-checkbox { + display: flex; + align-items: center; + gap: 8px; +} + +.scope-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.scope-checkbox label { + margin-bottom: 0; + font-weight: normal; + cursor: pointer; +} + +/* Advanced proxy options */ +.proxy-advanced-toggle { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + cursor: pointer; + color: var(--primary-color); + font-weight: 600; +} + +.proxy-advanced-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.proxy-advanced-content.expanded { + max-height: 500px; + margin-top: 15px; +} + +/* Proxy test panel */ +.proxy-test-panel { + background: rgba(0, 0, 0, 0.03); + border-radius: var(--border-radius); + padding: 15px; + margin-top: 20px; + border: 1px dashed #dee2e6; +} + +.proxy-test-result { + margin-top: 10px; + padding: 10px; + border-radius: var(--border-radius); + display: none; +} + +.proxy-test-result.success { + background: rgba(76, 201, 240, 0.1); + border-left: 4px solid var(--success-color); + display: block; +} + +.proxy-test-result.error { + background: rgba(247, 37, 133, 0.1); + border-left: 4px solid var(--danger-color); + display: block; +} + +/* Responsive design for proxy */ +@media (max-width: 768px) { + .scope-checkboxes { + grid-template-columns: 1fr; + } + + .proxy-btn-group { + flex-direction: column; + } + + .proxy-btn-group .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/resources/web/proxy.js b/resources/web/proxy.js new file mode 100644 index 0000000..5e66e4d --- /dev/null +++ b/resources/web/proxy.js @@ -0,0 +1,418 @@ +// Proxy Management Module + +class ProxyManager { + constructor(API_BASE) { + this.API_BASE = API_BASE; + this.providers = []; + } + + // Initialize proxy manager + init(providers) { + this.providers = providers; + } + + // Load proxy forms + async loadProxyForms() { + const proxyContainer = document.getElementById('proxy-container'); + + if (!this.providers || this.providers.length === 0) { + proxyContainer.innerHTML = ` +
+ +

No providers available

+

Load providers from credentials tab first

+
+ `; + return; + } + + const htmls = await Promise.all(this.providers.map(async (provider) => { + return await this.createProxyCard(provider); + })); + + proxyContainer.innerHTML = htmls.join(''); + } + + // Create proxy card HTML for a provider + async createProxyCard(provider) { + try { + // Try to load existing proxy config + const existingProxy = await this.getProxyConfig(provider.name); + + return ` +
+
+ ${provider.logo ? `` : ''} +
+

${provider.label}

+
Proxy Configuration • ${provider.country}
+ ${existingProxy ? ` +
+ Proxy Configured + + ${(existingProxy.proxy_type || 'http').toUpperCase()} + +
+ ` : ` +
+ No Proxy +
+ `} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + ${this.createAdvancedProxySection(provider.name, existingProxy)} + +
+ + ${existingProxy ? ` + + ` : ''} + +
+ +
+
+ `; + } catch (error) { + console.error(`Error creating proxy card for ${provider.name}:`, error); + return ''; // Return empty string on error + } + } + + // Create advanced proxy section + createAdvancedProxySection(providerName, existingProxy) { + const scope = existingProxy?.scope || { + api_calls: true, + authentication: true, + manifests: true, + license: true, + all: true + }; + + return ` +
+ + Advanced Options +
+ +
+
+

Proxy Scope

+

Select which operations should use this proxy:

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ +
+
+ `; + } + + // Get proxy configuration from API + async getProxyConfig(providerName) { + try { + const response = await fetch(`${this.API_BASE}/api/providers/${providerName}/proxy`); + if (response.ok) { + const data = await response.json(); + return data.proxy_config || null; + } + return null; + } catch (error) { + console.error(`Error getting proxy config for ${providerName}:`, error); + return null; + } + } + + // Save proxy configuration + async saveProxy(providerName) { + const host = document.getElementById(`proxy-host-${providerName}`).value.trim(); + const port = document.getElementById(`proxy-port-${providerName}`).value.trim(); + const type = document.getElementById(`proxy-type-${providerName}`).value; + const user = document.getElementById(`proxy-user-${providerName}`).value.trim(); + const pass = document.getElementById(`proxy-pass-${providerName}`).value; + + // Advanced options + const timeout = document.getElementById(`proxy-timeout-${providerName}`)?.value || 30; + const verifySsl = document.getElementById(`proxy-verify-ssl-${providerName}`)?.checked !== false; + + // Scope settings + const scope = { + api_calls: document.getElementById(`proxy-scope-api-${providerName}`)?.checked || false, + authentication: document.getElementById(`proxy-scope-auth-${providerName}`)?.checked || false, + manifests: document.getElementById(`proxy-scope-manifests-${providerName}`)?.checked || false, + license: document.getElementById(`proxy-scope-license-${providerName}`)?.checked || false, + all: document.getElementById(`proxy-scope-all-${providerName}`)?.checked || false + }; + + if (!host || !port) { + this.showProxyAlert('error', 'Please fill in at least Host and Port fields', providerName); + return; + } + + const proxyConfig = { + host, + port: parseInt(port), + proxy_type: type, + timeout: parseInt(timeout), + verify_ssl: verifySsl, + scope: scope + }; + + if (user) { + proxyConfig.auth = { username: user }; + if (pass) { + proxyConfig.auth.password = pass; + } + } + + try { + const response = await fetch(`${this.API_BASE}/api/providers/${providerName}/proxy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(proxyConfig) + }); + + if (response.ok) { + this.showProxyAlert('success', `Proxy configuration saved for ${providerName}`, providerName); + // Clear password field + if (document.getElementById(`proxy-pass-${providerName}`)) { + document.getElementById(`proxy-pass-${providerName}`).value = ''; + } + // Reload proxy form to show updated status + await this.loadProxyForms(); + } else { + const result = await response.json(); + this.showProxyAlert('error', result.error || `Failed to save proxy for ${providerName}`, providerName); + } + } catch (error) { + this.showProxyAlert('error', `Network error: ${error.message}`, providerName); + console.error('Proxy save error:', error); + } + } + + // Delete proxy configuration + async deleteProxy(providerName) { + if (!confirm(`Delete proxy configuration for ${providerName}?`)) return; + + try { + const response = await fetch(`${this.API_BASE}/api/providers/${providerName}/proxy`, { + method: 'DELETE' + }); + + if (response.ok) { + this.showProxyAlert('success', `Proxy configuration deleted for ${providerName}`, providerName); + // Reload proxy form + await this.loadProxyForms(); + } else { + const result = await response.json(); + this.showProxyAlert('error', result.error || `Failed to delete proxy for ${providerName}`, providerName); + } + } catch (error) { + this.showProxyAlert('error', `Network error: ${error.message}`, providerName); + console.error('Proxy delete error:', error); + } + } + + // Test proxy connection + async testProxy(providerName) { + const testResultEl = document.getElementById(`proxy-test-result-${providerName}`); + if (testResultEl) { + testResultEl.innerHTML = ' Testing proxy connection...'; + testResultEl.className = 'proxy-test-result'; + } + + try { + // Try to fetch a test endpoint through the provider + const response = await fetch(`${this.API_BASE}/api/providers/${providerName}/channels?limit=1`); + + if (response.ok) { + if (testResultEl) { + testResultEl.innerHTML = ' Proxy connection successful!'; + testResultEl.className = 'proxy-test-result success'; + } + this.showProxyAlert('success', `Proxy test passed for ${providerName}`, providerName); + } else { + if (testResultEl) { + testResultEl.innerHTML = ` Proxy test failed (HTTP ${response.status})`; + testResultEl.className = 'proxy-test-result error'; + } + this.showProxyAlert('error', `Proxy test failed for ${providerName} (HTTP ${response.status})`, providerName); + } + } catch (error) { + if (testResultEl) { + testResultEl.innerHTML = ` Network error: ${error.message}`; + testResultEl.className = 'proxy-test-result error'; + } + this.showProxyAlert('error', `Proxy test failed: ${error.message}`, providerName); + console.error('Proxy test error:', error); + } + } + + // Toggle advanced options + toggleAdvanced(providerName) { + const contentEl = document.getElementById(`proxy-advanced-content-${providerName}`); + const iconEl = document.getElementById(`proxy-advanced-icon-${providerName}`); + + if (contentEl && iconEl) { + const isExpanded = contentEl.classList.contains('expanded'); + if (isExpanded) { + contentEl.classList.remove('expanded'); + iconEl.classList.remove('fa-chevron-up'); + iconEl.classList.add('fa-chevron-down'); + } else { + contentEl.classList.add('expanded'); + iconEl.classList.remove('fa-chevron-down'); + iconEl.classList.add('fa-chevron-up'); + } + } + } + + // Show alert for proxy operations + showProxyAlert(type, message, providerName = null) { + const alert = document.createElement('div'); + alert.className = `alert alert-${type}`; + alert.innerHTML = ` + +
${providerName ? `${providerName}: ` : ''}${message}
+ `; + + // Insert at beginning of proxy container + const proxyContainer = document.getElementById('proxy-container'); + if (proxyContainer) { + proxyContainer.insertBefore(alert, proxyContainer.firstChild); + } else { + // Fallback to card body + const cardBody = document.querySelector('.card-body'); + if (cardBody) { + cardBody.insertBefore(alert, cardBody.firstChild); + } + } + + // Remove after 5 seconds + setTimeout(() => alert.remove(), 5000); + } +} + +// Make ProxyManager globally available +window.ProxyManager = ProxyManager; + +// Initialize proxy manager when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + window.proxyManager = new ProxyManager(window.location.origin); +}); \ No newline at end of file diff --git a/service.py b/service.py index 23eae9b..cd0cdf0 100644 --- a/service.py +++ b/service.py @@ -126,6 +126,10 @@ class UltimateService: css_path = os.path.join(web_dir, 'config.css') js_path = os.path.join(web_dir, 'config.js') + # NEW: Proxy files + proxy_css_path = os.path.join(web_dir, 'proxy.css') + proxy_js_path = os.path.join(web_dir, 'proxy.js') + # EPG mapping files epg_css_path = os.path.join(web_dir, 'epg_mapping.css') epg_js_path = os.path.join(web_dir, 'epg_mapping.js') @@ -141,6 +145,10 @@ class UltimateService: with open(css_path, 'r', encoding='utf-8') as f: css = f.read() + # NEW: Load proxy CSS + with open(proxy_css_path, 'r', encoding='utf-8') as f: + proxy_css = f.read() + # Load EPG CSS with open(epg_css_path, 'r', encoding='utf-8') as f: epg_css = f.read() @@ -149,6 +157,10 @@ class UltimateService: with open(js_path, 'r', encoding='utf-8') as f: js = f.read() + # NEW: Load proxy JS + with open(proxy_js_path, 'r', encoding='utf-8') as f: + proxy_js = f.read() + # Load EPG JS and libraries with open(epg_js_path, 'r', encoding='utf-8') as f: epg_js = f.read() @@ -159,21 +171,23 @@ class UltimateService: with open(debounce_path, 'r', encoding='utf-8') as f: debounce_js = f.read() - # Combine all CSS - combined_css = f"{css}\n\n/* EPG Mapping CSS */\n{epg_css}" + # Combine all CSS (correct order: base -> proxy -> epg) + combined_css = f"{css}\n\n/* Proxy CSS */\n{proxy_css}\n\n/* EPG Mapping CSS */\n{epg_css}" # Combine all JS (with proper order) combined_js = f""" - /* Debounce Utility */ {debounce_js} - + /* FuzzySet Library */ {fuzzyset_js} - + /* Main Config JS */ {js} - + + /* Proxy Management JS */ + {proxy_js} + /* EPG Mapping JS */ {epg_js} """ @@ -182,14 +196,12 @@ class UltimateService: html = html.replace('', f'') - # Inject JavaScript before tag since ' - # Check if script tag exists in HTML (for backward compatibility) if '' in html: html = html.replace('', script_tag) else: - # Script tag was removed, inject before html = html.replace('', f'{script_tag}\n') return html @@ -1793,7 +1805,222 @@ class UltimateService: response.status = 500 return {'error': f'Internal server error: {str(e)}'} - # Add to service.py setup_routes() + @self.app.route('/api/providers//subscription') + def get_provider_subscription(provider): + """Get subscription status for a provider""" + try: + subscription = self.manager.get_subscription_status(provider) + + if subscription: + return { + 'success': True, + 'subscription': subscription.to_dict() + } + else: + return { + 'success': True, + 'subscription': None, + 'message': 'No subscription information available' + } + + except ValueError as val_err: + response.status = 404 + return {'error': str(val_err)} + except Exception as api_err: + logger.error(f"API Error in subscription endpoint: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} + + @self.app.route('/api/providers//channels/subscribed') + def get_subscribed_channels(provider): + """Get channels the user is subscribed to for a provider""" + try: + channels = self.manager.get_subscribed_channels(provider) + + # Get provider instance for additional info + provider_instance = self.manager.get_provider(provider) + provider_catchup_hours = getattr(provider_instance, 'catchup_window', 0) if provider_instance else 0 + + channels_data = [] + for c in channels: + channel_dict = c.to_dict() + + # Add catchup hours + if hasattr(c, 'catchup_hours'): + channel_dict['CatchupHours'] = c.catchup_hours + else: + channel_dict['CatchupHours'] = provider_catchup_hours + + channels_data.append(channel_dict) + + return { + 'provider': provider, + 'channels': channels_data, + 'count': len(channels_data), + 'is_filtered': True # Indicates subscription filtering was applied + } + + except ValueError as val_err: + response.status = 404 + return {'error': str(val_err)} + except Exception as api_err: + logger.error(f"API Error in subscribed channels endpoint: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} + + @self.app.route('/api/providers//packages') + def get_provider_packages(provider): + """Get available subscription packages for a provider""" + try: + packages = self.manager.get_available_packages(provider) + + return { + 'success': True, + 'provider': provider, + 'packages': [ + { + 'package_id': pkg.package_id, + 'name': pkg.name, + 'description': pkg.description, + 'price_info': pkg.price_info, + 'channel_count': pkg.channel_count, + 'metadata': pkg.metadata + } + for pkg in packages + ], + 'count': len(packages) + } + + except ValueError as val_err: + response.status = 404 + return {'error': str(val_err)} + except Exception as api_err: + logger.error(f"API Error in packages endpoint: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} + + @self.app.route('/api/providers/channels/subscribed') + def get_all_subscribed_channels(): + """Get all subscribed channels across all providers""" + try: + all_subscribed = {} + provider_list = self.manager.list_providers() + + for provider in provider_list: + try: + channels = self.manager.get_subscribed_channels(provider) + if channels: + all_subscribed[provider] = [c.to_dict() for c in channels] + except Exception as provider_err: + logger.warning(f"Could not get subscribed channels for {provider}: {provider_err}") + continue + + total_channels = sum(len(channels) for channels in all_subscribed.values()) + + return { + 'success': True, + 'providers': list(all_subscribed.keys()), + 'channels_by_provider': all_subscribed, + 'total_channels': total_channels, + 'provider_count': len(all_subscribed) + } + + except Exception as api_err: + logger.error(f"API Error in all subscribed channels endpoint: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} + + @self.app.route('/api/m3u/subscribed') + def get_m3u_subscribed(): + """ + Generate M3U playlist with only subscribed channels. + + Note: This uses the same caching mechanism as regular M3U, + but with '_subscribed' suffix in cache filename. + """ + try: + cache_file = "playlist_subscribed.m3u" + + # Try cached version + cached_content = self.vfs.read_text(cache_file) + if cached_content: + logger.info("Serving cached subscribed M3U playlist") + response.content_type = 'audio/x-mpegurl; charset=utf-8' + response.headers['Content-Disposition'] = 'attachment; filename="playlist_subscribed.m3u8"' + return cached_content + + # Generate new M3U with subscribed channels + base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" + m3u_content = "#EXTM3U\n" + + provider_list = self.manager.list_providers() + for provider_name in provider_list: + try: + channels = self.manager.get_subscribed_channels(provider_name) + + for channel in channels: + # Generate M3U entry for each subscribed channel + channel_id = channel.channel_id + channel_name = channel.name + channel_logo = channel.logo_url or '' + + # Get provider label + try: + provider_instance = self.manager.get_provider(provider_name) + provider_label = getattr(provider_instance, 'provider_label', provider_name) + except: + provider_label = provider_name + + # Build stream URL + stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream" + + # Add M3U entry + m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}" tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n' + + # Add DRM directives if available + try: + drm_configs = self.manager.get_channel_drm_configs(provider_name, channel_id) + if drm_configs: + drm_directives = self._generate_drm_directives(drm_configs) + m3u_content += drm_directives + except Exception as drm_err: + logger.debug(f"Could not get DRM for {provider_name}/{channel_id}: {drm_err}") + + m3u_content += f'{stream_url}\n' + + except Exception as provider_err: + logger.warning( + f"Failed to process subscribed channels for '{provider_name}': {str(provider_err)}") + continue + + # Cache the result + if self.vfs.write_text(cache_file, m3u_content): + logger.info(f"Subscribed M3U playlist cached to {cache_file}") + + response.content_type = 'audio/x-mpegurl; charset=utf-8' + response.headers['Content-Disposition'] = 'attachment; filename="playlist_subscribed.m3u8"' + return m3u_content + + except Exception as api_err: + logger.error(f"API Error in /api/m3u/subscribed: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} + + @self.app.route('/api/m3u/subscribed/generate') + def generate_m3u_subscribed(): + """Force regenerate subscribed M3U playlist""" + try: + # Clear cache and regenerate + cache_file = "playlist_subscribed.m3u" + self.vfs.delete(cache_file) # Delete if exists + + # Call the subscribed M3U endpoint which will regenerate + return get_m3u_subscribed() + + except Exception as api_err: + logger.error(f"API Error in /api/m3u/subscribed/generate: {str(api_err)}") + response.status = 500 + return {'error': f'Internal server error: {str(api_err)}'} @self.app.route('/api/providers/enabled', method='GET') def get_all_enabled_status():