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 `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${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