Compare commits
3 Commits
01e176d902
...
7db40fbf47
Author | SHA1 | Date |
---|---|---|
Colin | 7db40fbf47 | |
Colin | 152a5d4c92 | |
Colin | e33b7d7701 |
|
@ -760,6 +760,8 @@ in
|
|||
|
||||
rustc = {};
|
||||
|
||||
sane-cast = {}; #< TODO: sandbox this the same way i sandbox go2tv
|
||||
|
||||
sane-die-with-parent.sandbox.enable = false; #< it's a launcher; can't sandbox
|
||||
|
||||
sane-open-desktop.sandbox.enable = false; #< trivial script, and all our deps are sandboxed
|
||||
|
|
|
@ -184,7 +184,7 @@ in
|
|||
|
||||
suggestedPrograms = [
|
||||
"blast-to-default"
|
||||
"go2tv"
|
||||
"sane-cast"
|
||||
"sane-die-with-parent"
|
||||
"xdg-terminal-exec"
|
||||
];
|
||||
|
@ -198,7 +198,7 @@ in
|
|||
sandbox.whitelistWayland = true;
|
||||
sandbox.extraHomePaths = [
|
||||
".config/mpv" #< else mpris plugin crashes on launch
|
||||
".local/share/applications" #< for xdg-terminal-exec (go2tv)
|
||||
".local/share/applications" #< for xdg-terminal-exec (sane-cast)
|
||||
# it's common for album (or audiobook, podcast) images/lyrics/metadata to live adjacent to the primary file.
|
||||
# CLI detection is too poor to pick those up, so expose the common media dirs to the sandbox to make that *mostly* work.
|
||||
"Books/local"
|
||||
|
|
|
@ -34,6 +34,4 @@ alt+i script-binding uosc/keybinds #! Utils > Key bindings
|
|||
O script-binding uosc/show-in-directory #! Utils > Show in directory
|
||||
# script-binding uosc/open-config-directory #! Utils > Open config directory
|
||||
ctrl+r script-binding sane_cast/blast #! Audiocast
|
||||
ctrl+t script-binding sane_cast/go2tv-video #! Cast
|
||||
# script-binding sane_cast/go2tv-stream #! Cast (...) > Stream
|
||||
# script-binding sane_cast/go2tv-gui #! Cast (...) > GUI
|
||||
ctrl+t script-binding sane_cast/sane-cast #! Cast
|
||||
|
|
|
@ -18,17 +18,16 @@ function subprocess(in_terminal, args)
|
|||
})
|
||||
end
|
||||
|
||||
function invoke_go2tv(in_terminal, args)
|
||||
function invoke_paused(in_terminal, args)
|
||||
mp.commandv("set", "pause", "yes")
|
||||
subprocess(in_terminal, { "go2tv", table.unpack(args) })
|
||||
for k, v in ipairs(args) do
|
||||
if v == "@FILE@" then
|
||||
args[k] = mp.get_property("stream-open-filename")
|
||||
end
|
||||
end
|
||||
subprocess(in_terminal, args)
|
||||
end
|
||||
|
||||
function invoke_go2tv_on_open_file(mode)
|
||||
msg.trace("invoke_go2tv_on_open_file")
|
||||
local path = mp.get_property("stream-open-filename")
|
||||
msg.trace("path:", path)
|
||||
invoke_go2tv(true, { mode, path })
|
||||
end
|
||||
|
||||
-- invoke blast in a way where it dies when we die, because:
|
||||
-- 1. when mpv exits, it `SIGKILL`s this toplevel subprocess.
|
||||
|
@ -36,8 +35,6 @@ end
|
|||
-- 3. bwrap does not pass SIGKILL or SIGTERM to its child.
|
||||
-- 4. hence, to properly kill blast, we have to kill all the descendants.
|
||||
mp.add_key_binding(nil, "blast", function() subprocess(false, { "sane-die-with-parent", "--descendants", "--use-pgroup", "--catch-sigkill", "blast-to-default" }) end)
|
||||
mp.add_key_binding(nil, "go2tv-gui", function() invoke_go2tv(false, {}) end)
|
||||
mp.add_key_binding(nil, "go2tv-video", function() invoke_go2tv_on_open_file("-v") end)
|
||||
mp.add_key_binding(nil, "go2tv-stream", function() invoke_go2tv_on_open_file("-s") end)
|
||||
mp.add_key_binding(nil, "sane-cast", function() invoke_paused(true, { "sane-cast", "--verbose", "@FILE@" }) end)
|
||||
|
||||
msg.trace("load: complete")
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{ static-nix-shell }:
|
||||
static-nix-shell.mkPython3Bin {
|
||||
pname = "sane-cast";
|
||||
srcRoot = ./.;
|
||||
pyPkgs = [ ];
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
#!/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
|
||||
and a menu to select a device if there's more than one online
|
||||
"""
|
||||
|
||||
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 choose_device(devices: list[Device]) -> Device | None:
|
||||
if not devices:
|
||||
logger.info("no devices found!")
|
||||
return
|
||||
|
||||
if len(devices) == 1:
|
||||
return devices[0]
|
||||
|
||||
dev = None
|
||||
while dev is None:
|
||||
# TODO: use a GUI menu like zenity?
|
||||
print("choose a device:")
|
||||
for i, d in enumerate(devices):
|
||||
print(f"[{i + 1}] {d.model}")
|
||||
print("[q] quit")
|
||||
print("")
|
||||
print("> ", end="")
|
||||
|
||||
choice = input()
|
||||
if choice.strip() == "q":
|
||||
return
|
||||
try:
|
||||
dev = devices[int(choice.strip()) - 1]
|
||||
except:
|
||||
print(f"invalid choice {choice!r}")
|
||||
|
||||
return dev
|
||||
|
||||
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()
|
||||
|
||||
dev = choose_device(devices)
|
||||
if dev:
|
||||
go2tv.cast_to(dev, args.media)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -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 { };
|
||||
|
|
Loading…
Reference in New Issue