Skip to content

Commit

Permalink
Hls transcoder can now generate the whole file index and handles seek…
Browse files Browse the repository at this point in the history
…ing in native clients
  • Loading branch information
thomaserlang committed Dec 9, 2023
1 parent 2371c7b commit e1d5574
Show file tree
Hide file tree
Showing 7 changed files with 355 additions and 69 deletions.
3 changes: 3 additions & 0 deletions seplis_play_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
close_session,
download_source,
request_media,
hls,
)

app = FastAPI(
Expand All @@ -37,9 +38,11 @@
app.include_router(close_session.router)
app.include_router(download_source.router)
app.include_router(request_media.router)
app.include_router(hls.router)

# The media.m3u8 gets updated too fast and the browser gets an old version
StaticFiles.is_not_modified = lambda *args, **kwargs: False
app.include_router(hls.router)
app.mount('/files', StaticFiles(directory=config.transcode_folder), name='files')

@app.on_event('startup')
Expand Down
92 changes: 92 additions & 0 deletions seplis_play_server/routes/hls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import asyncio
import os.path
import anyio
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import FileResponse

from seplis_play_server import logger

from .. import config
from ..dependencies import get_metadata
from ..transcoders.video import Transcode_settings, close_transcoder, sessions
from ..transcoders.hls import Hls_transcoder

router = APIRouter()

@router.get('/hls/media.m3u8')
async def start_media(
settings: Transcode_settings = Depends(),
metadata = Depends(get_metadata),
):
if not metadata or settings.source_index > len(metadata):
raise HTTPException(404, 'No metadata')

if settings.session in sessions:
transcoder = Hls_transcoder(settings=settings, metadata=metadata[settings.source_index])
else:
transcoder = await start_transcode(settings)
return Response(
content=transcoder.generate_hls_playlist(),
media_type='application/x-mpegURL',
)

@router.get('/hls/media{segment}.m4s')
async def get_media(
segment: int,
settings: Transcode_settings = Depends(),
):
if settings.session in sessions:
folder = sessions[settings.session].transcode_folder

if await Hls_transcoder.is_segment_ready(folder, segment):
return FileResponse(Hls_transcoder.get_segment_path(folder, segment))

# If the segment is within 15 segments of the last transcoded segment
# then wait for the segment to be transcoded.
first_transcoded_segment, last_transcoded_segment = await Hls_transcoder.first_last_transcoded_segment(folder)
if first_transcoded_segment <= segment and (last_transcoded_segment + 15) >= segment:
logger.debug(f'Requested segment {segment} is within the range {first_transcoded_segment}-{last_transcoded_segment+15} to wait for transcoding')
if await Hls_transcoder.wait_for_segment(folder, segment):
return FileResponse(Hls_transcoder.get_segment_path(folder, segment))

logger.debug(f'Requested segment {segment} is not within the range {first_transcoded_segment}-{last_transcoded_segment+15} to wait for transcoding, start a new transcoder')
else:
logger.debug(f'Start new transcoder since the session does not exist')

await start_transcode(settings, segment)

folder = sessions[settings.session].transcode_folder
if await Hls_transcoder.wait_for_segment(folder, segment):
return FileResponse(Hls_transcoder.get_segment_path(folder, segment))

raise HTTPException(404, 'No media')

@router.get('/hls/init.mp4')
def get_init(
settings: Transcode_settings = Depends(),
):
try:
return FileResponse(os.path.join(
config.transcode_folder,
settings.session,
'init.mp4',
))
except:
raise HTTPException(404, 'No init file')

async def start_transcode(settings: Transcode_settings, start_segment: int = -1):
metadata = await get_metadata(settings.play_id)
if not metadata or settings.source_index > len(metadata):
raise HTTPException(404, 'No metadata')
transcode = Hls_transcoder(settings=settings, metadata=metadata[settings.source_index])
if start_segment == -1:
transcode.settings.start_segment = transcode.start_segment_from_start_time(settings.start_time)
transcode.settings.start_time = transcode.start_time_from_segment(transcode.settings.start_segment)
else:
transcode.settings.start_time = transcode.start_time_from_segment(start_segment)
transcode.settings.start_segment = start_segment

ready = await transcode.start()
if ready == False:
raise HTTPException(500, 'Transcode failed to start')
return transcode
10 changes: 2 additions & 8 deletions seplis_play_server/routes/request_media.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, RootModel
from pydantic import BaseModel
from urllib.parse import urlencode
from ..transcoders.video import Transcode_settings, Transcoder
from ..dependencies import get_metadata
Expand All @@ -10,7 +10,6 @@ class Request_media(BaseModel):
direct_play_url: str
can_direct_play: bool
transcode_url: str
transcode_start_time: float

@router.get('/request-media', response_model=Request_media)
async def request_media(
Expand All @@ -23,16 +22,11 @@ async def request_media(

t = Transcoder(settings=settings, metadata=metadata[source_index])

settings_dict = RootModel[Transcode_settings](settings).model_dump(exclude_none=True, exclude_unset=True)
for key in settings_dict:
if isinstance(settings_dict[key], list):
settings_dict[key] = ','.join(settings_dict[key])
can_device_direct_play = t.can_device_direct_play()
format_supported = any(fmt in settings.supported_video_containers \
for fmt in metadata[source_index]['format']['format_name'].split(','))
return Request_media(
direct_play_url=f'/source?play_id={settings.play_id}&source_index={source_index}',
can_direct_play=format_supported and can_device_direct_play and t.can_copy_audio(),
transcode_url=f'/transcode?source_index={source_index}&{urlencode(settings_dict)}',
transcode_start_time=t.closest_keyframe_time(settings.start_time),
transcode_url=f'/hls/media.m3u8?{urlencode(settings.to_args_dict())}',
)
140 changes: 112 additions & 28 deletions seplis_play_server/transcoders/hls.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,151 @@
import asyncio, os
import math
import re
from urllib.parse import urlencode
from decimal import Decimal
from aiofile import async_open
import anyio

from seplis_play_server import logger
from . import video

class Hls_transcoder(video.Transcoder):

media_name: str = 'media.m3u8'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# For now force h264 for hls hevc breaks in safari for some reason
# For now force h264 since hls hevc breaks in safari for some reason
self.settings.transcode_video_codec = 'h264'
self.settings.supported_video_codecs = ['h264']

def ffmpeg_extend_args(self) -> None:
self.ffmpeg_args.extend([
*self.keyframe_params(),
{'-f': 'hls'},
{'-hls_playlist_type': 'event'},
{'-hls_segment_type': 'fmp4'},
{'-hls_time': str(self.segment_time())},
{'-hls_list_size': '0'},
{'-start_number': str(self.settings.start_segment or 0)},
{self.media_path: None},
])

@property
def media_path(self) -> str:
return os.path.join(self.transcode_folder, self.media_name)

@property
def media_name(self) -> str:
return 'media.m3u8'

async def wait_for_media(self):
files = 0
await self.wait_for_segment(
self.transcode_folder,
self.settings.start_segment or 0,
)

@classmethod
async def wait_for_segment(cls, transcode_folder: str, segment: str | int):
async def wait_for():
while True:
if await cls.is_segment_ready(transcode_folder, segment):
return True
await asyncio.sleep(0.1)
try:
return await asyncio.wait_for(wait_for(), timeout=10)
except asyncio.TimeoutError:
logger.error(f'[{transcode_folder}] Timeout waiting for segment {segment}')
return False

while True:
if os.path.exists(self.media_path):
async with async_open(self.media_path, "r") as afp:
async for line in afp:
if not '#' in line:
files += 1
if files >= 1:
return True
await asyncio.sleep(0.5)
@classmethod
async def first_last_transcoded_segment(cls, transcode_folder: str):
f = os.path.join(transcode_folder, cls.media_name)
first, last = (0, 0)
if await anyio.to_thread.run_sync(os.path.exists, f):
async with async_open(f, "r") as afp:
async for line in afp:
if not '#' in line:
m = re.search(r'(\d+)\.m4s', line)
last = int(m.group(1))
if not first:
first = last
else:
logger.debug(f'No media file {f}')
return (first, last)

@classmethod
async def is_segment_ready(cls, transcode_folder: str, segment: int):
return await anyio.to_thread.run_sync(
os.path.exists,
cls.get_segment_path(transcode_folder, segment)
)

@staticmethod
def get_segment_path(transcode_folder: str, segment: int):
return os.path.join(transcode_folder, f'media{segment}.m4s')

async def write_hls_playlist(self) -> None:
def generate_hls_playlist(self):
settings_dict = self.settings.to_args_dict()
url_settings = urlencode(settings_dict)
segments = self.get_segments()
l = []
l.append('#EXTM3U')
l.append('#EXT-X-VERSION:7')
l.append('#EXT-X-PLAYLIST-TYPE:VOD')
l.append(f'#EXT-X-TARGETDURATION:{str(self.segment_time())}')
l.append(f'#EXT-X-TARGETDURATION:{round(max(segments)) if len(segments) > 0 else str(self.segment_time())}')
l.append('#EXT-X-MEDIA-SEQUENCE:0')
l.append(f'#EXT-X-MAP:URI="/hls/init.mp4?{url_settings}"')

# Keyframes is in self.metadata['keyframes']

# Make the EXTINF lines
prev = 0.0
for i, t in enumerate(self.metadata['keyframes']):
l.append(f'#EXTINF:{str(t-prev)},')
l.append(f'media{i}.m4s')
for i, segment_time in enumerate(segments):
l.append(f'#EXTINF:{str(segment_time)}, nodesc')
l.append(f'/hls/media{i}.m4s?{url_settings}')
l.append('#EXT-X-ENDLIST')
return '\n'.join(l)

def get_segments(self):
if self.can_copy_video():
return self.calculate_keyframe_segments()
else:
return self.calculate_equal_segments()

logger.info(l)
def calculate_keyframe_segments(self):
result: list[Decimal] = []
target_duration = Decimal(self.segment_time())
keyframes = [Decimal(t) for t in self.metadata['keyframes']]
break_time = target_duration
prev_keyframe = Decimal(0)
for keyframe in keyframes:
if keyframe >= break_time:
result.append(keyframe - prev_keyframe)
prev_keyframe = keyframe
break_time += target_duration
result.append(Decimal(self.metadata['format']['duration']) - prev_keyframe)
return result

def calculate_equal_segments(self):
target_duration = Decimal(self.segment_time())
duration = Decimal(self.metadata['format']['duration'])
segments = duration / target_duration
left_over = duration % target_duration
result = [target_duration for _ in range(int(segments))]
if left_over:
result.append(left_over)
return result

def start_time_from_segment(self, segment: int) -> Decimal:
segments = self.get_segments()
if segment >= len(segments) or segment < 1:
return Decimal(0)
return sum(segments[:segment])

def start_segment_from_start_time(self, start_time: Decimal) -> int:
if start_time <= 0:
return 0
segments = self.get_segments()
time = Decimal(0)
for i, t in enumerate(segments):
time += t
if time > start_time:
return i
return 0

def keyframe_params(self) -> list[dict]:
if self.output_codec_lib == 'copy':
return []
Expand All @@ -69,12 +154,11 @@ def keyframe_params(self) -> list[dict]:
keyframe_args = [
{'-force_key_frames:0': f'expr:gte(t,n_forced*{self.segment_time()})'},
]

if self.video_stream.get('r_frame_rate'):
r_frame_rate = self.video_stream['r_frame_rate'].split('/')
r_frame_rate = int(r_frame_rate[0]) / int(r_frame_rate[1])
r_frame_rate = Decimal(r_frame_rate[0]) / Decimal(r_frame_rate[1])

v = self.segment_time() * r_frame_rate
v = math.ceil(Decimal(self.segment_time()) * r_frame_rate)
go_args.extend([
{'-g:v:0': str(v)},
{'-keyint_min:v:0': str(v)},
Expand Down
8 changes: 4 additions & 4 deletions seplis_play_server/transcoders/subtitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ async def get_subtitle_file(metadata: Dict, lang: str, start_time: int):
if not sub_index:
return
args = [
{'-analyzeduration': '20000000'},
{'-probesize': '20000000'},
{'-analyzeduration': '200M'},
{'-probesize': '200M'},
{'-ss': str(start_time)},
{'-i': metadata['format']['filename']},
{'-y': None},
Expand Down Expand Up @@ -57,8 +57,8 @@ async def get_subtitle_file_from_external(id_: int, start_time: int):
return None

args = [
{'-analyzeduration': '20000000'},
{'-probesize': '20000000'},
{'-analyzeduration': '200M'},
{'-probesize': '200M'},
{'-ss': str(start_time)},
{'-i': sub_metadata.path},
{'-y': None},
Expand Down
Loading

0 comments on commit e1d5574

Please sign in to comment.