diff --git a/OpenCast.sh b/OpenCast.sh index 91fc1e08..b4161770 100755 --- a/OpenCast.sh +++ b/OpenCast.sh @@ -53,7 +53,6 @@ function stop() { # Todo hardcoded port lsof -t -i :2020 | xargs kill >/dev/null 2>&1 lsof -t -i :8081 | xargs kill >/dev/null 2>&1 - sudo killall omxplayer.bin >/dev/null 2>&1 echo "Done." } @@ -81,7 +80,12 @@ function test() { if [ -z "$1" ]; then run_in_env python -m unittest discover -v else - run_in_env python -m unittest "$TEST_DIR.$1" + local selector="$1" + + if [[ "$selector" != "$TEST_DIR"* ]]; then + selector="$TEST_DIR.$selector" + fi + run_in_env python -m unittest "$selector" fi } diff --git a/OpenCast/__init__.py b/OpenCast/__init__.py index febb60ae..f9423a0e 100644 --- a/OpenCast/__init__.py +++ b/OpenCast/__init__.py @@ -3,6 +3,7 @@ from concurrent.futures import ThreadPoolExecutor import structlog +from vlc import Instance as VlcInstance from .app.controller.module import ControllerModule from .app.facade import AppFacade @@ -42,9 +43,10 @@ def main(argv=None): repo_factory = RepoFactory() data_facade = DataFacade(repo_factory) - downloader_executor = ThreadPoolExecutor(config["downloader.max_concurrency"]) - io_factory = IoFactory(downloader_executor) - media_factory = MediaFactory() + io_factory = IoFactory() + media_factory = MediaFactory( + VlcInstance(), ThreadPoolExecutor(config["downloader.max_concurrency"]) + ) infra_facade = InfraFacade(io_factory, media_factory) ControllerModule(app_facade, infra_facade, data_facade, service_factory) diff --git a/OpenCast/app/command/player.py b/OpenCast/app/command/player.py index 1aab9150..4dc4c641 100644 --- a/OpenCast/app/command/player.py +++ b/OpenCast/app/command/player.py @@ -14,7 +14,7 @@ class QueueVideo(Command): @command -class StopVideo(Command): +class StopPlayer(Command): pass diff --git a/OpenCast/app/command/video.py b/OpenCast/app/command/video.py index 865f0cb0..99bf1b7b 100644 --- a/OpenCast/app/command/video.py +++ b/OpenCast/app/command/video.py @@ -24,6 +24,11 @@ class RetrieveVideo(Command): output_directory: str +@command +class ParseVideo(Command): + pass + + @command class FetchVideoSubtitle(Command): language: str diff --git a/OpenCast/app/controller/controller.py b/OpenCast/app/controller/controller.py index 79fd0bc6..ded4d6cb 100644 --- a/OpenCast/app/controller/controller.py +++ b/OpenCast/app/controller/controller.py @@ -1,4 +1,7 @@ +import inspect + from OpenCast.domain.service.identity import IdentityService +from OpenCast.util.naming import name_handler_method class Controller: @@ -11,6 +14,13 @@ def _dispatch(self, cmd_cls, component_id, *args, **kwargs): cmd_id = IdentityService.id_command(cmd_cls, component_id) self._cmd_dispatcher.dispatch(cmd_cls(cmd_id, component_id, *args, **kwargs)) + def _observe(self, module): + classes = inspect.getmembers(module, inspect.isclass) + for _, cls in classes: + if cls.__module__ == module.__name__: + handler_name = name_handler_method(cls) + self._evt_dispatcher.observe(None, {cls: getattr(self, handler_name)}) + def _start_workflow(self, workflow_cls, resource_id, *args, **kwargs): workflow_id = IdentityService.id_workflow(workflow_cls, resource_id) workflow = workflow_cls(workflow_id, self._app_facade, *args, **kwargs) diff --git a/OpenCast/app/controller/module.py b/OpenCast/app/controller/module.py index 90e105fd..d7f1a6d4 100644 --- a/OpenCast/app/controller/module.py +++ b/OpenCast/app/controller/module.py @@ -1,4 +1,5 @@ from .file import FileController +from .player import PlayerController from .player_monitor import PlayerMonitController @@ -8,3 +9,4 @@ def __init__(self, app_facade, infra_facade, data_facade, service_factory): self._player_monitor = PlayerMonitController( app_facade, infra_facade, data_facade, service_factory ) + self._player_controller = PlayerController(app_facade, data_facade) diff --git a/OpenCast/app/controller/player.py b/OpenCast/app/controller/player.py new file mode 100644 index 00000000..cdd65293 --- /dev/null +++ b/OpenCast/app/controller/player.py @@ -0,0 +1,33 @@ +import structlog +from OpenCast.app.command import player as Cmd +from OpenCast.domain.service.identity import IdentityService +from OpenCast.infra.event import player as player_events + +from .controller import Controller + + +class PlayerController(Controller): + def __init__(self, app_facade, data_facade): + super(PlayerController, self).__init__(app_facade) + + self._logger = structlog.get_logger(__name__) + self._player_repo = data_facade.player_repo + self._video_repo = data_facade.video_repo + + self._observe(player_events) + + # Infra event handler interface implementation + + def _media_end_reached(self, evt): + model = self._player_repo.get_player() + video = model.next_video() + if video is None: + self._dispatch(Cmd.StopPlayer) + else: + self._dispatch(Cmd.PlayVideo, video.id) + + def _dispatch(self, cmd_cls, *args, **kwargs): + player_id = IdentityService.id_player() + return super(PlayerController, self)._dispatch( + cmd_cls, player_id, *args, **kwargs + ) diff --git a/OpenCast/app/controller/player_monitor.py b/OpenCast/app/controller/player_monitor.py index c2b2cdad..de72e180 100755 --- a/OpenCast/app/controller/player_monitor.py +++ b/OpenCast/app/controller/player_monitor.py @@ -19,8 +19,10 @@ class PlayerMonitController(Controller): def __init__(self, app_facade, infra_facade, data_facade, service_factory): super(PlayerMonitController, self).__init__(app_facade) + media_factory = infra_facade.media_factory self._source_service = service_factory.make_source_service( - infra_facade.io_factory.make_downloader(app_facade.evt_dispatcher) + media_factory.make_downloader(app_facade.evt_dispatcher), + media_factory.make_video_parser(), ) self._player_repo = data_facade.player_repo self._video_repo = data_facade.video_repo @@ -80,15 +82,15 @@ def _video(self): if control == "pause": self._dispatch(Cmd.ToggleVideoState) elif control == "stop": - self._dispatch(Cmd.StopVideo) + self._dispatch(Cmd.StopPlayer) elif control == "right": - self._dispatch(Cmd.SeekVideo, 30) + self._dispatch(Cmd.SeekVideo, Player.SHORT_TIME_STEP) elif control == "left": - self._dispatch(Cmd.SeekVideo, -30) + self._dispatch(Cmd.SeekVideo, -Player.SHORT_TIME_STEP) elif control == "longright": - self._dispatch(Cmd.SeekVideo, 300) + self._dispatch(Cmd.SeekVideo, Player.LONG_TIME_STEP) elif control == "longleft": - self._dispatch(Cmd.SeekVideo, -300) + self._dispatch(Cmd.SeekVideo, -Player.LONG_TIME_STEP) elif control == "prev": self._dispatch(Cmd.PrevVideo) elif control == "next": @@ -100,9 +102,9 @@ def _video(self): def _sound(self): if request.query["vol"] == "more": - self._dispatcher(Cmd.ChangeVolume, Player.VOLUME_STEP) + self._dispatch(Cmd.ChangeVolume, Player.VOLUME_STEP) else: - self._dispatcher(Cmd.ChangeVolume, -Player.VOLUME_STEP) + self._dispatch(Cmd.ChangeVolume, -Player.VOLUME_STEP) return "1" def _subtitle(self): diff --git a/OpenCast/app/service/module.py b/OpenCast/app/service/module.py index 0f2f403e..fa4572c9 100644 --- a/OpenCast/app/service/module.py +++ b/OpenCast/app/service/module.py @@ -8,5 +8,5 @@ def __init__(self, app_facade, infra_facade, data_facade, service_factory): app_facade, data_facade, infra_facade.media_factory ) self._video_service = VideoService( - app_facade, service_factory, data_facade, infra_facade.io_factory + app_facade, service_factory, data_facade, infra_facade.media_factory ) diff --git a/OpenCast/app/service/player.py b/OpenCast/app/service/player.py index fccb6372..5ed66062 100644 --- a/OpenCast/app/service/player.py +++ b/OpenCast/app/service/player.py @@ -1,11 +1,8 @@ -from functools import partial - import structlog from OpenCast.app.command import player as player_cmds from OpenCast.app.error import CommandFailure -from OpenCast.domain.event import player as player_events +from OpenCast.config import config from OpenCast.domain.model.player_state import PlayerState -from OpenCast.infra.event import player as infra_events from .service import Service @@ -13,52 +10,32 @@ class PlayerService(Service): def __init__(self, app_facade, data_facade, media_factory): logger = structlog.get_logger(__name__) - super(PlayerService, self).__init__( - app_facade, logger, self, player_cmds, infra_events - ) + super(PlayerService, self).__init__(app_facade, logger, self, player_cmds) self._player_repo = data_facade.player_repo self._video_repo = data_facade.video_repo self._player = media_factory.make_player(app_facade.evt_dispatcher) - # Infra event handler interface implementation - - def _player_stopped(self, evt): - def stop_player(model): - model.stop() - - def play_next(model): - video = model.next_video() - if video is not None: - self._player.play(video, model.volume) - model.play(video) - - self._update(evt.id, stop_player) - if evt.id is not None: - return - - self._update(evt.id, play_next) + model = self._player_model() + self._player.set_volume(model.volume) # Command handler interface implementation def _play_video(self, cmd): - def queue_video(model, video): - model.queue(video, with_priority=True) - video = self._video_repo.get(cmd.video_id) - self._update(cmd.id, queue_video, video) + self._queue_video_impl(cmd.id, video) self._play_video_impl(cmd.id, video) def _queue_video(self, cmd): - def queue_video(model): - video = self._video_repo.get(cmd.video_id) - model.queue(video) + video = self._video_repo.get(cmd.video_id) + self._queue_video_impl(cmd.id, video) - self._update(cmd.id, queue_video) + def _stop_player(self, cmd): + def stop_video(model): + model.stop() + self._player.stop() - def _stop_video(self, cmd): - self._player.stop(cmd.id) - # Model updates are done from the infra event handler + self._update(cmd.id, stop_video) def _toggle_video_state(self, cmd): def pause(model): @@ -101,45 +78,45 @@ def _prev_video(self, cmd): self._play_video_impl(cmd.id, prev_video) def _toggle_subtitle(self, cmd): - def impl(model, state): - model.subtitle_state = state + def impl(model): + model.subtitle_state = not model.subtitle_state - model = self._player_model() - state = not model.subtitle_state - self._player.update_subtitle_state(state) - self._update(cmd.id, impl, state) + self._player.toggle_subtitle() + self._update(cmd.id, impl) def _increase_subtitle_delay(self, cmd): def impl(model): model.subtitle_delay = model.subtitle_delay + cmd.amount + self._player.set_subtitle_delay(model.subtitle_delay) - self._player.increase_subtitle_delay() self._update(cmd.id, impl) def _decrease_subtitle_delay(self, cmd): def impl(model): model.subtitle_delay = model.subtitle_delay - cmd.amount + self._player.set_subtitle_delay(model.subtitle_delay) - self._player.increase_subtitle_delay() self._update(cmd.id, impl) # Private def _play_video_impl(self, cmd_id, video): - def play_video(model, video, *_): - def impl(model): - model.play(video) - - self._player.play(video, model.volume) - self._update(cmd_id, impl) + def play_video(model): + model.play(video) + + self._player.play(video.id, str(video.path)) + player = self._player_model() + if player.subtitle_state is True: + sub_stream = video.stream("subtitle", config["subtitle.language"]) + if sub_stream is not None: + self._player.select_subtitle_stream(sub_stream.index) + self._update(cmd_id, play_video) + + def _queue_video_impl(self, cmd_id, video): + def queue_video(model): + model.queue(video) - model = self._player_model() - if model.state is not PlayerState.STOPPED: - callback = partial(play_video, model, video) - self._evt_dispatcher.once(player_events.PlayerStopped, callback) - self._player.stop(cmd_id) - else: - play_video(model, video) + self._update(cmd_id, queue_video) def _player_model(self): return self._player_repo.get_player() diff --git a/OpenCast/app/service/video.py b/OpenCast/app/service/video.py index e7995047..950a45c8 100644 --- a/OpenCast/app/service/video.py +++ b/OpenCast/app/service/video.py @@ -9,15 +9,15 @@ class VideoService(Service): - def __init__(self, app_facade, service_factory, data_facade, io_factory): + def __init__(self, app_facade, service_factory, data_facade, media_factory): logger = structlog.get_logger(__name__) super(VideoService, self).__init__(app_facade, logger, self, video_cmds) self._video_repo = data_facade.video_repo - self._downloader = io_factory.make_downloader(app_facade.evt_dispatcher) - self._source_service = service_factory.make_source_service(self._downloader) - self._subtitle_service = service_factory.make_subtitle_service( - io_factory.make_ffmpeg_wrapper(), self._downloader + self._downloader = media_factory.make_downloader(app_facade.evt_dispatcher) + self._source_service = service_factory.make_source_service( + self._downloader, media_factory.make_video_parser() ) + self._subtitle_service = service_factory.make_subtitle_service(self._downloader) # Command handler interface implementation def _create_video(self, cmd): @@ -54,7 +54,7 @@ def impl(ctx, video): ctx.update(video) video = self._video_repo.get(cmd.model_id) - if video.is_file(): + if video.from_disk(): self._start_transaction(self._video_repo, cmd.id, impl, video) return @@ -75,6 +75,15 @@ def abort_operation(evt): ) self._downloader.download_video(cmd.id, video.source, str(video.path)) + def _parse_video(self, cmd): + def impl(ctx): + video = self._video_repo.get(cmd.model_id) + streams = self._source_service.list_streams(video) + video.streams = streams + ctx.update(video) + + self._start_transaction(self._video_repo, cmd.id, impl) + def _fetch_video_subtitle(self, cmd): def impl(ctx): video = self._video_repo.get(cmd.model_id) diff --git a/OpenCast/app/workflow/video.py b/OpenCast/app/workflow/video.py index 08f73eff..ae0b88c6 100644 --- a/OpenCast/app/workflow/video.py +++ b/OpenCast/app/workflow/video.py @@ -31,6 +31,7 @@ class States(Enum): CREATING = auto() IDENTIFYING = auto() RETRIEVING = auto() + PARSING = auto() FINALISING = auto() COMPLETED = auto() DELETING = auto() @@ -42,7 +43,8 @@ class States(Enum): ["_create", States.INITIAL, States.CREATING], ["_video_created", States.CREATING, States.IDENTIFYING], ["_video_identified", States.IDENTIFYING, States.RETRIEVING], - ["_video_retrieved", States.RETRIEVING, States.FINALISING], + ["_video_retrieved", States.RETRIEVING, States.PARSING], + ["_video_parsed", States.PARSING, States.FINALISING], ["_video_subtitle_fetched", States.FINALISING, States.COMPLETED], ["_operation_error", States.CREATING, States.ABORTED], @@ -81,6 +83,11 @@ def on_enter_RETRIEVING(self, _): config["downloader.output_directory"], ) + def on_enter_PARSING(self, _): + self._observe_dispatch( + VideoEvt.VideoParsed, Cmd.ParseVideo, self._video.id, + ) + def on_enter_FINALISING(self, _): self._observe_dispatch( VideoEvt.VideoSubtitleFetched, diff --git a/OpenCast/config.py b/OpenCast/config.py index 50597a57..e8bf5093 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -179,7 +179,7 @@ def _override_from_env(self, content: dict, env: dict, key: list): "max_concurrency": 3 }, "subtitle": { - "language": "en" + "language": "eng" } }, check_env=True) # fmt: on diff --git a/OpenCast/domain/event/player.py b/OpenCast/domain/event/player.py index 789f3fe7..f0a2cf6f 100644 --- a/OpenCast/domain/event/player.py +++ b/OpenCast/domain/event/player.py @@ -36,7 +36,7 @@ class VideoSeeked(Event): @dataclass class VolumeUpdated(Event): - pass + volume: int @dataclass diff --git a/OpenCast/domain/event/video.py b/OpenCast/domain/event/video.py index da34e57b..5c6a0c3d 100644 --- a/OpenCast/domain/event/video.py +++ b/OpenCast/domain/event/video.py @@ -26,6 +26,11 @@ class VideoRetrieved(Event): path: Path +@dataclass +class VideoParsed(Event): + streams: list + + @dataclass class VideoSubtitleFetched(Event): subtitle: Path diff --git a/OpenCast/domain/model/player.py b/OpenCast/domain/model/player.py index 6820fc40..48ea29a0 100644 --- a/OpenCast/domain/model/player.py +++ b/OpenCast/domain/model/player.py @@ -10,15 +10,17 @@ class Player(Entity): VOLUME_STEP = 10 SUBTITLE_DELAY_STEP = 100 + SHORT_TIME_STEP = 1000 + LONG_TIME_STEP = 30000 def __init__(self, id_): super(Player, self).__init__(id_) self._state = PlayerState.STOPPED self._queue = [] self._index = 0 - self._sub_state = False + self._sub_state = True self._sub_delay = 0 - self._volume = 100 + self._volume = 70 def __repr__(self): base_repr = super(Player, self).__repr__() @@ -66,13 +68,15 @@ def unpause(self): def next_video(self): if self._index + 1 >= len(self._queue): - if config["player.loop_last"] is True: + if self._queue and config["player.loop_last"] is True: return self._queue[self._index] return None return self._queue[self._index + 1] def prev_video(self): + if not self._queue: + return None if self._index == 0: return self._queue[0] return self._queue[self._index - 1] @@ -113,4 +117,4 @@ def subtitle_delay(self, delay): @volume.setter def volume(self, v): self._volume = max(min(200, v), 0) - self._record(Evt.VolumeUpdated) + self._record(Evt.VolumeUpdated, self._volume) diff --git a/OpenCast/domain/model/video.py b/OpenCast/domain/model/video.py index 8857cd41..5d6732fd 100644 --- a/OpenCast/domain/model/video.py +++ b/OpenCast/domain/model/video.py @@ -1,10 +1,19 @@ +from dataclasses import dataclass from pathlib import Path +from typing import List from OpenCast.domain.event import video as Evt from .entity import Entity +@dataclass +class Stream: + index: int + type: str + language: str + + class Video(Entity): def __init__(self, id_, source, playlist_id): super(Video, self).__init__(id_) @@ -12,6 +21,7 @@ def __init__(self, id_, source, playlist_id): self._playlist_id = playlist_id self._title = None self._path = None + self._streams = [] self._subtitle = None self._record(Evt.VideoCreated, self._source, self._playlist_id) @@ -36,6 +46,10 @@ def title(self): def playlist_id(self): return self._playlist_id + @property + def streams(self): + return self._streams + @property def subtitle(self): return self._subtitle @@ -54,13 +68,28 @@ def path(self, path: Path): self._path = path self._record(Evt.VideoRetrieved, self._path) + @streams.setter + def streams(self, streams: List[Stream]): + self._streams = streams + self._record(Evt.VideoParsed, self._streams) + @subtitle.setter def subtitle(self, subtitle: str): self._subtitle = subtitle self._record(Evt.VideoSubtitleFetched, self._subtitle) + def from_disk(self): + return Path(self._source).is_file() + + def stream(self, type: str, language: str): + return next( + ( + stream + for stream in self._streams + if stream.type == type and stream.language == language + ), + None, + ) + def delete(self): self._record(Evt.VideoDeleted) - - def is_file(self): - return Path(self._source).is_file() diff --git a/OpenCast/domain/service/factory.py b/OpenCast/domain/service/factory.py index a04c2226..2e096960 100644 --- a/OpenCast/domain/service/factory.py +++ b/OpenCast/domain/service/factory.py @@ -10,6 +10,4 @@ def make_source_service(self, *args): return SourceService(*args) def make_subtitle_service(self, *args): - return SubtitleService( - self._infra_service_factory.make_subtitle_converter(), *args - ) + return SubtitleService(*args) diff --git a/OpenCast/domain/service/source.py b/OpenCast/domain/service/source.py index 3cb88365..0800131c 100644 --- a/OpenCast/domain/service/source.py +++ b/OpenCast/domain/service/source.py @@ -1,9 +1,15 @@ from pathlib import Path +from typing import List + +import structlog +from OpenCast.domain.model.video import Stream class SourceService: - def __init__(self, downloader): + def __init__(self, downloader, video_parser): + self._logger = structlog.get_logger(__name__) self._downloader = downloader + self._video_parser = video_parser def is_playlist(self, source): return "/playlist" in source @@ -12,6 +18,11 @@ def unfold(self, source): return self._downloader.unfold_playlist(source) def pick_stream_metadata(self, video): - if video.is_file(): - return {"title": str(Path(video.source).name)} + if video.from_disk(): + return {"title": Path(video.source).stem} return self._downloader.pick_stream_metadata(video.source, ["title"]) + + def list_streams(self, video) -> List[Stream]: + video_path = str(video.path) + streams = self._video_parser.parse_streams(video_path) + return [Stream(*stream) for stream in streams] diff --git a/OpenCast/domain/service/subtitle.py b/OpenCast/domain/service/subtitle.py index c27376a1..d151617c 100644 --- a/OpenCast/domain/service/subtitle.py +++ b/OpenCast/domain/service/subtitle.py @@ -4,21 +4,17 @@ class SubtitleService: - def __init__(self, subtitle_converter, ffmpeg_wrapper, downloader): + def __init__(self, downloader): self._logger = structlog.get_logger(__name__) - self._subtitle_converter = subtitle_converter - self._ffmpeg_wrapper = ffmpeg_wrapper self._downloader = downloader - def fetch_subtitle( - self, video, language: str, search_source=True, search_online=True - ) -> Path: - subtitle = self._load_from_disk(video.path, language) + def fetch_subtitle(self, video, language: str, search_online=True) -> Path: + subtitle = self.load_from_disk(video.path, language) if subtitle is not None: return subtitle - if search_source: - subtitle = self._download_from_source(video.source, video.path, language) + if not video.from_disk(): + subtitle = self.download_from_source(video.source, video.path, language) if subtitle is not None: return subtitle @@ -27,48 +23,21 @@ def fetch_subtitle( return None - def _load_from_disk(self, video_path: Path, language: str) -> str: + def load_from_disk(self, video_path: Path, language: str) -> Path: parent_path = video_path.parents[0] subtitle = str(video_path.with_suffix(".srt")) - # Find the matching subtitle from a .srt file - srtFiles = list(parent_path.glob("*.srt")) + srtFiles = parent_path.glob("*.srt") if Path(subtitle) in srtFiles: self._logger.debug("Found srt file", subtitle=subtitle) return subtitle - - # Extract file metadata - # Find subtitle with matching language - self._logger.debug("Searching softcoded subtitles", subtitle=subtitle) - metadata = self._ffmpeg_wrapper.probe(video_path) - for stream in metadata["streams"]: - self._logger.debug( - f"Channel #{stream['index']}", - type=stream["codec_type"], - name=stream["codec_long_name"], - ) - if ( - stream["codec_type"] == "subtitle" - and stream["tags"]["language"] == language - ): - self._logger.debug(f"Match: {subtitle}") - if self._ffmpeg_wrapper.extract_stream( - src=video_path, - dest=subtitle, - stream_idx=stream["index"], - override=False, - ): - return subtitle return None - def _download_from_source( + def download_from_source( self, video_source: str, video_path: Path, language: str - ) -> str: + ) -> Path: dest = str(video_path.with_suffix("")) subtitle = self._downloader.download_subtitle( video_source, dest, language, ["vtt"] ) - if subtitle is None or Path(subtitle).suffix == "srt": - return subtitle - - return self._subtitle_converter.vtt_to_srt(Path(subtitle)) + return Path(subtitle) diff --git a/OpenCast/infra/event/player.py b/OpenCast/infra/event/player.py index d6111449..f4440ad5 100644 --- a/OpenCast/infra/event/player.py +++ b/OpenCast/infra/event/player.py @@ -4,5 +4,5 @@ @dataclass -class PlayerStopped(Event): +class MediaEndReached(Event): pass diff --git a/OpenCast/infra/io/error.py b/OpenCast/infra/io/error.py deleted file mode 100644 index 286ccd36..00000000 --- a/OpenCast/infra/io/error.py +++ /dev/null @@ -1,2 +0,0 @@ -class DownloadError(Exception): - pass diff --git a/OpenCast/infra/io/factory.py b/OpenCast/infra/io/factory.py index 2b0d1cd2..6fcb68fa 100644 --- a/OpenCast/infra/io/factory.py +++ b/OpenCast/infra/io/factory.py @@ -1,17 +1,6 @@ -from .ffmpeg_wrapper import FFmpegWrapper from .server import Server -from .downloader import Downloader class IoFactory: - def __init__(self, downloader_executor): - self._downloader_executor = downloader_executor - - def make_ffmpeg_wrapper(self, *args): - return FFmpegWrapper(*args) - def make_server(self, *args): return Server(*args) - - def make_downloader(self, *args): - return Downloader(self._downloader_executor, *args) diff --git a/OpenCast/infra/io/ffmpeg_wrapper.py b/OpenCast/infra/io/ffmpeg_wrapper.py deleted file mode 100644 index 1152f3e9..00000000 --- a/OpenCast/infra/io/ffmpeg_wrapper.py +++ /dev/null @@ -1,30 +0,0 @@ -import ffmpeg -import structlog - - -class FFmpegWrapper: - def __init__(self): - self._logger = structlog.get_logger(__name__) - - def probe(self, file_path): - try: - return ffmpeg.probe(str(file_path)) - except ffmpeg.Error as e: - self._logger.error("Probing error", error=e) - return None - - def extract_stream(self, src, dest, stream_idx, override): - channel = "0:{}".format(stream_idx) - args = [] - if override is True: - args.append("-n") - try: - ffmpeg.input(str(src)).output(str(dest), map=channel).global_args( - *args - ).run() - return dest - except ffmpeg.Error as e: - if e is None and override is True: # The file probably exists - return True - self._logger.error("Extraction error", error=e.stderr) - return False diff --git a/OpenCast/infra/io/download_logger.py b/OpenCast/infra/media/download_logger.py similarity index 100% rename from OpenCast/infra/io/download_logger.py rename to OpenCast/infra/media/download_logger.py diff --git a/OpenCast/infra/io/downloader.py b/OpenCast/infra/media/downloader.py similarity index 94% rename from OpenCast/infra/io/downloader.py rename to OpenCast/infra/media/downloader.py index cf84e9d8..f92e6d34 100644 --- a/OpenCast/infra/io/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -1,6 +1,7 @@ from typing import List -import youtube_dl +from youtube_dl import YoutubeDL +from youtube_dl.utils import ISO639Utils import structlog from OpenCast.infra.event.downloader import DownloadError, DownloadSuccess @@ -29,7 +30,7 @@ def impl(): "quiet": True, "progress_hooks": [self._dl_logger.log_download_progress], } - ydl = youtube_dl.YoutubeDL(options) + ydl = YoutubeDL(options) with ydl: # Download the video try: ydl.download([source]) @@ -49,6 +50,7 @@ def impl(): def download_subtitle(self, url: str, dest: str, lang: str, exts: List[str]): self._logger.debug("Downloading subtitle", subtitle=dest, lang=lang) + lang = ISO639Utils.long2short(lang) for ext in exts: options = { "skip_download": True, @@ -59,7 +61,7 @@ def download_subtitle(self, url: str, dest: str, lang: str, exts: List[str]): "progress_hooks": [self._dl_logger.log_download_progress], "quiet": True, } - ydl = youtube_dl.YoutubeDL(options) + ydl = YoutubeDL(options) with ydl: try: ydl.download([url]) @@ -103,7 +105,7 @@ def _download_stream_metadata(self, url, options): "progress_hooks": [self._dl_logger.log_download_progress], } ) - ydl = youtube_dl.YoutubeDL(options) + ydl = YoutubeDL(options) with ydl: try: return ydl.extract_info(url, download=False) diff --git a/OpenCast/infra/media/error.py b/OpenCast/infra/media/error.py index 5de8951a..0d2579b3 100644 --- a/OpenCast/infra/media/error.py +++ b/OpenCast/infra/media/error.py @@ -1,2 +1,6 @@ class PlayerError(Exception): pass + + +class VideoParsingError(Exception): + pass diff --git a/OpenCast/infra/media/factory.py b/OpenCast/infra/media/factory.py index e6a99fce..e68ea3eb 100644 --- a/OpenCast/infra/media/factory.py +++ b/OpenCast/infra/media/factory.py @@ -1,15 +1,18 @@ -from pathlib import Path - -from omxplayer.player import OMXPlayer - +from .downloader import Downloader +from .parser import VideoParser from .player_wrapper import PlayerWrapper class MediaFactory: + def __init__(self, vlc_instance, downloader_executor): + self._downloader_executor = downloader_executor + self._vlc = vlc_instance + def make_player(self, *args): - def player_factory(path, command, dbus_name, exit_callback): - player = OMXPlayer(Path(path), command, dbus_name=dbus_name) - player.exitEvent += exit_callback - return player + return PlayerWrapper(self._vlc, *args) + + def make_downloader(self, *args): + return Downloader(self._downloader_executor, *args) - return PlayerWrapper(player_factory, *args) + def make_video_parser(self, *args): + return VideoParser(self._vlc, *args) diff --git a/OpenCast/infra/media/parser.py b/OpenCast/infra/media/parser.py new file mode 100644 index 00000000..05032eee --- /dev/null +++ b/OpenCast/infra/media/parser.py @@ -0,0 +1,55 @@ +from threading import Condition + +import structlog +from vlc import EventType, MediaParsedStatus, MediaParseFlag, TrackType + +from .error import VideoParsingError + + +class VideoParser: + def __init__(self, vlc_instance): + self._logger = structlog.get_logger(__name__) + self._vlc = vlc_instance + + def parse_streams(self, video_path: str): + media = self._vlc.media_new(video_path) + cv = Condition() + + def parse_status_update(_): + with cv: + cv.notify() + + def raise_on_error(): + status = media.get_parsed_status() + if status != MediaParsedStatus.done: + self._logger.error( + "Stream parsing error", video=video_path, status=status + ) + raise VideoParsingError( + f"Can't parse streams from '{video_path}', status='{str(status)}'" + ) + + media.event_manager().event_attach( + EventType.MediaParsedChanged, parse_status_update + ) + with cv: + if media.parse_with_options(MediaParseFlag.local, timeout=5000) == -1: + raise_on_error() + cv.wait_for(media.is_parsed) + + raise_on_error() + streams = media.tracks_get() + + type_to_code = { + TrackType.audio: "audio", + TrackType.video: "video", + TrackType.ext: "subtitle", + } + return [ + ( + stream.id, + type_to_code.get(stream.type, "unknown"), + None if stream.language is None else stream.language.decode("UTF-8"), + ) + for stream in streams + ] diff --git a/OpenCast/infra/media/player_wrapper.py b/OpenCast/infra/media/player_wrapper.py index 1034c915..93297872 100644 --- a/OpenCast/infra/media/player_wrapper.py +++ b/OpenCast/infra/media/player_wrapper.py @@ -1,130 +1,74 @@ -from threading import Lock - -import psutil +from threading import Condition +from uuid import UUID import OpenCast.infra.event.player as e import structlog -from omxplayer import keys -from OpenCast.config import config +from vlc import EventType from .error import PlayerError -# OmxPlayer documentation: https://elinux.org/Omxplayer class PlayerWrapper: - def __init__(self, player_factory, evt_dispatcher): + def __init__(self, vlc_instance, evt_dispatcher): self._logger = structlog.get_logger(__name__) - self._player_factory = player_factory + + self._instance = vlc_instance self._evt_dispatcher = evt_dispatcher - self._player = None - self._player_lock = Lock() - self._stop_operation_id = None - - def play(self, video, volume): - command = ["--vol", self._downscale(volume)] - if config["player.hide_background"] is True: - command += ["--blank"] - - if video.subtitle is not None: - command += ["--subtitles", video.subtitle] - - def start_player(): - self._logger.debug("Opening video", video=video, opt=command) - try: - self._player = self._player_factory( - video.path, - command, - "org.mpris.MediaPlayer2.omxplayer1", - self._on_exit, - ) - return True - except SystemError: - self._logger.error("Dbus error", error="Couldn't connect") - # Kill instance if it is a dbus problem - for proc in psutil.process_iter(): - if "omxplayer" in proc.name(): - self._logger.debug(f"Killing process", process=proc.name()) - proc.kill() - return False - - player_started = False - with self._player_lock: - for _ in range(5): - player_started = start_player() - if player_started: - break - - if not player_started: - raise PlayerError("error starting the player") - - def stop(self, op_id): - def impl(): - self._stop_operation_id = op_id - self._player.stop() - # Event is dispatched from _on_exit - - self._exec_command(impl) - def pause(self): - def impl(): - self._player.play_pause() + self._player = self._instance.media_player_new() + self._id_to_media = {} - self._exec_command(impl) + player_events = self._player.event_manager() + player_events.event_attach(EventType.MediaPlayerEndReached, self._on_media_end) - def unpause(self): - def impl(): - self._player.play_pause() + def play(self, video_id: UUID, video_path: str): + media = self._id_to_media.get(video_id, None) + if media is None: + media = self._instance.media_new(video_path) + self._id_to_media[video_id] = media + self._player.set_media(media) + self._player.play() + + def stop(self): + self._player.stop() - self._exec_command(impl) + def pause(self): + self._player.pause() - def update_subtitle_state(self, state): - def impl(): - if state is True: - self._player.show_subtitles() - else: - self._player.hide_subtitles() + def unpause(self): + self._player.pause() - self._exec_command(impl) + def select_subtitle_stream(self, index: int): + media = self._player.get_media() + if media is None: + raise PlayerError("the player is not started") - def increase_subtitle_delay(self): - def impl(): - self._player.action(keys.INCREASE_SUBTITLE_DELAY) + def is_playing(_, cv): + with cv: + cv.notify() - self._exec_command(impl) + cv = Condition() + self._player.event_manager().event_attach( + EventType.MediaPlayerPlaying, is_playing, cv + ) + with cv: + cv.wait_for(self._player.is_playing) + self._player.video_set_spu(index) - def decrease_subtitle_delay(self): - def impl(): - self._player.action(keys.DECREASE_SUBTITLE_DELAY) + def toggle_subtitle(self): + self._player.toggle_teletext() - self._exec_command(impl) + def set_subtitle_delay(self, delay: int): + self._player.video_set_spu_delay(delay * 1000) def set_volume(self, volume): - def impl(): - self._player.set_volume(self._downscale(volume)) - - self._exec_command(impl) + self._player.audio_set_volume(volume) def seek(self, duration): - def impl(): - self._player.seek(duration) - - self._exec_command(impl) - - def _downscale(self, volume): - return volume / 100 - - def _exec_command(self, command): - with self._player_lock: - if self._player is None: - raise PlayerError("the player is not started") - command() - - def _dispatch(self, event): - self._evt_dispatcher.dispatch(event) + current_time = self._player.get_time() + if current_time != -1: + self._player.set_time(current_time + duration) - def _on_exit(self, player, code): - with self._player_lock: - evt = e.PlayerStopped(self._stop_operation_id) - self._stop_operation_id = None - self._player = None - self._dispatch(evt) + def _on_media_end(self, event): + evt = e.MediaEndReached(None) + self._evt_dispatcher.dispatch(evt) diff --git a/OpenCast/infra/service/factory.py b/OpenCast/infra/service/factory.py index c6e08fe6..f3d0c996 100644 --- a/OpenCast/infra/service/factory.py +++ b/OpenCast/infra/service/factory.py @@ -1,6 +1,2 @@ -from .subtitle import SubtitleConverter - - class ServiceFactory: - def make_subtitle_converter(self, *args): - return SubtitleConverter(*args) + pass diff --git a/OpenCast/infra/service/subtitle.py b/OpenCast/infra/service/subtitle.py deleted file mode 100644 index b8177d1e..00000000 --- a/OpenCast/infra/service/subtitle.py +++ /dev/null @@ -1,34 +0,0 @@ -from pathlib import Path - - -class SubtitleConverter: - def vtt_to_srt(self, subtitle: Path): - print(f"subtitle: {subtitle}") - dest = subtitle.with_suffix(".srt") - with open(subtitle, "r") as fp: - content = fp.readlines() - - output = [] - subtitle_count = 0 - in_caption = False - for line in content: - if "-->" in line: - tokens = line.split(" --> ") - if len(tokens) < 3: - continue - - in_caption = True - if subtitle_count > 0: - output.append("\n") - subtitle_count += 1 - output.append(f"{subtitle_count}\n") - output.append(line.replace(".", ",")) - elif line != "" and in_caption: # Skip vtt header and notes - output.append(line) - else: - in_caption = False - - with open(dest, "w") as fp: - fp.writelines(output) - - return dest diff --git a/config.yml b/config.yml index 0c3bd121..190f2dcf 100644 --- a/config.yml +++ b/config.yml @@ -24,4 +24,4 @@ downloader: subtitle: # The default language for subtitles. - language: en + language: eng diff --git a/poetry.lock b/poetry.lock index 02ecf27a..e9f7a9db 100644 --- a/poetry.lock +++ b/poetry.lock @@ -82,22 +82,6 @@ version = "5.1" [package.extras] toml = ["toml"] -[[package]] -category = "main" -description = "Python bindings for libdbus" -name = "dbus-python" -optional = false -python-versions = "*" -version = "1.2.16" - -[[package]] -category = "main" -description = "Decorators for Humans" -name = "decorator" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*" -version = "4.4.2" - [[package]] category = "main" description = "Docutils -- Python Documentation Utilities" @@ -106,36 +90,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.16" -[[package]] -category = "main" -description = "Observer pattern made muy facil" -name = "evento" -optional = false -python-versions = "*" -version = "1.0.2" - -[[package]] -category = "main" -description = "Python bindings for FFmpeg - with complex filtering support" -name = "ffmpeg-python" -optional = false -python-versions = "*" -version = "0.2.0" - -[package.dependencies] -future = "*" - -[package.extras] -dev = ["future (0.17.1)", "numpy (1.16.4)", "pytest-mock (1.10.4)", "pytest (4.6.1)", "Sphinx (2.1.0)", "tox (3.12.1)"] - -[[package]] -category = "main" -description = "Clean single-source support for Python 3 and 2" -name = "future" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.18.2" - [[package]] category = "main" description = "A simple Python library for human readable file sizes (or anything sized in bytes)." @@ -209,24 +163,6 @@ optional = false python-versions = ">=3.5" version = "8.3.0" -[[package]] -category = "main" -description = "A library for controlling omxplayer on the Raspberry Pi" -name = "omxplayer-wrapper" -optional = false -python-versions = "*" -version = "0.3.3" - -[package.dependencies] -dbus-python = "*" -decorator = "*" -evento = "*" -pathlib2 = "*" - -[package.extras] -docs = ["sphinx", "alabaster", "pygments"] -test = ["mock", "pytest", "pytest-cov", "nose", "parameterized"] - [[package]] category = "main" description = "Core utilities for Python packages" @@ -239,17 +175,6 @@ version = "20.4" pyparsing = ">=2.0.2" six = "*" -[[package]] -category = "main" -description = "Object-oriented filesystem paths" -name = "pathlib2" -optional = false -python-versions = "*" -version = "2.3.5" - -[package.dependencies] -six = "*" - [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -266,17 +191,6 @@ version = ">=0.12" [package.extras] dev = ["pre-commit", "tox"] -[[package]] -category = "main" -description = "Cross-platform lib for process and system monitoring in Python." -name = "psutil" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "5.7.0" - -[package.extras] -enum = ["enum34"] - [[package]] category = "dev" description = "library with cross-python path, ini-parsing, io, code, log facilities" @@ -334,6 +248,14 @@ pytest = ">=3.6" [package.extras] testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +[[package]] +category = "main" +description = "VLC bindings for python." +name = "python-vlc" +optional = false +python-versions = "*" +version = "3.0.9113" + [[package]] category = "main" description = "World timezone definitions, modern and historical" @@ -390,7 +312,7 @@ description = "Python documentation generator" name = "sphinx" optional = false python-versions = ">=3.5" -version = "3.0.3" +version = "3.0.4" [package.dependencies] Jinja2 = ">=2.3" @@ -557,7 +479,7 @@ description = "YouTube video downloader" name = "youtube-dl" optional = false python-versions = "*" -version = "2020.5.8" +version = "2020.5.29" [[package]] category = "dev" @@ -573,7 +495,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "9b678dad850bc124a93387d7640940365d3f7d35cddd2d637ec9c793aed3af8a" +content-hash = "f2286064f6040a5efa9b8c07f86f9a9ffdfed0f0fcc8fa655109c7b60c869f61" python-versions = "^3.7" [metadata.files] @@ -642,28 +564,10 @@ coverage = [ {file = "coverage-5.1-cp39-cp39-win_amd64.whl", hash = "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e"}, {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, ] -dbus-python = [ - {file = "dbus-python-1.2.16.tar.gz", hash = "sha256:11238f1d86c995d8aed2e22f04a1e3779f0d70e587caffeab4857f3c662ed5a4"}, -] -decorator = [ - {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, - {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, -] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] -evento = [ - {file = "evento-1.0.2-py3-none-any.whl", hash = "sha256:7a010c2a7d64b84dff06fb56a28d7e64b9234b15778bf96d526d6d839f181547"}, - {file = "evento-1.0.2.tar.gz", hash = "sha256:093c054ae590b968c3c2c7852d02ddbaea25fc1305024f83dd636fcba3545871"}, -] -ffmpeg-python = [ - {file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"}, - {file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"}, -] -future = [ - {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, -] "hurry.filesize" = [ {file = "hurry.filesize-0.9.tar.gz", hash = "sha256:f5368329adbef86accd3bc9490522340bb79260455ae89b1a42c10f63801b9a6"}, ] @@ -722,35 +626,14 @@ more-itertools = [ {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, ] -omxplayer-wrapper = [ - {file = "omxplayer-wrapper-0.3.3.tar.gz", hash = "sha256:05b64036a90220707608c5dd958428dd3805d544c1be5d2d26f6bec10a805b6d"}, - {file = "omxplayer_wrapper-0.3.3-py2.py3-none-any.whl", hash = "sha256:0b56408c91d075298b88209fb68ef96ab8ac25bafd8b4d47c314987e460a1dad"}, -] packaging = [ {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] -pathlib2 = [ - {file = "pathlib2-2.3.5-py2.py3-none-any.whl", hash = "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db"}, - {file = "pathlib2-2.3.5.tar.gz", hash = "sha256:6cd9a47b597b37cc57de1c05e56fb1a1c9cc9fab04fe78c29acd090418529868"}, -] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] -psutil = [ - {file = "psutil-5.7.0-cp27-none-win32.whl", hash = "sha256:298af2f14b635c3c7118fd9183843f4e73e681bb6f01e12284d4d70d48a60953"}, - {file = "psutil-5.7.0-cp27-none-win_amd64.whl", hash = "sha256:75e22717d4dbc7ca529ec5063000b2b294fc9a367f9c9ede1f65846c7955fd38"}, - {file = "psutil-5.7.0-cp35-cp35m-win32.whl", hash = "sha256:f344ca230dd8e8d5eee16827596f1c22ec0876127c28e800d7ae20ed44c4b310"}, - {file = "psutil-5.7.0-cp35-cp35m-win_amd64.whl", hash = "sha256:e2d0c5b07c6fe5a87fa27b7855017edb0d52ee73b71e6ee368fae268605cc3f5"}, - {file = "psutil-5.7.0-cp36-cp36m-win32.whl", hash = "sha256:a02f4ac50d4a23253b68233b07e7cdb567bd025b982d5cf0ee78296990c22d9e"}, - {file = "psutil-5.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1413f4158eb50e110777c4f15d7c759521703bd6beb58926f1d562da40180058"}, - {file = "psutil-5.7.0-cp37-cp37m-win32.whl", hash = "sha256:d008ddc00c6906ec80040d26dc2d3e3962109e40ad07fd8a12d0284ce5e0e4f8"}, - {file = "psutil-5.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:73f35ab66c6c7a9ce82ba44b1e9b1050be2a80cd4dcc3352cc108656b115c74f"}, - {file = "psutil-5.7.0-cp38-cp38-win32.whl", hash = "sha256:60b86f327c198561f101a92be1995f9ae0399736b6eced8f24af41ec64fb88d4"}, - {file = "psutil-5.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d84029b190c8a66a946e28b4d3934d2ca1528ec94764b180f7d6ea57b0e75e26"}, - {file = "psutil-5.7.0.tar.gz", hash = "sha256:685ec16ca14d079455892f25bd124df26ff9137664af445563c1bd36629b5e0e"}, -] py = [ {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, @@ -771,6 +654,10 @@ pytest-cov = [ {file = "pytest-cov-2.9.0.tar.gz", hash = "sha256:b6a814b8ed6247bd81ff47f038511b57fe1ce7f4cc25b9106f1a4b106f1d9322"}, {file = "pytest_cov-2.9.0-py2.py3-none-any.whl", hash = "sha256:c87dfd8465d865655a8213859f1b4749b43448b5fae465cb981e16d52a811424"}, ] +python-vlc = [ + {file = "python-vlc-3.0.9113.tar.gz", hash = "sha256:5422b79d347b6419008ee91cfd9663edc37eaf2a0bd8fb9017d4cc2e5f249dda"}, + {file = "python_vlc-3.0.9113-py3-none-any.whl", hash = "sha256:6e6799cae79a1cda737144f6ac72d72d449752c5384fb2b5cb326600626e3bef"}, +] pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, @@ -801,8 +688,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, ] sphinx = [ - {file = "Sphinx-3.0.3-py3-none-any.whl", hash = "sha256:f5505d74cf9592f3b997380f9bdb2d2d0320ed74dd69691e3ee0644b956b8d83"}, - {file = "Sphinx-3.0.3.tar.gz", hash = "sha256:62edfd92d955b868d6c124c0942eba966d54b5f3dcb4ded39e65f74abac3f572"}, + {file = "Sphinx-3.0.4-py3-none-any.whl", hash = "sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c"}, + {file = "Sphinx-3.0.4.tar.gz", hash = "sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807"}, ] sphinx-rtd-theme = [ {file = "sphinx_rtd_theme-0.4.3-py2.py3-none-any.whl", hash = "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4"}, @@ -849,8 +736,8 @@ urllib3 = [ {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, ] youtube-dl = [ - {file = "youtube_dl-2020.5.8-py2.py3-none-any.whl", hash = "sha256:0b5d3280522469968eb62eecb1f966f422b2be22f000a801bf87cb2172d8ea39"}, - {file = "youtube_dl-2020.5.8.tar.gz", hash = "sha256:22da6788b55b7b267c6d59bcdfaf10e67a9ac980976d50d29a670473ad2a05bb"}, + {file = "youtube_dl-2020.5.29-py2.py3-none-any.whl", hash = "sha256:2d45840772ecc57e151b0be78dd89e9772b6aa29295746be38abb9c30dad5bb3"}, + {file = "youtube_dl-2020.5.29.tar.gz", hash = "sha256:1a3d84afa851dce2fccc2dfc0f9ffa0e22314ffba6d528b34b4a7fe3e0cf2264"}, ] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, diff --git a/pyproject.toml b/pyproject.toml index 0d1d20c4..6103b31e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,17 +16,15 @@ keywords = ['raspberry', 'opencast', 'chromecast'] python = "^3.7" toml = "^0.9" bottle = "^0.12.18" -omxplayer-wrapper = "^0.3.3" youtube-dl = ">= 2020.3.24" pyyaml = "^5.3.1" "hurry.filesize" = "^0.9" -psutil = "^5.7.0" -ffmpeg-python = "^0.2.0" transitions = "^0.8.1" structlog = "^20.1.0" colorama = "^0.4.3" sphinx = "^3.0.3" sphinx_rtd_theme = "^0.4.3" +python-vlc = "^3.0.9113" [tool.poetry.dev-dependencies] pytest = "^3.0" diff --git a/setup.sh b/setup.sh index e69e666a..0e52987d 100755 --- a/setup.sh +++ b/setup.sh @@ -44,7 +44,7 @@ function install_system_deps() { info "Installing system dependencies..." sudo apt-get update - sudo apt-get install -y curl lsof python python3 python3-venv python3-pip libdbus-glib-1-dev libdbus-1-dev || + sudo apt-get install -y curl lsof python python3 python3-venv python3-pip || error "failed to install dependencies" curl -sSL "https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py" | python3 } diff --git a/test/integration/app/service/test_player.py b/test/integration/app/service/test_player.py index 0d45f62f..d36bcab7 100644 --- a/test/integration/app/service/test_player.py +++ b/test/integration/app/service/test_player.py @@ -1,4 +1,3 @@ -from test.shared.infra.media.player_mock import make_player_mock from unittest.mock import Mock from OpenCast.app.command import player as Cmd @@ -14,17 +13,17 @@ class PlayerServiceTest(ServiceTestCase): def setUp(self): super(PlayerServiceTest, self).setUp() - self.player = make_player_mock(self.app_facade.evt_dispatcher) + self.player = Mock() media_factory = self.infra_facade.media_factory media_factory.make_player = Mock(return_value=self.player) + self.data_producer.player().populate(self.data_facade) self.service = PlayerService(self.app_facade, self.data_facade, media_factory) self.player_repo = self.data_facade.player_repo self.video_repo = self.data_facade.video_repo self.player_id = IdentityService.id_player() - self.data_producer.player().populate(self.data_facade) def test_play_video(self): self.data_producer.video("source", None).populate(self.data_facade) @@ -42,12 +41,14 @@ def test_queue_video(self): Cmd.QueueVideo, self.player_id, video_id ) - def test_stop_video(self): + def test_stop_player(self): self.data_producer.player().video("source", None).play().populate( self.data_facade ) - self.evt_expecter.expect(Evt.PlayerStopped).from_(Cmd.StopVideo, self.player_id) + self.evt_expecter.expect(Evt.PlayerStopped).from_( + Cmd.StopPlayer, self.player_id + ) def test_pause_video(self): self.data_producer.player().video("source", None).play().populate( @@ -72,9 +73,10 @@ def test_change_video_volume(self): self.data_facade ) - self.evt_expecter.expect(Evt.VolumeUpdated).from_( - Cmd.ChangeVolume, self.player_id, Player.VOLUME_STEP - ) + player = self.player_repo.get_player() + self.evt_expecter.expect( + Evt.VolumeUpdated, player.volume + Player.VOLUME_STEP + ).from_(Cmd.ChangeVolume, self.player_id, Player.VOLUME_STEP) def test_next_video(self): self.data_producer.player().video("source", None).play().video( @@ -82,9 +84,9 @@ def test_next_video(self): ).populate(self.data_facade) next_video_id = IdentityService.id_video("source2") - self.evt_expecter.expect(Evt.PlayerStopped).expect( - Evt.PlayerStarted, next_video_id - ).from_(Cmd.NextVideo, self.player_id) + self.evt_expecter.expect(Evt.PlayerStarted, next_video_id).from_( + Cmd.NextVideo, self.player_id + ) def test_prev_video(self): self.data_producer.player().video("source", None).video( @@ -92,26 +94,26 @@ def test_prev_video(self): ).play().populate(self.data_facade) prev_video_id = IdentityService.id_video("source") - self.evt_expecter.expect(Evt.PlayerStopped).expect( - Evt.PlayerStarted, prev_video_id - ).from_(Cmd.PrevVideo, self.player_id) + self.evt_expecter.expect(Evt.PlayerStarted, prev_video_id).from_( + Cmd.PrevVideo, self.player_id + ) def test_toggle_subtitle(self): - self.data_producer.player().populate(self.data_facade) + self.data_producer.populate(self.data_facade) self.evt_expecter.expect(Evt.SubtitleStateUpdated).from_( Cmd.ToggleSubtitle, self.player_id ) def test_increase_subtitle_delay(self): - self.data_producer.player().populate(self.data_facade) + self.data_producer.populate(self.data_facade) self.evt_expecter.expect(Evt.SubtitleDelayUpdated).from_( Cmd.IncreaseSubtitleDelay, self.player_id, Player.SUBTITLE_DELAY_STEP ) def test_decrease_subtitle_delay(self): - self.data_producer.player().populate(self.data_facade) + self.data_producer.populate(self.data_facade) self.evt_expecter.expect(Evt.SubtitleDelayUpdated).from_( Cmd.DecreaseSubtitleDelay, self.player_id, Player.SUBTITLE_DELAY_STEP diff --git a/test/integration/app/service/test_video.py b/test/integration/app/service/test_video.py index 2d427017..d45855fa 100644 --- a/test/integration/app/service/test_video.py +++ b/test/integration/app/service/test_video.py @@ -1,12 +1,12 @@ from pathlib import Path -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from OpenCast.app.command import video as Cmd from OpenCast.app.service.error import OperationError from OpenCast.app.service.video import VideoService from OpenCast.config import config from OpenCast.domain.event import video as Evt -from OpenCast.domain.model.video import Video +from OpenCast.domain.model.video import Stream, Video from OpenCast.domain.service.identity import IdentityService from OpenCast.infra.event.downloader import DownloadError, DownloadSuccess @@ -18,13 +18,13 @@ def setUp(self): super(VideoServiceTest, self).setUp() self.downloader = Mock() - self.ffmpeg_wrapper = Mock() - self.io_factory = self.infra_facade.io_factory - self.io_factory.make_downloader.return_value = self.downloader - self.io_factory.make_ffmpeg_wrapper.return_value = self.ffmpeg_wrapper + self.video_parser = Mock() + self.media_factory = self.infra_facade.media_factory + self.media_factory.make_downloader.return_value = self.downloader + self.media_factory.make_video_parser.return_value = self.video_parser self.service = VideoService( - self.app_facade, self.service_factory, self.data_facade, self.io_factory + self.app_facade, self.service_factory, self.data_facade, self.media_factory ) self.video_repo = self.data_facade.video_repo @@ -98,26 +98,43 @@ def dispatch_error(op_id, *args): Cmd.RetrieveVideo, video_id, output_dir ) - def test_fetch_video_subtitle(self): + def test_parse_video(self): self.data_producer.video("source", None, path=Path("/tmp/source.mp4")).populate( self.data_facade ) - subtitle = "/tmp/source.srt" - subtitle_language = config["subtitle.language"] - self.ffmpeg_wrapper.probe.return_value = { - "streams": [ - { - "index": 1, - "codec_type": "subtitle", - "codec_long_name": "subtitle", - "tags": {"language": subtitle_language}, - } - ] - } - self.ffmpeg_wrapper.extract_stream.return_value = subtitle + streams = [ + (0, "video", None), + (1, "audio", None), + (2, "subtitle", "subtitle_lang"), + ] + self.video_parser.parse_streams.return_value = streams + + expected = [Stream(*stream) for stream in streams] + video_id = IdentityService.id_video("source") + self.evt_expecter.expect(Evt.VideoParsed, expected).from_( + Cmd.ParseVideo, video_id + ) + + def test_fetch_video_subtitle(self): + path_mock = MagicMock() + self.data_producer.video("source", None, path=path_mock).populate( + self.data_facade + ) + + # Load from disk + disk_subtitle = "/tmp/source.srt" + path_mock.with_suffix.return_value = disk_subtitle + parent_mock = Mock() + path_mock.parents.__getitem__.return_value = parent_mock + parent_mock.glob.return_value = [] + + # Download from source + source_subtitle = "/tmp/source.vtt" + self.downloader.download_subtitle.return_value = source_subtitle video_id = IdentityService.id_video("source") - self.evt_expecter.expect(Evt.VideoSubtitleFetched, subtitle).from_( + subtitle_language = config["subtitle.language"] + self.evt_expecter.expect(Evt.VideoSubtitleFetched, Path(source_subtitle)).from_( Cmd.FetchVideoSubtitle, video_id, subtitle_language ) diff --git a/test/shared/infra/media/player_mock.py b/test/shared/infra/media/player_mock.py deleted file mode 100644 index e01c4c30..00000000 --- a/test/shared/infra/media/player_mock.py +++ /dev/null @@ -1,12 +0,0 @@ -from unittest.mock import Mock - -from OpenCast.infra.event.player import PlayerStopped - - -def make_player_mock(evt_dispatcher): - def dispatch_stopped(op_id): - evt_dispatcher.dispatch(PlayerStopped(op_id)) - - player = Mock() - player.stop = Mock(side_effect=dispatch_stopped) - return player diff --git a/test/unit/app/controller/__init__.py b/test/unit/app/controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/app/controller/test_player.py b/test/unit/app/controller/test_player.py new file mode 100644 index 00000000..45c7aac4 --- /dev/null +++ b/test/unit/app/controller/test_player.py @@ -0,0 +1,29 @@ +from OpenCast.app.command import player as Cmd +from OpenCast.app.controller.player import PlayerController +from OpenCast.domain.service.identity import IdentityService +from OpenCast.infra.event import player as Evt + +from .util import ControllerTestCase + + +class PlayerControllerTest(ControllerTestCase): + def setUp(self): + super(PlayerControllerTest, self).setUp() + + self.data_producer.player().populate(self.data_facade) + self.controller = PlayerController(self.app_facade, self.data_facade) + + def test_media_end_reached_without_video(self): + self.raise_event(self.controller, Evt.MediaEndReached, None) + self.expect_dispatch(Cmd.StopPlayer, IdentityService.id_player()) + + def test_media_end_reached_with_videos(self): + self.data_producer.player().video("source", None).video( + "next_video", None + ).populate(self.data_facade) + self.raise_event(self.controller, Evt.MediaEndReached, None) + self.expect_dispatch( + Cmd.PlayVideo, + IdentityService.id_player(), + IdentityService.id_video("next_video"), + ) diff --git a/test/unit/app/controller/util.py b/test/unit/app/controller/util.py new file mode 100644 index 00000000..5bb0bde5 --- /dev/null +++ b/test/unit/app/controller/util.py @@ -0,0 +1,31 @@ +import uuid +from test.shared.app.facade_mock import AppFacadeMock +from test.shared.infra.data.producer import DataProducer +from test.util import TestCase +from unittest.mock import Mock + +from OpenCast.app.service.error import OperationError +from OpenCast.domain.service.identity import IdentityService +from OpenCast.infra.data.facade import DataFacade +from OpenCast.infra.data.repo.factory import RepoFactory +from OpenCast.util.naming import name_factory_method, name_handler_method + + +class ControllerTestCase(TestCase): + def setUp(self): + self.app_facade = AppFacadeMock() + + repo_factory = RepoFactory() + self.data_facade = DataFacade(repo_factory) + + self.data_producer = DataProducer.make() + + def expect_dispatch(self, cmd_cls, model_id, *args, **kwargs): + cmd_id = IdentityService.id_command(cmd_cls, model_id) + cmd = cmd_cls(cmd_id, model_id, *args, **kwargs) + self.app_facade.cmd_dispatcher.dispatch.assert_called_once_with(cmd) + return cmd + + def raise_event(self, controller, evt_cls, *args, **kwargs): + event = evt_cls(*args, **kwargs) + getattr(controller, name_handler_method(evt_cls))(event) diff --git a/test/unit/app/workflow/test_video.py b/test/unit/app/workflow/test_video.py index 275a9f44..9c104126 100644 --- a/test/unit/app/workflow/test_video.py +++ b/test/unit/app/workflow/test_video.py @@ -73,17 +73,33 @@ def test_retrieving_to_deleting(self): self.raise_error(self.workflow, cmd) self.assertTrue(self.workflow.is_DELETING()) - def test_retrieving_to_finalising(self): + def test_retrieving_to_parsing(self): event = Evt.VideoIdentified(None, self.video.id, "") self.workflow.to_RETRIEVING(event) cmd = self.expect_dispatch(Cmd.RetrieveVideo, self.video.id, "/tmp") self.raise_event( self.workflow, Evt.VideoRetrieved, cmd.id, self.video.id, "/tmp/video.mp4", ) + self.assertTrue(self.workflow.is_PARSING()) + + def test_parsing_to_deleting(self): + event = Evt.VideoRetrieved(None, self.video.id, "/tmp") + self.workflow.to_PARSING(event) + cmd = self.expect_dispatch(Cmd.ParseVideo, self.video.id) + self.raise_error(self.workflow, cmd) + self.assertTrue(self.workflow.is_DELETING()) + + def test_parsing_to_finalising(self): + event = Evt.VideoRetrieved(None, self.video.id, "/tmp") + self.workflow.to_PARSING(event) + cmd = self.expect_dispatch(Cmd.ParseVideo, self.video.id) + self.raise_event( + self.workflow, Evt.VideoParsed, cmd.id, self.video.id, {}, + ) self.assertTrue(self.workflow.is_FINALISING()) def test_finalising_to_deleting(self): - event = Evt.VideoRetrieved(None, self.video.id, "/tmp") + event = Evt.VideoParsed(None, self.video.id, {}) self.workflow.to_FINALISING(event) cmd = self.expect_dispatch( Cmd.FetchVideoSubtitle, self.video.id, config["subtitle.language"] @@ -92,7 +108,7 @@ def test_finalising_to_deleting(self): self.assertTrue(self.workflow.is_DELETING()) def test_finalising_to_completed(self): - event = Evt.VideoRetrieved(None, self.video.id, "/tmp") + event = Evt.VideoParsed(None, self.video.id, {}) self.workflow.to_FINALISING(event) cmd = self.expect_dispatch( Cmd.FetchVideoSubtitle, self.video.id, config["subtitle.language"] diff --git a/test/unit/domain/model/test_player.py b/test/unit/domain/model/test_player.py index 1987adc2..1e158dc8 100644 --- a/test/unit/domain/model/test_player.py +++ b/test/unit/domain/model/test_player.py @@ -20,8 +20,8 @@ def make_video(self, source="source_1"): return Video(IdentityService.id_video(source), source, None) def test_construction(self): - self.assertEqual(100, self.player.volume) - self.assertFalse(self.player.subtitle_state) + self.assertEqual(70, self.player.volume) + self.assertTrue(self.player.subtitle_state) self.assertEqual(0, self.player.subtitle_delay) self.assertEqual(PlayerState.STOPPED, self.player.state) @@ -60,6 +60,10 @@ def test_next(self): self.player.play(self.player.next_video()) self.assertEqual(None, self.player.next_video()) + def test_next_no_video(self): + config.load_from_dict({"player": {"loop_last": True}}) + self.assertEqual(None, self.player.next_video()) + def test_prev(self): videos = self.make_videos(video_count=2) for video in videos: @@ -68,6 +72,9 @@ def test_prev(self): self.player.play(self.player.next_video()) self.assertEqual(videos[0], self.player.prev_video()) + def test_prev_no_video(self): + self.assertEqual(None, self.player.prev_video()) + def test_play(self): video = self.make_video() self.player.queue(video) diff --git a/test/unit/domain/model/test_video.py b/test/unit/domain/model/test_video.py index 0435e579..3e64bf9f 100644 --- a/test/unit/domain/model/test_video.py +++ b/test/unit/domain/model/test_video.py @@ -11,6 +11,7 @@ def test_construction(self): self.assertEqual("source", video.source) self.assertEqual(None, video.title) self.assertEqual(None, video.path) + self.assertEqual([], video.streams) self.assertEqual(None, video.subtitle) self.expect_events(video, Evt.VideoCreated) @@ -30,6 +31,11 @@ def test_retrieve(self): video.path = "/tmp" self.expect_events(video, Evt.VideoRetrieved) + def test_parse(self): + video = self.make_video() + video.streams = {} + self.expect_events(video, Evt.VideoParsed) + def test_set_subtitles(self): video = self.make_video() video.subtitle = "/tmp/toto.srt" diff --git a/test/unit/domain/service/test_source.py b/test/unit/domain/service/test_source.py new file mode 100644 index 00000000..e1647048 --- /dev/null +++ b/test/unit/domain/service/test_source.py @@ -0,0 +1,47 @@ +from pathlib import Path +from test.util import TestCase +from unittest.mock import Mock + +from OpenCast.domain.model.video import Stream +from OpenCast.domain.service.source import SourceService + + +class SourceServiceTest(TestCase): + def setUp(self): + self.downloader = Mock() + self.video_parser = Mock() + self.service = SourceService(self.downloader, self.video_parser) + + def test_is_playlist(self): + self.assertTrue( + self.service.is_playlist("https://www.youtube.com/playlist?list=id") + ) + self.assertFalse( + self.service.is_playlist( + "https://www.youtube.com/watch?v=id&list=id&index=2" + ) + ) + + def test_pick_stream_metadata_from_disk(self): + video = Mock() + video.from_disk.return_value = True + video.source = "/tmp/toto.mp4" + + metadata = self.service.pick_stream_metadata(video) + expected = {"title": "toto"} + self.assertEqual(expected, metadata) + + def test_list_streams(self): + video = Mock() + video.Path = Path("/tmp/toto.mp4") + + streams = [ + (0, "video", "video_lang"), + (1, "audio", "audio_lang"), + (2, "subtitle", "subtitle_lang"), + ] + self.video_parser.parse_streams.return_value = streams + + expected = [Stream(*stream) for stream in streams] + stream_list = self.service.list_streams(video) + self.assertEqual(expected, stream_list) diff --git a/test/unit/domain/service/test_subtitle.py b/test/unit/domain/service/test_subtitle.py index b13d2489..9035e858 100644 --- a/test/unit/domain/service/test_subtitle.py +++ b/test/unit/domain/service/test_subtitle.py @@ -1,11 +1,37 @@ +from pathlib import Path from test.util import TestCase +from unittest.mock import MagicMock, Mock +from OpenCast.config import config from OpenCast.domain.service.subtitle import SubtitleService class SubtitleServiceTest(TestCase): def setUp(self): - self._service = SubtitleService(None, None, None) + self.downloader = Mock() + self.service = SubtitleService(self.downloader) def test_load_from_disk(self): - pass + path_mock = MagicMock() + language = config["subtitle.language"] + + parent_mock = Mock() + path_mock.parents.__getitem__.return_value = parent_mock + exp_subtitle = Path("/tmp/source.srt") + path_mock.with_suffix.return_value = exp_subtitle + + parent_mock.glob.return_value = [exp_subtitle] + + subtitle = self.service.load_from_disk(path_mock, language) + self.assertEqual(str(exp_subtitle), subtitle) + + def test_download_from_source(self): + video_source = "http://someurl/id" + video_path = Mock() + language = config["subtitle.language"] + + source_subtitle = "/tmp/source.vtt" + self.downloader.download_subtitle.return_value = source_subtitle + + subtitle = self.service.download_from_source(video_source, video_path, language) + self.assertEqual(Path(source_subtitle), subtitle) diff --git a/test/unit/infra/media/__init__.py b/test/unit/infra/media/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/infra/media/test_parser.py b/test/unit/infra/media/test_parser.py new file mode 100644 index 00000000..00f1dc32 --- /dev/null +++ b/test/unit/infra/media/test_parser.py @@ -0,0 +1,61 @@ +from collections import namedtuple +from test.util import TestCase +from unittest.mock import Mock + +from OpenCast.infra.media.parser import VideoParser, VideoParsingError +from vlc import MediaParsedStatus, TrackType + +Stream = namedtuple("Stream", ["id", "type", "language"]) + + +class VideoParserTest(TestCase): + def setUp(self): + self.vlc = Mock() + self.parser = VideoParser(self.vlc) + + def test_parse_streams(self): + video_path = "/tmp/source.mp4" + media = Mock() + self.vlc.media_new.return_value = media + media.parse_with_options.return_value = 0 + media.get_parsed_status.return_value = MediaParsedStatus.done + media.is_parsed.return_value = 1 + + input_languages = [b"eng", None, b"\xE2\x82\xAC"] + output_languages = ["eng", None, "€"] + input_types = [TrackType.audio, TrackType.video, TrackType.ext] + output_types = ["audio", "video", "subtitle"] + + streams = [(i, input_types[i], input_languages[i]) for i in range(3)] + expected = [(i, output_types[i], output_languages[i]) for i in range(3)] + + media.tracks_get.return_value = [Stream(*stream) for stream in streams] + self.assertEqual(expected, self.parser.parse_streams(video_path)) + + def test_parse_streams_failed(self): + video_path = "/tmp/source.mp4" + media = Mock() + self.vlc.media_new.return_value = media + status = MediaParsedStatus.failed + media.get_parsed_status.return_value = status + media.parse_with_options.return_value = -1 + with self.assertRaises(VideoParsingError) as ctx: + self.parser.parse_streams(video_path) + self.assertEqual( + f"Can't parse streams from '{video_path}', status='{status}'", + str(ctx.exception), + ) + + def test_parse_streams_timeout(self): + video_path = "/tmp/source.mp4" + media = Mock() + self.vlc.media_new.return_value = media + status = MediaParsedStatus.timeout + media.get_parsed_status.return_value = status + media.parse_with_options.return_value = 0 + with self.assertRaises(VideoParsingError) as ctx: + self.parser.parse_streams(video_path) + self.assertEqual( + f"Can't parse streams from '{video_path}', status='{status}'", + str(ctx.exception), + )