├── .editorconfig ├── pyproject.toml ├── .vscode └── settings.json ├── License.txt ├── utils ├── constants.py ├── epg.py └── edcb.py ├── .gitignore ├── 03-AnnotateEPGDatasetSubset.py ├── Readme.md ├── 01-GenerateEPGDataset.py └── 02-GenerateEPGDatasetSubset.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_size = 4 8 | indent_style = space 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "EPGDatasetGenerator" 3 | version = "1.0.0" 4 | description = "" 5 | authors = ["tsukumi "] 6 | # ref: https://github.com/python-poetry/poetry/issues/1537#issuecomment-1154642727 7 | classifiers = ["Private :: Do Not Upload"] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | ariblib = {url = "https://github.com/tsukumijima/ariblib/releases/download/v0.1.4/ariblib-0.1.4-py3-none-any.whl"} 12 | gradio = "^4.21.0" 13 | pydantic = "^2.6.4" 14 | jsonlines = "^4.0.0" 15 | rich = "^13.7.1" 16 | typer = "^0.9.0" 17 | typing-extensions = "^4.10.0" 18 | 19 | [build-system] 20 | requires = ["poetry-core"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // JSONL が見やすくなるように折り返しを無効化 3 | "editor.wordWrap": "off", 4 | // Pylance の Type Checking を有効化 5 | "python.languageServer": "Pylance", 6 | "python.analysis.typeCheckingMode": "strict", 7 | // Pylance の Type Checking のうち、いくつかのエラー報告を抑制する 8 | "python.analysis.diagnosticSeverityOverrides": { 9 | "reportConstantRedefinition": "none", 10 | "reportMissingTypeStubs": "none", 11 | "reportPrivateImportUsage": "none", 12 | "reportShadowedImports": "none", 13 | "reportUnnecessaryComparison": "none", 14 | "reportUnknownArgumentType": "none", 15 | "reportUnknownMemberType": "none", 16 | "reportUnknownVariableType": "none", 17 | "reportUnusedFunction": "none", 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 tsukumi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /utils/constants.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import datetime 3 | from pydantic import BaseModel 4 | 5 | from utils.edcb import EventInfo 6 | 7 | 8 | class EPGDataset(BaseModel): 9 | id: str 10 | network_id: int 11 | service_id: int 12 | transport_stream_id: int 13 | event_id: int 14 | start_time: datetime 15 | duration: int 16 | title: str 17 | title_without_symbols: str 18 | description: str 19 | description_without_symbols: str 20 | major_genre_id: int 21 | middle_genre_id: int 22 | raw: EventInfo 23 | 24 | 25 | class EPGDatasetSubset(BaseModel): 26 | id: str 27 | network_id: int 28 | service_id: int 29 | transport_stream_id: int 30 | event_id: int 31 | start_time: str 32 | duration: int 33 | title: str 34 | title_without_symbols: str 35 | description: str 36 | description_without_symbols: str 37 | major_genre_id: int 38 | middle_genre_id: int 39 | # ここから下は後で自動 or 人力で追加するフィールド 40 | series_title: str = '' 41 | episode_number: str | None = None 42 | subtitle: str | None = None 43 | 44 | class EPGDatasetSubsetInternal(EPGDatasetSubset): 45 | weight: float = 1.0 # 内部でのみ使用 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # PyInstaller 14 | # Usually these files are written by a python script from a template 15 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 16 | *.manifest 17 | *.spec 18 | 19 | # Installer logs 20 | pip-log.txt 21 | pip-delete-this-directory.txt 22 | 23 | # Unit test / coverage reports 24 | htmlcov/ 25 | .tox/ 26 | .nox/ 27 | .coverage 28 | .coverage.* 29 | .cache 30 | nosetests.xml 31 | coverage.xml 32 | *.cover 33 | *.py,cover 34 | .hypothesis/ 35 | .pytest_cache/ 36 | cover/ 37 | 38 | # Translations 39 | *.mo 40 | *.pot 41 | 42 | # Django stuff: 43 | *.log 44 | local_settings.py 45 | db.sqlite3 46 | db.sqlite3-journal 47 | 48 | # Flask stuff: 49 | instance/ 50 | .webassets-cache 51 | 52 | # Scrapy stuff: 53 | .scrapy 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | .pybuilder/ 60 | target/ 61 | 62 | # Jupyter Notebook 63 | .ipynb_checkpoints 64 | 65 | # IPython 66 | profile_default/ 67 | ipython_config.py 68 | 69 | # pyenv 70 | # For a library or package, you might want to ignore these files since the code is 71 | # intended to run in multiple environments; otherwise, check them in: 72 | # .python-version 73 | 74 | # pipenv 75 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 76 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 77 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 78 | # install all needed dependencies. 79 | #Pipfile.lock 80 | 81 | # poetry 82 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 83 | # This is especially recommended for binary packages to ensure reproducibility, and is more 84 | # commonly ignored for libraries. 85 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 86 | #poetry.lock 87 | 88 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 89 | __pypackages__/ 90 | 91 | # Celery stuff 92 | celerybeat-schedule 93 | celerybeat.pid 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | # pytype static type analyzer 126 | .pytype/ 127 | 128 | # Cython debug symbols 129 | cython_debug/ 130 | 131 | # PyCharm 132 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 133 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 134 | # and can be added to the global gitignore or merged into this file. For a more nuclear 135 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 136 | #.idea/ 137 | 138 | # End of https://www.toptal.com/developers/gitignore/api/python 139 | 140 | # macOS 141 | .DS_Store 142 | ._* 143 | 144 | *.jsonl 145 | -------------------------------------------------------------------------------- /03-AnnotateEPGDatasetSubset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import gradio 4 | import jsonlines 5 | import typer 6 | from pathlib import Path 7 | from typing import Annotated 8 | 9 | from utils.constants import EPGDatasetSubset 10 | 11 | 12 | app = typer.Typer() 13 | 14 | @app.command() 15 | def main( 16 | subset_path: Annotated[Path, typer.Option(help='アノテーションを付加するデータセットのサブセットのパス。', dir_okay=False)] = Path('epg_dataset_subset.jsonl'), 17 | start_index: Annotated[int, typer.Option(help='アノテーションを開始するインデックス。', show_default=True)] = 0, 18 | ): 19 | """ 20 | EPG データセットのサブセットにシリーズタイトル・話数・サブタイトルのアノテーションを付加するための Web UI ツール。 21 | 22 | アノテーション方針: 23 | - シリーズタイトル: 連続して放送されている番組のシリーズタイトルを入力 24 | - 話数: 話数が番組情報に含まれている場合のみ入力、複数話ある場合は ・ (中点) で区切る 25 | - 表現は極力変更してはならない (「第1話」とあるなら 1 に正規化せずにそのまま入力すること) 26 | - 番組概要に含まれている話数の方が詳細な場合は、番組概要の方の話数表現を採用する 27 | - サブタイトル: サブタイトルが番組情報に含まれている場合のみ入力、複数話ある場合は / (全角スラッシュ) で区切る 28 | - 基本鉤括弧は除去すべきだが、墨付きカッコで囲まれている場合のみそのまま入力すること 29 | - サブタイトルが番組概要に含まれている場合は、番組概要の方のサブタイトル表現を採用する 30 | """ 31 | 32 | if not subset_path.exists(): 33 | print(f'ファイル {subset_path} は存在しません。') 34 | return 35 | 36 | typer.echo('=' * 80) 37 | print('ロード中...') 38 | subsets: list[EPGDatasetSubset] = [] 39 | with jsonlines.open(subset_path, 'r') as reader: 40 | for obj in reader: 41 | subsets.append(EPGDatasetSubset.model_validate(obj)) 42 | print(f'ロード完了: {len(subsets)} 件') 43 | typer.echo('=' * 80) 44 | 45 | # 現在処理中の EPG データサブセットのインデックス 46 | current_index = start_index 47 | 48 | def OnClick( 49 | id: str, 50 | title_without_symbols: str, 51 | description_without_symbols: str, 52 | series_title: str, 53 | episode_number: str, 54 | subtitle: str, 55 | ) -> tuple[gradio.Textbox, gradio.Textbox, gradio.Textbox, gradio.Textbox, gradio.Textbox, gradio.Textbox]: 56 | """ 確定ボタンが押されたときの処理 """ 57 | 58 | nonlocal current_index, subsets 59 | 60 | # 初期画面から「確定」を押して実行されたイベントなので、保存処理は実行しない 61 | if id == '確定ボタンを押して、データセット作成を開始してください。': 62 | typer.echo('=' * 80) 63 | typer.echo('Selection of segment files has started.') 64 | typer.echo('=' * 80) 65 | 66 | # サブセットのアノテーションを更新 67 | elif current_index < len(subsets): 68 | 69 | # サブセットのアノテーションを更新 70 | subsets[current_index].title_without_symbols = title_without_symbols.strip() 71 | subsets[current_index].description_without_symbols = description_without_symbols.strip() 72 | subsets[current_index].series_title = series_title.strip() 73 | subsets[current_index].episode_number = episode_number.strip() 74 | if subsets[current_index].episode_number == '': 75 | subsets[current_index].episode_number = None 76 | subsets[current_index].subtitle = subtitle.strip() 77 | if subsets[current_index].subtitle == '': 78 | subsets[current_index].subtitle = None 79 | 80 | print(f'番組タイトル: {subsets[current_index].title_without_symbols}') 81 | print(f'番組概要: {subsets[current_index].description_without_symbols}') 82 | typer.echo('-' * 80) 83 | print(f'シリーズタイトル: {subsets[current_index].series_title}') 84 | print(f'話数: {subsets[current_index].episode_number} / サブタイトル: {subsets[current_index].subtitle}') 85 | typer.echo('-' * 80) 86 | print(f'残りデータ数: {len(subsets) - current_index - 1}') 87 | typer.echo('=' * 80) 88 | 89 | # ファイルに保存 90 | with jsonlines.open(subset_path, 'w', flush=True) as writer: 91 | for subset in subsets: 92 | writer.write(subset.model_dump(mode='json')) 93 | 94 | # 次の処理対象のファイルのインデックスに進める 95 | current_index += 1 96 | 97 | # 次の処理対象のファイルがない場合は終了 98 | if current_index >= len(subsets): 99 | typer.echo('=' * 80) 100 | typer.echo('All files processed.') 101 | typer.echo('=' * 80) 102 | return ( 103 | gradio.Textbox(value='アノテーションをすべて完了しました!プロセスを終了してください。', label='ID (読み取り専用)', interactive=False), 104 | gradio.Textbox(value='', label='番組タイトル (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True), 105 | gradio.Textbox(value='', label='番組概要 (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True), 106 | gradio.Textbox(value='', label='シリーズタイトル', interactive=True), 107 | gradio.Textbox(value='', label='話数 (該当情報がない場合は空欄)', interactive=True), 108 | gradio.Textbox(value='', label='サブタイトル (該当情報がない場合は空欄)', interactive=True), 109 | ) 110 | 111 | # UI を更新 112 | return ( 113 | gradio.Textbox(value=subsets[current_index].id, label='ID (読み取り専用)', interactive=False), 114 | gradio.Textbox(value=subsets[current_index].title_without_symbols, label='番組タイトル (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True), 115 | gradio.Textbox(value=subsets[current_index].description_without_symbols, label='番組概要 (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True), 116 | gradio.Textbox(value=subsets[current_index].title_without_symbols, label='シリーズタイトル', interactive=True), 117 | gradio.Textbox(value=subsets[current_index].title_without_symbols, label='話数 (該当情報がない場合は空欄)', interactive=True), 118 | gradio.Textbox(value=subsets[current_index].title_without_symbols, label='サブタイトル (該当情報がない場合は空欄)', interactive=True), 119 | ) 120 | 121 | # Gradio UI の定義と起動 122 | with gradio.Blocks(css='.gradio-container { max-width: 768px !important; }') as gui: 123 | with gradio.Column(): 124 | gradio.Markdown(""" 125 | # EPG データセットサブセットアノテーションツール 126 | Tab キー / Shift + Tab キー を押すと、フォームやボタン間で素早くフォーカスを移動できます。 127 | """) 128 | id_box = gradio.Textbox(value='確定ボタンを押して、データセット作成を開始してください。', label='ID (読み取り専用)', interactive=False) 129 | title_box = gradio.Textbox(value='', label='番組タイトル (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True) 130 | description_box = gradio.Textbox(value='', label='番組概要 (明確に番組枠名や記号の除去に失敗している場合のみ編集可)', interactive=True) 131 | series_title_box = gradio.Textbox(value='', label='シリーズタイトル', interactive=True) 132 | episode_number_box = gradio.Textbox(value='', label='話数 (該当情報がない場合は空欄)', interactive=True) 133 | subtitle_box = gradio.Textbox(value='', label='サブタイトル (該当情報がない場合は空欄)', interactive=True) 134 | with gradio.Row(): 135 | confirm_button = gradio.Button('確定', variant='primary') 136 | confirm_button.click( 137 | fn = OnClick, 138 | inputs = [ 139 | id_box, 140 | title_box, 141 | description_box, 142 | series_title_box, 143 | episode_number_box, 144 | subtitle_box, 145 | ], 146 | outputs = [ 147 | id_box, 148 | title_box, 149 | description_box, 150 | series_title_box, 151 | episode_number_box, 152 | subtitle_box, 153 | ], 154 | ) 155 | 156 | # 0.0.0.0:7860 で Gradio UI を起動 157 | gui.launch(server_name='0.0.0.0', server_port=7860) 158 | 159 | 160 | if __name__ == '__main__': 161 | app() 162 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # EPGDatasetGenerator 3 | 4 | 手元の [EDCB](https://github.com/xtne6f/EDCB) (EpgTimerSrv) に保存されている過去の EPG データ (一部は将来の EPG データ) を抽出し、機械学習向けデータセットとして保存・加工するツール群です。 5 | 番組情報データセットは JSONL (JSON Lines) 形式で保存されます。 6 | 7 | ## Requirements 8 | 9 | - Python 3.11 + Poetry 10 | - EDCB (EpgTimerSrv) がローカルネットワーク上の PC で稼働している 11 | - EDCB は xtne6f 版 or その派生の近年のバージョンのものを使用している 12 | - 事前に EDCB (EpgTimerSrv) の設定で EpgTimerNW (ネットワーク接続機能: ポート 4510) が有効になっている 13 | - 事前に EDCB (EpgTimerSrv) の設定で過去の EPG データを全期間 (∞) 保存するように設定してある 14 | - `EDCB\Setting\EpgArc2\` フォルダに過去の EPG データ (*.dat) が保存されている 15 | 16 | ## Usage 17 | 18 | EPGDatasetGenerator は3つのツールに分かれています。 19 | それぞれのツールの詳細な使い方は以下の通りです。基本的に順番に実行していくことを想定しています。 20 | 21 | ## 01-GenerateEPGDataset.py 22 | 23 | ```bash 24 | > poetry run ./01-GenerateEPGDataset.py --help 25 | 26 | Usage: 01-GenerateEPGDataset.py [OPTIONS] 27 | 28 | EDCB (EpgTimerSrv) に保存されている過去の EPG データを期間やネットワーク ID を指定して抽出し、JSONL 形式のデータセットを生成する。 29 | 30 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ 31 | │ --dataset-path PATH 保存先の JSONL ファイルのパス。 │ 32 | │ [default: epg_dataset.jsonl] │ 33 | │ --edcb-host TEXT ネットワーク接続する EDCB │ 34 | │ のホスト名。 │ 35 | │ [default: 127.0.0.1] │ 36 | │ --start-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-% 過去 EPG データの取得開始日時 │ 37 | │ m-%d %H:%M:%S] (UTC+9) 。 │ 38 | │ [default: 2024-03-17 │ 39 | │ 07:18:43.622855] │ 40 | │ --end-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-% 過去 EPG データの取得終了日時 │ 41 | │ m-%d %H:%M:%S] (UTC+9)。 │ 42 | │ [default: 2024-03-18 │ 43 | │ 07:18:43.622865] │ 44 | │ --include-network-ids INTEGER 取得対象のネットワーク ID │ 45 | │ のリスト。 │ 46 | │ [default: 4, 6, 7, 32736, 32737, │ 47 | │ 32738, 32741, 32739, 32742, │ 48 | │ 32740, 32391] │ 49 | │ --install-completion Install completion for the │ 50 | │ current shell. │ 51 | │ --show-completion Show completion for the current │ 52 | │ shell, to copy it or customize │ 53 | │ the installation. │ 54 | │ --help Show this message and exit. │ 55 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 56 | ``` 57 | 58 | ## 02-GenerateEPGDatasetSubset.py 59 | 60 | ```bash 61 | > poetry run ./02-GenerateEPGDatasetSubset.py --help 62 | 63 | Usage: 02-GenerateEPGDatasetSubset.py [OPTIONS] 64 | 65 | JSONL 形式の EPG データセットのサブセットを期間やサイズを指定して生成する。 66 | 動作ロジック: 67 | - 地上波: 65%、BS (無料放送): 25%、BS (有料放送) + CS: 10% とする 68 | - 重複している番組は除外する 69 | - ショッピング番組は除外する 70 | - 不明なジャンル ID の番組は除外する 71 | - ジャンル自体が EPG データに含まれていない番組は除外する 72 | - タイトルが空文字列の番組は除外する 73 | - 重み付けされたデータを適切にサンプリングして、subset_size で指定されたサイズのサブセットを生成する 74 | - 大元の JSONL データの各行には "raw" という EDCB から取得した生データの辞書が含まれているが、サブセットでは利用しないので除外する 75 | - 最終的に ID でソートされた JSONL データが生成される 76 | 77 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ 78 | │ --dataset-path FILE データ元の JSONL │ 79 | │ データセットのパス。 │ 80 | │ [default: epg_dataset.jsonl] │ 81 | │ --subset-path FILE 生成するデータセットのサブセット … │ 82 | │ [default: │ 83 | │ epg_dataset_subset.jsonl] │ 84 | │ --subset-size INTEGER 生成するデータセットのサブセット … │ 85 | │ [default: 5000] │ 86 | │ --start-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-% サブセットとして抽出する番組範囲 … │ 87 | │ m-%d %H:%M:%S] [default: None] │ 88 | │ --end-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-% サブセットとして抽出する番組範囲 … │ 89 | │ m-%d %H:%M:%S] [default: None] │ 90 | │ --install-completion Install completion for the current │ 91 | │ shell. │ 92 | │ --show-completion Show completion for the current │ 93 | │ shell, to copy it or customize the │ 94 | │ installation. │ 95 | │ --help Show this message and exit. │ 96 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 97 | ``` 98 | 99 | ## 03-AnnotateEPGDatasetSubset.py 100 | 101 | ```bash 102 | > poetry run ./03-AnnotateEPGDatasetSubset.py --help 103 | 104 | Usage: 03-AnnotateEPGDatasetSubset.py [OPTIONS] 105 | 106 | EPG データセットのサブセットにシリーズタイトル・話数・サブタイトルのアノテーションを付加するための Web UI ツール。 107 | アノテーション方針: 108 | - シリーズタイトル: 連続して放送されている番組のシリーズタイトルを入力 109 | - 話数: 話数が番組情報に含まれている場合のみ入力、複数話ある場合は ・ (中点) で区切る 110 | - 表現は極力変更してはならない (「第1話」とあるなら 1 に正規化せずにそのまま入力すること) 111 | - 番組概要に含まれている話数の方が詳細な場合は、番組概要の方の話数表現を採用する 112 | - サブタイトル: サブタイトルが番組情報に含まれている場合のみ入力、複数話ある場合は / (全角スラッシュ) で区切る 113 | - 基本鉤括弧は除去すべきだが、墨付きカッコで囲まれている場合のみそのまま入力すること 114 | - サブタイトルが番組概要に含まれている場合は、番組概要の方のサブタイトル表現を採用する 115 | 116 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ 117 | │ --subset-path FILE アノテーションを付加するデータセットのサブセットのパス。 │ 118 | │ [default: epg_dataset_subset.jsonl] │ 119 | │ --start-index INTEGER アノテーションを開始するインデックス。 [default: 0] │ 120 | │ --install-completion Install completion for the current shell. │ 121 | │ --show-completion Show completion for the current shell, to copy it or │ 122 | │ customize the installation. │ 123 | │ --help Show this message and exit. │ 124 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 125 | ``` 126 | 127 | ## License 128 | 129 | [MIT License](License.txt) 130 | -------------------------------------------------------------------------------- /01-GenerateEPGDataset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import asyncio 4 | import jsonlines 5 | import time 6 | import typer 7 | from datetime import datetime, timedelta 8 | from pathlib import Path 9 | from typing import Annotated 10 | 11 | from utils.constants import EPGDataset 12 | from utils.edcb import CtrlCmdUtil, EDCBUtil, ServiceEventInfo 13 | from utils.epg import FormatString, RemoveSymbols 14 | 15 | 16 | DEFAULT_INCLUDE_NETWORK_IDS = [ 17 | 0x0004, # BS 18 | 0x0006, # CS1 19 | 0x0007, # CS2 20 | 32736, # NHK総合1・東京 21 | 32737, # NHKEテレ1東京 22 | 32738, # 日テレ 23 | 32741, # テレビ朝日 24 | 32739, # TBS 25 | 32742, # テレビ東京 26 | 32740, # フジテレビ 27 | 32391, # TOKYO MX 28 | ] 29 | 30 | app = typer.Typer() 31 | 32 | @app.command() 33 | def main( 34 | dataset_path: Annotated[Path, typer.Option(help='保存先の JSONL ファイルのパス。')] = Path('epg_dataset.jsonl'), 35 | edcb_host: Annotated[str, typer.Option(help='ネットワーク接続する EDCB のホスト名。')] = '127.0.0.1', 36 | start_date: Annotated[datetime, typer.Option(help='過去 EPG データの取得開始日時 (UTC+9) 。')] = datetime.now() - timedelta(days=1), 37 | end_date: Annotated[datetime, typer.Option(help='過去 EPG データの取得終了日時 (UTC+9)。')] = datetime.now(), 38 | include_network_ids: Annotated[list[int], typer.Option(help='取得対象のネットワーク ID のリスト。', show_default=True)] = DEFAULT_INCLUDE_NETWORK_IDS, 39 | ): 40 | """ 41 | EDCB (EpgTimerSrv) に保存されている過去の EPG データを期間やネットワーク ID を指定して抽出し、JSONL 形式のデータセットを生成する。 42 | """ 43 | 44 | # 既にファイルが存在している場合は終了 45 | if dataset_path.exists(): 46 | print(f'ファイル {dataset_path} は既に存在しています。') 47 | return 48 | 49 | start_time = time.time() 50 | 51 | # tzinfo が None ならば JST に変換 52 | ## この時入力値は常に UTC+9 なので、astimezone() ではなく replace を使う 53 | if start_date.tzinfo is None: 54 | start_date = start_date.replace(tzinfo=CtrlCmdUtil.TZ) 55 | if end_date.tzinfo is None: 56 | end_date = end_date.replace(tzinfo=CtrlCmdUtil.TZ) 57 | print(f'過去 EPG データの取得開始日時: {start_date}') 58 | print(f'過去 EPG データの取得終了日時: {end_date}') 59 | 60 | # CtrlCmdUtil インスタンスを生成 61 | edcb = CtrlCmdUtil() 62 | edcb.setNWSetting(edcb_host, 4510) 63 | edcb.setConnectTimeOutSec(60) # かなり時間かかることも見据えて長めに設定 64 | 65 | # 重複する番組を除外するためのセット 66 | unique_set = set() 67 | 68 | # 古い日付から EPG データを随時 JSONL ファイルに保存 69 | with jsonlines.open(dataset_path, mode='w') as writer: 70 | 71 | # 1 週間ごとに EDCB から過去の EPG データを取得 72 | ## sendEnumPgArc は 1 回のリクエストで取得できるデータ量に制限があるため、1 週間ごとに取得する 73 | current_start_date = start_date 74 | while current_start_date < end_date: 75 | current_end_date = current_start_date + timedelta(weeks=1) 76 | if current_end_date > end_date: 77 | current_end_date = end_date 78 | 79 | print(f'取得期間: {current_start_date} ~ {current_end_date}') 80 | service_event_info_list: list[ServiceEventInfo] = [] 81 | 82 | # EDCB から指定期間の EPG データを取得 83 | result: list[ServiceEventInfo] | None = asyncio.run(edcb.sendEnumPgArc([ 84 | # 絞り込み対象のネットワーク ID・トランスポートストリーム ID・サービス ID に掛けるビットマスク (?????) 85 | ## よく分かってないけどとりあえずこれで全番組が対象になる 86 | 0xffffffffffff, 87 | # 絞り込み対象のネットワーク ID・トランスポートストリーム ID・サービス ID 88 | ## (network_id << 32 | transport_stream_id << 16 | service_id) の形式で指定しなければならないらしい 89 | ## よく分かってないけどとりあえずこれで全番組が対象になる 90 | 0xffffffffffff, 91 | # 絞り込み対象の番組開始時刻の最小値 92 | EDCBUtil.datetimeToFileTime(current_start_date, tz=CtrlCmdUtil.TZ), 93 | # 絞り込み対象の番組開始時刻の最大値 (自分自身を含まず、番組「開始」時刻が指定した時刻より前の番組が対象になる) 94 | # たとえば 11:00:00 ならば 10:59:59 までの番組が対象になるし、11:00:01 ならば 11:00:00 までの番組が対象になる 95 | EDCBUtil.datetimeToFileTime(current_end_date, tz=CtrlCmdUtil.TZ), 96 | ])) 97 | if result is None: 98 | print('Warning: 過去 EPG データの取得に失敗しました。') 99 | else: 100 | service_event_info_list.extend(result) 101 | 102 | # もし「現在処理中の」取得終了日時が現在時刻よりも未来の場合、別の API を使って現在時刻以降の EPG データを取得 103 | if current_end_date > datetime.now(tz=CtrlCmdUtil.TZ): 104 | print('取得終了日時が現在時刻よりも未来なので、現在時刻以降の EPG データも取得します。') 105 | result: list[ServiceEventInfo] | None = asyncio.run(edcb.sendEnumPgInfoEx([ 106 | # 絞り込み対象のネットワーク ID・トランスポートストリーム ID・サービス ID に掛けるビットマスク (?????) 107 | ## よく分かってないけどとりあえずこれで全番組が対象になる 108 | 0xffffffffffff, 109 | # 絞り込み対象のネットワーク ID・トランスポートストリーム ID・サービス ID 110 | ## (network_id << 32 | transport_stream_id << 16 | service_id) の形式で指定しなければならないらしい 111 | ## よく分かってないけどとりあえずこれで全番組が対象になる 112 | 0xffffffffffff, 113 | # 絞り込み対象の番組開始時刻の最小値 114 | EDCBUtil.datetimeToFileTime(current_start_date, tz=CtrlCmdUtil.TZ), 115 | # 絞り込み対象の番組開始時刻の最大値 (自分自身を含まず、番組「開始」時刻が指定した時刻より前の番組が対象になる) 116 | # たとえば 11:00:00 ならば 10:59:59 までの番組が対象になるし、11:00:01 ならば 11:00:00 までの番組が対象になる 117 | EDCBUtil.datetimeToFileTime(current_end_date, tz=CtrlCmdUtil.TZ), 118 | ])) 119 | if result is None: 120 | print('Warning: 将来 EPG データの取得に失敗しました。') 121 | else: 122 | service_event_info_list.extend(result) 123 | 124 | # EPG データを整形 125 | dataset_list: list[EPGDataset] = [] 126 | for service_event_info in service_event_info_list: 127 | for event_info in service_event_info['event_list']: 128 | 129 | # デジタルTVサービスのみを対象にする 130 | ## ワンセグや独立データ放送は収集対象外 131 | if service_event_info['service_info']['service_type'] != 0x01: 132 | continue 133 | 134 | # 指定したネットワーク ID のみを対象にする 135 | if event_info['onid'] not in include_network_ids: 136 | continue 137 | 138 | # もし start_time or duration_sec or short_info がなければ中途半端な番組情報なのでスキップ 139 | if 'start_time' not in event_info or 'duration_sec' not in event_info or 'short_info' not in event_info: 140 | continue 141 | 142 | # short_info はあるがタイトルが空文字列ならスキップ 143 | if event_info['short_info']['event_name'] == '': 144 | continue 145 | 146 | # ID: 202301011230-NID32736-SID01024-EID00535 のフォーマット 147 | # 最初に番組開始時刻を付けて完全な一意性を担保する 148 | epg_id = f"{event_info['start_time'].strftime('%Y%m%d%H%M')}-NID{event_info['onid']:05d}-SID{event_info['sid']:05d}-EID{event_info['eid']:05d}" 149 | 150 | # 万が一 ID が重複する番組があれば除外 151 | ## EDCB の仕様に不備がなければ基本的にないはず 152 | if epg_id in unique_set: 153 | print(f'Skip: {epg_id}') 154 | continue 155 | unique_set.add(epg_id) 156 | 157 | # 番組タイトルと番組概要を半角に変換 158 | title = FormatString(event_info['short_info']['event_name']) 159 | description = FormatString(event_info['short_info']['text_char']) 160 | 161 | # ジャンルの ID を取得 162 | ## 複数のジャンルが存在する場合、最初のジャンルのみを取得 163 | major_genre_id = -1 164 | middle_genre_id = -1 165 | if 'content_info' in event_info and len(event_info['content_info']['nibble_list']) >= 1: 166 | major_genre_id = event_info['content_info']['nibble_list'][0]['content_nibble'] >> 8 167 | middle_genre_id = event_info['content_info']['nibble_list'][0]['content_nibble'] & 0xf 168 | 169 | dataset_list.append(EPGDataset( 170 | id = epg_id, 171 | network_id = event_info['onid'], 172 | service_id = event_info['sid'], 173 | transport_stream_id = event_info['tsid'], 174 | event_id = event_info['eid'], 175 | title = title, 176 | title_without_symbols = RemoveSymbols(title), 177 | description = description, 178 | description_without_symbols = RemoveSymbols(description), 179 | start_time = event_info['start_time'], 180 | duration = event_info['duration_sec'], 181 | major_genre_id = major_genre_id, 182 | middle_genre_id = middle_genre_id, 183 | raw = event_info, 184 | )) 185 | 186 | # ID 順にソート 187 | dataset_list.sort(key=lambda x: x.id) # type: ignore 188 | 189 | # JSONL ファイルに保存 190 | for dataset in dataset_list: 191 | print(f'Add: {dataset.id}') 192 | writer.write(dataset.model_dump(mode='json')) 193 | 194 | # 次のループのために開始日時を更新 195 | current_start_date = current_end_date 196 | 197 | elapsed_time = time.time() - start_time 198 | print(f'処理時間: {elapsed_time:.2f} 秒') 199 | 200 | 201 | if __name__ == '__main__': 202 | app() 203 | -------------------------------------------------------------------------------- /02-GenerateEPGDatasetSubset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import ariblib.constants 4 | import jsonlines 5 | import random 6 | import time 7 | import typer 8 | from collections import defaultdict 9 | from datetime import datetime 10 | from pathlib import Path 11 | from typing import Annotated, Union 12 | 13 | from utils.constants import EPGDatasetSubset, EPGDatasetSubsetInternal 14 | from utils.edcb import CtrlCmdUtil 15 | 16 | 17 | def is_terrestrial(network_id: int) -> bool: 18 | return 0x7880 <= network_id <= 0x7FE8 19 | 20 | def is_free_bs(network_id: int, service_id: int) -> bool: 21 | return network_id == 0x0004 and not (191 <= service_id <= 209 or 234 <= service_id <= 256) 22 | 23 | def is_paid_bs_cs(network_id: int, service_id: int) -> bool: 24 | return (network_id == 0x0006 or network_id == 0x0007) or (network_id == 0x0004 and (191 <= service_id <= 209 or 234 <= service_id <= 256)) 25 | 26 | def meets_condition(data: EPGDatasetSubset) -> bool: 27 | # ref: https://github.com/youzaka/ariblib/blob/master/ariblib/constants.py 28 | # ショッピング番組は除外 29 | if data.major_genre_id == 0x2 and data.middle_genre_id == 0x4: 30 | return False 31 | # ジャンルIDが不明な番組は除外 32 | if data.major_genre_id >= 0xC: 33 | return False 34 | # ジャンル自体が EPG データに含まれていない場合は除外 35 | if data.major_genre_id == -1 or data.middle_genre_id == -1: 36 | return False 37 | # タイトルが空文字列の番組は除外 38 | if data.title.strip() == '': 39 | return False 40 | return True 41 | 42 | def get_weight(data: EPGDatasetSubset) -> float: 43 | 44 | # 新しい番組ほど重みを大きくする 45 | start_time = datetime.fromisoformat(data.start_time) 46 | start_date = datetime(2019, 10, 1) # 基準日を2019年10月1日に設定 47 | months_diff = (start_time.year - start_date.year) * 12 + start_time.month - start_date.month 48 | months_diff = max(months_diff, 0) # months_diff が負の値になることを防ぐ 49 | weight = months_diff / 60 + 1 # 2019年10月を 1.0 、2024年3月を 2.0 とするための計算 50 | 51 | # 下記は実際の割合に基づいてサブセット化用の重みを調整している 52 | ## 定時ニュース: 基本録画されないので重みを減らす 53 | if data.major_genre_id == 0x0 and data.middle_genre_id == 0x0: 54 | weight *= 0.8 55 | ## ニュース・報道: 地上波で放送されるもののみ若干重みを大きくする 56 | if data.major_genre_id == 0x0 and data.middle_genre_id != 0x0 and is_terrestrial(data.network_id): 57 | weight *= 1.1 58 | ## スポーツ: 地上波で放送されるもののみ若干重みを大きくする 59 | if data.major_genre_id == 0x1 and is_terrestrial(data.network_id): 60 | weight *= 1.75 61 | ## 情報・ワイドショー: まず録画されないので減らす 62 | if data.major_genre_id == 0x2: 63 | weight *= 0.7 64 | ## 国内ドラマ: 放送数がそう多くない割に重要なジャンルなので重みを大きくする (地上波のみ) 65 | if data.major_genre_id == 0x3 and data.middle_genre_id == 0x0 and is_terrestrial(data.network_id): 66 | # 朝4時〜18時に放送される主婦向けの再放送や昼ドラを除いて適用する 67 | if not (4 <= start_time.hour <= 18): 68 | weight *= 3.4 69 | ## 地上波以外 (無料BSなど) の国内ドラマ: 過去の高齢者向け刑事ドラマ系が多すぎるので減らす 70 | if data.major_genre_id == 0x3 and data.middle_genre_id == 0x0 and not is_terrestrial(data.network_id): 71 | weight *= 0.25 72 | ## 海外ドラマ: 高齢者しか見ない割に多すぎるので全体的に減らす 73 | if data.major_genre_id == 0x3 and data.middle_genre_id == 0x1: 74 | weight *= 0.25 75 | ## バラエティ: 地上波で放送されるもののみ若干重みを大きくする 76 | if data.major_genre_id == 0x5 and is_terrestrial(data.network_id): 77 | weight *= 1.1 78 | ## 映画: 数が少ない割に重要なジャンルなので重みを大きくする (地上波、無料BSのみ) 79 | if data.major_genre_id == 0x6 and (is_terrestrial(data.network_id) or is_free_bs(data.network_id, data.service_id)): 80 | weight *= 2.2 81 | # 特にアニメ映画は少ない割に重要なので重みをさらに大きくする 82 | if data.middle_genre_id == 0x2: 83 | weight *= 5.0 84 | ## 国内アニメ: 重要なジャンルなので重みを大きくする (地上波、無料BSのみ) 85 | if data.major_genre_id == 0x7 and data.middle_genre_id == 0x0 and (is_terrestrial(data.network_id) or is_free_bs(data.network_id, data.service_id)): 86 | # 朝4時〜21時に放送されるアニメを除いて適用する (つまり深夜アニメのみ) 87 | if not (4 <= start_time.hour <= 21): 88 | weight *= 2.2 89 | ## ドキュメンタリー・教養: 地上波で放送されるもののみ若干重みを大きくする 90 | if data.major_genre_id == 0x8 and is_terrestrial(data.network_id): 91 | weight *= 1.2 92 | ## 趣味・教育: 見る人が少ないので若干減らす 93 | if data.major_genre_id == 0xA: 94 | weight *= 0.8 95 | ## AT-X のアニメ: 例外的に少し重みを大きくする 96 | if data.network_id == 0x0007 and data.service_id == 333 and data.major_genre_id == 0x7: 97 | weight *= 1.3 98 | ## 「NHKスペシャル」がタイトルに入ってる番組: 数は少ないが重要なので重みを大きくする 99 | if 'NHKスペシャル' in data.title: 100 | weight *= 3.5 101 | ## 「大河ドラマ」がタイトルに入ってる番組: 数は少ないが重要なので重みを大きくする 102 | if '大河ドラマ' in data.title and 'min.' not in data.title: 103 | weight *= 3.5 104 | 105 | return weight 106 | 107 | 108 | app = typer.Typer() 109 | 110 | @app.command() 111 | def main( 112 | dataset_path: Annotated[Path, typer.Option(help='データ元の JSONL データセットのパス。', exists=True, file_okay=True, dir_okay=False)] = Path('epg_dataset.jsonl'), 113 | subset_path: Annotated[Path, typer.Option(help='生成するデータセットのサブセットのパス。', dir_okay=False)] = Path('epg_dataset_subset.jsonl'), 114 | subset_size: Annotated[int, typer.Option(help='生成するデータセットのサブセットのサイズ')] = 5000, 115 | start_date: Annotated[Union[datetime, None], typer.Option(help='サブセットとして抽出する番組範囲の開始日時。')] = None, 116 | end_date: Annotated[Union[datetime, None], typer.Option(help='サブセットとして抽出する番組範囲の終了日時。')] = None, 117 | ): 118 | """ 119 | JSONL 形式の EPG データセットのサブセットを期間やサイズを指定して生成する。 120 | 121 | 動作ロジック: 122 | - 地上波: 65%、BS (無料放送): 25%、BS (有料放送) + CS: 10% とする 123 | - 重複している番組は除外する 124 | - ショッピング番組は除外する 125 | - 不明なジャンル ID の番組は除外する 126 | - ジャンル自体が EPG データに含まれていない番組は除外する 127 | - タイトルが空文字列の番組は除外する 128 | - 重み付けされたデータを適切にサンプリングして、subset_size で指定されたサイズのサブセットを生成する 129 | - 大元の JSONL データの各行には "raw" という EDCB から取得した生データの辞書が含まれているが、サブセットでは利用しないので除外する 130 | - 最終的に ID でソートされた JSONL データが生成される 131 | """ 132 | 133 | TERRESTRIAL_PERCENTAGE = 0.65 134 | FREE_BS_PERCENTAGE = 0.25 135 | PAID_BS_CS_PERCENTAGE = 0.10 136 | 137 | if subset_path.exists(): 138 | print(f'ファイル {subset_path} は既に存在しています。') 139 | return 140 | 141 | # tzinfo が None ならば JST に変換 142 | ## この時入力値は常に UTC+9 なので、astimezone() ではなく replace を使う 143 | if start_date is not None and start_date.tzinfo is None: 144 | start_date = start_date.replace(tzinfo=CtrlCmdUtil.TZ) 145 | if end_date is not None and end_date.tzinfo is None: 146 | end_date = end_date.replace(tzinfo=CtrlCmdUtil.TZ) 147 | print(f'サブセットとして抽出する番組範囲の開始日時: {start_date}') 148 | print(f'サブセットとして抽出する番組範囲の終了日時: {end_date}') 149 | 150 | start_time = time.time() 151 | 152 | all_epg_count = 0 # 重複している番組も含めた全データセットの件数 153 | all_epg_data: list[EPGDatasetSubsetInternal] = [] 154 | terrestrial_data: list[EPGDatasetSubsetInternal] = [] 155 | free_bs_data: list[EPGDatasetSubsetInternal] = [] 156 | paid_bs_cs_data: list[EPGDatasetSubsetInternal] = [] 157 | unique_keys = set() 158 | 159 | with jsonlines.open(dataset_path, 'r') as reader: 160 | for obj in reader: 161 | all_epg_count += 1 162 | data = EPGDatasetSubsetInternal.model_validate(obj) 163 | if meets_condition(data) is False: 164 | print(f'Skipping (condition not met): {data.id}') 165 | continue 166 | if start_date is not None and datetime.fromisoformat(data.start_time) < start_date: 167 | print(f'Skipping (before start date): {data.id}') 168 | continue 169 | if end_date is not None and datetime.fromisoformat(data.start_time) > end_date: 170 | print(f'Skipping (after end date): {data.id}') 171 | continue 172 | # 一意キーを作成 173 | unique_key = (data.title, data.description) 174 | if unique_key in unique_keys: 175 | print(f'Skipping (duplicate): {data.id}') 176 | continue 177 | unique_keys.add(unique_key) 178 | print(f'Processing: {data.id}') 179 | data.weight = get_weight(data) 180 | all_epg_data.append(data) 181 | if is_terrestrial(data.network_id): 182 | terrestrial_data.append(data) 183 | elif is_free_bs(data.network_id, data.service_id): 184 | free_bs_data.append(data) 185 | elif is_paid_bs_cs(data.network_id, data.service_id): 186 | paid_bs_cs_data.append(data) 187 | 188 | print('-' * 80) 189 | print(f'データセットに含まれる番組数: {all_epg_count}') 190 | print(f'重複を除いた番組数: {len(unique_keys)}') 191 | 192 | def sample_data(data_list: list[EPGDatasetSubsetInternal], target_size: int) -> list[EPGDatasetSubsetInternal]: 193 | sampled_data = [] 194 | selected_indices = set() 195 | data_list_length = len(data_list) 196 | for _ in range(target_size): 197 | if len(selected_indices) >= data_list_length: # すべての要素が選択された場合、ループを抜ける 198 | break 199 | # 選択されていない要素の重みの合計を計算 200 | available_weights = [data.weight if index not in selected_indices else 0 for index, data in enumerate(data_list)] 201 | total_weight = sum(available_weights) 202 | if total_weight == 0: # すべての要素の重みが0になった場合、ループを抜ける 203 | break 204 | # 重みに基づいて要素をランダムに選択 205 | chosen_index = random.choices(range(data_list_length), weights=available_weights, k=1)[0] 206 | selected_indices.add(chosen_index) 207 | sampled_data.append(data_list[chosen_index]) 208 | return sampled_data 209 | 210 | subset_size_terrestrial = int(subset_size * TERRESTRIAL_PERCENTAGE) 211 | subset_size_free_bs = int(subset_size * FREE_BS_PERCENTAGE) 212 | subset_size_paid_bs_cs = int(subset_size * PAID_BS_CS_PERCENTAGE) 213 | 214 | subsets: list[EPGDatasetSubsetInternal] = [] 215 | subsets += sample_data(terrestrial_data, subset_size_terrestrial) 216 | subsets += sample_data(free_bs_data, subset_size_free_bs) 217 | subsets += sample_data(paid_bs_cs_data, subset_size_paid_bs_cs) 218 | 219 | # ID でソート 220 | subsets.sort(key=lambda x: x.id) 221 | 222 | # 万が一 ID が重複している場合は警告を出して当該番組を除外 223 | unique_ids = set() 224 | for subset in subsets: 225 | if subset.id in unique_ids: 226 | print(f'Warning: ID が重複しています: {subset.id}') 227 | subsets.remove(subset) 228 | unique_ids.add(subset.id) 229 | 230 | # 最終的なサブセットデータセットの割合を月ごと、チャンネル種別ごと、ジャンルごとに確認 231 | channel_counts = defaultdict(int) 232 | year_counts = defaultdict(int) 233 | month_counts = defaultdict(int) 234 | major_genre_counts = defaultdict(int) 235 | middle_genre_counts = defaultdict(int) 236 | for data in subsets: 237 | if is_terrestrial(data.network_id): 238 | channel_counts['terrestrial'] += 1 239 | elif is_free_bs(data.network_id, data.service_id): 240 | channel_counts['free_bs'] += 1 241 | elif is_paid_bs_cs(data.network_id, data.service_id): 242 | channel_counts['paid_bs_cs'] += 1 243 | year_counts[datetime.fromisoformat(data.start_time).year] += 1 244 | month_counts[datetime.fromisoformat(data.start_time).strftime('%Y-%m')] += 1 245 | major_genre_counts[data.major_genre_id] += 1 246 | middle_genre_counts[(data.major_genre_id, data.middle_genre_id)] += 1 247 | 248 | total_count = len(subsets) 249 | print('-' * 80) 250 | print(f'サブセットの総件数: {total_count}') 251 | 252 | # チャンネル種別ごとの割合を表示 253 | print('-' * 80) 254 | print(f'地上波: {channel_counts["terrestrial"]: >4} 件 ({channel_counts["terrestrial"] / total_count * 100:.2f}%)') 255 | print(f'BS (無料放送): {channel_counts["free_bs"]: >4} 件 ({channel_counts["free_bs"] / total_count * 100:.2f}%)') 256 | print(f'BS (有料放送) & CS: {channel_counts["paid_bs_cs"]: >4} 件 ({channel_counts["paid_bs_cs"] / total_count * 100:.2f}%)') 257 | 258 | # 年ごとの割合を表示 259 | print('-' * 80) 260 | print('年ごとの割合:') 261 | for year, count in sorted(year_counts.items()): 262 | print(f' {year}: {count: >4} 件 ({count / total_count * 100:.2f}%)') 263 | 264 | # 月ごとの割合を表示 265 | print('-' * 80) 266 | print('月ごとの割合:') 267 | for month, count in sorted(month_counts.items()): 268 | print(f' {month}: {count: >4} 件 ({count / total_count * 100:.2f}%)') 269 | 270 | print('-' * 80) 271 | print('大分類ジャンルごとの割合:') 272 | for major_genre, count in sorted(major_genre_counts.items()): 273 | print(f' {ariblib.constants.CONTENT_TYPE[major_genre][0]}: {count: >4} 件 ({count / total_count * 100:.2f}%)') 274 | 275 | print('-' * 80) 276 | print('中分類ジャンルごとの割合:') 277 | for genre, count in sorted(middle_genre_counts.items()): 278 | print(f' {ariblib.constants.CONTENT_TYPE[genre[0]][0]} - {ariblib.constants.CONTENT_TYPE[genre[0]][1][genre[1]]}: {count: >4} 件 ({count / total_count * 100:.2f}%)') 279 | 280 | print('-' * 80) 281 | print(f'{subset_path} に書き込んでいます...') 282 | with jsonlines.open(subset_path, 'w') as writer: 283 | for subset in subsets: 284 | writer.write(subset.model_dump(mode='json', exclude={'weight'})) # weight は出力しない 285 | 286 | elapsed_time = time.time() - start_time 287 | print(f'処理時間: {elapsed_time:.2f} 秒') 288 | print('-' * 80) 289 | 290 | if __name__ == '__main__': 291 | app() 292 | -------------------------------------------------------------------------------- /utils/epg.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | from typing import cast 4 | 5 | 6 | # 変換マップ 7 | __format_string_translation_map: dict[int, str] | None = None 8 | __format_string_regex: re.Pattern[str] | None = None 9 | __enclosed_characters_translation_map: dict[int, str] | None = None 10 | 11 | 12 | def FormatString(string: str) -> str: 13 | """ 14 | 文字列に含まれる英数や記号を半角に置換し、一律な表現に整える 15 | https://github.com/tsukumijima/KonomiTV/blob/master/server/app/utils/TSInformation.py から移植 16 | 17 | Args: 18 | string (str): 文字列 19 | 20 | Returns: 21 | str: 置換した文字列 22 | """ 23 | 24 | global __format_string_translation_map, __format_string_regex 25 | 26 | # 全角英数を半角英数に置換 27 | # ref: https://github.com/ikegami-yukino/jaconv/blob/master/jaconv/conv_table.py 28 | zenkaku_table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 29 | hankaku_table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 30 | merged_table = dict(zip(list(zenkaku_table), list(hankaku_table))) 31 | 32 | # 全角記号を半角記号に置換 33 | symbol_zenkaku_table = '"#$%&'()+,-./:;<=>[\]^_`{|} ' 34 | symbol_hankaku_table = '"#$%&\'()+,-./:;<=>[\\]^_`{|} ' 35 | merged_table.update(zip(list(symbol_zenkaku_table), list(symbol_hankaku_table))) 36 | merged_table.update({ 37 | # 一部の半角記号を全角に置換 38 | # 主に見栄え的な問題(全角の方が字面が良い) 39 | '!': '!', 40 | '?': '?', 41 | '*': '*', 42 | '~': '~', 43 | # シャープ → ハッシュ 44 | '♯': '#', 45 | # 波ダッシュ → 全角チルダ 46 | ## EDCB は ~ を全角チルダとして扱っているため、KonomiTV でもそのように統一する 47 | ## TODO: 番組検索を実装する際は検索文字列の波ダッシュを全角チルダに置換する下処理が必要 48 | ## ref: https://qiita.com/kasei-san/items/3ce2249f0a1c1af1cbd2 49 | '〜': '~', 50 | }) 51 | 52 | # 置換を実行 53 | if __format_string_translation_map is None: 54 | __format_string_translation_map = str.maketrans(merged_table) 55 | result = string.translate(__format_string_translation_map) 56 | 57 | # 逆に代替の文字表現に置換された ARIB 外字を Unicode に置換するテーブル 58 | ## 主に EDCB (EpgDataCap3_Unicode.dll 不使用) 環境向けの処理 59 | ## EDCB は通常 Shift-JIS で表現できない文字をサロゲートペア範囲外の文字も含めてすべて代替の文字表現に変換するが、今回の用途では都合が悪い 60 | ## そこで、逆変換可能 (明確に区別可能な) な文字列表現をすべて対応する Unicode 文字に置換する 61 | ## 英数字を半角に変換した後に適応するのでキー英数字は半角になっている 62 | ## KonomiTV では見栄えの関係で変換していない文字もすべて置換する 63 | ## ちなみに EpgDataCap3_Unicode.dll 不使用状態で保存された過去 EPG データはどうも文字列をデコードされた状態で保存されているようで、 64 | ## 残念ながら後から EpgDataCap3_Unicode.dll に差し替えても Unicode では返ってこない… 65 | ## ref: https://github.com/xtne6f/EDCB/blob/work-plus-s-230526/EpgDataCap3/EpgDataCap3/ARIB8CharDecode.cpp#L1324-L1614 66 | __format_string_regex_table = { 67 | '[HV]': '\U0001f14a', 68 | '[SD]': '\U0001f14c', 69 | '[P]': '\U0001f13f', 70 | '[W]': '\U0001f146', 71 | '[MV]': '\U0001f14b', 72 | '[手]': '\U0001f210', 73 | '[字]': '\U0001f211', 74 | '[双]': '\U0001f212', 75 | '[デ]': '\U0001f213', 76 | '[S]': '\U0001f142', 77 | '[二]': '\U0001f214', 78 | '[多]': '\U0001f215', 79 | '[解]': '\U0001f216', 80 | '[SS]': '\U0001f14d', 81 | '[B]': '\U0001f131', 82 | '[N]': '\U0001f13d', 83 | '[天]': '\U0001f217', 84 | '[交]': '\U0001f218', 85 | '[映]': '\U0001f219', 86 | '[無]': '\U0001f21a', 87 | '[料]': '\U0001f21b', 88 | '[・]': '⚿', 89 | '[前]': '\U0001f21c', 90 | '[後]': '\U0001f21d', 91 | '[再]': '\U0001f21e', 92 | '[新]': '\U0001f21f', 93 | '[初]': '\U0001f220', 94 | '[終]': '\U0001f221', 95 | '[生]': '\U0001f222', 96 | '[販]': '\U0001f223', 97 | '[声]': '\U0001f224', 98 | '[吹]': '\U0001f225', 99 | '[PPV]': '\U0001f14e', 100 | '(秘)': '㊙', 101 | '[ほか]': '\U0001f200', 102 | 'm^2': 'm²', 103 | 'm^3': 'm³', 104 | 'cm^2': 'cm²', 105 | 'cm^3': 'cm³', 106 | 'km^2': 'km²', 107 | '[社]': '㈳', 108 | '[財]': '㈶', 109 | '[有]': '㈲', 110 | '[株]': '㈱', 111 | '[代]': '㈹', 112 | '(問)': '㉄', 113 | '^2': '²', 114 | '^3': '³', 115 | '(箏)': '㉇', 116 | '(〒)': '〶', 117 | '()()': '⚾', 118 | } 119 | 120 | # 正規表現で置換 121 | if __format_string_regex is None: 122 | __format_string_regex = re.compile("|".join(map(re.escape, __format_string_regex_table.keys()))) 123 | result = __format_string_regex.sub(lambda match: cast(dict[str, str], __format_string_regex_table)[match.group(0)], result) # type: ignore 124 | 125 | # CRLF を LF に置換 126 | result = result.replace('\r\n', '\n') 127 | 128 | # 置換した文字列を返す 129 | return result 130 | 131 | 132 | def RemoveSymbols(string: str) -> str: 133 | """ 134 | 文字列から囲み文字・記号・番組枠名などのノイズを除去する 135 | 136 | Args: 137 | string (str): 文字列 138 | 139 | Returns: 140 | str: 記号を除去した文字列 141 | """ 142 | 143 | global __enclosed_characters_translation_map 144 | 145 | # 番組表で使用される囲み文字の置換テーブル 146 | ## ref: https://note.nkmk.me/python-chr-ord-unicode-code-point/ 147 | ## ref: https://github.com/l3tnun/EPGStation/blob/v2.6.17/src/util/StrUtil.ts#L7-L46 148 | ## ref: https://github.com/xtne6f/EDCB/blob/work-plus-s-230526/EpgDataCap3/EpgDataCap3/ARIB8CharDecode.cpp#L1324-L1614 149 | enclosed_characters_table = { 150 | '\U0001f14a': '[HV]', 151 | '\U0001f14c': '[SD]', 152 | '\U0001f13f': '[P]', 153 | '\U0001f146': '[W]', 154 | '\U0001f14b': '[MV]', 155 | '\U0001f210': '[手]', 156 | '\U0001f211': '[字]', 157 | '\U0001f212': '[双]', 158 | '\U0001f213': '[デ]', 159 | '\U0001f142': '[S]', 160 | '\U0001f214': '[二]', 161 | '\U0001f215': '[多]', 162 | '\U0001f216': '[解]', 163 | '\U0001f14d': '[SS]', 164 | '\U0001f131': '[B]', 165 | '\U0001f13d': '[N]', 166 | '\U0001f217': '[天]', 167 | '\U0001f218': '[交]', 168 | '\U0001f219': '[映]', 169 | '\U0001f21a': '[無]', 170 | '\U0001f21b': '[料]', 171 | '\U0001f21c': '[前]', 172 | '\U0001f21d': '[後]', 173 | '⚿': '[・]', 174 | '\U0001f21e': '[再]', 175 | '\U0001f21f': '[新]', 176 | '\U0001f220': '[初]', 177 | '\U0001f221': '[終]', 178 | '\U0001f222': '[生]', 179 | '\U0001f223': '[販]', 180 | '\U0001f224': '[声]', 181 | '\U0001f225': '[吹]', 182 | '\U0001f14e': '[PPV]', 183 | } 184 | 185 | # Unicode の囲み文字を大かっこで囲った文字に置換する 186 | # この後の処理で大かっこで囲まれた文字を削除するためのもの 187 | if __enclosed_characters_translation_map is None: 188 | __enclosed_characters_translation_map = str.maketrans(enclosed_characters_table) 189 | result = string.translate(__enclosed_characters_translation_map) 190 | 191 | # [字] [再] などの囲み文字を半角スペースに正規表現で置換する 192 | # 本来 ARIB 外字である記号の一覧 193 | # ref: https://ja.wikipedia.org/wiki/%E7%95%AA%E7%B5%84%E8%A1%A8 194 | # ref: https://github.com/xtne6f/EDCB/blob/work-plus-s/EpgDataCap3/EpgDataCap3/ARIB8CharDecode.cpp#L1319 195 | mark = ('新|終|再|交|映|手|声|多|副|字|文|CC|OP|二|S|B|SS|無|無料|' 196 | 'C|S1|S2|S3|MV|双|デ|D|N|W|P|H|HV|SD|天|解|料|前|後初|生|販|吹|PPV|' 197 | '演|移|他|収|・|英|韓|中|字/日|字/日英|3D|2K|4K|8K|5.1|7.1|22.2|60P|120P|d|HC|HDR|SHV|UHD|VOD|配|初') 198 | pattern1 = re.compile(r'\((二|字|字幕|再|再放送|吹|吹替|無料|無料放送)\)', re.IGNORECASE) # 通常の括弧で囲まれている記号 199 | pattern2 = re.compile(r'\[(' + mark + r')\]', re.IGNORECASE) 200 | pattern3 = re.compile(r'【(' + mark + r')】', re.IGNORECASE) 201 | result = pattern1.sub(' ', result) 202 | result = pattern2.sub(' ', result) 203 | result = pattern3.sub(' ', result) 204 | 205 | # 前後の半角スペースを削除する 206 | result = result.strip() 207 | 208 | # 番組枠名などのノイズを削除する 209 | ## 正規表現でゴリ押し執念の削除を実行……… 210 | ## かなり悩ましかったが、「(字幕版)」はあくまでそういう版であることを示す情報なので削除しないことにした (「【日本語字幕版】」も同様) 211 | result = re.sub(r'※2K放送', '', result) 212 | result = re.sub(r'※字幕スーパー', '', result) 213 | result = re.sub(r'<(HD|SD)>', '', result) 214 | result = re.sub(r'<字幕>', '', result) 215 | result = re.sub(r'<字幕スーパー>', '', result) 216 | result = re.sub(r'<字幕・(レターボックス|スタンダード)サイズ>', '', result) 217 | result = re.sub(r'<字幕スーパー・(レターボックス|スタンダード)サイズ>', '', result) 218 | result = re.sub(r'<(レターボックス|スタンダード)サイズ>', '', result) 219 | result = re.sub(r'<ノーカット字幕>', '', result) 220 | result = re.sub(r'<(初放送|TV初放送|地上波初放送)>', '', result) 221 | result = re.sub(r'\[字幕\]', '', result) 222 | result = re.sub(r'\[字幕スーパー\]', '', result) 223 | result = re.sub(r'〔字幕〕', '', result) 224 | result = re.sub(r'〔字幕スーパー〕', '', result) 225 | result = re.sub(r'【字幕】', '', result) 226 | result = re.sub(r'【字幕スーパー】', '', result) 227 | result = re.sub(r'【無料】', '', result) 228 | result = re.sub(r'【KNTV】', '', result) 229 | result = re.sub(r'【中】', '', result) 230 | result = re.sub(r'【韓】', '', result) 231 | result = re.sub(r'【リクエスト】', '', result) 232 | result = re.sub(r'【解説放送】', '', result) 233 | result = re.sub(r'<独占>', '', result) 234 | result = re.sub(r'【独占】', '', result) 235 | result = re.sub(r'<独占放送>', '', result) 236 | result = re.sub(r'【独占放送】', '', result) 237 | result = re.sub(r'【最新作】', '', result) 238 | result = re.sub(r'【歌詞入り】', '', result) 239 | result = re.sub(r'【.{0,8}ドラマ】', '', result) 240 | result = re.sub(r'【ドラマ.{0,8}】', '', result) 241 | result = re.sub(r'【.{0,8}夜ドラ.{0,8}】', '', result) 242 | result = re.sub(r'【.{0,8}昼ドラ.{0,8}】', '', result) 243 | result = re.sub(r'【.{0,8}時代劇.{0,8}】', '', result) 244 | result = re.sub(r'【.{0,8}一挙.{0,8}】', '', result) 245 | result = re.sub(r'【.*?日本初.*?】', '', result) 246 | result = re.sub(r'【.*?初放送.*?】', '', result) 247 | result = re.sub(r'<.*?一挙.*?>', '', result) 248 | result = re.sub(r'^TV初(★|☆|◆|◇)', '', result) 249 | result = re.sub(r'^\[(録|映画|バラエティ|旅バラエティ|釣り|プロレス|ゴルフ|プロ野球|高校野球|ゴルフ|テニス|モーター|モータースポーツ|卓球|ラグビー|ボウリング|バレーボール|アメリカンフットボール)\]', '', result) 250 | result = re.sub(r'^特: ', '', result) 251 | result = re.sub(r'^アニメ ', '', result) 252 | result = re.sub(r'^アニメ・', '', result) 253 | result = re.sub(r'^アニメ「(?P.*?)」', r'\g<title> ', result) 254 | result = re.sub(r'^アニメ『(?P<title>.*?)』', r'\g<title> ', result) 255 | result = re.sub(r'^アニメ\d{1,2}・', '', result) 256 | result = re.sub(r'^アニメ\d{1,2}', '', result) 257 | result = re.sub(r'^テレビアニメ ', '', result) 258 | result = re.sub(r'^テレビアニメ・', '', result) 259 | result = re.sub(r'^テレビアニメ「(?P<title>.*?)」', r'\g<title> ', result) 260 | result = re.sub(r'^テレビアニメ『(?P<title>.*?)』', r'\g<title> ', result) 261 | result = re.sub(r'^TVアニメ ', '', result) 262 | result = re.sub(r'^TVアニメ・', '', result) 263 | result = re.sub(r'^TVアニメ「(?P<title>.*?)」', r'\g<title> ', result) 264 | result = re.sub(r'^TVアニメ『(?P<title>.*?)』', r'\g<title> ', result) 265 | result = re.sub(r'^ドラマ ', '', result) 266 | result = re.sub(r'^ドラマ・', '', result) 267 | result = re.sub(r'^ドラマ「(?P<title>.*?)」', r'\g<title> ', result) 268 | result = re.sub(r'^ドラマ『(?P<title>.*?)』', r'\g<title> ', result) 269 | result = re.sub(r'^ドラマシリーズ ', '', result) 270 | result = re.sub(r'^ドラマシリーズ・', '', result) 271 | result = re.sub(r'^ドラマシリーズ「(?P<title>.*?)」', r'\g<title> ', result) 272 | result = re.sub(r'^ドラマシリーズ『(?P<title>.*?)』', r'\g<title> ', result) 273 | result = re.sub(r'^大河ドラマ「(?P<title>.*?)」', r'大河ドラマ \g<title> ', result) 274 | result = re.sub(r'^大河ドラマ『(?P<title>.*?)』', r'大河ドラマ \g<title> ', result) 275 | result = re.sub(r'^【連続テレビ小説】', '連続テレビ小説 ', result) 276 | result = re.sub(r'【(朝|昼|夕|夕方|夜)アンコール】', '', result) 277 | result = re.sub(r'【(あさ|ひる|よる)ドラ】', '', result) 278 | result = re.sub(r'【(あさ|ひる|よる)ドラアンコール】', '', result) 279 | result = re.sub(r'^ドラマ\d{1,2}・', '', result) 280 | result = re.sub(r'^ドラマ\d{1,2}', '', result) 281 | result = re.sub(r'^ドラマ(\+|パラビ|Paravi|NEXT|プレミア\d{1,2}|チューズ!|ホリック!|ストリーム) ', '', result) 282 | result = re.sub(r'^ドラマ(\+|パラビ|Paravi|NEXT|プレミア\d{1,2}|チューズ!|ホリック!|ストリーム)・', '', result) 283 | result = re.sub(r'^ドラマ(\+|パラビ|Paravi|NEXT|プレミア\d{1,2}|チューズ!|ホリック!|ストリーム)「(?P<title>.*?)」', r'\g<title> ', result) 284 | result = re.sub(r'^ドラマ(\+|パラビ|Paravi|NEXT|プレミア\d{1,2}|チューズ!|ホリック!|ストリーム)『(?P<title>.*?)』', r'\g<title> ', result) 285 | result = re.sub(r'^ドラマ(\+|パラビ|Paravi|NEXT|プレミア\d{1,2}|チューズ!|ホリック!|ストリーム)', '', result) 286 | result = re.sub(r'<BSフジ.*?>', '', result) 287 | result = re.sub(r'<サスペンス劇場>', '', result) 288 | result = re.sub(r'<フジバラナイト (SAT|SUN|MON|TUE|WED|THU|FRI)>', '', result) 289 | result = re.sub(r'<(月|火|水|木|金|土|日)(曜PLUS|曜ACTION|曜NEXT|曜RISE)!>', '', result) 290 | result = re.sub(r'<(名作ドラマ劇場|午後の名作ドラマ劇場|ブレイクマンデー\d{1,2}|フジテレビからの!|サンデーMIDNIGHT)>', '', result) 291 | result = re.sub(r'<(月|火|水|木|金|土|日)(ドラ|ドラ★イレブン|曜劇場|曜ドラマ|曜ナイトドラマ)>', '', result) 292 | result = re.sub(r'^オトナの(月|火|水|木|金|土|日)ドラ', '', result) 293 | result = re.sub(r'^(月|火|水|木|金|土|日)曜\d{1,2}時のドラマ', '', result) 294 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ) ', '', result) 295 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)・', '', result) 296 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)「(?P<title>.*?)」', r'\g<title> ', result) 297 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)『(?P<title>.*?)』', r'\g<title> ', result) 298 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)\d{1,2}・', '', result) 299 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)\d{1,2}', '', result) 300 | result = re.sub(r'^(月|火|水|木|金|土|日)(ドラ|曜劇場|曜ドラマ|曜ドラマDEEP|曜ナイトドラマ)', '', result) 301 | result = re.sub(r'^(真夜中ドラマ|シンドラ|ドラマL|Zドラマ|よるおびドラマ) ', '', result) 302 | result = re.sub(r'^(真夜中ドラマ|シンドラ|ドラマL|Zドラマ|よるおびドラマ)・', '', result) 303 | result = re.sub(r'^(真夜中ドラマ|シンドラ|ドラマL|Zドラマ|よるおびドラマ)「(?P<title>.*?)」', r'\g<title> ', result) 304 | result = re.sub(r'^(真夜中ドラマ|シンドラ|ドラマL|Zドラマ|よるおびドラマ)『(?P<title>.*?)』', r'\g<title> ', result) 305 | result = re.sub(r'^(真夜中ドラマ|シンドラ|ドラマL|Zドラマ|よるおびドラマ)', '', result) 306 | result = re.sub(r'^連続ドラマW', '', result) 307 | result = re.sub(r'(★|☆|◆|◇)ドラマイズム】', '】', result) 308 | result = re.sub(r'<韓ドラ>', '', result) 309 | result = re.sub(r'【韓ドラ】', '', result) 310 | result = re.sub(r'^韓ドラ ', '', result) 311 | result = re.sub(r'^韓ドラ・', '', result) 312 | result = re.sub(r'^韓ドラ「(?P<title>.*?)」', r'\g<title> ', result) 313 | result = re.sub(r'^韓ドラ『(?P<title>.*?)』', r'\g<title> ', result) 314 | result = re.sub(r'^(台湾|タイ)ドラマ ', '', result) 315 | result = re.sub(r'^(台湾|タイ)ドラマ・', '', result) 316 | result = re.sub(r'^(台湾|タイ)ドラマ「(?P<title>.*?)」', r'\g<title> ', result) 317 | result = re.sub(r'^(台湾|タイ)ドラマ『(?P<title>.*?)』', r'\g<title> ', result) 318 | result = re.sub(r'^韓(★|☆|◆|◇)', '', result) 319 | result = re.sub(r'^韓ドラ(★|☆|◆|◇)', '', result) 320 | result = re.sub(r'^華(★|☆|◆|◇)', '', result) 321 | result = re.sub(r'^華ドラ(★|☆|◆|◇)', '', result) 322 | result = re.sub(r'^(中国|中華|韓国|韓ドラ)時代劇(★|☆|◆|◇)', '', result) 323 | result = re.sub(r'^(韓流プレミア|韓流朝ドラ\d{1,2}) ', '', result) 324 | result = re.sub(r'^韓流プレミア・', '', result) 325 | result = re.sub(r'^韓流プレミア「(?P<title>.*?)」', r'\g<title> ', result) 326 | result = re.sub(r'^韓流プレミア『(?P<title>.*?)』', r'\g<title> ', result) 327 | result = re.sub(r'^(中|韓)(国|国BL|国歴史|流|流BL)ドラマ ', '', result) 328 | result = re.sub(r'^(中|韓)(国|国BL|国歴史|流|流BL)ドラマ・', '', result) 329 | result = re.sub(r'^(中|韓)(国|国BL|国歴史|流|流BL)ドラマ「(?P<title>.*?)」', r'\g<title> ', result) 330 | result = re.sub(r'^(中|韓)(国|国BL|国歴史|流|流BL)ドラマ『(?P<title>.*?)』', r'\g<title> ', result) 331 | result = re.sub(r'^(中|韓)(国|国BL|国歴史|流|流BL)ドラマ【(?P<title>.*?)】', r'\g<title> ', result) 332 | result = re.sub(r'<時代劇.*?>', '', result) 333 | result = re.sub(r'\([0-9][0-9][0-9]ch(時代劇|中国ドラマ|韓国ドラマ)\)', '', result) 334 | result = re.sub(r'【時代劇】', '', result) 335 | result = re.sub(r'^時代劇 ', '', result) 336 | result = re.sub(r'^時代劇・', '', result) 337 | result = re.sub(r'^時代劇「(?P<title>.*?)」', r'\g<title> ', result) 338 | result = re.sub(r'^時代劇『(?P<title>.*?)』', r'\g<title> ', result) 339 | result = re.sub(r'^(中|韓)(国|流|国ファンタジー)時代劇 ', '', result) 340 | result = re.sub(r'^(中|韓)(国|流|国ファンタジー)時代劇・', '', result) 341 | result = re.sub(r'^(中|韓)(国|流|国ファンタジー)時代劇「(?P<title>.*?)」', r'\g<title> ', result) 342 | result = re.sub(r'^(中|韓)(国|流|国ファンタジー)時代劇『(?P<title>.*?)』', r'\g<title> ', result) 343 | result = re.sub(r'^(月|火|水|木|金|土|日)[0-9]「(?P<title>.*?)」', r'\g<title> ', result) 344 | result = re.sub(r'^(月|火|水|木|金|土|日)[0-9]『(?P<title>.*?)』', r'\g<title> ', result) 345 | result = re.sub(r'^アニメA ', '', result) 346 | result = re.sub(r'^アニメA・', '', result) 347 | result = re.sub(r'<アニメギルド>', '', result) 348 | result = re.sub(r'<(M|T|W)ナイト>', '', result) 349 | result = re.sub(r'<ノイタミナ>', '', result) 350 | result = re.sub(r'<\+Ultra>', '', result) 351 | result = re.sub(r'<B8station>', '', result) 352 | result = re.sub(r'AnichU', '', result) 353 | result = re.sub(r'FRIDAY ANIME NIGHT', '', result) 354 | result = re.sub(r'^(月|火|水|木|金|土|日)曜アニメ・水もん ', '', result) 355 | result = re.sub(r'【(アニメ|アニメシャワー|アニメ特区|アニメイズム|スーパーアニメイズム|ヌマニメーション|ANiMAZiNG!!!|ANiMAZiNG2!!!)】', '', result) 356 | result = re.sub(r'アニメイズム$', '', result) 357 | result = re.sub(r'^・', '', result) 358 | 359 | # 前後の半角スペースを削除する 360 | result = result.strip() 361 | 362 | # 連続する半角スペースを 1 つにする 363 | result = re.sub(r'[ \t]+', ' ', result) 364 | 365 | # 置換した文字列を返す 366 | return result 367 | -------------------------------------------------------------------------------- /utils/edcb.py: -------------------------------------------------------------------------------- 1 | """ 2 | EDCB に関連するユーティリティモジュール 3 | https://github.com/xtne6f/edcb.py/blob/master/edcb.py 4 | 5 | The MIT License 6 | 7 | Copyright (c) 2023 xtne6f 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | """ 27 | 28 | import asyncio 29 | import datetime 30 | import socket 31 | import time 32 | from typing import BinaryIO, Callable, Literal, TypeVar 33 | from typing_extensions import TypedDict 34 | 35 | # ジェネリック型 36 | T = TypeVar('T') 37 | 38 | 39 | class ChSet5Item(TypedDict): 40 | """ ChSet5.txt の一行の情報 """ 41 | service_name: str 42 | network_name: str 43 | onid: int 44 | tsid: int 45 | sid: int 46 | service_type: int 47 | partial_flag: bool 48 | epg_cap_flag: bool 49 | search_flag: bool 50 | 51 | 52 | class EDCBUtil: 53 | """ EDCB に関連する雑多なユーティリティ """ 54 | 55 | @staticmethod 56 | def convertBytesToString(buf: bytes | bytearray | memoryview) -> str: 57 | """ BOM に基づいて Bytes データを文字列に変換する """ 58 | if len(buf) == 0: 59 | return '' 60 | elif len(buf) >= 2 and buf[0] == 0xff and buf[1] == 0xfe: 61 | return str(memoryview(buf)[2:], 'utf_16_le', 'replace') 62 | elif len(buf) >= 3 and buf[0] == 0xef and buf[1] == 0xbb and buf[2] == 0xbf: 63 | return str(memoryview(buf)[3:], 'utf_8', 'replace') 64 | else: 65 | return str(buf, 'cp932', 'replace') 66 | 67 | @staticmethod 68 | def parseChSet5(s: str) -> list[ChSet5Item]: 69 | """ ChSet5.txt を解析する """ 70 | v: list[ChSet5Item] = [] 71 | for line in s.splitlines(): 72 | a = line.split('\t') 73 | if len(a) >= 9: 74 | try: 75 | v.append({ 76 | 'service_name': a[0], 77 | 'network_name': a[1], 78 | 'onid': int(a[2]), 79 | 'tsid': int(a[3]), 80 | 'sid': int(a[4]), 81 | 'service_type': int(a[5]), 82 | 'partial_flag': int(a[6]) != 0, 83 | 'epg_cap_flag': int(a[7]) != 0, 84 | 'search_flag': int(a[8]) != 0 85 | }) 86 | except Exception: 87 | pass 88 | return v 89 | 90 | @staticmethod 91 | def getLogoIDFromLogoDataIni(s: str, onid: int, sid: int) -> int: 92 | """ LogoData.ini をもとにロゴ識別を取得する。失敗のとき負値を返す """ 93 | target = f'{onid:04X}{sid:04X}' 94 | for line in s.splitlines(): 95 | kv = line.split('=', 1) 96 | if len(kv) == 2 and kv[0].strip().upper() == target: 97 | try: 98 | return int(kv[1].strip()) 99 | except Exception: 100 | break 101 | return -1 102 | 103 | @staticmethod 104 | def getLogoFileNameFromDirectoryIndex(s: str, onid: int, logo_id: int, logo_type: int) -> str | None: 105 | """ ファイルリストをもとにロゴファイル名を取得する """ 106 | target = f'{onid:04X}_{logo_id:03X}_' 107 | target_type = f'_{logo_type:02d}.' 108 | for line in s.splitlines(): 109 | a = line.split(' ', 3) 110 | if len(a) == 4: 111 | name = a[3] 112 | if len(name) >= 16 and name[0:9].upper() == target and name[12:16] == target_type: 113 | return name 114 | return None 115 | 116 | @staticmethod 117 | def parseProgramExtendedText(s: str) -> dict[str, str]: 118 | """ 詳細情報テキストを解析して項目ごとの辞書を返す """ 119 | s = s.replace('\r', '') 120 | v = {} 121 | head = '' 122 | i = 0 123 | while True: 124 | if i == 0 and s.startswith('- '): 125 | j = 2 126 | elif (j := s.find('\n- ', i)) >= 0: 127 | # 重複する項目名にはタブ文字を付加する 128 | while head in v: 129 | head += '\t' 130 | v[head] = s[(0 if i == 0 else i + 1):j + 1] 131 | j += 3 132 | else: 133 | if len(s) != 0: 134 | while head in v: 135 | head += '\t' 136 | v[head] = s[(0 if i == 0 else i + 1):] 137 | break 138 | i = s.find('\n', j) 139 | if i < 0: 140 | head = s[j:] 141 | while head in v: 142 | head += '\t' 143 | v[head] = '' 144 | break 145 | head = s[j:i] 146 | return v 147 | 148 | @staticmethod 149 | def datetimeToFileTime(dt: datetime.datetime, tz: datetime.timezone = datetime.UTC) -> int: 150 | """ FILETIME 時間 (1601 年からの 100 ナノ秒時刻) に変換する """ 151 | return int((dt.timestamp() + tz.utcoffset(None).total_seconds()) * 10000000) + 116444736000000000 152 | 153 | @staticmethod 154 | async def openPipeStream(process_id: int, buffering: int, timeout_sec: float = 10.) -> BinaryIO | None: 155 | """ システムに存在する SrvPipe ストリームを開き、ファイルオブジェクトを返す """ 156 | to = time.monotonic() + timeout_sec 157 | wait = 0.1 158 | while time.monotonic() < to: 159 | # ポートは必ず 0 から 29 まで 160 | for port in range(30): 161 | try: 162 | path = '\\\\.\\pipe\\SendTSTCP_' + str(port) + '_' + str(process_id) 163 | return open(path, mode='rb', buffering=buffering) 164 | except Exception: 165 | pass 166 | await asyncio.sleep(wait) 167 | # 初期に成功しなければ見込みは薄いので問い合わせを疎にしていく 168 | wait = min(wait + 0.1, 1.0) 169 | return None 170 | 171 | @staticmethod 172 | async def openViewStream(host: str, port: int, process_id: int, timeout_sec: float = 10.) -> socket.socket | None: 173 | """ 174 | View アプリの SrvPipe ストリームの転送を開始する 175 | 176 | ストリーム接続処理や返却するソケットは非同期のものでないことに注意し、長めの I/O 待ちが許さ 177 | れないアプリケーションでは非同期ソケットへの改変またはワーカースレッドの利用を検討すること。 178 | """ 179 | edcb = CtrlCmdUtil() 180 | edcb.setNWSetting(host, port) 181 | edcb.setConnectTimeOutSec(timeout_sec) 182 | to = time.monotonic() + timeout_sec 183 | wait = 0.1 184 | while time.monotonic() < to: 185 | sock = edcb.openViewStream(process_id) 186 | if sock is not None: 187 | return sock 188 | await asyncio.sleep(wait) 189 | # 初期に成功しなければ見込みは薄いので問い合わせを疎にしていく 190 | wait = min(wait + 0.1, 1.0) 191 | return None 192 | 193 | 194 | # 以下、 CtrlCmdUtil で受け渡しする辞書の型ヒント 195 | # ・キーの意味は https://github.com/xtne6f/EDCB の Readme_Mod.txt のテーブル定義の対応する説明を参照 196 | # のこと。キーについてのコメントはこの説明と異なるものだけ行う 197 | # ・辞書やキーの命名は EpgTimer の CtrlCmdDef.cs を基準とする 198 | # ・注記がなければ受け取り方向ではすべてのキーが存在し、引き渡し方向は存在しないキーを 0 や False や 199 | # 空文字列などとして解釈する 200 | 201 | 202 | class SetChInfo(TypedDict, total=False): 203 | """ チャンネル・ NetworkTV モード変更情報 """ 204 | use_sid: int 205 | onid: int 206 | tsid: int 207 | sid: int 208 | use_bon_ch: int 209 | space_or_id: int 210 | ch_or_mode: int 211 | 212 | 213 | class ServiceInfo(TypedDict): 214 | """ サービス情報 """ 215 | onid: int 216 | tsid: int 217 | sid: int 218 | service_type: int 219 | partial_reception_flag: int 220 | service_provider_name: str 221 | service_name: str 222 | network_name: str 223 | ts_name: str 224 | remote_control_key_id: int 225 | 226 | 227 | class FileData(TypedDict): 228 | """ 転送ファイルデータ """ 229 | name: str 230 | data: bytes 231 | 232 | 233 | class RecFileSetInfo(TypedDict, total=False): 234 | """ 録画フォルダ情報 """ 235 | rec_folder: str 236 | write_plug_in: str 237 | rec_name_plug_in: str 238 | 239 | 240 | class RecSettingData(TypedDict, total=False): 241 | """ 録画設定 """ 242 | rec_mode: int # 0-4: 全サービス~視聴, 5-8: 無効の指定サービス~視聴, 9: 無効の全サービス 243 | priority: int 244 | tuijyuu_flag: bool 245 | service_mode: int 246 | pittari_flag: bool 247 | bat_file_path: str 248 | rec_folder_list: list[RecFileSetInfo] 249 | suspend_mode: int 250 | reboot_flag: bool 251 | start_margin: int # デフォルトのとき存在しない 252 | end_margin: int # デフォルトのとき存在しない 253 | continue_rec_flag: bool 254 | partial_rec_flag: int 255 | tuner_id: int 256 | partial_rec_folder: list[RecFileSetInfo] 257 | 258 | 259 | class ReserveData(TypedDict, total=False): 260 | """ 予約情報 """ 261 | title: str 262 | start_time: datetime.datetime 263 | duration_second: int 264 | station_name: str 265 | onid: int 266 | tsid: int 267 | sid: int 268 | eid: int 269 | comment: str 270 | reserve_id: int 271 | overlap_mode: int 272 | start_time_epg: datetime.datetime 273 | rec_setting: RecSettingData 274 | rec_file_name_list: list[str] # 録画予定ファイル名 275 | 276 | 277 | class RecFileInfo(TypedDict, total=False): 278 | """ 録画済み情報 """ 279 | id: int 280 | rec_file_path: str 281 | title: str 282 | start_time: datetime.datetime 283 | duration_sec: int 284 | service_name: str 285 | onid: int 286 | tsid: int 287 | sid: int 288 | eid: int 289 | drops: int 290 | scrambles: int 291 | rec_status: int 292 | start_time_epg: datetime.datetime 293 | comment: str 294 | program_info: str 295 | err_info: str 296 | protect_flag: bool 297 | 298 | 299 | class TunerReserveInfo(TypedDict): 300 | """ チューナー予約情報 """ 301 | tuner_id: int 302 | tuner_name: str 303 | reserve_list: list[int] 304 | 305 | 306 | class ShortEventInfo(TypedDict): 307 | """ イベントの基本情報 """ 308 | event_name: str 309 | text_char: str 310 | 311 | 312 | class ExtendedEventInfo(TypedDict): 313 | """ イベントの拡張情報 """ 314 | text_char: str 315 | 316 | 317 | class ContentData(TypedDict): 318 | """ ジャンルの個別データ """ 319 | content_nibble: int 320 | user_nibble: int 321 | 322 | 323 | class ContentInfo(TypedDict): 324 | """ ジャンル情報 """ 325 | nibble_list: list[ContentData] 326 | 327 | 328 | class ComponentInfo(TypedDict): 329 | """ 映像情報 """ 330 | stream_content: int 331 | component_type: int 332 | component_tag: int 333 | text_char: str 334 | 335 | 336 | class AudioComponentInfoData(TypedDict): 337 | """ 音声情報の個別データ """ 338 | stream_content: int 339 | component_type: int 340 | component_tag: int 341 | stream_type: int 342 | simulcast_group_tag: int 343 | es_multi_lingual_flag: int 344 | main_component_flag: int 345 | quality_indicator: int 346 | sampling_rate: int 347 | text_char: str 348 | 349 | 350 | class AudioComponentInfo(TypedDict): 351 | """ 音声情報 """ 352 | component_list: list[AudioComponentInfoData] 353 | 354 | 355 | class EventData(TypedDict): 356 | """ イベントグループの個別データ """ 357 | onid: int 358 | tsid: int 359 | sid: int 360 | eid: int 361 | 362 | 363 | class EventGroupInfo(TypedDict): 364 | """ イベントグループ情報 """ 365 | group_type: int 366 | event_data_list: list[EventData] 367 | 368 | 369 | class EventInfoRequired(TypedDict): 370 | """ イベント情報の必須項目 """ 371 | onid: int 372 | tsid: int 373 | sid: int 374 | eid: int 375 | free_ca_flag: int 376 | 377 | 378 | class EventInfo(EventInfoRequired, total=False): 379 | """ イベント情報 """ 380 | start_time: datetime.datetime # 不明のとき存在しない 381 | duration_sec: int # 不明のとき存在しない 382 | short_info: ShortEventInfo # 情報がないとき存在しない、以下同様 383 | ext_info: ExtendedEventInfo 384 | content_info: ContentInfo 385 | component_info: ComponentInfo 386 | audio_info: AudioComponentInfo 387 | event_group_info: EventGroupInfo 388 | event_relay_info: EventGroupInfo 389 | 390 | 391 | class ServiceEventInfo(TypedDict): 392 | """ サービスとそのイベント一覧 """ 393 | service_info: ServiceInfo 394 | event_list: list[EventInfo] 395 | 396 | 397 | class SearchDateInfo(TypedDict, total=False): 398 | """ 対象期間 """ 399 | start_day_of_week: int 400 | start_hour: int 401 | start_min: int 402 | end_day_of_week: int 403 | end_hour: int 404 | end_min: int 405 | 406 | 407 | class SearchKeyInfo(TypedDict, total=False): 408 | """ 検索条件 """ 409 | and_key: str # 登録無効、大小文字区別、番組長についての接頭辞は処理済み 410 | not_key: str 411 | key_disabled: bool 412 | case_sensitive: bool 413 | reg_exp_flag: bool 414 | title_only_flag: bool 415 | content_list: list[ContentData] 416 | date_list: list[SearchDateInfo] 417 | service_list: list[int] # (onid << 32 | tsid << 16 | sid) のリスト 418 | video_list: list[int] # 無視してよい 419 | audio_list: list[int] # 無視してよい 420 | aimai_flag: bool 421 | not_contet_flag: bool 422 | not_date_flag: bool 423 | free_ca_flag: int 424 | chk_rec_end: bool 425 | chk_rec_day: int 426 | chk_rec_no_service: bool 427 | chk_duration_min: int 428 | chk_duration_max: int 429 | 430 | 431 | class AutoAddData(TypedDict, total=False): 432 | """ 自動予約登録情報 """ 433 | data_id: int 434 | search_info: SearchKeyInfo 435 | rec_setting: RecSettingData 436 | add_count: int 437 | 438 | 439 | class ManualAutoAddData(TypedDict, total=False): 440 | """ 自動予約 (プログラム) 登録情報 """ 441 | data_id: int 442 | day_of_week_flag: int 443 | start_time: int 444 | duration_second: int 445 | title: str 446 | station_name: str 447 | onid: int 448 | tsid: int 449 | sid: int 450 | rec_setting: RecSettingData 451 | 452 | 453 | class NWPlayTimeShiftInfo(TypedDict): 454 | """ CMD_EPG_SRV_NWPLAY_TF_OPEN で受け取る情報 """ 455 | ctrl_id: int 456 | file_path: str 457 | 458 | 459 | class NotifySrvInfo(TypedDict): 460 | """ 情報通知用パラメーター """ 461 | notify_id: int # 通知情報の種類 462 | time: datetime.datetime # 通知状態の発生した時間 463 | param1: int # パラメーター1 (種類によって内容変更) 464 | param2: int # パラメーター2 (種類によって内容変更) 465 | count: int # 通知の巡回カウンタ 466 | param4: str # パラメーター4 (種類によって内容変更) 467 | param5: str # パラメーター5 (種類によって内容変更) 468 | param6: str # パラメーター6 (種類によって内容変更) 469 | 470 | 471 | # 以上、 CtrlCmdUtil で受け渡しする辞書の型ヒント 472 | 473 | 474 | class NotifyUpdate: 475 | """ 通知情報の種類 """ 476 | EPGDATA = 1 # EPGデータが更新された 477 | RESERVE_INFO = 2 # 予約情報が更新された 478 | REC_INFO = 3 # 録画済み情報が更新された 479 | AUTOADD_EPG = 4 # 自動予約登録情報が更新された 480 | AUTOADD_MANUAL = 5 # 自動予約 (プログラム) 登録情報が更新された 481 | PROFILE = 51 # 設定ファイル (ini) が更新された 482 | SRV_STATUS = 100 # Srv の動作状況が変更 (param1: ステータス 0:通常、1:録画中、2:EPG取得中) 483 | PRE_REC_START = 101 # 録画準備開始 (param4: ログ用メッセージ) 484 | REC_START = 102 # 録画開始 (param4: ログ用メッセージ) 485 | REC_END = 103 # 録画終了 (param4: ログ用メッセージ) 486 | REC_TUIJYU = 104 # 録画中に追従が発生 (param4: ログ用メッセージ) 487 | CHG_TUIJYU = 105 # 追従が発生 (param4: ログ用メッセージ) 488 | PRE_EPGCAP_START = 106 # EPG 取得準備開始 489 | EPGCAP_START = 107 # EPG 取得開始 490 | EPGCAP_END = 108 # EPG 取得終了 491 | 492 | 493 | class CtrlCmdUtil: 494 | """ 495 | EpgTimerSrv の CtrlCmd インタフェースと通信する (EDCB/EpgTimer の CtrlCmd(Def).cs を移植したもの) 496 | 497 | ・利用可能なコマンドはもっとあるが使いそうなものだけ 498 | ・sendView* 系コマンドは EpgDataCap_Bon 等との通信用。接続先パイプは "View_Ctrl_BonNoWaitPipe_{プロセス ID}" 499 | """ 500 | 501 | # EDCB の日付は OS のタイムゾーンに関わらず常に UTC+9 502 | TZ = datetime.timezone(datetime.timedelta(hours=9), 'JST') 503 | 504 | # 読み取った日付が不正なときや既定値に使う UNIX エポック 505 | UNIX_EPOCH = datetime.datetime(1970, 1, 1, 9, tzinfo=TZ) 506 | 507 | __connect_timeout_sec: float 508 | __pipe_name: str 509 | __host: str | None 510 | __port: int 511 | 512 | def __init__(self) -> None: 513 | self.__connect_timeout_sec = 15. 514 | self.__pipe_name = 'EpgTimerSrvNoWaitPipe' 515 | self.__host = None 516 | self.__port = 0 517 | 518 | def setPipeSetting(self, name: str) -> None: 519 | """ 名前付きパイプモードにする """ 520 | self.__pipe_name = name 521 | self.__host = None 522 | 523 | def pipeExists(self) -> bool: 524 | """ 接続先パイプが存在するか調べる """ 525 | try: 526 | with open('\\\\.\\pipe\\' + self.__pipe_name, mode='r+b'): 527 | pass 528 | except FileNotFoundError: 529 | return False 530 | except Exception: 531 | pass 532 | return True 533 | 534 | def setNWSetting(self, host: str, port: int) -> None: 535 | """ TCP/IP モードにする """ 536 | self.__host = host 537 | self.__port = port 538 | 539 | def setConnectTimeOutSec(self, timeout: float) -> None: 540 | """ 接続処理時のタイムアウト設定 """ 541 | self.__connect_timeout_sec = timeout 542 | 543 | async def sendViewSetBonDriver(self, name: str) -> bool: 544 | """ BonDriver の切り替え """ 545 | ret, _ = await self.__sendCmd(self.__CMD_VIEW_APP_SET_BONDRIVER, 546 | lambda buf: self.__writeString(buf, name)) 547 | return ret == self.__CMD_SUCCESS 548 | 549 | async def sendViewGetBonDriver(self) -> str | None: 550 | """ 使用中の BonDriver のファイル名を取得 """ 551 | ret, rbuf = await self.__sendCmd(self.__CMD_VIEW_APP_GET_BONDRIVER) 552 | if ret == self.__CMD_SUCCESS: 553 | try: 554 | return self.__readString(memoryview(rbuf), [0], len(rbuf)) 555 | except self.__ReadError: 556 | pass 557 | return None 558 | 559 | async def sendViewSetCh(self, set_ch_info: SetChInfo) -> bool: 560 | """ チャンネル切り替え """ 561 | ret, _ = await self.__sendCmd(self.__CMD_VIEW_APP_SET_CH, 562 | lambda buf: self.__writeSetChInfo(buf, set_ch_info)) 563 | return ret == self.__CMD_SUCCESS 564 | 565 | async def sendViewAppClose(self) -> bool: 566 | """ アプリケーションの終了 """ 567 | ret, _ = await self.__sendCmd(self.__CMD_VIEW_APP_CLOSE) 568 | return ret == self.__CMD_SUCCESS 569 | 570 | async def sendReloadEpg(self) -> bool: 571 | """ EPG 再読み込みを開始する """ 572 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_RELOAD_EPG) 573 | return ret == self.__CMD_SUCCESS 574 | 575 | async def sendReloadSetting(self) -> bool: 576 | """ 設定を再読み込みする """ 577 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_RELOAD_SETTING) 578 | return ret == self.__CMD_SUCCESS 579 | 580 | async def sendEnumService(self) -> list[ServiceInfo] | None: 581 | """ サービス一覧を取得する """ 582 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_ENUM_SERVICE) 583 | if ret == self.__CMD_SUCCESS: 584 | try: 585 | return self.__readVector(self.__readServiceInfo, memoryview(rbuf), [0], len(rbuf)) 586 | except self.__ReadError: 587 | pass 588 | return None 589 | 590 | async def sendEnumPgInfoEx(self, service_time_list: list[int]) -> list[ServiceEventInfo] | None: 591 | """ 592 | サービス指定と時間指定で番組情報一覧を取得する 593 | 594 | 引数の list の最終2要素で番組の開始時間の範囲、その他の要素でサービスを指定する。最終要素の 595 | 1つ前は時間の始点、最終要素は時間の終点、それぞれ FILETIME 時間で指定する。その他の奇数イン 596 | デックス要素は (onid << 32 | tsid << 16 | sid) で表現するサービスの ID 、各々1つ手前の要素は 597 | 比較対象のサービスの ID に対するビット OR マスクを指定する。 598 | """ 599 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_ENUM_PG_INFO_EX, 600 | lambda buf: self.__writeVector(self.__writeLong, buf, service_time_list)) 601 | if ret == self.__CMD_SUCCESS: 602 | try: 603 | return self.__readVector(self.__readServiceEventInfo, memoryview(rbuf), [0], len(rbuf)) 604 | except self.__ReadError: 605 | pass 606 | return None 607 | 608 | async def sendEnumPgArc(self, service_time_list: list[int]) -> list[ServiceEventInfo] | None: 609 | """ 610 | サービス指定と時間指定で過去番組情報一覧を取得する 611 | 612 | 引数については sendEnumPgInfoEx() と同じ。このコマンドはファイルアクセスを伴うこと、また実装 613 | 上の限界があることから、せいぜい1週間を目安に極端に大きな時間範囲を指定してはならない。 614 | """ 615 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_ENUM_PG_ARC, 616 | lambda buf: self.__writeVector(self.__writeLong, buf, service_time_list)) 617 | if ret == self.__CMD_SUCCESS: 618 | try: 619 | return self.__readVector(self.__readServiceEventInfo, memoryview(rbuf), [0], len(rbuf)) 620 | except self.__ReadError: 621 | pass 622 | return None 623 | 624 | async def sendFileCopy(self, name: str) -> bytes | None: 625 | """ 指定ファイルを転送する """ 626 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_FILE_COPY, 627 | lambda buf: self.__writeString(buf, name)) 628 | if ret == self.__CMD_SUCCESS: 629 | return rbuf 630 | return None 631 | 632 | async def sendFileCopy2(self, name_list: list[str]) -> list[FileData] | None: 633 | """ 指定ファイルをまとめて転送する """ 634 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_FILE_COPY2, 635 | lambda buf: self.__writeVector(self.__writeString, buf, name_list)) 636 | if ret == self.__CMD_SUCCESS: 637 | bufview = memoryview(rbuf) 638 | pos = [0] 639 | try: 640 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 641 | return self.__readVector(self.__readFileData, bufview, pos, len(rbuf)) 642 | except self.__ReadError: 643 | pass 644 | return None 645 | 646 | async def sendNwTVIDSetCh(self, set_ch_info: SetChInfo) -> int | None: 647 | """ NetworkTV モードの View アプリのチャンネルを切り替え、または起動の確認 (ID 指定) """ 648 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_NWTV_ID_SET_CH, 649 | lambda buf: self.__writeSetChInfo(buf, set_ch_info)) 650 | if ret == self.__CMD_SUCCESS: 651 | try: 652 | return self.__readInt(memoryview(rbuf), [0], len(rbuf)) 653 | except self.__ReadError: 654 | pass 655 | return None 656 | 657 | async def sendNwTVIDClose(self, nwtv_id: int) -> bool: 658 | """ NetworkTV モードで起動中の View アプリを終了 (ID 指定) """ 659 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_NWTV_ID_CLOSE, 660 | lambda buf: self.__writeInt(buf, nwtv_id)) 661 | return ret == self.__CMD_SUCCESS 662 | 663 | async def sendEnumReserve(self) -> list[ReserveData] | None: 664 | """ 予約一覧を取得する """ 665 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_ENUM_RESERVE2) 666 | if ret == self.__CMD_SUCCESS: 667 | bufview = memoryview(rbuf) 668 | pos = [0] 669 | try: 670 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 671 | return self.__readVector(self.__readReserveData, bufview, pos, len(rbuf)) 672 | except self.__ReadError: 673 | pass 674 | return None 675 | 676 | async def sendAddReserve(self, reserve_list: list[ReserveData]) -> bool: 677 | """ 予約を追加する """ 678 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_ADD_RESERVE2, 679 | lambda buf: self.__writeVector(self.__writeReserveData, buf, reserve_list)) 680 | return ret == self.__CMD_SUCCESS 681 | 682 | async def sendChgReserve(self, reserve_list: list[ReserveData]) -> bool: 683 | """ 予約を変更する """ 684 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_CHG_RESERVE2, 685 | lambda buf: self.__writeVector(self.__writeReserveData, buf, reserve_list)) 686 | return ret == self.__CMD_SUCCESS 687 | 688 | async def sendDelReserve(self, reserve_id_list: list[int]) -> bool: 689 | """ 予約を削除する """ 690 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_DEL_RESERVE, 691 | lambda buf: self.__writeVector(self.__writeInt, buf, reserve_id_list)) 692 | return ret == self.__CMD_SUCCESS 693 | 694 | async def sendEnumRecInfoBasic(self) -> list[RecFileInfo] | None: 695 | """ 録画済み情報一覧取得 (programInfo と errInfo を除く) """ 696 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_ENUM_RECINFO_BASIC2) 697 | if ret == self.__CMD_SUCCESS: 698 | bufview = memoryview(rbuf) 699 | pos = [0] 700 | try: 701 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 702 | return self.__readVector(self.__readRecFileInfo, bufview, pos, len(rbuf)) 703 | except self.__ReadError: 704 | pass 705 | return None 706 | 707 | async def sendGetRecInfo(self, info_id: int) -> RecFileInfo | None: 708 | """ 録画済み情報取得 """ 709 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_GET_RECINFO2, 710 | lambda buf: self.__writeInt(buf, info_id)) 711 | if ret == self.__CMD_SUCCESS: 712 | bufview = memoryview(rbuf) 713 | pos = [0] 714 | try: 715 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 716 | return self.__readRecFileInfo(bufview, pos, len(rbuf)) 717 | except self.__ReadError: 718 | pass 719 | return None 720 | 721 | async def sendChgPathRecInfo(self, info_list: list[RecFileInfo]) -> bool: 722 | """ 録画済み情報のファイルパスを変更する """ 723 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_CHG_PATH_RECINFO, 724 | lambda buf: self.__writeVector(self.__writeRecFileInfo, buf, info_list)) 725 | return ret == self.__CMD_SUCCESS 726 | 727 | async def sendChgProtectRecInfo(self, info_list: list[RecFileInfo]) -> bool: 728 | """ 録画済み情報のプロテクト変更 """ 729 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_CHG_PROTECT_RECINFO2, 730 | lambda buf: self.__writeVector(self.__writeRecFileInfo2, buf, info_list)) 731 | return ret == self.__CMD_SUCCESS 732 | 733 | async def sendDelRecInfo(self, id_list: list[int]) -> bool: 734 | """ 録画済み情報を削除する """ 735 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_DEL_RECINFO, 736 | lambda buf: self.__writeVector(self.__writeInt, buf, id_list)) 737 | return ret == self.__CMD_SUCCESS 738 | 739 | async def sendGetRecFileNetworkPath(self, path: str) -> str | None: 740 | """ 録画ファイルのネットワークパスを取得 (tkntrec 拡張) """ 741 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_GET_NETWORK_PATH, 742 | lambda buf: self.__writeString(buf, path)) 743 | if ret == self.__CMD_SUCCESS: 744 | try: 745 | return self.__readString(memoryview(rbuf), [0], len(rbuf)) 746 | except self.__ReadError: 747 | pass 748 | return None 749 | 750 | async def sendGetRecFilePath(self, reserve_id: int) -> str | None: 751 | """ 録画中かつ視聴予約でない予約の録画ファイルパスを取得する """ 752 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_NWPLAY_TF_OPEN, 753 | lambda buf: self.__writeInt(buf, reserve_id)) 754 | if ret == self.__CMD_SUCCESS: 755 | bufview = memoryview(rbuf) 756 | pos = [0] 757 | try: 758 | info = self.__readNWPlayTimeShiftInfo(bufview, pos, len(rbuf)) 759 | await self.__sendCmd(self.__CMD_EPG_SRV_NWPLAY_CLOSE, 760 | lambda buf: self.__writeInt(buf, info['ctrl_id'])) 761 | return info['file_path'] 762 | except self.__ReadError: 763 | pass 764 | return None 765 | 766 | async def sendEnumTunerReserve(self) -> list[TunerReserveInfo] | None: 767 | """ チューナーごとの予約一覧を取得する """ 768 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_ENUM_TUNER_RESERVE) 769 | if ret == self.__CMD_SUCCESS: 770 | try: 771 | return self.__readVector(self.__readTunerReserveInfo, memoryview(rbuf), [0], len(rbuf)) 772 | except self.__ReadError: 773 | pass 774 | return None 775 | 776 | async def sendEpgCapNow(self) -> bool: 777 | """ EPG 取得開始を要求する """ 778 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_EPG_CAP_NOW) 779 | return ret == self.__CMD_SUCCESS 780 | 781 | async def sendEnumPlugIn(self, index: Literal[1, 2]) -> list[str] | None: 782 | """ PlugIn ファイルの一覧を取得する """ 783 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_ENUM_PLUGIN, 784 | lambda buf: self.__writeUshort(buf, index)) 785 | if ret == self.__CMD_SUCCESS: 786 | try: 787 | return self.__readVector(self.__readString, memoryview(rbuf), [0], len(rbuf)) 788 | except self.__ReadError: 789 | pass 790 | return None 791 | 792 | async def sendSearchPg(self, key_list: list[SearchKeyInfo]) -> list[EventInfo] | None: 793 | """ 指定キーワードで番組情報を検索する """ 794 | ret, rbuf = await self.__sendCmd(self.__CMD_EPG_SRV_SEARCH_PG, 795 | lambda buf: self.__writeVector(self.__writeSearchKeyInfo, buf, key_list)) 796 | if ret == self.__CMD_SUCCESS: 797 | try: 798 | return self.__readVector(self.__readEventInfo, memoryview(rbuf), [0], len(rbuf)) 799 | except self.__ReadError: 800 | pass 801 | return None 802 | 803 | async def sendEnumAutoAdd(self) -> list[AutoAddData] | None: 804 | """ 自動予約登録情報一覧を取得する """ 805 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_ENUM_AUTO_ADD2) 806 | if ret == self.__CMD_SUCCESS: 807 | bufview = memoryview(rbuf) 808 | pos = [0] 809 | try: 810 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 811 | return self.__readVector(self.__readAutoAddData, bufview, pos, len(rbuf)) 812 | except self.__ReadError: 813 | pass 814 | return None 815 | 816 | async def sendAddAutoAdd(self, data_list: list[AutoAddData]) -> bool: 817 | """ 自動予約登録情報を追加する """ 818 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_ADD_AUTO_ADD2, 819 | lambda buf: self.__writeVector(self.__writeAutoAddData, buf, data_list)) 820 | return ret == self.__CMD_SUCCESS 821 | 822 | async def sendChgAutoAdd(self, data_list: list[AutoAddData]) -> bool: 823 | """ 自動予約登録情報を変更する """ 824 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_CHG_AUTO_ADD2, 825 | lambda buf: self.__writeVector(self.__writeAutoAddData, buf, data_list)) 826 | return ret == self.__CMD_SUCCESS 827 | 828 | async def sendDelAutoAdd(self, id_list: list[int]) -> bool: 829 | """ 自動予約登録情報を削除する """ 830 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_DEL_AUTO_ADD, 831 | lambda buf: self.__writeVector(self.__writeInt, buf, id_list)) 832 | return ret == self.__CMD_SUCCESS 833 | 834 | async def sendEnumManualAdd(self) -> list[ManualAutoAddData] | None: 835 | """ 自動予約 (プログラム) 登録情報一覧を取得する """ 836 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_ENUM_MANU_ADD2) 837 | if ret == self.__CMD_SUCCESS: 838 | bufview = memoryview(rbuf) 839 | pos = [0] 840 | try: 841 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 842 | return self.__readVector(self.__readManualAutoAddData, bufview, pos, len(rbuf)) 843 | except self.__ReadError: 844 | pass 845 | return None 846 | 847 | async def sendAddManualAdd(self, data_list: list[ManualAutoAddData]) -> bool: 848 | """ 自動予約 (プログラム) 登録情報を追加する """ 849 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_ADD_MANU_ADD2, 850 | lambda buf: self.__writeVector(self.__writeManualAutoAddData, buf, data_list)) 851 | return ret == self.__CMD_SUCCESS 852 | 853 | async def sendChgManualAdd(self, data_list: list[ManualAutoAddData]) -> bool: 854 | """ 自動予約 (プログラム) 登録情報を変更する """ 855 | ret, _ = await self.__sendCmd2(self.__CMD_EPG_SRV_CHG_MANU_ADD2, 856 | lambda buf: self.__writeVector(self.__writeManualAutoAddData, buf, data_list)) 857 | return ret == self.__CMD_SUCCESS 858 | 859 | async def sendDelManualAdd(self, id_list: list[int]) -> bool: 860 | """ 自動予約 (プログラム) 登録情報を削除する """ 861 | ret, _ = await self.__sendCmd(self.__CMD_EPG_SRV_DEL_MANU_ADD, 862 | lambda buf: self.__writeVector(self.__writeInt, buf, id_list)) 863 | return ret == self.__CMD_SUCCESS 864 | 865 | async def sendGetNotifySrvInfo(self, target_count: int) -> NotifySrvInfo | None: 866 | """ target_count より大きいカウントの通知を待つ (TCP/IP モードのときロングポーリング) """ 867 | ret, rbuf = await self.__sendCmd2(self.__CMD_EPG_SRV_GET_STATUS_NOTIFY2, 868 | lambda buf: self.__writeUint(buf, target_count)) 869 | if ret == self.__CMD_SUCCESS: 870 | bufview = memoryview(rbuf) 871 | pos = [0] 872 | try: 873 | if self.__readUshort(bufview, pos, len(rbuf)) >= self.__CMD_VER: 874 | return self.__readNotifySrvInfo(bufview, pos, len(rbuf)) 875 | except self.__ReadError: 876 | pass 877 | return None 878 | 879 | async def sendGetNotifySrvStatus(self) -> NotifySrvInfo | None: 880 | """ 現在の NotifyUpdate.SRV_STATUS を取得する """ 881 | return await self.sendGetNotifySrvInfo(0) 882 | 883 | def openViewStream(self, process_id: int) -> socket.socket | None: 884 | """ View アプリの SrvPipe ストリームの転送を開始する """ 885 | buf = bytearray() 886 | self.__writeInt(buf, self.__CMD_EPG_SRV_RELAY_VIEW_STREAM) 887 | self.__writeInt(buf, 4) 888 | self.__writeInt(buf, process_id) 889 | 890 | # TCP/IP モードであること 891 | if self.__host is None: 892 | return None 893 | 894 | try: 895 | sock = socket.create_connection((self.__host, self.__port), self.__connect_timeout_sec) 896 | except Exception: 897 | return None 898 | try: 899 | sock.settimeout(self.__connect_timeout_sec) 900 | sock.sendall(buf) 901 | rbuf = bytearray() 902 | while len(rbuf) < 8: 903 | r = sock.recv(8 - len(rbuf)) 904 | if not r: 905 | break 906 | rbuf.extend(r) 907 | except Exception: 908 | sock.close() 909 | return None 910 | 911 | if len(rbuf) == 8: 912 | ret = self.__readInt(memoryview(rbuf), [0], 8) 913 | if ret == self.__CMD_SUCCESS: 914 | return sock 915 | sock.close() 916 | return None 917 | 918 | # EDCB/EpgTimer の CtrlCmd.cs より 919 | __CMD_SUCCESS = 1 920 | __CMD_VER = 5 921 | __CMD_VIEW_APP_SET_BONDRIVER = 201 922 | __CMD_VIEW_APP_GET_BONDRIVER = 202 923 | __CMD_VIEW_APP_SET_CH = 205 924 | __CMD_VIEW_APP_CLOSE = 208 925 | __CMD_EPG_SRV_RELOAD_EPG = 2 926 | __CMD_EPG_SRV_RELOAD_SETTING = 3 927 | __CMD_EPG_SRV_RELAY_VIEW_STREAM = 301 928 | __CMD_EPG_SRV_DEL_RESERVE = 1014 929 | __CMD_EPG_SRV_ENUM_TUNER_RESERVE = 1016 930 | __CMD_EPG_SRV_DEL_RECINFO = 1018 931 | __CMD_EPG_SRV_CHG_PATH_RECINFO = 1019 932 | __CMD_EPG_SRV_ENUM_SERVICE = 1021 933 | __CMD_EPG_SRV_SEARCH_PG = 1025 934 | __CMD_EPG_SRV_ENUM_PG_INFO_EX = 1029 935 | __CMD_EPG_SRV_ENUM_PG_ARC = 1030 936 | __CMD_EPG_SRV_DEL_AUTO_ADD = 1033 937 | __CMD_EPG_SRV_DEL_MANU_ADD = 1043 938 | __CMD_EPG_SRV_EPG_CAP_NOW = 1053 939 | __CMD_EPG_SRV_FILE_COPY = 1060 940 | __CMD_EPG_SRV_ENUM_PLUGIN = 1061 941 | __CMD_EPG_SRV_NWTV_ID_SET_CH = 1073 942 | __CMD_EPG_SRV_NWTV_ID_CLOSE = 1074 943 | __CMD_EPG_SRV_NWPLAY_CLOSE = 1081 944 | __CMD_EPG_SRV_NWPLAY_TF_OPEN = 1087 945 | __CMD_EPG_SRV_GET_NETWORK_PATH = 1299 946 | __CMD_EPG_SRV_ENUM_RESERVE2 = 2011 947 | __CMD_EPG_SRV_GET_RESERVE2 = 2012 948 | __CMD_EPG_SRV_ADD_RESERVE2 = 2013 949 | __CMD_EPG_SRV_CHG_RESERVE2 = 2015 950 | __CMD_EPG_SRV_CHG_PROTECT_RECINFO2 = 2019 951 | __CMD_EPG_SRV_ENUM_RECINFO_BASIC2 = 2020 952 | __CMD_EPG_SRV_GET_RECINFO2 = 2024 953 | __CMD_EPG_SRV_FILE_COPY2 = 2060 954 | __CMD_EPG_SRV_ENUM_AUTO_ADD2 = 2131 955 | __CMD_EPG_SRV_ADD_AUTO_ADD2 = 2132 956 | __CMD_EPG_SRV_CHG_AUTO_ADD2 = 2134 957 | __CMD_EPG_SRV_ENUM_MANU_ADD2 = 2141 958 | __CMD_EPG_SRV_ADD_MANU_ADD2 = 2142 959 | __CMD_EPG_SRV_CHG_MANU_ADD2 = 2144 960 | __CMD_EPG_SRV_GET_STATUS_NOTIFY2 = 2200 961 | 962 | async def __sendAndReceive(self, buf: bytearray) -> tuple[int | None, bytes]: 963 | to = time.monotonic() + self.__connect_timeout_sec 964 | if self.__host is None: 965 | # 名前付きパイプモード 966 | while True: 967 | try: 968 | with open('\\\\.\\pipe\\' + self.__pipe_name, mode='r+b') as f: 969 | f.write(buf) 970 | f.flush() 971 | rbuf = f.read(8) 972 | if len(rbuf) == 8: 973 | bufview = memoryview(rbuf) 974 | pos = [0] 975 | ret = self.__readInt(bufview, pos, 8) 976 | size = self.__readInt(bufview, pos, 8) 977 | rbuf = f.read(size) 978 | if len(rbuf) == size: 979 | return ret, rbuf 980 | break 981 | except FileNotFoundError: 982 | break 983 | except Exception: 984 | pass 985 | await asyncio.sleep(0.01) 986 | if time.monotonic() >= to: 987 | break 988 | return None, b'' 989 | 990 | # TCP/IP モード 991 | try: 992 | r, w = await asyncio.wait_for(asyncio.open_connection(self.__host, self.__port), max(to - time.monotonic(), 0.)) 993 | except Exception: 994 | return None, b'' 995 | try: 996 | w.write(buf) 997 | await asyncio.wait_for(w.drain(), max(to - time.monotonic(), 0.)) 998 | ret = 0 999 | size = 8 1000 | rbuf = await asyncio.wait_for(r.readexactly(8), max(to - time.monotonic(), 0.)) 1001 | if len(rbuf) == 8: 1002 | bufview = memoryview(rbuf) 1003 | pos = [0] 1004 | ret = self.__readInt(bufview, pos, 8) 1005 | size = self.__readInt(bufview, pos, 8) 1006 | rbuf = await asyncio.wait_for(r.readexactly(size), max(to - time.monotonic(), 0.)) 1007 | except Exception: 1008 | return None, b'' 1009 | finally: 1010 | w.close() 1011 | try: 1012 | await asyncio.wait_for(w.wait_closed(), max(to - time.monotonic(), 0.)) 1013 | except Exception: 1014 | pass 1015 | if len(rbuf) == size: 1016 | return ret, rbuf 1017 | return None, b'' 1018 | 1019 | async def __sendCmd(self, cmd: int, write_func: Callable[[bytearray], None] | None = None) -> tuple[int | None, bytes]: 1020 | buf = bytearray() 1021 | self.__writeInt(buf, cmd) 1022 | self.__writeInt(buf, 0) 1023 | if write_func: 1024 | write_func(buf) 1025 | self.__writeIntInplace(buf, 4, len(buf) - 8) 1026 | return await self.__sendAndReceive(buf) 1027 | 1028 | async def __sendCmd2(self, cmd2: int, write_func: Callable[[bytearray], None] | None = None) -> tuple[int | None, bytes]: 1029 | buf = bytearray() 1030 | self.__writeInt(buf, cmd2) 1031 | self.__writeInt(buf, 0) 1032 | self.__writeUshort(buf, self.__CMD_VER) 1033 | if write_func: 1034 | write_func(buf) 1035 | self.__writeIntInplace(buf, 4, len(buf) - 8) 1036 | return await self.__sendAndReceive(buf) 1037 | 1038 | @staticmethod 1039 | def __writeByte(buf: bytearray, v: int) -> None: 1040 | buf.extend(v.to_bytes(1, 'little')) 1041 | 1042 | @staticmethod 1043 | def __writeUshort(buf: bytearray, v: int) -> None: 1044 | buf.extend(v.to_bytes(2, 'little')) 1045 | 1046 | @staticmethod 1047 | def __writeInt(buf: bytearray, v: int) -> None: 1048 | buf.extend(v.to_bytes(4, 'little', signed=True)) 1049 | 1050 | @staticmethod 1051 | def __writeUint(buf: bytearray, v: int) -> None: 1052 | buf.extend(v.to_bytes(4, 'little')) 1053 | 1054 | @staticmethod 1055 | def __writeLong(buf: bytearray, v: int) -> None: 1056 | buf.extend(v.to_bytes(8, 'little', signed=True)) 1057 | 1058 | @staticmethod 1059 | def __writeIntInplace(buf: bytearray, pos: int, v: int) -> None: 1060 | buf[pos:pos + 4] = v.to_bytes(4, 'little', signed=True) 1061 | 1062 | @classmethod 1063 | def __writeSystemTime(cls, buf: bytearray, v: datetime.datetime) -> None: 1064 | cls.__writeUshort(buf, v.year) 1065 | cls.__writeUshort(buf, v.month) 1066 | cls.__writeUshort(buf, v.isoweekday() % 7) 1067 | cls.__writeUshort(buf, v.day) 1068 | cls.__writeUshort(buf, v.hour) 1069 | cls.__writeUshort(buf, v.minute) 1070 | cls.__writeUshort(buf, v.second) 1071 | cls.__writeUshort(buf, 0) 1072 | 1073 | @classmethod 1074 | def __writeString(cls, buf: bytearray, v: str) -> None: 1075 | vv = v.encode('utf_16_le') 1076 | cls.__writeInt(buf, 6 + len(vv)) 1077 | buf.extend(vv) 1078 | cls.__writeUshort(buf, 0) 1079 | 1080 | @classmethod 1081 | def __writeVector(cls, write_func: Callable[[bytearray, T], None], buf: bytearray, v: list[T]) -> None: 1082 | pos = len(buf) 1083 | cls.__writeInt(buf, 0) 1084 | cls.__writeInt(buf, len(v)) 1085 | for e in v: 1086 | write_func(buf, e) 1087 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1088 | 1089 | # 以下、各構造体のライター 1090 | 1091 | @classmethod 1092 | def __writeSetChInfo(cls, buf: bytearray, v: SetChInfo) -> None: 1093 | pos = len(buf) 1094 | cls.__writeInt(buf, 0) 1095 | cls.__writeInt(buf, 1 if v.get('use_sid') else 0) 1096 | cls.__writeUshort(buf, v.get('onid', 0)) 1097 | cls.__writeUshort(buf, v.get('tsid', 0)) 1098 | cls.__writeUshort(buf, v.get('sid', 0)) 1099 | cls.__writeInt(buf, 1 if v.get('use_bon_ch') else 0) 1100 | cls.__writeInt(buf, v.get('space_or_id', 0)) 1101 | cls.__writeInt(buf, v.get('ch_or_mode', 0)) 1102 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1103 | 1104 | @classmethod 1105 | def __writeRecFileSetInfo(cls, buf: bytearray, v: RecFileSetInfo) -> None: 1106 | pos = len(buf) 1107 | cls.__writeInt(buf, 0) 1108 | cls.__writeString(buf, v.get('rec_folder', '')) 1109 | cls.__writeString(buf, v.get('write_plug_in', '')) 1110 | cls.__writeString(buf, v.get('rec_name_plug_in', '')) 1111 | cls.__writeString(buf, '') 1112 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1113 | 1114 | @classmethod 1115 | def __writeRecSettingData(cls, buf: bytearray, v: RecSettingData) -> None: 1116 | pos = len(buf) 1117 | cls.__writeInt(buf, 0) 1118 | cls.__writeByte(buf, v.get('rec_mode', 0)) 1119 | cls.__writeByte(buf, v.get('priority', 0)) 1120 | cls.__writeByte(buf, v.get('tuijyuu_flag', False)) 1121 | cls.__writeUint(buf, v.get('service_mode', 0)) 1122 | cls.__writeByte(buf, v.get('pittari_flag', False)) 1123 | cls.__writeString(buf, v.get('bat_file_path', '')) 1124 | cls.__writeVector(cls.__writeRecFileSetInfo, buf, v.get('rec_folder_list', [])) 1125 | cls.__writeByte(buf, v.get('suspend_mode', 0)) 1126 | cls.__writeByte(buf, v.get('reboot_flag', False)) 1127 | cls.__writeByte(buf, v.get('start_margin') is not None and v.get('end_margin') is not None) 1128 | cls.__writeInt(buf, v.get('start_margin', 0)) 1129 | cls.__writeInt(buf, v.get('end_margin', 0)) 1130 | cls.__writeByte(buf, v.get('continue_rec_flag', False)) 1131 | cls.__writeByte(buf, v.get('partial_rec_flag', 0)) 1132 | cls.__writeUint(buf, v.get('tuner_id', 0)) 1133 | cls.__writeVector(cls.__writeRecFileSetInfo, buf, v.get('partial_rec_folder', [])) 1134 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1135 | 1136 | @classmethod 1137 | def __writeReserveData(cls, buf: bytearray, v: ReserveData) -> None: 1138 | pos = len(buf) 1139 | cls.__writeInt(buf, 0) 1140 | cls.__writeString(buf, v.get('title', '')) 1141 | cls.__writeSystemTime(buf, v.get('start_time', cls.UNIX_EPOCH)) 1142 | cls.__writeUint(buf, v.get('duration_second', 0)) 1143 | cls.__writeString(buf, v.get('station_name', '')) 1144 | cls.__writeUshort(buf, v.get('onid', 0)) 1145 | cls.__writeUshort(buf, v.get('tsid', 0)) 1146 | cls.__writeUshort(buf, v.get('sid', 0)) 1147 | cls.__writeUshort(buf, v.get('eid', 0)) 1148 | cls.__writeString(buf, v.get('comment', '')) 1149 | cls.__writeInt(buf, v.get('reserve_id', 0)) 1150 | cls.__writeByte(buf, 0) 1151 | cls.__writeByte(buf, v.get('overlap_mode', 0)) 1152 | cls.__writeString(buf, '') 1153 | cls.__writeSystemTime(buf, v.get('start_time_epg', cls.UNIX_EPOCH)) 1154 | cls.__writeRecSettingData(buf, v.get('rec_setting', {})) 1155 | cls.__writeInt(buf, 0) 1156 | cls.__writeVector(cls.__writeString, buf, v.get('rec_file_name_list', [])) 1157 | cls.__writeInt(buf, 0) 1158 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1159 | 1160 | @classmethod 1161 | def __writeRecFileInfo(cls, buf: bytearray, v: RecFileInfo, has_protect_flag: bool = False) -> None: 1162 | pos = len(buf) 1163 | cls.__writeInt(buf, 0) 1164 | cls.__writeInt(buf, v.get('id', 0)) 1165 | cls.__writeString(buf, v.get('rec_file_path', '')) 1166 | cls.__writeString(buf, v.get('title', '')) 1167 | cls.__writeSystemTime(buf, v.get('start_time', cls.UNIX_EPOCH)) 1168 | cls.__writeUint(buf, v.get('duration_sec', 0)) 1169 | cls.__writeString(buf, v.get('service_name', '')) 1170 | cls.__writeUshort(buf, v.get('onid', 0)) 1171 | cls.__writeUshort(buf, v.get('tsid', 0)) 1172 | cls.__writeUshort(buf, v.get('sid', 0)) 1173 | cls.__writeUshort(buf, v.get('eid', 0)) 1174 | cls.__writeLong(buf, v.get('drops', 0)) 1175 | cls.__writeLong(buf, v.get('scrambles', 0)) 1176 | cls.__writeInt(buf, v.get('rec_status', 0)) 1177 | cls.__writeSystemTime(buf, v.get('start_time_epg', cls.UNIX_EPOCH)) 1178 | cls.__writeString(buf, v.get('comment', '')) 1179 | cls.__writeString(buf, v.get('program_info', '')) 1180 | cls.__writeString(buf, v.get('err_info', '')) 1181 | if has_protect_flag: 1182 | cls.__writeByte(buf, v.get('protect_flag', False)) 1183 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1184 | 1185 | @classmethod 1186 | def __writeRecFileInfo2(cls, buf: bytearray, v: RecFileInfo) -> None: 1187 | cls.__writeRecFileInfo(buf, v, True) 1188 | 1189 | @classmethod 1190 | def __writeContentData(cls, buf: bytearray, v: ContentData) -> None: 1191 | pos = len(buf) 1192 | cls.__writeInt(buf, 0) 1193 | cn = v.get('content_nibble', 0) 1194 | un = v.get('user_nibble', 0) 1195 | cls.__writeUshort(buf, (cn >> 8 | cn << 8) & 0xffff) 1196 | cls.__writeUshort(buf, (un >> 8 | un << 8) & 0xffff) 1197 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1198 | 1199 | @classmethod 1200 | def __writeSearchDateInfo(cls, buf: bytearray, v: SearchDateInfo) -> None: 1201 | pos = len(buf) 1202 | cls.__writeInt(buf, 0) 1203 | cls.__writeByte(buf, v.get('start_day_of_week', 0)) 1204 | cls.__writeUshort(buf, v.get('start_hour', 0)) 1205 | cls.__writeUshort(buf, v.get('start_min', 0)) 1206 | cls.__writeByte(buf, v.get('end_day_of_week', 0)) 1207 | cls.__writeUshort(buf, v.get('end_hour', 0)) 1208 | cls.__writeUshort(buf, v.get('end_min', 0)) 1209 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1210 | 1211 | @classmethod 1212 | def __writeSearchKeyInfo(cls, buf: bytearray, v: SearchKeyInfo, has_chk_rec_end: bool = False) -> None: 1213 | pos = len(buf) 1214 | cls.__writeInt(buf, 0) 1215 | chk_duration = int(v.get('chk_duration_min', 0) * 10000 + v.get('chk_duration_max', 0)) % 100000000 1216 | cls.__writeString(buf, ('^!{999}' if v.get('key_disabled', False) else '') + 1217 | ('C!{999}' if v.get('case_sensitive', False) else '') + 1218 | (f'D!{{1{chk_duration:08d}}}' if chk_duration > 0 else '') + v.get('and_key', '')) 1219 | cls.__writeString(buf, v.get('not_key', '')) 1220 | cls.__writeInt(buf, v.get('reg_exp_flag', False)) 1221 | cls.__writeInt(buf, v.get('title_only_flag', False)) 1222 | cls.__writeVector(cls.__writeContentData, buf, v.get('content_list', [])) 1223 | cls.__writeVector(cls.__writeSearchDateInfo, buf, v.get('date_list', [])) 1224 | cls.__writeVector(cls.__writeLong, buf, v.get('service_list', [])) 1225 | cls.__writeVector(cls.__writeUshort, buf, v.get('video_list', [])) 1226 | cls.__writeVector(cls.__writeUshort, buf, v.get('audio_list', [])) 1227 | cls.__writeByte(buf, v.get('aimai_flag', False)) 1228 | cls.__writeByte(buf, v.get('not_contet_flag', False)) 1229 | cls.__writeByte(buf, v.get('not_date_flag', False)) 1230 | cls.__writeByte(buf, v.get('free_ca_flag', 0)) 1231 | if has_chk_rec_end: 1232 | cls.__writeByte(buf, v.get('chk_rec_end', False)) 1233 | chk_rec_day = v.get('chk_rec_day', 0) 1234 | cls.__writeUshort(buf, chk_rec_day % 10000 + 40000 if v.get('chk_rec_no_service', False) else chk_rec_day) 1235 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1236 | 1237 | @classmethod 1238 | def __writeSearchKeyInfo2(cls, buf: bytearray, v: SearchKeyInfo) -> None: 1239 | cls.__writeSearchKeyInfo(buf, v, True) 1240 | 1241 | @classmethod 1242 | def __writeAutoAddData(cls, buf: bytearray, v: AutoAddData) -> None: 1243 | pos = len(buf) 1244 | cls.__writeInt(buf, 0) 1245 | cls.__writeInt(buf, v.get('data_id', 0)) 1246 | cls.__writeSearchKeyInfo2(buf, v.get('search_info', {})) 1247 | cls.__writeRecSettingData(buf, v.get('rec_setting', {})) 1248 | cls.__writeInt(buf, v.get('add_count', 0)) 1249 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1250 | 1251 | @classmethod 1252 | def __writeManualAutoAddData(cls, buf: bytearray, v: ManualAutoAddData) -> None: 1253 | pos = len(buf) 1254 | cls.__writeInt(buf, 0) 1255 | cls.__writeInt(buf, v.get('data_id', 0)) 1256 | cls.__writeByte(buf, v.get('day_of_week_flag', 0)) 1257 | cls.__writeUint(buf, v.get('start_time', 0)) 1258 | cls.__writeUint(buf, v.get('duration_second', 0)) 1259 | cls.__writeString(buf, v.get('title', '')) 1260 | cls.__writeString(buf, v.get('station_name', '')) 1261 | cls.__writeUshort(buf, v.get('onid', 0)) 1262 | cls.__writeUshort(buf, v.get('tsid', 0)) 1263 | cls.__writeUshort(buf, v.get('sid', 0)) 1264 | cls.__writeRecSettingData(buf, v.get('rec_setting', {})) 1265 | cls.__writeIntInplace(buf, pos, len(buf) - pos) 1266 | 1267 | class __ReadError(Exception): 1268 | """ バッファをデータ構造として読み取るのに失敗したときの内部エラー """ 1269 | pass 1270 | 1271 | @classmethod 1272 | def __readByte(cls, buf: memoryview, pos: list[int], size: int) -> int: 1273 | if size - pos[0] < 1: 1274 | raise cls.__ReadError 1275 | v = buf[pos[0]] 1276 | pos[0] += 1 1277 | return v 1278 | 1279 | @classmethod 1280 | def __readUshort(cls, buf: memoryview, pos: list[int], size: int) -> int: 1281 | if size - pos[0] < 2: 1282 | raise cls.__ReadError 1283 | v = buf[pos[0]] | buf[pos[0] + 1] << 8 1284 | pos[0] += 2 1285 | return v 1286 | 1287 | @classmethod 1288 | def __readInt(cls, buf: memoryview, pos: list[int], size: int) -> int: 1289 | if size - pos[0] < 4: 1290 | raise cls.__ReadError 1291 | v = int.from_bytes(buf[pos[0]:pos[0] + 4], 'little', signed=True) 1292 | pos[0] += 4 1293 | return v 1294 | 1295 | @classmethod 1296 | def __readUint(cls, buf: memoryview, pos: list[int], size: int) -> int: 1297 | if size - pos[0] < 4: 1298 | raise cls.__ReadError 1299 | v = int.from_bytes(buf[pos[0]:pos[0] + 4], 'little') 1300 | pos[0] += 4 1301 | return v 1302 | 1303 | @classmethod 1304 | def __readLong(cls, buf: memoryview, pos: list[int], size: int) -> int: 1305 | if size - pos[0] < 8: 1306 | raise cls.__ReadError 1307 | v = int.from_bytes(buf[pos[0]:pos[0] + 8], 'little', signed=True) 1308 | pos[0] += 8 1309 | return v 1310 | 1311 | @classmethod 1312 | def __readSystemTime(cls, buf: memoryview, pos: list[int], size: int) -> datetime.datetime: 1313 | if size - pos[0] < 16: 1314 | raise cls.__ReadError 1315 | try: 1316 | pos0 = pos[0] 1317 | v = datetime.datetime(buf[pos0] | buf[pos0 + 1] << 8, 1318 | buf[pos0 + 2] | buf[pos0 + 3] << 8, 1319 | buf[pos0 + 6] | buf[pos0 + 7] << 8, 1320 | buf[pos0 + 8] | buf[pos0 + 9] << 8, 1321 | buf[pos0 + 10] | buf[pos0 + 11] << 8, 1322 | buf[pos0 + 12] | buf[pos0 + 13] << 8, 1323 | tzinfo=cls.TZ) 1324 | except Exception: 1325 | v = cls.UNIX_EPOCH 1326 | pos[0] += 16 1327 | return v 1328 | 1329 | @classmethod 1330 | def __readString(cls, buf: memoryview, pos: list[int], size: int) -> str: 1331 | vs = cls.__readInt(buf, pos, size) 1332 | if vs < 6 or size - pos[0] < vs - 4: 1333 | raise cls.__ReadError 1334 | v = str(buf[pos[0]:pos[0] + vs - 6], 'utf_16_le') 1335 | pos[0] += vs - 4 1336 | return v 1337 | 1338 | @classmethod 1339 | def __readVector(cls, read_func: Callable[[memoryview, list[int], int], T], buf: memoryview, pos: list[int], size: int) -> list[T]: 1340 | vs = cls.__readInt(buf, pos, size) 1341 | vc = cls.__readInt(buf, pos, size) 1342 | if vs < 8 or vc < 0 or size - pos[0] < vs - 8: 1343 | raise cls.__ReadError 1344 | size = pos[0] + vs - 8 1345 | v: list[T] = [] 1346 | for i in range(vc): 1347 | v.append(read_func(buf, pos, size)) 1348 | pos[0] = size 1349 | return v 1350 | 1351 | @classmethod 1352 | def __readStructIntro(cls, buf: memoryview, pos: list[int], size: int) -> int: 1353 | vs = cls.__readInt(buf, pos, size) 1354 | if vs < 4 or size - pos[0] < vs - 4: 1355 | raise cls.__ReadError 1356 | return pos[0] + vs - 4 1357 | 1358 | # 以下、各構造体のリーダー 1359 | 1360 | @classmethod 1361 | def __readFileData(cls, buf: memoryview, pos: list[int], size: int) -> FileData: 1362 | size = cls.__readStructIntro(buf, pos, size) 1363 | name = cls.__readString(buf, pos, size) 1364 | data_size = cls.__readInt(buf, pos, size) 1365 | cls.__readInt(buf, pos, size) 1366 | if data_size < 0 or size - pos[0] < data_size: 1367 | raise cls.__ReadError 1368 | v: FileData = { 1369 | 'name': name, 1370 | 'data': bytes(buf[pos[0]:pos[0] + data_size]) 1371 | } 1372 | pos[0] = size 1373 | return v 1374 | 1375 | @classmethod 1376 | def __readRecFileSetInfo(cls, buf: memoryview, pos: list[int], size: int) -> RecFileSetInfo: 1377 | size = cls.__readStructIntro(buf, pos, size) 1378 | v: RecFileSetInfo = { 1379 | 'rec_folder': cls.__readString(buf, pos, size), 1380 | 'write_plug_in': cls.__readString(buf, pos, size), 1381 | 'rec_name_plug_in': cls.__readString(buf, pos, size) 1382 | } 1383 | cls.__readString(buf, pos, size) 1384 | pos[0] = size 1385 | return v 1386 | 1387 | @classmethod 1388 | def __readRecSettingData(cls, buf: memoryview, pos: list[int], size: int) -> RecSettingData: 1389 | size = cls.__readStructIntro(buf, pos, size) 1390 | v: RecSettingData = { 1391 | 'rec_mode': cls.__readByte(buf, pos, size), 1392 | 'priority': cls.__readByte(buf, pos, size), 1393 | 'tuijyuu_flag': cls.__readByte(buf, pos, size) != 0, 1394 | 'service_mode': cls.__readUint(buf, pos, size), 1395 | 'pittari_flag': cls.__readByte(buf, pos, size) != 0, 1396 | 'bat_file_path': cls.__readString(buf, pos, size), 1397 | 'rec_folder_list': cls.__readVector(cls.__readRecFileSetInfo, buf, pos, size), 1398 | 'suspend_mode': cls.__readByte(buf, pos, size), 1399 | 'reboot_flag': cls.__readByte(buf, pos, size) != 0 1400 | } 1401 | use_margin_flag = cls.__readByte(buf, pos, size) != 0 1402 | start_margin = cls.__readInt(buf, pos, size) 1403 | end_margin = cls.__readInt(buf, pos, size) 1404 | if use_margin_flag: 1405 | v['start_margin'] = start_margin 1406 | v['end_margin'] = end_margin 1407 | v['continue_rec_flag'] = cls.__readByte(buf, pos, size) != 0 1408 | v['partial_rec_flag'] = cls.__readByte(buf, pos, size) 1409 | v['tuner_id'] = cls.__readUint(buf, pos, size) 1410 | v['partial_rec_folder'] = cls.__readVector(cls.__readRecFileSetInfo, buf, pos, size) 1411 | pos[0] = size 1412 | return v 1413 | 1414 | @classmethod 1415 | def __readReserveData(cls, buf: memoryview, pos: list[int], size: int) -> ReserveData: 1416 | size = cls.__readStructIntro(buf, pos, size) 1417 | v: ReserveData = { 1418 | 'title': cls.__readString(buf, pos, size), 1419 | 'start_time': cls.__readSystemTime(buf, pos, size), 1420 | 'duration_second': cls.__readUint(buf, pos, size), 1421 | 'station_name': cls.__readString(buf, pos, size), 1422 | 'onid': cls.__readUshort(buf, pos, size), 1423 | 'tsid': cls.__readUshort(buf, pos, size), 1424 | 'sid': cls.__readUshort(buf, pos, size), 1425 | 'eid': cls.__readUshort(buf, pos, size), 1426 | 'comment': cls.__readString(buf, pos, size), 1427 | 'reserve_id': cls.__readInt(buf, pos, size) 1428 | } 1429 | cls.__readByte(buf, pos, size) 1430 | v['overlap_mode'] = cls.__readByte(buf, pos, size) 1431 | cls.__readString(buf, pos, size) 1432 | v['start_time_epg'] = cls.__readSystemTime(buf, pos, size) 1433 | v['rec_setting'] = cls.__readRecSettingData(buf, pos, size) 1434 | cls.__readInt(buf, pos, size) 1435 | v['rec_file_name_list'] = cls.__readVector(cls.__readString, buf, pos, size) 1436 | cls.__readInt(buf, pos, size) 1437 | pos[0] = size 1438 | return v 1439 | 1440 | @classmethod 1441 | def __readRecFileInfo(cls, buf: memoryview, pos: list[int], size: int) -> RecFileInfo: 1442 | size = cls.__readStructIntro(buf, pos, size) 1443 | v: RecFileInfo = { 1444 | 'id': cls.__readInt(buf, pos, size), 1445 | 'rec_file_path': cls.__readString(buf, pos, size), 1446 | 'title': cls.__readString(buf, pos, size), 1447 | 'start_time': cls.__readSystemTime(buf, pos, size), 1448 | 'duration_sec': cls.__readUint(buf, pos, size), 1449 | 'service_name': cls.__readString(buf, pos, size), 1450 | 'onid': cls.__readUshort(buf, pos, size), 1451 | 'tsid': cls.__readUshort(buf, pos, size), 1452 | 'sid': cls.__readUshort(buf, pos, size), 1453 | 'eid': cls.__readUshort(buf, pos, size), 1454 | 'drops': cls.__readLong(buf, pos, size), 1455 | 'scrambles': cls.__readLong(buf, pos, size), 1456 | 'rec_status': cls.__readInt(buf, pos, size), 1457 | 'start_time_epg': cls.__readSystemTime(buf, pos, size), 1458 | 'comment': cls.__readString(buf, pos, size), 1459 | 'program_info': cls.__readString(buf, pos, size), 1460 | 'err_info': cls.__readString(buf, pos, size), 1461 | 'protect_flag': cls.__readByte(buf, pos, size) != 0 1462 | } 1463 | pos[0] = size 1464 | return v 1465 | 1466 | @classmethod 1467 | def __readTunerReserveInfo(cls, buf: memoryview, pos: list[int], size: int) -> TunerReserveInfo: 1468 | size = cls.__readStructIntro(buf, pos, size) 1469 | v: TunerReserveInfo = { 1470 | 'tuner_id': cls.__readUint(buf, pos, size), 1471 | 'tuner_name': cls.__readString(buf, pos, size), 1472 | 'reserve_list': cls.__readVector(cls.__readInt, buf, pos, size) 1473 | } 1474 | pos[0] = size 1475 | return v 1476 | 1477 | @classmethod 1478 | def __readServiceEventInfo(cls, buf: memoryview, pos: list[int], size: int) -> ServiceEventInfo: 1479 | size = cls.__readStructIntro(buf, pos, size) 1480 | v: ServiceEventInfo = { 1481 | 'service_info': cls.__readServiceInfo(buf, pos, size), 1482 | 'event_list': cls.__readVector(cls.__readEventInfo, buf, pos, size) 1483 | } 1484 | pos[0] = size 1485 | return v 1486 | 1487 | @classmethod 1488 | def __readServiceInfo(cls, buf: memoryview, pos: list[int], size: int) -> ServiceInfo: 1489 | size = cls.__readStructIntro(buf, pos, size) 1490 | v: ServiceInfo = { 1491 | 'onid': cls.__readUshort(buf, pos, size), 1492 | 'tsid': cls.__readUshort(buf, pos, size), 1493 | 'sid': cls.__readUshort(buf, pos, size), 1494 | 'service_type': cls.__readByte(buf, pos, size), 1495 | 'partial_reception_flag': cls.__readByte(buf, pos, size), 1496 | 'service_provider_name': cls.__readString(buf, pos, size), 1497 | 'service_name': cls.__readString(buf, pos, size), 1498 | 'network_name': cls.__readString(buf, pos, size), 1499 | 'ts_name': cls.__readString(buf, pos, size), 1500 | 'remote_control_key_id': cls.__readByte(buf, pos, size) 1501 | } 1502 | pos[0] = size 1503 | return v 1504 | 1505 | @classmethod 1506 | def __readEventInfo(cls, buf: memoryview, pos: list[int], size: int) -> EventInfo: 1507 | size = cls.__readStructIntro(buf, pos, size) 1508 | v: EventInfo = { 1509 | 'onid': cls.__readUshort(buf, pos, size), 1510 | 'tsid': cls.__readUshort(buf, pos, size), 1511 | 'sid': cls.__readUshort(buf, pos, size), 1512 | 'eid': cls.__readUshort(buf, pos, size), 1513 | 'free_ca_flag': 0 1514 | } 1515 | 1516 | start_time_flag = cls.__readByte(buf, pos, size) 1517 | start_time = cls.__readSystemTime(buf, pos, size) 1518 | if start_time_flag != 0: 1519 | v['start_time'] = start_time 1520 | 1521 | duration_flag = cls.__readByte(buf, pos, size) 1522 | duration_sec = cls.__readInt(buf, pos, size) 1523 | if duration_flag != 0: 1524 | v['duration_sec'] = duration_sec 1525 | 1526 | if cls.__readInt(buf, pos, size) != 4: 1527 | pos[0] -= 4 1528 | v['short_info'] = cls.__readShortEventInfo(buf, pos, size) 1529 | 1530 | if cls.__readInt(buf, pos, size) != 4: 1531 | pos[0] -= 4 1532 | v['ext_info'] = cls.__readExtendedEventInfo(buf, pos, size) 1533 | 1534 | if cls.__readInt(buf, pos, size) != 4: 1535 | pos[0] -= 4 1536 | v['content_info'] = cls.__readContentInfo(buf, pos, size) 1537 | 1538 | if cls.__readInt(buf, pos, size) != 4: 1539 | pos[0] -= 4 1540 | v['component_info'] = cls.__readComponentInfo(buf, pos, size) 1541 | 1542 | if cls.__readInt(buf, pos, size) != 4: 1543 | pos[0] -= 4 1544 | v['audio_info'] = cls.__readAudioComponentInfo(buf, pos, size) 1545 | 1546 | if cls.__readInt(buf, pos, size) != 4: 1547 | pos[0] -= 4 1548 | v['event_group_info'] = cls.__readEventGroupInfo(buf, pos, size) 1549 | 1550 | if cls.__readInt(buf, pos, size) != 4: 1551 | pos[0] -= 4 1552 | v['event_relay_info'] = cls.__readEventGroupInfo(buf, pos, size) 1553 | 1554 | v['free_ca_flag'] = cls.__readByte(buf, pos, size) 1555 | pos[0] = size 1556 | return v 1557 | 1558 | @classmethod 1559 | def __readShortEventInfo(cls, buf: memoryview, pos: list[int], size: int) -> ShortEventInfo: 1560 | size = cls.__readStructIntro(buf, pos, size) 1561 | v: ShortEventInfo = { 1562 | 'event_name': cls.__readString(buf, pos, size), 1563 | 'text_char': cls.__readString(buf, pos, size) 1564 | } 1565 | pos[0] = size 1566 | return v 1567 | 1568 | @classmethod 1569 | def __readExtendedEventInfo(cls, buf: memoryview, pos: list[int], size: int) -> ExtendedEventInfo: 1570 | size = cls.__readStructIntro(buf, pos, size) 1571 | v: ExtendedEventInfo = { 1572 | 'text_char': cls.__readString(buf, pos, size) 1573 | } 1574 | pos[0] = size 1575 | return v 1576 | 1577 | @classmethod 1578 | def __readContentInfo(cls, buf: memoryview, pos: list[int], size: int) -> ContentInfo: 1579 | size = cls.__readStructIntro(buf, pos, size) 1580 | v: ContentInfo = { 1581 | 'nibble_list': cls.__readVector(cls.__readContentData, buf, pos, size) 1582 | } 1583 | pos[0] = size 1584 | return v 1585 | 1586 | @classmethod 1587 | def __readContentData(cls, buf: memoryview, pos: list[int], size: int) -> ContentData: 1588 | size = cls.__readStructIntro(buf, pos, size) 1589 | cn = cls.__readUshort(buf, pos, size) 1590 | un = cls.__readUshort(buf, pos, size) 1591 | v: ContentData = { 1592 | 'content_nibble': (cn >> 8 | cn << 8) & 0xffff, 1593 | 'user_nibble': (un >> 8 | un << 8) & 0xffff 1594 | } 1595 | pos[0] = size 1596 | return v 1597 | 1598 | @classmethod 1599 | def __readComponentInfo(cls, buf: memoryview, pos: list[int], size: int) -> ComponentInfo: 1600 | size = cls.__readStructIntro(buf, pos, size) 1601 | v: ComponentInfo = { 1602 | 'stream_content': cls.__readByte(buf, pos, size), 1603 | 'component_type': cls.__readByte(buf, pos, size), 1604 | 'component_tag': cls.__readByte(buf, pos, size), 1605 | 'text_char': cls.__readString(buf, pos, size) 1606 | } 1607 | pos[0] = size 1608 | return v 1609 | 1610 | @classmethod 1611 | def __readAudioComponentInfo(cls, buf: memoryview, pos: list[int], size: int) -> AudioComponentInfo: 1612 | size = cls.__readStructIntro(buf, pos, size) 1613 | v: AudioComponentInfo = { 1614 | 'component_list': cls.__readVector(cls.__readAudioComponentInfoData, buf, pos, size) 1615 | } 1616 | pos[0] = size 1617 | return v 1618 | 1619 | @classmethod 1620 | def __readAudioComponentInfoData(cls, buf: memoryview, pos: list[int], size: int) -> AudioComponentInfoData: 1621 | size = cls.__readStructIntro(buf, pos, size) 1622 | v: AudioComponentInfoData = { 1623 | 'stream_content': cls.__readByte(buf, pos, size), 1624 | 'component_type': cls.__readByte(buf, pos, size), 1625 | 'component_tag': cls.__readByte(buf, pos, size), 1626 | 'stream_type': cls.__readByte(buf, pos, size), 1627 | 'simulcast_group_tag': cls.__readByte(buf, pos, size), 1628 | 'es_multi_lingual_flag': cls.__readByte(buf, pos, size), 1629 | 'main_component_flag': cls.__readByte(buf, pos, size), 1630 | 'quality_indicator': cls.__readByte(buf, pos, size), 1631 | 'sampling_rate': cls.__readByte(buf, pos, size), 1632 | 'text_char': cls.__readString(buf, pos, size) 1633 | } 1634 | pos[0] = size 1635 | return v 1636 | 1637 | @classmethod 1638 | def __readEventGroupInfo(cls, buf: memoryview, pos: list[int], size: int) -> EventGroupInfo: 1639 | size = cls.__readStructIntro(buf, pos, size) 1640 | v: EventGroupInfo = { 1641 | 'group_type': cls.__readByte(buf, pos, size), 1642 | 'event_data_list': cls.__readVector(cls.__readEventData, buf, pos, size) 1643 | } 1644 | pos[0] = size 1645 | return v 1646 | 1647 | @classmethod 1648 | def __readEventData(cls, buf: memoryview, pos: list[int], size: int) -> EventData: 1649 | size = cls.__readStructIntro(buf, pos, size) 1650 | v: EventData = { 1651 | 'onid': cls.__readUshort(buf, pos, size), 1652 | 'tsid': cls.__readUshort(buf, pos, size), 1653 | 'sid': cls.__readUshort(buf, pos, size), 1654 | 'eid': cls.__readUshort(buf, pos, size) 1655 | } 1656 | pos[0] = size 1657 | return v 1658 | 1659 | @classmethod 1660 | def __readSearchDateInfo(cls, buf: memoryview, pos: list[int], size: int) -> SearchDateInfo: 1661 | size = cls.__readStructIntro(buf, pos, size) 1662 | v: SearchDateInfo = { 1663 | 'start_day_of_week': cls.__readByte(buf, pos, size), 1664 | 'start_hour': cls.__readUshort(buf, pos, size), 1665 | 'start_min': cls.__readUshort(buf, pos, size), 1666 | 'end_day_of_week': cls.__readByte(buf, pos, size), 1667 | 'end_hour': cls.__readUshort(buf, pos, size), 1668 | 'end_min': cls.__readUshort(buf, pos, size) 1669 | } 1670 | pos[0] = size 1671 | return v 1672 | 1673 | @classmethod 1674 | def __readSearchKeyInfo(cls, buf: memoryview, pos: list[int], size: int) -> SearchKeyInfo: 1675 | size = cls.__readStructIntro(buf, pos, size) 1676 | and_key = cls.__readString(buf, pos, size) 1677 | key_disabled = and_key.startswith('^!{999}') 1678 | and_key = and_key.removeprefix('^!{999}') 1679 | case_sensitive = and_key.startswith('C!{999}') 1680 | and_key = and_key.removeprefix('C!{999}') 1681 | chk_duration_min = 0 1682 | chk_duration_max = 0 1683 | if (len(and_key) >= 13 and and_key.startswith('D!{1') and and_key[12] == '}' and 1684 | all([c >= '0' and c <= '9' for c in and_key[4:12]])): 1685 | chk_duration_max = int(and_key[3:12]) 1686 | and_key = and_key[13:] 1687 | chk_duration_min = chk_duration_max // 10000 % 10000 1688 | chk_duration_max = chk_duration_max % 10000 1689 | v: SearchKeyInfo = { 1690 | 'and_key': and_key, 1691 | 'not_key': cls.__readString(buf, pos, size), 1692 | 'key_disabled': key_disabled, 1693 | 'case_sensitive': case_sensitive, 1694 | 'reg_exp_flag': cls.__readInt(buf, pos, size) != 0, 1695 | 'title_only_flag': cls.__readInt(buf, pos, size) != 0, 1696 | 'content_list': cls.__readVector(cls.__readContentData, buf, pos, size), 1697 | 'date_list': cls.__readVector(cls.__readSearchDateInfo, buf, pos, size), 1698 | 'service_list': cls.__readVector(cls.__readLong, buf, pos, size), 1699 | 'video_list': cls.__readVector(cls.__readUshort, buf, pos, size), 1700 | 'audio_list': cls.__readVector(cls.__readUshort, buf, pos, size), 1701 | 'aimai_flag': cls.__readByte(buf, pos, size) != 0, 1702 | 'not_contet_flag': cls.__readByte(buf, pos, size) != 0, 1703 | 'not_date_flag': cls.__readByte(buf, pos, size) != 0, 1704 | 'free_ca_flag': cls.__readByte(buf, pos, size), 1705 | 'chk_rec_end': cls.__readByte(buf, pos, size) != 0, 1706 | 'chk_duration_min': chk_duration_min, 1707 | 'chk_duration_max': chk_duration_max 1708 | } 1709 | chk_rec_day = cls.__readUshort(buf, pos, size) 1710 | v['chk_rec_day'] = chk_rec_day % 10000 if chk_rec_day >= 40000 else chk_rec_day 1711 | v['chk_rec_no_service'] = chk_rec_day >= 40000 1712 | pos[0] = size 1713 | return v 1714 | 1715 | @classmethod 1716 | def __readAutoAddData(cls, buf: memoryview, pos: list[int], size: int) -> AutoAddData: 1717 | size = cls.__readStructIntro(buf, pos, size) 1718 | v: AutoAddData = { 1719 | 'data_id': cls.__readInt(buf, pos, size), 1720 | 'search_info': cls.__readSearchKeyInfo(buf, pos, size), 1721 | 'rec_setting': cls.__readRecSettingData(buf, pos, size), 1722 | 'add_count': cls.__readInt(buf, pos, size) 1723 | } 1724 | pos[0] = size 1725 | return v 1726 | 1727 | @classmethod 1728 | def __readManualAutoAddData(cls, buf: memoryview, pos: list[int], size: int) -> ManualAutoAddData: 1729 | size = cls.__readStructIntro(buf, pos, size) 1730 | v: ManualAutoAddData = { 1731 | 'data_id': cls.__readInt(buf, pos, size), 1732 | 'day_of_week_flag': cls.__readByte(buf, pos, size), 1733 | 'start_time': cls.__readUint(buf, pos, size), 1734 | 'duration_second': cls.__readUint(buf, pos, size), 1735 | 'title': cls.__readString(buf, pos, size), 1736 | 'station_name': cls.__readString(buf, pos, size), 1737 | 'onid': cls.__readUshort(buf, pos, size), 1738 | 'tsid': cls.__readUshort(buf, pos, size), 1739 | 'sid': cls.__readUshort(buf, pos, size), 1740 | 'rec_setting': cls.__readRecSettingData(buf, pos, size) 1741 | } 1742 | pos[0] = size 1743 | return v 1744 | 1745 | @classmethod 1746 | def __readNWPlayTimeShiftInfo(cls, buf: memoryview, pos: list[int], size: int) -> NWPlayTimeShiftInfo: 1747 | size = cls.__readStructIntro(buf, pos, size) 1748 | v: NWPlayTimeShiftInfo = { 1749 | 'ctrl_id': cls.__readInt(buf, pos, size), 1750 | 'file_path': cls.__readString(buf, pos, size) 1751 | } 1752 | pos[0] = size 1753 | return v 1754 | 1755 | @classmethod 1756 | def __readNotifySrvInfo(cls, buf: memoryview, pos: list[int], size: int) -> NotifySrvInfo: 1757 | size = cls.__readStructIntro(buf, pos, size) 1758 | v: NotifySrvInfo = { 1759 | 'notify_id': cls.__readUint(buf, pos, size), 1760 | 'time': cls.__readSystemTime(buf, pos, size), 1761 | 'param1': cls.__readUint(buf, pos, size), 1762 | 'param2': cls.__readUint(buf, pos, size), 1763 | 'count': cls.__readUint(buf, pos, size), 1764 | 'param4': cls.__readString(buf, pos, size), 1765 | 'param5': cls.__readString(buf, pos, size), 1766 | 'param6': cls.__readString(buf, pos, size) 1767 | } 1768 | pos[0] = size 1769 | return v 1770 | --------------------------------------------------------------------------------