mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-23 01:58:00 +00:00
Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr
This commit is contained in:
commit
fa4ee19ab2
9 changed files with 106 additions and 41 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
.DS_Store
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
**/.vscode/
|
||||
*.pyc
|
||||
|
|
@ -9,8 +8,6 @@ staticfiles/
|
|||
docker/DockerfileAIO
|
||||
docker/Dockerfile DEV
|
||||
static/
|
||||
docker/DockerfileAIO
|
||||
docker/Dockerfile DEV
|
||||
data/
|
||||
.next
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
|
@ -12,6 +12,7 @@ from apps.channels.models import Channel, Stream
|
|||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from core.models import UserAgent, CoreSettings
|
||||
from .stream_buffer import StreamBuffer
|
||||
from .utils import detect_stream_type
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
|
|
@ -81,6 +82,16 @@ class StreamManager:
|
|||
self.stop_requested = False
|
||||
|
||||
try:
|
||||
# Check stream type before connecting
|
||||
stream_type = detect_stream_type(self.url)
|
||||
if self.transcode == False and stream_type == 'hls':
|
||||
logger.info(f"Detected HLS stream: {self.url}")
|
||||
logger.info(f"HLS streams will be handled with FFmpeg for now - future version will support HLS natively")
|
||||
# Enable transcoding for HLS streams
|
||||
self.transcode = True
|
||||
# We'll override the stream profile selection with ffmpeg in the transcoding section
|
||||
self.force_ffmpeg = True
|
||||
|
||||
# Start health monitor thread
|
||||
health_thread = threading.Thread(target=self._monitor_health, daemon=True)
|
||||
health_thread.start()
|
||||
|
|
@ -96,7 +107,20 @@ class StreamManager:
|
|||
# Generate transcode command
|
||||
logger.debug(f"Building transcode command for channel {self.channel_id}")
|
||||
channel = get_object_or_404(Channel, uuid=self.channel_id)
|
||||
stream_profile = channel.get_stream_profile()
|
||||
|
||||
# Use FFmpeg specifically for HLS streams
|
||||
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
|
||||
from core.models import StreamProfile
|
||||
try:
|
||||
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
|
||||
logger.info("Using FFmpeg stream profile for HLS content")
|
||||
except StreamProfile.DoesNotExist:
|
||||
# Fall back to channel's profile if FFmpeg not found
|
||||
stream_profile = channel.get_stream_profile()
|
||||
logger.warning("FFmpeg profile not found, using channel default profile")
|
||||
else:
|
||||
stream_profile = channel.get_stream_profile()
|
||||
|
||||
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
|
||||
# Start command process for transcoding
|
||||
logger.debug(f"Starting transcode process: {self.transcode_cmd}")
|
||||
|
|
@ -225,10 +249,10 @@ class StreamManager:
|
|||
break
|
||||
|
||||
timeout = min(2 ** self.retry_count, 30)
|
||||
|
||||
|
||||
# When a connection fails and reconnect is needed:
|
||||
self.reconnecting = True
|
||||
|
||||
self.reconnecting = True
|
||||
|
||||
# Cancel all existing buffer timers during reconnect
|
||||
for timer in list(self._buffer_check_timers):
|
||||
try:
|
||||
|
|
@ -237,10 +261,10 @@ class StreamManager:
|
|||
except Exception as e:
|
||||
logger.error(f"Error canceling buffer timer: {e}")
|
||||
self._buffer_check_timers = []
|
||||
|
||||
|
||||
logger.info(f"Reconnecting in {timeout} seconds... (attempt {self.retry_count})")
|
||||
time.sleep(timeout)
|
||||
|
||||
|
||||
self.reconnecting = False # Reset flag after sleep
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -272,7 +296,7 @@ class StreamManager:
|
|||
"""Stop the stream manager and cancel all timers"""
|
||||
# Add at the beginning of your stop method
|
||||
self.stopping = True
|
||||
|
||||
|
||||
# Cancel all buffer check timers
|
||||
for timer in list(self._buffer_check_timers):
|
||||
try:
|
||||
|
|
@ -280,9 +304,9 @@ class StreamManager:
|
|||
timer.cancel()
|
||||
except Exception as e:
|
||||
logger.error(f"Error canceling buffer check timer: {e}")
|
||||
|
||||
|
||||
self._buffer_check_timers.clear()
|
||||
|
||||
|
||||
# Rest of your existing stop method...
|
||||
# Set the flag first
|
||||
self.stop_requested = True
|
||||
|
|
@ -525,10 +549,10 @@ class StreamManager:
|
|||
if getattr(self, 'stopping', False) or getattr(self, 'reconnecting', False):
|
||||
logger.debug(f"Buffer check aborted - channel {self.buffer.channel_id} is stopping or reconnecting")
|
||||
return
|
||||
|
||||
|
||||
# Clean up completed timers
|
||||
self._buffer_check_timers = [t for t in self._buffer_check_timers if t.is_alive()]
|
||||
|
||||
|
||||
if hasattr(self.buffer, 'index') and hasattr(self.buffer, 'channel_id'):
|
||||
current_buffer_index = self.buffer.index
|
||||
initial_chunks_needed = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
|
||||
|
|
@ -541,7 +565,7 @@ class StreamManager:
|
|||
else:
|
||||
# Still waiting, log progress and schedule another check
|
||||
logger.debug(f"Buffer filling for channel {channel_id}: {current_buffer_index}/{initial_chunks_needed} chunks")
|
||||
|
||||
|
||||
# Schedule another check - NOW WITH TRACKING
|
||||
if not getattr(self, 'stopping', False):
|
||||
timer = threading.Timer(0.5, self._check_buffer_and_set_state)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import logging
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
def detect_stream_type(url):
|
||||
"""
|
||||
Detect if stream URL is HLS or TS format.
|
||||
|
||||
Args:
|
||||
url (str): The stream URL to analyze
|
||||
|
||||
Returns:
|
||||
str: 'hls' or 'ts' depending on detected format
|
||||
"""
|
||||
if not url:
|
||||
return 'unknown'
|
||||
|
||||
url_lower = url.lower()
|
||||
|
||||
# Look for common HLS indicators
|
||||
if (url_lower.endswith('.m3u8') or
|
||||
'.m3u8?' in url_lower or
|
||||
'/playlist.m3u' in url_lower):
|
||||
return 'hls'
|
||||
|
||||
# Additional HLS patterns
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.lower()
|
||||
if ('playlist' in path and ('.m3u' in path or '.m3u8' in path)) or \
|
||||
('manifest' in path and ('.m3u' in path or '.m3u8' in path)) or \
|
||||
('master' in path and ('.m3u' in path or '.m3u8' in path)):
|
||||
return 'hls'
|
||||
|
||||
# Default to TS
|
||||
return 'ts'
|
||||
|
|
@ -856,4 +856,17 @@ export default class API {
|
|||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async matchEpg() {
|
||||
const response = await fetch(`${host}/api/channels/channels/match-epg/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
stream_profile_id: '0',
|
||||
stream_profile_id: null,
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
},
|
||||
|
|
@ -104,7 +104,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
name: channel.name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
stream_profile_id: channel.stream_profile_id || '0',
|
||||
stream_profile_id: channel.stream_profile_id,
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_name: channel.tvg_name,
|
||||
});
|
||||
|
|
@ -307,10 +307,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
? formik.touched.stream_profile_id
|
||||
: ''
|
||||
}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
data={[{ value: null, label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
|
|
|||
|
|
@ -376,22 +376,14 @@ const ChannelsTable = ({}) => {
|
|||
const matchEpg = async () => {
|
||||
try {
|
||||
// Hit our new endpoint that triggers the fuzzy matching Celery task
|
||||
const resp = await fetch('/api/channels/channels/match-epg/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
await API.matchEpg();
|
||||
|
||||
if (resp.ok) {
|
||||
showAlert('EPG matching task started!');
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
showAlert(`Failed to start EPG matching: ${text}`);
|
||||
}
|
||||
notifications.show({
|
||||
title: 'EPG matching task started!',
|
||||
// style: { width: '200px', left: '200px' },
|
||||
});
|
||||
} catch (err) {
|
||||
showAlert(`Error: ${err.message}`);
|
||||
notifications.show(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ const StreamsTable = ({}) => {
|
|||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
mantineTableHeadCellProps: {
|
||||
style: { textAlign: 'center' }, // Center-align the header
|
||||
style: { textAlign: 'center', backgroundColor: 'rgb(56, 58, 63)' }, // Center-align the header
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
return;
|
||||
}
|
||||
// Build a playable stream URL for that channel
|
||||
let vidUrl = `/proxy/ts/stream/${matched.uui}`;
|
||||
let vidUrl = `/proxy/ts/stream/${matched.uuid}`;
|
||||
if (env_mode == 'dev') {
|
||||
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const useAuthStore = create((set, get) => ({
|
|||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
if (isTokenExpired(tokenExpiration)) {
|
||||
accessToken = await get().refreshToken();
|
||||
accessToken = await get().getRefreshToken();
|
||||
} else {
|
||||
accessToken = localStorage.getItem('accessToken');
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ const useAuthStore = create((set, get) => ({
|
|||
},
|
||||
|
||||
// Action to refresh the token
|
||||
refreshToken: async () => {
|
||||
getRefreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ const useAuthStore = create((set, get) => ({
|
|||
localStorage.setItem('accessToken', data.access);
|
||||
localStorage.setItem('tokenExpiration', decodeToken(data.access));
|
||||
|
||||
return true;
|
||||
return data.access;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue