Add subscription / Refactor config.js

This commit is contained in:
Nirvana
2025-12-22 14:32:36 +01:00
parent e142241920
commit ffb04f49ea
11 changed files with 1323 additions and 224 deletions
+1 -1
View File
@@ -246,7 +246,7 @@ services:
container_name: ultimate-backend
restart: unless-stopped
ports:
- 7777:7777
- "7777:7777"
environment:
- ULTIMATE_PORT=7777
- ULTIMATE_DEBUG=false
+119
View File
@@ -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.
@@ -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']
@@ -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
+102
View File
@@ -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]:
"""
+33 -30
View File
@@ -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;
}
}
+3 -3
View File
@@ -74,9 +74,9 @@
</div>
<div id="proxy-container" class="providers-grid">
<div class="empty-state">
<i class="fas fa-network-wired"></i>
<p>Select a provider from the credentials tab to configure proxy settings</p>
<div class="empty-state" id="loading-proxy">
<i class="fas fa-spinner loader"></i>
<p>Loading proxy configurations...</p>
</div>
</div>
</div>
+12 -179
View File
@@ -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 `
<div class="provider-card" data-provider="${provider.name}">
<div class="provider-header">
${provider.logo ? `<img src="${provider.logo}" alt="${provider.label}" class="provider-logo">` : ''}
<div class="provider-info">
<h3>${provider.label}</h3>
<div class="provider-id">Proxy Configuration</div>
</div>
</div>
<div class="form-group">
<label for="proxy-host-${provider.name}">
<i class="fas fa-server"></i> Proxy Host
</label>
<input type="text"
id="proxy-host-${provider.name}"
class="form-control"
placeholder="proxy.example.com"
value="${existingProxy?.host || ''}">
</div>
<div class="form-group">
<label for="proxy-port-${provider.name}">
<i class="fas fa-plug"></i> Proxy Port
</label>
<input type="number"
id="proxy-port-${provider.name}"
class="form-control"
placeholder="8080"
min="1"
max="65535"
value="${existingProxy?.port || ''}">
</div>
<div class="form-group">
<label for="proxy-type-${provider.name}">
<i class="fas fa-network-wired"></i> Proxy Type
</label>
<select id="proxy-type-${provider.name}" class="form-control">
<option value="http" ${(!existingProxy || existingProxy.proxy_type === 'http') ? 'selected' : ''}>HTTP</option>
<option value="https" ${(existingProxy?.proxy_type === 'https') ? 'selected' : ''}>HTTPS</option>
<option value="socks4" ${(existingProxy?.proxy_type === 'socks4') ? 'selected' : ''}>SOCKS4</option>
<option value="socks5" ${(existingProxy?.proxy_type === 'socks5') ? 'selected' : ''}>SOCKS5</option>
</select>
</div>
<div class="form-group">
<label for="proxy-user-${provider.name}">
<i class="fas fa-user"></i> Username (Optional)
</label>
<input type="text"
id="proxy-user-${provider.name}"
class="form-control"
placeholder="proxyuser"
value="${existingProxy?.auth?.username || ''}">
</div>
<div class="form-group">
<label for="proxy-pass-${provider.name}">
<i class="fas fa-lock"></i> Password (Optional)
</label>
<input type="password"
id="proxy-pass-${provider.name}"
class="form-control"
placeholder="••••••••"
value="${existingProxy?.auth?.password || ''}">
</div>
<div class="btn-group">
<button onclick="saveProxy('${provider.name}')" class="btn btn-primary">
<i class="fas fa-save"></i> ${existingProxy ? 'Update' : 'Save'} Proxy
</button>
${existingProxy ? `
<button onclick="deleteProxy('${provider.name}')" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete Proxy
</button>
` : ''}
</div>
</div>
`;
} 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 '<i class="fas fa-question-circle"></i>';
@@ -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;
window.testAuth = testAuth;
+263
View File
@@ -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%;
}
}
+418
View File
@@ -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 = `
<div class="empty-state">
<i class="fas fa-network-wired"></i>
<p>No providers available</p>
<p>Load providers from credentials tab first</p>
</div>
`;
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 `
<div class="proxy-card" data-provider="${provider.name}">
<div class="proxy-header">
${provider.logo ? `<img src="${provider.logo}" alt="${provider.label}" class="proxy-logo">` : ''}
<div class="proxy-info">
<h3>${provider.label}</h3>
<div class="proxy-id">Proxy Configuration • ${provider.country}</div>
${existingProxy ? `
<div class="proxy-status proxy-status-active">
<i class="fas fa-check-circle"></i> Proxy Configured
<span class="proxy-type-indicator proxy-type-${existingProxy.proxy_type || 'http'}">
${(existingProxy.proxy_type || 'http').toUpperCase()}
</span>
</div>
` : `
<div class="proxy-status proxy-status-inactive">
<i class="fas fa-times-circle"></i> No Proxy
</div>
`}
</div>
</div>
<div class="proxy-form-group">
<label for="proxy-host-${provider.name}">
<i class="fas fa-server"></i> Proxy Host
</label>
<input type="text"
id="proxy-host-${provider.name}"
class="proxy-form-control"
placeholder="proxy.example.com"
value="${existingProxy?.host || ''}">
</div>
<div class="proxy-form-group">
<label for="proxy-port-${provider.name}">
<i class="fas fa-plug"></i> Proxy Port
</label>
<input type="number"
id="proxy-port-${provider.name}"
class="proxy-form-control"
placeholder="8080"
min="1"
max="65535"
value="${existingProxy?.port || ''}">
</div>
<div class="proxy-form-group">
<label for="proxy-type-${provider.name}">
<i class="fas fa-network-wired"></i> Proxy Type
</label>
<select id="proxy-type-${provider.name}" class="proxy-select">
<option value="http" ${(!existingProxy || existingProxy.proxy_type === 'http') ? 'selected' : ''}>HTTP</option>
<option value="https" ${(existingProxy?.proxy_type === 'https') ? 'selected' : ''}>HTTPS</option>
<option value="socks4" ${(existingProxy?.proxy_type === 'socks4') ? 'selected' : ''}>SOCKS4</option>
<option value="socks5" ${(existingProxy?.proxy_type === 'socks5') ? 'selected' : ''}>SOCKS5</option>
</select>
</div>
<div class="proxy-form-group">
<label for="proxy-user-${provider.name}">
<i class="fas fa-user"></i> Username (Optional)
</label>
<input type="text"
id="proxy-user-${provider.name}"
class="proxy-form-control"
placeholder="proxyuser"
value="${existingProxy?.auth?.username || ''}">
</div>
<div class="proxy-form-group">
<label for="proxy-pass-${provider.name}">
<i class="fas fa-lock"></i> Password (Optional)
</label>
<input type="password"
id="proxy-pass-${provider.name}"
class="proxy-form-control"
placeholder="••••••••"
value="${existingProxy?.auth?.password || ''}">
</div>
${this.createAdvancedProxySection(provider.name, existingProxy)}
<div class="proxy-btn-group">
<button onclick="window.proxyManager.saveProxy('${provider.name}')" class="btn btn-primary">
<i class="fas fa-save"></i> ${existingProxy ? 'Update' : 'Save'} Proxy
</button>
${existingProxy ? `
<button onclick="window.proxyManager.deleteProxy('${provider.name}')" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete Proxy
</button>
` : ''}
<button onclick="window.proxyManager.testProxy('${provider.name}')" class="btn btn-success">
<i class="fas fa-vial"></i> Test Proxy
</button>
</div>
<div id="proxy-test-result-${provider.name}" class="proxy-test-result"></div>
</div>
`;
} 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 `
<div class="proxy-advanced-toggle" onclick="window.proxyManager.toggleAdvanced('${providerName}')">
<i class="fas fa-chevron-down" id="proxy-advanced-icon-${providerName}"></i>
<span>Advanced Options</span>
</div>
<div class="proxy-advanced-content" id="proxy-advanced-content-${providerName}">
<div class="proxy-scope-settings">
<h4><i class="fas fa-filter"></i> Proxy Scope</h4>
<p>Select which operations should use this proxy:</p>
<div class="scope-checkboxes">
<div class="scope-checkbox">
<input type="checkbox"
id="proxy-scope-api-${providerName}"
${scope.all || scope.api_calls ? 'checked' : ''}>
<label for="proxy-scope-api-${providerName}">API Calls</label>
</div>
<div class="scope-checkbox">
<input type="checkbox"
id="proxy-scope-auth-${providerName}"
${scope.all || scope.authentication ? 'checked' : ''}>
<label for="proxy-scope-auth-${providerName}">Authentication</label>
</div>
<div class="scope-checkbox">
<input type="checkbox"
id="proxy-scope-manifests-${providerName}"
${scope.all || scope.manifests ? 'checked' : ''}>
<label for="proxy-scope-manifests-${providerName}">Manifests</label>
</div>
<div class="scope-checkbox">
<input type="checkbox"
id="proxy-scope-license-${providerName}"
${scope.all || scope.license ? 'checked' : ''}>
<label for="proxy-scope-license-${providerName}">License Requests</label>
</div>
<div class="scope-checkbox">
<input type="checkbox"
id="proxy-scope-all-${providerName}"
${scope.all ? 'checked' : ''}>
<label for="proxy-scope-all-${providerName}">All Operations</label>
</div>
</div>
</div>
<div class="proxy-form-group">
<label for="proxy-timeout-${providerName}">
<i class="fas fa-clock"></i> Timeout (seconds)
</label>
<input type="number"
id="proxy-timeout-${providerName}"
class="proxy-form-control"
placeholder="30"
min="1"
max="300"
value="${existingProxy?.timeout || 30}">
</div>
<div class="proxy-form-group">
<label for="proxy-verify-ssl-${providerName}" style="display: flex; align-items: center;">
<input type="checkbox"
id="proxy-verify-ssl-${providerName}"
${existingProxy?.verify_ssl !== false ? 'checked' : ''}
style="margin-right: 8px;">
Verify SSL Certificate
</label>
</div>
</div>
`;
}
// 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 = '<span class="loader"></span> 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 = '<i class="fas fa-check-circle"></i> Proxy connection successful!';
testResultEl.className = 'proxy-test-result success';
}
this.showProxyAlert('success', `Proxy test passed for ${providerName}`, providerName);
} else {
if (testResultEl) {
testResultEl.innerHTML = `<i class="fas fa-exclamation-circle"></i> 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 = `<i class="fas fa-exclamation-circle"></i> 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 = `
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i>
<div>${providerName ? `<strong>${providerName}:</strong> ` : ''}${message}</div>
`;
// 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);
});
+237 -10
View File
@@ -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('<link rel="stylesheet" href="config.css">',
f'<style>\n{combined_css}\n</style>')
# Inject JavaScript before </body> tag since <script src="config.js"> was removed
# Inject JavaScript before </body> tag
script_tag = f'<script>\n{combined_js}\n</script>'
# Check if script tag exists in HTML (for backward compatibility)
if '<script src="config.js"></script>' in html:
html = html.replace('<script src="config.js"></script>', script_tag)
else:
# Script tag was removed, inject before </body>
html = html.replace('</body>', f'{script_tag}\n</body>')
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/<provider>/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/<provider>/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/<provider>/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():