diff --git a/pkgs/additional/sane-cast/default.nix b/pkgs/additional/sane-cast/default.nix new file mode 100644 index 00000000..26b2d801 --- /dev/null +++ b/pkgs/additional/sane-cast/default.nix @@ -0,0 +1,7 @@ +{ static-nix-shell }: +static-nix-shell.mkPython3Bin { + pname = "sane-cast"; + srcRoot = ./.; + pyPkgs = [ ]; +} + diff --git a/pkgs/additional/sane-cast/sane-cast b/pkgs/additional/sane-cast/sane-cast new file mode 100755 index 00000000..30b68cef --- /dev/null +++ b/pkgs/additional/sane-cast/sane-cast @@ -0,0 +1,167 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p go2tv +# vim: set filetype=python : +""" +cast media (local video or audio files) to a device on the same network +with some awareness of device-specific quirks. +""" + +from dataclasses import dataclass +from enum import Enum + +import argparse +import logging +import os +import subprocess +import tempfile + +logger = logging.getLogger(__name__) + +class Compat(Enum): + Default = "default" + # RenameToMp4: ensure the name of the file sent ends in ".mp4". + # this does not *transcode*. it doesn't modify the contents of the file at all. it just changes the name. + # some devices are just so dumb that they'll only render a mkv file if it ends in .mp4. + RenameToMp4 = "RenameToMp4" + +@dataclass +class Device: + # "model", or device name. e.g. "Theater TV". + model: str + compat: Compat | None = None + # URL: endpoint at which the device can be controlled. e.g. http://1.2.3.4:567/MediaRenderer.xml + url: str | None = None + + def augmented(self, other: "Device") -> "Device": + return Device( + model = self.model or other.model, + compat = self.compat or other.compat, + url = self.url or other.url, + ) + +# ranked in order of preference +KNOWN_DEVICES = [ + Device("Theater TV", Compat.RenameToMp4), + Device("[LG] webOS TV OLED55C9PUA", Compat.Default), +] + +class Go2TvParser: + def __init__(self) -> None: + self.parsed_devices = [] + self.partial_model = None + self.partial_url = None + + def into_devices(self) -> list[Device]: + self.close_device() + return self.parsed_devices[:] + + def feed_line(self, line: str) -> None: + sanitized = line \ + .replace("\x1b[0m", "") \ + .replace("\x1b[1m", "") \ + .strip() + + logger.debug(repr(sanitized)) + if sanitized and all(c == "-" for c in sanitized): + self.close_device() + elif sanitized.startswith("Model:"): + self.partial_model = sanitized[len("Model:"):].strip() + elif sanitized.startswith("URL:"): + self.partial_url = sanitized[len("URL:"):].strip() + + def close_device(self) -> None: + """ + if there's any device data parsed, move it into `parsed_devices` + """ + if self.partial_model is not None and self.partial_url: + self.parsed_devices.append(Device( + model=self.partial_model, + url=self.partial_url, + )) + + self.partial_model = None + self.partial_url = None + +class Go2TvDriver: + visible_devices = None + def scan_devices(self) -> None: + go2tv = subprocess.Popen( + [ "go2tv", "-l" ], + stdout=subprocess.PIPE, + ) + parser = Go2TvParser() + for line in iter(go2tv.stdout.readline, b''): + parser.feed_line(line.decode("utf-8")) + + self.visible_devices = parser.into_devices() + + def rank_devices(self) -> list[Device]: + ranked = [] + for known in KNOWN_DEVICES: + for vis in self.visible_devices: + if vis.model == known.model: + ranked.append(known.augmented(vis)) + + for vis in self.visible_devices: + if not any(vis.model == r.model for r in ranked): + ranked.append(vis) + + return ranked + + def cast_to(self, dev: Device, media: str) -> None: + logger.info(f"casting to {dev.model} at {dev.url} with compat {dev.compat}") + + if dev.compat == Compat.RenameToMp4: + if not media.endswith(".mp4"): + if media.startswith("http://") or media.startswith("https://"): + logger.info(f"ignoring compat requirement {dev.compat} for {media}") + else: + # TODO: make sure this directory gets cleaned up! + dir_ = tempfile.mkdtemp(prefix="sane-cast-") + new_name = os.path.join(dir_, os.path.basename(media) + ".mp4") + os.symlink(media, new_name) + media = new_name + + if media.startswith("http://") or media.startswith("https://"): + media_args = [ "-u", media ] + else: + media_args = [ "-v", media ] + + cli_args = [ "go2tv", "-t", dev.url ] + media_args + logger.info(" ".join(cli_args)) + + os.execvp("go2tv", cli_args) + + +def main(): + logging.basicConfig() + logging.getLogger().setLevel(logging.INFO) + + parser = argparse.ArgumentParser(description="cast media to a DLNA receiver in range") + parser.add_argument("--verbose", action="store_true", help="more logging") + parser.add_argument("media", help="file or URL to send to the DLNA device") + + args = parser.parse_args() + + if args.verbose: + logger.setLevel(logging.DEBUG) + + go2tv = Go2TvDriver() + go2tv.scan_devices() + devices = go2tv.rank_devices() + if not devices: + logger.info("no devices found! exiting") + return + + for d in devices: + logger.info(f"found: {d.model}") + + if len(devices) != 1: + logger.info("found multiple devices! exiting") + # TODO: allow choosing, or a "--yes" option + return + + go2tv.cast_to(devices[0], args.media) + +if __name__ == "__main__": + main() diff --git a/pkgs/default.nix b/pkgs/default.nix index ea52df09..e67a1649 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -57,6 +57,7 @@ let pipeline = callPackage ./additional/pipeline { }; rtl8723cs-firmware = callPackage ./additional/rtl8723cs-firmware { }; rtl8723cs-wowlan = callPackage ./additional/rtl8723cs-wowlan { }; + sane-cast = callPackage ./additional/sane-cast { }; sane-die-with-parent = callPackage ./additional/sane-die-with-parent { }; sane-open-desktop = callPackage ./additional/sane-open-desktop { }; sane-sandboxed = callPackage ./additional/sane-sandboxed { };