mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
"""fMP4 buffer management - mirrors StreamBuffer but without TS packet alignment."""
|
|
|
|
import threading
|
|
import time
|
|
import gevent.event
|
|
from ...redis_keys import RedisKeys
|
|
from ...config_helper import ConfigHelper
|
|
from ...utils import get_logger
|
|
|
|
logger = get_logger()
|
|
|
|
|
|
class FMP4StreamBuffer:
|
|
"""
|
|
Redis-backed buffer for fMP4 remux output.
|
|
|
|
Functionally identical to StreamBuffer except:
|
|
- Uses the fmp4:buffer:* Redis keyspace
|
|
- No 188-byte TS packet alignment (raw byte accumulation)
|
|
"""
|
|
|
|
def __init__(self, channel_id, redis_client=None, fmt='fmp4'):
|
|
self.channel_id = channel_id
|
|
self.redis_client = redis_client
|
|
self.fmt = fmt
|
|
self.lock = threading.Lock()
|
|
self.index = 0
|
|
|
|
self.buffer_index_key = RedisKeys.output_buffer_index(channel_id, fmt)
|
|
self.buffer_prefix = RedisKeys.output_buffer_chunk_prefix(channel_id, fmt)
|
|
self.chunk_timestamps_key = RedisKeys.output_chunk_timestamps(channel_id, fmt)
|
|
self.chunk_ttl = ConfigHelper.redis_chunk_ttl()
|
|
self.stopping = False
|
|
|
|
if self.redis_client and channel_id:
|
|
try:
|
|
current_index = self.redis_client.get(self.buffer_index_key)
|
|
if current_index:
|
|
self.index = int(current_index)
|
|
except Exception as e:
|
|
logger.error(f"[fMP4Buffer:{channel_id}] Error initialising from Redis: {e}")
|
|
|
|
self.chunk_available = gevent.event.Event()
|
|
|
|
# Lua script for time-based start positioning (same as StreamBuffer)
|
|
_LUA = """
|
|
local ts_key = KEYS[1]
|
|
local target = tonumber(ARGV[1])
|
|
local result = redis.call('ZREVRANGEBYSCORE', ts_key, target, '-inf', 'LIMIT', 0, 1)
|
|
if #result == 0 then return -1 end
|
|
return tonumber(result[1])
|
|
"""
|
|
if self.redis_client:
|
|
try:
|
|
self._find_chunk_by_time_sha = self.redis_client.register_script(_LUA)
|
|
except Exception:
|
|
self._find_chunk_by_time_sha = None
|
|
else:
|
|
self._find_chunk_by_time_sha = None
|
|
|
|
def put_fragment(self, data: bytes) -> bool:
|
|
"""Store a single complete fMP4 fragment directly to Redis as its own chunk."""
|
|
if not data or not self.redis_client:
|
|
return False
|
|
try:
|
|
now = time.time()
|
|
with self.lock:
|
|
chunk_index = self.redis_client.incr(self.buffer_index_key)
|
|
chunk_key = RedisKeys.output_buffer_chunk(self.channel_id, self.fmt, chunk_index)
|
|
pipe = self.redis_client.pipeline(transaction=False)
|
|
pipe.setex(chunk_key, self.chunk_ttl, data)
|
|
pipe.zadd(self.chunk_timestamps_key, {str(chunk_index): now})
|
|
pipe.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl)
|
|
pipe.expire(self.chunk_timestamps_key, self.chunk_ttl)
|
|
pipe.execute()
|
|
self.index = chunk_index
|
|
self.chunk_available.set()
|
|
self.chunk_available.clear()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"[fMP4Buffer:{self.channel_id}] Error putting fragment: {e}")
|
|
return False
|
|
|
|
def get_chunks(self, start_index=None):
|
|
"""Retrieve chunks from start_index up to current head. Returns (chunks, new_index)."""
|
|
try:
|
|
if not self.redis_client:
|
|
return [], self.index
|
|
|
|
current_index = self.redis_client.get(self.buffer_index_key)
|
|
if not current_index:
|
|
return [], self.index
|
|
|
|
current_index = int(current_index)
|
|
|
|
if start_index is None:
|
|
start_index = max(0, current_index - 5)
|
|
|
|
if start_index >= current_index:
|
|
return [], current_index
|
|
|
|
chunks = []
|
|
pipe = self.redis_client.pipeline(transaction=False)
|
|
indices = range(start_index + 1, current_index + 1)
|
|
for i in indices:
|
|
pipe.get(RedisKeys.output_buffer_chunk(self.channel_id, self.fmt, i))
|
|
results = pipe.execute()
|
|
|
|
for data in results:
|
|
if data:
|
|
chunks.append(data)
|
|
|
|
return chunks, current_index
|
|
|
|
except Exception as e:
|
|
logger.error(f"[fMP4Buffer:{self.channel_id}] Error getting chunks: {e}")
|
|
return [], self.index
|
|
|
|
def find_chunk_index_by_time(self, seconds_behind):
|
|
"""Return the fragment index that was received ~seconds_behind seconds ago.
|
|
|
|
Returns an int (last-consumed convention: next read starts at index+1)
|
|
or None if no suitable fragment exists.
|
|
"""
|
|
if not self.redis_client or not self._find_chunk_by_time_sha:
|
|
return None
|
|
target_time = time.time() - seconds_behind
|
|
try:
|
|
result = self._find_chunk_by_time_sha(
|
|
keys=[self.chunk_timestamps_key],
|
|
args=[target_time],
|
|
)
|
|
if result is None or int(result) == -1:
|
|
oldest = self.redis_client.zrange(self.chunk_timestamps_key, 0, 0)
|
|
if oldest:
|
|
return max(0, int(oldest[0]) - 1)
|
|
return None
|
|
return max(0, int(result) - 1)
|
|
except Exception as e:
|
|
logger.error(f"[fMP4Buffer:{self.channel_id}] Error in find_chunk_index_by_time: {e}")
|
|
return None
|
|
|
|
def stop(self):
|
|
self.stopping = True
|
|
|
|
def cleanup_redis(self):
|
|
"""Delete all fMP4 buffer keys for this channel from Redis."""
|
|
if not self.redis_client:
|
|
return
|
|
try:
|
|
prefix = self.buffer_prefix
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = self.redis_client.scan(cursor, match=f"{prefix}*", count=200)
|
|
if keys:
|
|
self.redis_client.delete(*keys)
|
|
if cursor == 0:
|
|
break
|
|
self.redis_client.delete(self.buffer_index_key)
|
|
try:
|
|
self.redis_client.delete(self.chunk_timestamps_key)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"[fMP4Buffer:{self.channel_id}] Error during Redis cleanup: {e}")
|