nonsensebot/app/modules/ts_transcode.py

231 lines
8.8 KiB
Python
Raw Normal View History

2024-09-12 20:18:57 +01:00
"""
This module actively listens for messages in a specific room from specific users, and checks if they contain
a video which uses an unsupported video codec. If it does, it will be transcoded and re-uploaded to the room.
This is used because we mirror Truth Social into a matrix room and sometimes the videos are in H265, which a lot of
clients cannot play.
This can easily be adapted to other rooms and users, or even to other types of media, by changing the USER_IDS list.
"""
2024-09-15 23:03:24 +01:00
2024-07-31 23:17:09 +01:00
import logging
2024-08-01 19:14:21 +01:00
import shlex
2024-07-31 23:17:09 +01:00
import shutil
import typing
import urllib.parse
import niobot
import tempfile
import asyncio
from pathlib import Path
class TruthSocialTranscode(niobot.Module):
2024-09-15 23:03:24 +01:00
USER_IDS = {"@trumpbot:shronk.net", "@tatebot:shronk.net", "@nex:nexy7574.co.uk"}
2024-07-31 23:17:09 +01:00
def __init__(self, bot: niobot.NioBot):
super().__init__(bot)
self.log = logging.getLogger(__name__)
if not shutil.which("ffprobe"):
raise RuntimeError("ffprobe is required for this module.")
if not shutil.which("ffmpeg"):
raise RuntimeError("ffmpeg is required for this module.")
# noinspection PyTypeChecker
self.bot.add_event_callback(self.on_message, niobot.RoomMessageVideo)
def __teardown__(self):
for callback in self.bot.event_callbacks:
if callback.func == self.on_message:
self.bot.event_callbacks.remove(callback)
super().__teardown__()
2024-09-15 23:03:24 +01:00
async def on_message_internal(
self, room: niobot.MatrixRoom, event: niobot.RoomMessageVideo
) -> str:
if self.bot.is_old(event):
return "IGNORED"
2024-07-31 23:17:09 +01:00
self.log.info("Processing event: %r (%r)", event, room)
# if not isinstance(event, niobot.RoomMessageVideo):
# # We are not interested in non-videos.
# return
# event: niobot.RoomMessageVideo
if event.sender not in self.USER_IDS:
# We are not interested in messages from other users.
self.log.info("Ignored user: %s", event.sender)
return "IGNORED"
# Videos sent in the truth room are not encrypted, so that makes this easier.
http_url = await self.bot.mxc_to_http(
event.url,
2024-09-15 23:03:24 +01:00
homeserver=event.sender.split(":", 1)[
1
], # use the sender homeserver for download
2024-07-31 23:17:09 +01:00
)
http_url = "https://" + http_url
name = event.flattened()["content.filename"]
with tempfile.NamedTemporaryFile(suffix="-%s" % name) as temp_fd:
# async with self.bot.client_session.get(http_url) as resp:
# temp_fd.write(await resp.read())
# temp_fd.flush()
dl = await self.bot.download(event.url, save_to=Path(temp_fd.name))
if isinstance(dl, niobot.DownloadError):
self.log.error("Error downloading video: %r", dl)
await self.bot.send_message(
room,
"Error downloading video, unable to transcode: %r" % dl,
2024-09-15 23:03:24 +01:00
reply_to=event.event_id,
2024-07-31 23:17:09 +01:00
)
return "ERROR_DOWNLOAD"
# First, probe the video to see if it has H265 content in it.
probe = await asyncio.create_subprocess_exec(
2024-09-15 23:03:24 +01:00
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokey=1",
2024-07-31 23:17:09 +01:00
temp_fd.name,
stdout=asyncio.subprocess.PIPE,
2024-09-15 23:03:24 +01:00
stderr=asyncio.subprocess.PIPE,
2024-07-31 23:17:09 +01:00
)
stdout, stderr = await probe.communicate()
if probe.returncode != 0:
# Something went wrong, let's not continue.
2024-09-15 23:03:24 +01:00
self.log.error(
"Error probing video:\n%s", (stderr or b"EOF").decode("utf-8")
)
2024-07-31 23:17:09 +01:00
await self.bot.send_message(
room,
"Error probing video, unable to determine whether a transcode is required.",
2024-09-15 23:03:24 +01:00
reply_to=event.event_id,
2024-07-31 23:17:09 +01:00
)
return "ERROR_PROBE"
codec = stdout.decode("utf-8").strip()
if codec in ["h264", "vp8", "vp9"]:
# We don't need to transcode this.
self.log.info("Video is already in a supported format: %s", codec)
return "NO_NEED"
# We now need to transcode this. For compatibility, we'll force it to be H264 and AAC.
# We will, however, use the High profile for H264, as almost everything supports it now, plus better
# compression.
# We will also limit the bitrate to about 5 megabit, because Trump has a habit of posting raw footage
# that ends up being something stupid like 20 megabit at 60fps.
2024-09-15 23:03:24 +01:00
with tempfile.NamedTemporaryFile(
suffix="-transcoded-" + name + ".mp4"
) as out_fd:
self.log.info("transcoding %s -> %s", temp_fd.name, out_fd.name)
2024-07-31 23:17:09 +01:00
args = [
"ffmpeg",
"-v",
2024-08-01 19:05:48 +01:00
"warning",
2024-07-31 23:17:09 +01:00
"-i",
temp_fd.name,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-preset",
"medium",
"-b:v",
"5M",
"-maxrate",
"5M",
"-bufsize",
"10M",
"-minrate",
"1M",
"-c:a",
"aac",
"-b:a",
"96k",
"-vf",
2024-08-01 19:15:08 +01:00
"scale=-2:'min(1080,ih)',fps=25",
2024-07-31 23:17:09 +01:00
"-movflags",
"faststart",
"-y",
# "-fps_mode",
# "vfr",
2024-07-31 23:17:09 +01:00
"-f",
"mp4",
2024-09-15 23:03:24 +01:00
out_fd.name,
2024-07-31 23:17:09 +01:00
]
transcode = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
2024-09-15 23:03:24 +01:00
stderr=asyncio.subprocess.PIPE,
2024-07-31 23:17:09 +01:00
)
self.log.info("Starting transcode...")
2024-08-01 19:14:21 +01:00
self.log.debug("Running command: %r", shlex.join(args))
2024-07-31 23:17:09 +01:00
stdout, stderr = await transcode.communicate()
self.log.info("Finished transcode.")
if transcode.returncode != 0:
2024-08-01 19:05:03 +01:00
self.log.error(
"Error transcoding video:\n%s\n\n%s",
stderr.decode("utf-8"),
2024-09-15 23:03:24 +01:00
stdout.decode("utf-8"),
2024-08-01 19:05:03 +01:00
)
2024-07-31 23:17:09 +01:00
await self.bot.send_message(
room,
2024-09-15 23:03:24 +01:00
"Error transcoding video, unable to transcode: got return code %d"
% transcode.returncode,
reply_to=event.event_id,
2024-07-31 23:17:09 +01:00
)
return "ERROR_TRANSCODE"
# We transcoded! Now we need to upload it.
file = await niobot.VideoAttachment.from_file(out_fd.name)
self.log.info("Uploading transcoded video")
await self.bot.send_message(
2024-09-15 23:03:24 +01:00
room.room_id, file=file, reply_to=event.event_id
2024-07-31 23:17:09 +01:00
)
self.log.info("Uploaded transcoded video")
return "OK"
async def on_message(self, room, event):
try:
return await self.on_message_internal(room, event)
except Exception as e:
self.log.exception("Error processing video", exc_info=e)
return "ERROR %r" % e
@staticmethod
def is_allowed(ctx: niobot.Context) -> bool:
2024-09-15 23:03:24 +01:00
return ctx.message.sender.split(":", 1)[1] in {
"shronk.net",
"nicroxio.co.uk",
"nexy7574.co.uk",
}
2024-07-31 23:17:09 +01:00
@niobot.command("transcode")
2024-09-15 23:03:24 +01:00
@niobot.from_homeserver(
"nexy7574.co.uk", "shronk.net", "nicroxio.co.uk", "transgender.ing"
)
async def do_transcode(
self,
ctx: niobot.Context,
message_link: typing.Annotated[
niobot.RoomMessage, niobot.EventParser("m.room.message")
],
):
2024-07-31 23:17:09 +01:00
"""
Transcodes a video to H264/AAC.
"""
if not isinstance(message_link, niobot.RoomMessageVideo):
await ctx.respond("That's not a video. %r" % message_link)
return
2024-09-15 23:03:24 +01:00
res = await ctx.respond("\N{HOURGLASS} Dispatching, please wait...")
2024-07-31 23:17:09 +01:00
ret = await self.on_message(ctx.room, message_link)
2024-09-15 23:03:24 +01:00
await res.edit(
f"\N{WHITE HEAVY CHECK MARK} Transcode complete. Result: `{ret}`"
)