autotako

Service to monitor moombox for completed livestream downloads to upload for distribution
git clone https://code.alwayswait.ing/autotako
Log | Files | Refs | README

job_render.py (20724B)


      1 #!/usr/bin/python3
      2 
      3 import asyncio
      4 import datetime
      5 import enum
      6 import itertools
      7 import json
      8 import logging
      9 import pathlib
     10 import re
     11 import urllib.parse
     12 from typing import Self
     13 
     14 import gofile.api  # type: ignore
     15 import httpx
     16 import microdot  # type: ignore
     17 import msgspec
     18 import stamina
     19 import torf  # type: ignore
     20 
     21 from .config import QBittorrentConfig, WebDavConfig, config_ctx
     22 from .database import database_ctx
     23 
     24 app = microdot.Microdot()
     25 
     26 log = logging.getLogger(__name__)
     27 
     28 background_tasks = set()
     29 render_tasks: dict[str, asyncio.Task] = {}
     30 upload_tasks: dict[pathlib.Path, asyncio.Task] = {}
     31 
     32 # https://www.japanesewithanime.com/2017/05/quotation-marks-japanese.html#sumitsukikakko
     33 # strip extraneous spaces and hashtag
     34 _pattern_bracketed_kw = re.compile(r"^【[\s#]*(?P<topic>.*?)[\s]?】")
     35 
     36 # bae prefers the double angle brackets because she's quirky like that
     37 _pattern_angled_bracketed_kw = re.compile(r"^≪[\s#]*(?P<topic>.*?)[\s]?≫")
     38 
     39 
     40 class JobUploadCriteria(enum.StrEnum):
     41     MANUALLY = "manual"
     42     WHEN_FINISHED = "finished"
     43     IF_PRIVATE = "private"
     44     IF_CUT = "cut"
     45 
     46     @property
     47     def automatic(self) -> bool:
     48         return self != JobUploadCriteria.MANUALLY
     49 
     50 
     51 class MoomboxDownloadStatus(enum.StrEnum):
     52     UNKNOWN = "Unknown"
     53     UNAVAILABLE = "Unavailable"
     54     WAITING = "Waiting"
     55     DOWNLOADING = "Downloading"
     56     MUXING = "Muxing"
     57     FINISHED = "Finished"
     58     ERROR = "Error"
     59 
     60 
     61 class MoomboxJobInfo(msgspec.Struct, kw_only=True, frozen=True):
     62     id: str
     63     title: str | None
     64     author: str | None
     65     channel_id: str | None
     66     video_id: str | None
     67     manifest_progress: dict
     68     status: MoomboxDownloadStatus = MoomboxDownloadStatus.UNKNOWN
     69     output_paths: set[str] | None = msgspec.field(default_factory=set)
     70     scheduled_start_datetime: datetime.datetime | None = None
     71     download_finish_datetime: datetime.datetime | None = None
     72 
     73     @property
     74     def uploadability_state(self) -> JobUploadCriteria | None:
     75         # returns a JobUploadCriteria that can be tested for equality to run
     76         if self.status != MoomboxDownloadStatus.FINISHED:
     77             return None
     78         if not self.files_available:
     79             return None
     80         if not self.is_configured_for_upload:
     81             return None
     82         return JobUploadCriteria.WHEN_FINISHED
     83 
     84     @property
     85     def is_configured_for_upload(self) -> bool:
     86         if not self.channel_id:
     87             return False
     88         config = config_ctx.get()
     89         channel_config = config.get_channel_config_by_id(self.channel_id)
     90         if not channel_config:
     91             return False
     92         return True
     93 
     94     @property
     95     def files_available(self) -> bool:
     96         return bool(self.output_pathobjs) and all(p.exists() for p in self.output_pathobjs)
     97 
     98     @property
     99     def output_pathobjs(self) -> set[pathlib.Path]:
    100         return set(map(pathlib.Path, self.output_paths or []))
    101 
    102     @property
    103     def last_activity_datetime(self) -> datetime.datetime:
    104         # this does not need to be very accurate
    105         return (
    106             self.download_finish_datetime
    107             or self.scheduled_start_datetime
    108             or datetime.datetime.fromtimestamp(0, tz=datetime.UTC)
    109         )
    110 
    111 
    112 def _extract_name_from_stream_title(title: str) -> str | None:
    113     """
    114     Attempts to extract a preset name from the stream title.
    115     """
    116     match = _pattern_bracketed_kw.search(title) or _pattern_angled_bracketed_kw.search(title)
    117     if match:
    118         return str(match["topic"]).strip()
    119     return None
    120 
    121 
    122 class JobConfig(msgspec.Struct):
    123     name: str
    124     upload: JobUploadCriteria
    125     author_note: str | None = None
    126 
    127     @classmethod
    128     def from_moombox_job(cls, job: MoomboxJobInfo) -> Self:
    129         # generate a default config from the job
    130         name = "Unspecified Stream"
    131         upload = JobUploadCriteria.MANUALLY
    132 
    133         if not job.title:
    134             return cls(name, upload)
    135 
    136         name = _extract_name_from_stream_title(job.title) or name
    137 
    138         # mapping of phrases and any possible synonyms
    139         # if a synonym isn't present then an empty set should be present
    140         phrases = {
    141             "unarchived": {"no archive", "unarchive"},
    142             "karaoke": {"rock n' rawr", "歌枠", "sing"},
    143             "rebroadcast": {"kara-rewind"},
    144         }
    145 
    146         phrase_set = set(phrases.keys()) | set(itertools.chain.from_iterable(phrases.values()))
    147         # not sure why mypy complains about job.title being possibly None here if left bare...
    148         matched_phrases = set(
    149             filter(lambda term: term in (job.title.lower() if job.title else ""), phrase_set)
    150         )
    151 
    152         # dedupe synonymous terms
    153         for phrase, syn_phrases in phrases.items():
    154             if syn_phrases and matched_phrases & syn_phrases:
    155                 matched_phrases = (matched_phrases - syn_phrases) | {phrase}
    156 
    157         if matched_phrases == {"unarchived"}:
    158             name = "Unarchived Stream"
    159             upload = JobUploadCriteria.WHEN_FINISHED
    160         elif matched_phrases == {"karaoke"}:
    161             name = "Karaoke"
    162             upload = JobUploadCriteria.IF_CUT
    163         elif matched_phrases == {"unarchived", "karaoke"}:
    164             name = "Unarchived Karaoke"
    165             upload = JobUploadCriteria.WHEN_FINISHED
    166         elif matched_phrases == {"unarchived", "karaoke", "rebroadcast"}:
    167             name = "Unarchived Karaoke (rebroadcast)"
    168             upload = JobUploadCriteria.MANUALLY
    169 
    170         # cannot auto-upload if no channel configuration exists
    171         if not job.is_configured_for_upload:
    172             upload = JobUploadCriteria.MANUALLY
    173 
    174         return cls(name, upload)
    175 
    176 
    177 def stream_display_date(dt: datetime.datetime) -> datetime.date:
    178     """
    179     Given the stream start time, returns a display date.
    180 
    181     If the stream start time is within the last 10 minutes of the day, we treat the next day
    182     as the stream date instead based on the assumption that the streamer has a waiting screen
    183     set up.
    184     """
    185     end_of_day = (23, 50)
    186     return dt.date() + datetime.timedelta(days=1 if (dt.hour, dt.minute) >= end_of_day else 0)
    187 
    188 
    189 async def get_moombox_job_by_id(jobid: str) -> MoomboxJobInfo | None:
    190     config = config_ctx.get()
    191     async with httpx.AsyncClient() as client:
    192         for attempt in stamina.retry_context(
    193             on=httpx.HTTPError, attempts=None, timeout=None, wait_initial=0.5, wait_max=10.0
    194         ):
    195             with attempt:
    196                 result = await client.get(urllib.parse.urljoin(config.moombox_url, "status"))
    197         for job in result.json():
    198             if job["id"] == jobid:
    199                 return msgspec.convert(job, type=MoomboxJobInfo)
    200         return None
    201 
    202 
    203 async def gofile_upload_and_persist(
    204     cursor, upload_file: pathlib.Path, job_id: str, token: str, folder_id: str
    205 ):
    206     with upload_file.open("rb") as upload_filereader:
    207         await gofile.api.upload_single(upload_filereader, token, folder_id)
    208         database = database_ctx.get()
    209         cursor = database.cursor()
    210         cursor.execute(
    211             "INSERT INTO gofile_files VALUES (?, ?)",
    212             (job_id, str(upload_file)),
    213         )
    214         database.commit()
    215 
    216 
    217 async def do_gofile_upload(job: MoomboxJobInfo):
    218     """
    219     Prepares a guest upload for the specific job's output files, then returns the folder URL
    220     while the uploads are running in the background.
    221 
    222     This operation is idempotent; if it is interrupted, running this function will only upload
    223     files that weren't already.
    224     """
    225     database = database_ctx.get()
    226 
    227     known_files = set()
    228     for known_file, *_ in database.execute(
    229         "SELECT filepath FROM gofile_files WHERE job_id = ?", (job.id,)
    230     ):
    231         known_files.add(pathlib.Path(known_file))
    232 
    233     pending_files = set(map(pathlib.Path, job.output_paths or [])) - known_files
    234 
    235     token = None
    236     folder_id = None
    237     gofile_url = None
    238 
    239     cursor = database.cursor()
    240     cursor.execute(
    241         "SELECT token, folder_id, url FROM gofile_tokens WHERE job_id = ?", (job.id,)
    242     )
    243     job_token_info = cursor.fetchone()
    244     if job_token_info:
    245         token, folder_id, gofile_url = job_token_info
    246     else:
    247         # get the smallest file to quickly get the upload result
    248         pending_file = min(pending_files, key=lambda p: p.stat().st_size)
    249         if pending_file in upload_tasks:
    250             # another task is uploading the same file; just block until that one is available
    251             upload = await upload_tasks[pending_file]
    252             token, folder_id, gofile_url = (
    253                 upload.result.guest_token,
    254                 upload.result.parent_folder,
    255                 upload.result.download_page,
    256             )
    257         else:
    258             with pending_file.open("rb") as fo:
    259                 upload_tasks[pending_file] = gofile.api.upload_single(fo)
    260                 upload = await upload_tasks[pending_file]
    261                 token, folder_id, gofile_url = (
    262                     upload.result.guest_token,
    263                     upload.result.parent_folder,
    264                     upload.result.download_page,
    265                 )
    266             cursor.execute(
    267                 "INSERT INTO gofile_tokens VALUES (?, ?, ?, ?)",
    268                 (job.id, token, folder_id, gofile_url),
    269             )
    270             cursor.execute(
    271                 "INSERT INTO gofile_files VALUES (?, ?)",
    272                 (job.id, str(pending_file)),
    273             )
    274             database.commit()
    275         pending_files.discard(pending_file)
    276 
    277     # upload any remaining files in the background
    278     for pending_file in pending_files:
    279         # don't double-dip and upload an in-progress file
    280         if pending_file in upload_tasks:
    281             continue
    282         upload_tasks[pending_file] = asyncio.create_task(
    283             gofile_upload_and_persist(cursor, pending_file, job.id, token, folder_id)
    284         )
    285     return gofile_url
    286 
    287 
    288 async def do_webdav_upload(webdav: WebDavConfig, filepath: pathlib.Path, target: str):
    289     auth = httpx.BasicAuth(username=webdav.username, password=webdav.password)
    290     async with httpx.AsyncClient(auth=auth) as client:
    291         connection_warning_seen = False
    292         while True:
    293             try:
    294                 dest = urllib.parse.urljoin(webdav.base_url, target)
    295                 file_check = await client.head(dest)
    296                 if file_check.status_code == httpx.codes.NOT_FOUND:
    297                     with filepath.open("rb") as fh:
    298                         await client.put(dest, content=fh.read())
    299                 return
    300             except httpx.ConnectTimeout:
    301                 if not connection_warning_seen:
    302                     log.warning(f"Failed to connect to {webdav.base_url}.  Retrying...")
    303                     connection_warning_seen = True
    304                 await asyncio.sleep(10)
    305 
    306 
    307 async def get_qbittorrent_session(qbt_config: QBittorrentConfig) -> httpx.AsyncClient:
    308     client = httpx.AsyncClient(
    309         base_url=urllib.parse.urljoin(qbt_config.host, "api/v2/"), verify=False
    310     )
    311     r = await client.post(
    312         "auth/login",
    313         data={"username": qbt_config.username, "password": qbt_config.password},
    314     )
    315     r.raise_for_status()
    316     return client
    317 
    318 
    319 async def _process_job(jobid):
    320     job = await get_moombox_job_by_id(jobid)
    321     if not job:
    322         return "Couldn't find matching job", 404
    323 
    324     # TODO fill in necessary fields in template, etc.
    325     job_config = _get_saved_jobconf(job.id) or JobConfig.from_moombox_job(job)
    326 
    327     config = config_ctx.get()
    328 
    329     channel = config.get_channel_config_by_id(job.channel_id)
    330     stream_time = job.scheduled_start_datetime or datetime.datetime.now(tz=datetime.UTC)
    331 
    332     torrent_output_dir = pathlib.Path("torrents")
    333     torrent_output_dir.mkdir(exist_ok=True)
    334 
    335     display_date = stream_display_date(stream_time)
    336     display_date_str = display_date.strftime("%Y%m%d")
    337     torrent_name = f"[{display_date_str}] {job_config.name} ({job.video_id})"
    338 
    339     folder_name = None
    340     if stream_time:
    341         folder_name = stream_time.strftime("%Y-%m-%dT%H:%MZ")
    342 
    343     readme_finalized = False
    344     render_kwargs = {}
    345     if job.output_paths:
    346         torrent_file = torrent_output_dir / f"{job.id} ({job.video_id}).torrent"
    347         source_files = list(map(pathlib.Path, job.output_paths))
    348 
    349         if not all(f.exists() for f in source_files):
    350             return
    351 
    352         save_path = str(source_files[0].parent)
    353 
    354         if not torrent_file.exists():
    355             t = torf.Torrent(
    356                 trackers=config.torrent.trackers,
    357             )
    358 
    359             # TODO extract key phrases from title / description
    360 
    361             t.filepaths = source_files
    362             # note that the name also sets the subdirectory for the files
    363             # as such, using other names for the same files *will* change the infohash
    364             t.name = torrent_name
    365             if config.torrent.comment:
    366                 t.comment = config.torrent.comment
    367             success = await asyncio.to_thread(t.generate)
    368             if not success:
    369                 raise RuntimeError("Failed to generate torrent")
    370             t.write(torrent_file)
    371             t.write(torrent_output_dir / f"{torrent_name}.torrent")
    372 
    373             # send torrent to qbittorrent for seeding
    374             if config.qbittorrent:
    375                 if config.qbittorrent.default_save_path:
    376                     save_path = config.qbittorrent.default_save_path
    377                 client = await get_qbittorrent_session(config.qbittorrent)
    378                 files = [
    379                     (
    380                         "torrents",
    381                         (
    382                             torrent_file.name,
    383                             torrent_file.open("rb"),
    384                             "application/x-bittorrent",
    385                         ),
    386                     ),
    387                 ]
    388                 data = {
    389                     "savepath": save_path,
    390                     "contentLayout": "NoSubfolder",
    391                     "stopped": "true" if config.qbittorrent.start_paused else "false",
    392                     "paused": "true" if config.qbittorrent.start_paused else "false",
    393                 }
    394                 task = asyncio.create_task(client.post("torrents/add", files=files, data=data))
    395                 background_tasks.add(task)
    396                 task.add_done_callback(background_tasks.discard)
    397         else:
    398             t = torf.Torrent.read(torrent_file)
    399         log.debug(t)
    400         log.debug(t.magnet(size=False))
    401         log.debug(t.files)
    402 
    403         # punt file to webdav remote
    404         if config.webdav and channel and channel.webdav_path:
    405             target_base = f"{channel.webdav_path}/{folder_name}"
    406             task = asyncio.create_task(
    407                 do_webdav_upload(
    408                     config.webdav, torrent_file, f"{target_base}/{torrent_name}.torrent"
    409                 )
    410             )
    411             background_tasks.add(task)
    412             task.add_done_callback(background_tasks.discard)
    413 
    414         render_kwargs["magnet_url"] = t.magnet(size=False)
    415 
    416         # punt files to gofile
    417         render_kwargs["gofile_url"] = await do_gofile_upload(job)
    418         readme_finalized = True
    419     rendered_job = await microdot.jinja.Template("job.md").render_async(
    420         job=job,
    421         author_override=channel.name if channel else None,
    422         stream_time=stream_time.strftime("%Y-%m-%dT%H:%M:%SZ") if stream_time else "(unknown)",
    423         config=job_config,
    424         **render_kwargs,
    425     )
    426     if readme_finalized and config.webdav and channel and channel.webdav_path:
    427         rendered_job_file = torrent_output_dir / f"{job.id} ({job.video_id}).md"
    428         rendered_job_file.write_text(rendered_job)
    429         target_base = f"{channel.webdav_path}/{folder_name}"
    430         task = asyncio.create_task(
    431             do_webdav_upload(config.webdav, rendered_job_file, f"{target_base}/REPORT.md")
    432         )
    433         background_tasks.add(task)
    434         task.add_done_callback(background_tasks.discard)
    435 
    436         database = database_ctx.get()
    437         cursor = database.cursor()
    438         cursor.execute(
    439             "INSERT INTO job_status VALUES (?, ?) ON CONFLICT(job_id) DO UPDATE SET completed=excluded.completed",
    440             (jobid, True),
    441         )
    442         database.commit()
    443     if not readme_finalized and jobid in render_tasks:
    444         # allow rerun if not finalized
    445         del render_tasks[jobid]
    446     return rendered_job
    447 
    448 
    449 def get_process_job_task(jobid: str) -> asyncio.Task:
    450     """
    451     Creates or retrieves a singleton job rendering task.
    452     This is decoupled from the request callback to allow cancellations and multiple consumers.
    453     """
    454     if jobid in render_tasks and render_tasks[jobid].done() and render_tasks[jobid].exception():
    455         del render_tasks[jobid]
    456     if jobid not in render_tasks:
    457         render_tasks[jobid] = asyncio.create_task(_process_job(jobid))
    458     return render_tasks[jobid]
    459 
    460 
    461 def _get_saved_jobconf(jobid: str) -> JobConfig | None:
    462     database = database_ctx.get()
    463     cursor = database.cursor()
    464     cursor.execute("SELECT config FROM job_config WHERE job_id = ?", (jobid,))
    465     result = cursor.fetchone()
    466     if result:
    467         job_config_payload, *_ = result
    468         return msgspec.json.decode(job_config_payload, type=JobConfig)
    469     return None
    470 
    471 
    472 @app.get("/<jobid>/config")
    473 async def show_job_editor(request, jobid: str):
    474     job = await get_moombox_job_by_id(jobid)
    475     if not job:
    476         return "Couldn't find matching job", 404
    477 
    478     job_config = _get_saved_jobconf(job.id) or JobConfig.from_moombox_job(job)
    479 
    480     return (
    481         await microdot.jinja.Template("edit.html").render_async(job=job, config=job_config),
    482         {
    483             "Content-Type": "text/html; charset=utf-8",
    484         },
    485     )
    486 
    487 
    488 @app.post("/<jobid>/config")
    489 async def apply_job_config(request, jobid: str):
    490     job = await get_moombox_job_by_id(jobid)
    491     if not job:
    492         return "Couldn't find matching job", 404
    493     jobconf = msgspec.convert(
    494         {item: request.form[item] for item in request.form}, type=JobConfig
    495     )
    496     database = database_ctx.get()
    497 
    498     cursor = database.cursor()
    499     cursor.execute(
    500         "INSERT INTO job_config VALUES (?, ?) ON CONFLICT(job_id) DO UPDATE SET config=excluded.config",
    501         (jobid, msgspec.json.encode(jobconf)),
    502     )
    503     database.commit()
    504 
    505     return (
    506         await microdot.jinja.Template("edit.html").render_async(job=job, config=jobconf),
    507         {
    508             "Content-Type": "text/html; charset=utf-8",
    509         },
    510     )
    511 
    512 
    513 @app.get("/<jobid>")
    514 async def show_job(request, jobid: str):
    515     return await get_process_job_task(jobid)
    516 
    517 
    518 @app.post("/<jobid>")
    519 async def upload_job(request, jobid: str):
    520     try:
    521         await get_process_job_task(jobid)
    522         return await microdot.jinja.Template("job/upload_success.html").render_async()
    523     except torf.TorfError:
    524         log.exception(f"Failed to upload job {jobid}")
    525         return await microdot.jinja.Template("job/upload_error.html").render_async()
    526 
    527 
    528 async def job_auto_monitor():
    529     while True:
    530         config = config_ctx.get()
    531 
    532         async with httpx.AsyncClient() as client:
    533             for attempt in stamina.retry_context(
    534                 on=(httpx.HTTPError, json.decoder.JSONDecodeError),
    535                 attempts=None,
    536                 timeout=None,
    537                 wait_initial=0.5,
    538                 wait_max=10.0,
    539             ):
    540                 with attempt:
    541                     result = await client.get(
    542                         urllib.parse.urljoin(config.moombox_url, "status")
    543                     )
    544                     result.raise_for_status()
    545                     result.json()
    546 
    547         jobs = []
    548         for serialized_job in result.json():
    549             try:
    550                 jobs.append(msgspec.convert(serialized_job, type=MoomboxJobInfo))
    551             except msgspec.ValidationError:
    552                 pass
    553 
    554         database = database_ctx.get()
    555         cursor = database.cursor()
    556         configs = {
    557             job_id: msgspec.json.decode(job_conf, type=JobConfig)
    558             for job_id, job_conf in cursor.execute("SELECT job_id, config FROM job_config")
    559         }
    560         statuses = {
    561             job_id: bool(completed)
    562             for job_id, completed in cursor.execute("SELECT job_id, completed FROM job_status")
    563         }
    564 
    565         for job in reversed(jobs):
    566             if not job.uploadability_state:
    567                 continue
    568             job_conf = configs.get(job.id)
    569             if not job_conf:
    570                 job_conf = JobConfig.from_moombox_job(job)
    571             if job.uploadability_state != job_conf.upload:
    572                 continue
    573             if not config.autoupload.active:
    574                 log.info(f"'{job.title}' meets conditions for upload")
    575                 continue
    576             if statuses.get(job.id):
    577                 continue
    578             log.info(f"'{job.title}' is scheduled for upload")
    579             get_process_job_task(job.id)
    580 
    581         await asyncio.sleep(120)