|
| 1 | +import asyncio |
1 | 2 | from pathlib import Path |
2 | 3 |
|
3 | 4 | from OTAnalytics.application.datastore import VideoParser |
4 | 5 | from OTAnalytics.application.logger import logger |
5 | | -from OTAnalytics.application.parser.track_parser import TrackParser |
| 6 | +from OTAnalytics.application.parser.track_parser import TrackParser, TracksParseResult |
6 | 7 | from OTAnalytics.application.state import TracksMetadata, VideosMetadata |
7 | 8 | from OTAnalytics.domain.progress import ProgressbarBuilder |
8 | 9 | from OTAnalytics.domain.track_repository import TrackFileRepository, TrackRepository |
@@ -31,28 +32,95 @@ def __init__( |
31 | 32 | self._videos_metadata = videos_metadata |
32 | 33 |
|
33 | 34 | def __call__(self, files: list[Path]) -> None: |
| 35 | + """Load and parse track files together with their videos, blocking. |
| 36 | +
|
| 37 | + For use outside an event loop only, such as preloading files given on the |
| 38 | + command line at startup. Nothing is connected then, so there is nothing to |
| 39 | + yield to. Inside an event loop use `load` instead, which keeps the ui |
| 40 | + responsive. |
| 41 | +
|
| 42 | + Args: |
| 43 | + files (list[Path]): files in ottrk format. |
| 44 | +
|
| 45 | + Raises: |
| 46 | + RuntimeError: if called while an event loop is running. |
34 | 47 | """ |
35 | | - Load and parse the given track file together with the corresponding video file. |
| 48 | + if self._event_loop_is_running(): |
| 49 | + raise RuntimeError( |
| 50 | + "Parsing track files blocks the event loop and freezes the ui." |
| 51 | + " Use load() instead." |
| 52 | + ) |
| 53 | + if files_to_load := self._files_to_load(files): |
| 54 | + self._publish(self._parse(files_to_load), files_to_load) |
36 | 55 |
|
37 | | - Each video is resolved relative to the parent folder of the track file it was |
38 | | - parsed from, so track files from different folders each find their own video. |
39 | | - This relies on `TrackParser.parse_files` returning exactly one `VideoMetadata` |
40 | | - per input file, in input order; `strict=True` turns a violation of that into |
41 | | - an error rather than silently dropping videos. |
| 56 | + async def load(self, files: list[Path]) -> None: |
| 57 | + """Load and parse track files together with their videos. |
| 58 | +
|
| 59 | + Parsing runs on a worker thread, so the ui keeps repainting while it runs. |
| 60 | + Everything that touches a repository stays on the event loop. |
| 61 | +
|
| 62 | + Args: |
| 63 | + files (list[Path]): files in ottrk format. |
| 64 | + """ |
| 65 | + if files_to_load := self._files_to_load(files): |
| 66 | + # parse_files must stay pure: no repository, no observer, no ui. It runs |
| 67 | + # on a worker thread here, and repositories notify observers that mutate |
| 68 | + # widgets, which is only safe on the event loop. |
| 69 | + parse_result = await asyncio.to_thread(self._parse, files_to_load) |
| 70 | + self._publish(parse_result, files_to_load) |
| 71 | + |
| 72 | + @staticmethod |
| 73 | + def _event_loop_is_running() -> bool: |
| 74 | + try: |
| 75 | + asyncio.get_running_loop() |
| 76 | + except RuntimeError: |
| 77 | + return False |
| 78 | + return True |
| 79 | + |
| 80 | + def _files_to_load(self, files: list[Path]) -> list[Path]: |
| 81 | + """Select the files that are not loaded already. |
42 | 82 |
|
43 | 83 | Args: |
44 | | - files (Path): files in ottrk format. |
| 84 | + files (list[Path]): files the caller asked to load. |
| 85 | +
|
| 86 | + Returns: |
| 87 | + list[Path]: the files still to be parsed. |
45 | 88 | """ |
46 | 89 | if not files: |
47 | | - return |
| 90 | + return [] |
48 | 91 | files_to_load = [ |
49 | 92 | file for file in files if not self._is_file_already_loaded(file) |
50 | 93 | ] |
51 | 94 | self._log_already_loaded_files(files, files_to_load) |
52 | | - if not files_to_load: |
53 | | - return |
| 95 | + return files_to_load |
| 96 | + |
| 97 | + def _parse(self, files_to_load: list[Path]) -> TracksParseResult: |
| 98 | + """Parse the given track files. Pure: safe to run off the event loop. |
| 99 | +
|
| 100 | + Args: |
| 101 | + files_to_load (list[Path]): files in ottrk format. |
| 102 | +
|
| 103 | + Returns: |
| 104 | + TracksParseResult: the parsed tracks and their metadata. |
| 105 | + """ |
54 | 106 | logger().info(f"Loading {len(files_to_load)} track files and videos...") |
55 | | - parse_result = self._track_parser.parse_files(files_to_load) |
| 107 | + return self._track_parser.parse_files(files_to_load) |
| 108 | + |
| 109 | + def _publish( |
| 110 | + self, parse_result: TracksParseResult, files_to_load: list[Path] |
| 111 | + ) -> None: |
| 112 | + """Publish the parse result to the repositories. Event loop only. |
| 113 | +
|
| 114 | + Each video is resolved relative to the parent folder of the track file it was |
| 115 | + parsed from, so track files from different folders each find their own video. |
| 116 | + This relies on `TrackParser.parse_files` returning exactly one `VideoMetadata` |
| 117 | + per input file, in input order; `strict=True` turns a violation of that into |
| 118 | + an error rather than silently dropping videos. |
| 119 | +
|
| 120 | + Args: |
| 121 | + parse_result (TracksParseResult): what the parser produced. |
| 122 | + files_to_load (list[Path]): the files handed to the parser. |
| 123 | + """ |
56 | 124 | for video_metadata in parse_result.videos_metadata: |
57 | 125 | self._videos_metadata.update(video_metadata) |
58 | 126 |
|
|
0 commit comments