mirror of
https://gitlab.com/futo-org/fcast.git
synced 2025-06-24 21:25:23 +00:00
Added nautilus plugin for casting.
This commit is contained in:
parent
631f2e3e27
commit
97c8396a2f
4 changed files with 336 additions and 0 deletions
302
clients/nautilus/fcast_nautilus.py
Normal file
302
clients/nautilus/fcast_nautilus.py
Normal file
|
@ -0,0 +1,302 @@
|
|||
import os
|
||||
import gi
|
||||
import json
|
||||
import mimetypes
|
||||
import socket
|
||||
import logging
|
||||
from netifaces import interfaces, ifaddresses, AF_INET
|
||||
from contextlib import closing
|
||||
import http.server
|
||||
import subprocess
|
||||
import sys
|
||||
from gi.repository import GLib
|
||||
|
||||
#log_file = os.path.join(os.path.expanduser('~'), 'fcast.log')
|
||||
#logging.basicConfig(filename=log_file, level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logging.info("Logging system initialized.")
|
||||
|
||||
gi.require_version('Nautilus', '3.0')
|
||||
gi.require_version('Soup', '3.0')
|
||||
from gi.repository import Nautilus, GObject, Gtk, Gio, Soup
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, file_path, *args, **kwargs):
|
||||
self.file_path = file_path
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
self.send_file(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.send_file(False)
|
||||
|
||||
def send_file(self, send_body):
|
||||
logging.debug(f"Preparing to send file: {self.file_path}.")
|
||||
|
||||
try:
|
||||
self.file_size = os.stat(self.file_path).st_size
|
||||
|
||||
# Check for partial download headers.
|
||||
range_header = self.headers.get('Range', None)
|
||||
if not range_header:
|
||||
self.send_response(200)
|
||||
start_byte = 0
|
||||
end_byte = self.file_size - 1
|
||||
else:
|
||||
start_byte, end_byte = self.parse_range_header(range_header)
|
||||
self.send_response(206)
|
||||
|
||||
# Dynamically determine the media type of the file
|
||||
media_type, _ = mimetypes.guess_type(self.file_path)
|
||||
if not media_type:
|
||||
# If the media type couldn't be determined, default to 'application/octet-stream'
|
||||
media_type = 'application/octet-stream'
|
||||
|
||||
self.send_header('Content-Type', media_type)
|
||||
self.send_header('Content-Disposition', f'attachment; filename="{os.path.basename(self.file_path)}"')
|
||||
self.send_header('Content-Range', f"bytes {start_byte}-{end_byte}/{self.file_size}")
|
||||
self.send_header('Content-Length', str(end_byte - start_byte + 1))
|
||||
self.end_headers()
|
||||
|
||||
if send_body:
|
||||
# Stream the file
|
||||
with open(self.file_path, 'rb') as f:
|
||||
f.seek(start_byte)
|
||||
self.copy_file_range(f, self.wfile, start_byte, end_byte)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error while sending file: {str(e)}")
|
||||
self.send_error(500, str(e))
|
||||
|
||||
def parse_range_header(self, range_header):
|
||||
logging.info(f"Parsing range header: {range_header}.")
|
||||
|
||||
# Expects a HTTP range header string and returns the two numbers as tuple
|
||||
start_byte, end_byte = range_header.split('=')[1].split('-')
|
||||
start_byte = int(start_byte.strip())
|
||||
end_byte = int(end_byte.strip()) if end_byte else self.file_size - 1
|
||||
return start_byte, end_byte
|
||||
|
||||
def copy_file_range(self, input_file, output_file, start=0, end=None):
|
||||
logging.info(f"Copying file range from {start} to {end}.")
|
||||
|
||||
bytes_to_send = end - start + 1
|
||||
buffer_size = 8192 # 8KB buffer
|
||||
bytes_sent = 0
|
||||
while bytes_sent < bytes_to_send:
|
||||
buffer = input_file.read(min(buffer_size, bytes_to_send - bytes_sent))
|
||||
if not buffer:
|
||||
break # EOF reached
|
||||
output_file.write(buffer)
|
||||
bytes_sent += len(buffer)
|
||||
|
||||
def run_http_server(ip, port, path):
|
||||
try:
|
||||
# Set the working directory to the directory of the file
|
||||
os.chdir(os.path.dirname(path))
|
||||
|
||||
# Start the server
|
||||
handler = lambda *args, **kwargs: CustomHTTPRequestHandler(path, *args, **kwargs)
|
||||
httpd = http.server.HTTPServer((ip, port), handler)
|
||||
logging.info(f"Web server started on port '{ip}:{port}'.")
|
||||
|
||||
# Get the local IP address
|
||||
logging.info(f"Local IP {ip}.")
|
||||
|
||||
httpd.serve_forever()
|
||||
except Exception as e:
|
||||
logging.error(f"Error in HTTP server: {e}")
|
||||
|
||||
logging.info("Stopped HTTP server.")
|
||||
|
||||
class FCastMenuProvider(GObject.GObject, Nautilus.MenuProvider):
|
||||
def __init__(self):
|
||||
self.selected_file_path = None
|
||||
logging.info("Initialized FCastMenuProvider.")
|
||||
|
||||
def get_file_items(self, window, files):
|
||||
if len(files) != 1:
|
||||
return
|
||||
|
||||
self.selected_file_path = files[0].get_location().get_path()
|
||||
item = Nautilus.MenuItem(name="NautilusPython::fcast_item",
|
||||
label="Cast To",
|
||||
tip="Cast selected file")
|
||||
submenu = Nautilus.Menu()
|
||||
item.set_submenu(submenu)
|
||||
|
||||
hosts = self.load_hosts()
|
||||
for host in hosts:
|
||||
host_item = Nautilus.MenuItem(name=f"NautilusPython::fcast_sub_item_{host}",
|
||||
label=host,
|
||||
tip=f"Cast to {host}")
|
||||
host_item.connect('activate', self.cast_to_host, host)
|
||||
submenu.append_item(host_item)
|
||||
|
||||
add_item = Nautilus.MenuItem(name="NautilusPython::fcast_add_host",
|
||||
label="Add New Host",
|
||||
tip="Add a new host")
|
||||
add_item.connect('activate', self.add_host)
|
||||
submenu.append_item(add_item)
|
||||
|
||||
return [item]
|
||||
|
||||
def load_hosts(self):
|
||||
config_path = os.path.join(os.path.expanduser('~'), '.fcast_hosts.json')
|
||||
logging.info(f"Loading hosts from {config_path}.")
|
||||
if not os.path.exists(config_path):
|
||||
logging.warning(f"Config file {config_path} does not exist.")
|
||||
return []
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
hosts = json.load(f)
|
||||
|
||||
if not isinstance(hosts, list):
|
||||
logging.error(f"Hosts data is not a list: {hosts}.")
|
||||
return []
|
||||
|
||||
logging.info(f"Loaded hosts: {hosts}.")
|
||||
return hosts
|
||||
|
||||
def save_host(self, host):
|
||||
logging.debug(f"Saving host: {host}.")
|
||||
|
||||
hosts = self.load_hosts()
|
||||
if host not in hosts:
|
||||
hosts.append(host)
|
||||
config_path = os.path.join(os.path.expanduser('~'), '.fcast_hosts.json')
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(hosts, f)
|
||||
|
||||
def add_host(self, menu_item):
|
||||
logging.info(f"Adding host.")
|
||||
|
||||
dialog = Gtk.Dialog(title="Add Host", parent=None, flags=0)
|
||||
host_entry = Gtk.Entry(placeholder_text="Enter Host IP e.g. 192.168.1.1")
|
||||
port_entry = Gtk.Entry(placeholder_text="Enter Port e.g. 46899")
|
||||
|
||||
box = dialog.get_content_area()
|
||||
box.add(host_entry)
|
||||
box.add(port_entry)
|
||||
|
||||
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
||||
dialog.connect('response', self.on_add_host_response, host_entry, port_entry)
|
||||
dialog.show_all()
|
||||
|
||||
def on_add_host_response(self, dialog, response, host_entry, port_entry):
|
||||
logging.debug("Received response from add host dialog.")
|
||||
|
||||
if response == Gtk.ResponseType.OK:
|
||||
host = host_entry.get_text()
|
||||
port = port_entry.get_text()
|
||||
if host and port:
|
||||
self.save_host(f"{host}:{port}")
|
||||
dialog.destroy()
|
||||
|
||||
def cast_to_host(self, menu_item, host_data):
|
||||
logging.info(f"Attempting to cast to {host_data} with file {self.selected_file_path}.")
|
||||
|
||||
host, port = host_data.split(":")
|
||||
mimetype, _ = mimetypes.guess_type(self.selected_file_path)
|
||||
if not mimetype:
|
||||
logging.error(f"Could not determine MIME type for file: {self.selected_file_path}")
|
||||
return
|
||||
|
||||
local_url = self.start_web_server(self.selected_file_path, host)
|
||||
logging.info(f"Started web server with local URL: {local_url}.")
|
||||
|
||||
def callback():
|
||||
self.notify_fcast_server(host, port, local_url, mimetype)
|
||||
|
||||
GLib.timeout_add_seconds(1, callback)
|
||||
|
||||
def get_local_ip_address(self, target_ip):
|
||||
logging.info(f"Getting local IP address suitable for target IP {target_ip}.")
|
||||
|
||||
# Extract the subnet from the target IP (assuming a typical subnet mask like 255.255.255.0)
|
||||
subnet = ".".join(target_ip.split('.')[:-1])
|
||||
|
||||
# Get all the network interfaces and their associated IP addresses
|
||||
ip_list = []
|
||||
for ifaceName in interfaces():
|
||||
addresses = [i['addr'] for i in ifaddresses(ifaceName).setdefault(AF_INET, [{'addr':'No IP addr'}])]
|
||||
ip_list.extend(addresses)
|
||||
|
||||
# Filter IPs based on the subnet
|
||||
ip_list = [ip for ip in ip_list if ip.startswith(subnet)]
|
||||
|
||||
if len(ip_list) == 1:
|
||||
return ip_list[0]
|
||||
elif len(ip_list) > 1:
|
||||
# If there are multiple IPs, ask the user to choose one
|
||||
dialog = Gtk.Dialog(title="Select IP Address", parent=None, flags=0)
|
||||
combo = Gtk.ComboBoxText()
|
||||
for ip in ip_list:
|
||||
combo.append_text(ip)
|
||||
combo.set_active(0)
|
||||
box = dialog.get_content_area()
|
||||
box.add(combo)
|
||||
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
||||
dialog.show_all()
|
||||
response = dialog.run()
|
||||
selected_ip = combo.get_active_text()
|
||||
dialog.destroy()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
return selected_ip
|
||||
else:
|
||||
logging.error("No valid IP address found!")
|
||||
return None
|
||||
|
||||
def find_free_port(self):
|
||||
logging.debug("Finding a free port.")
|
||||
|
||||
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(('', 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
def start_web_server(self, file_path, target_ip):
|
||||
logging.info(f"Starting the web server for file {file_path}.")
|
||||
|
||||
local_port = self.find_free_port()
|
||||
logging.info(f"Found free port {local_port}.")
|
||||
|
||||
local_ip = self.get_local_ip_address(target_ip)
|
||||
|
||||
# Use subprocess to spawn a new process running the server
|
||||
script_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
command = [sys.executable, '-c', 'import sys; sys.path.append("{}"); from fcast_nautilus import run_http_server; run_http_server("{}", {}, "{}")'.format(script_directory, local_ip, local_port, file_path)]
|
||||
subprocess.Popen(command, start_new_session=True, cwd=script_directory)
|
||||
|
||||
if local_ip:
|
||||
return f"http://{local_ip}:{local_port}/"
|
||||
else:
|
||||
logging.error("Unable to determine local IP address.")
|
||||
return None
|
||||
|
||||
def notify_fcast_server(self, host, port, url, mimetype):
|
||||
logging.info(f"Attempting to notify the fcast server at {host}:{port} with URL {url}.")
|
||||
|
||||
# Create the message
|
||||
message = {
|
||||
"container": mimetype,
|
||||
"url": url
|
||||
}
|
||||
json_data = json.dumps(message).encode('utf-8')
|
||||
|
||||
# Print JSON
|
||||
logging.info(f"Send JSON to ({host}:${port}): {json_data}.")
|
||||
|
||||
# Create the header
|
||||
opcode = 1 # Play opcode
|
||||
length = len(json_data) + 1 # 1 for opcode
|
||||
length_bytes = length.to_bytes(4, 'little')
|
||||
opcode_byte = opcode.to_bytes(1, 'little')
|
||||
|
||||
# Construct the packet
|
||||
packet = length_bytes + opcode_byte + json_data
|
||||
|
||||
# Send the packet using a TCP socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, int(port)))
|
||||
s.sendall(packet)
|
15
clients/nautilus/install.sh
Normal file
15
clients/nautilus/install.sh
Normal file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Define the directory for the Nautilus extension
|
||||
EXT_DIR="${HOME}/.local/share/nautilus-python/extensions"
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
mkdir -p "${EXT_DIR}"
|
||||
|
||||
# Copy the fcast_nautilus.py to the extensions directory
|
||||
cp fcast_nautilus.py "${EXT_DIR}/fcast_nautilus.py"
|
||||
|
||||
# Restart nautilus
|
||||
nautilus -q
|
||||
|
||||
echo "Installation complete!"
|
7
clients/nautilus/reinstall.sh
Normal file
7
clients/nautilus/reinstall.sh
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Call uninstall and then install scripts
|
||||
sh uninstall.sh
|
||||
sh install.sh
|
||||
|
||||
echo "Reinstallation complete!"
|
12
clients/nautilus/uninstall.sh
Normal file
12
clients/nautilus/uninstall.sh
Normal file
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Define the directory for the Nautilus extension
|
||||
EXT_DIR="${HOME}/.local/share/nautilus-python/extensions"
|
||||
|
||||
# Remove the fcast_nautilus.py from the extensions directory
|
||||
rm -f "${EXT_DIR}/fcast_nautilus.py"
|
||||
|
||||
# Restart nautilus
|
||||
nautilus -q
|
||||
|
||||
echo "Uninstallation complete!"
|
Loading…
Add table
Add a link
Reference in a new issue