diff --git a/apps/api/urls.py b/apps/api/urls.py index 7d9edb52..5b8002d7 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path('core/', include(('core.api_urls', 'core'), namespace='core')), path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')), path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')), + path('fuse/', include(('apps.fuse_api.api_urls', 'fuse_api'), namespace='fuse')), # path('output/', include(('apps.output.api_urls', 'output'), namespace='output')), #path('player/', include(('apps.player.api_urls', 'player'), namespace='player')), #path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')), diff --git a/apps/fuse_api/__init__.py b/apps/fuse_api/__init__.py new file mode 100644 index 00000000..e934c7b4 --- /dev/null +++ b/apps/fuse_api/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.fuse_api.apps.FuseApiConfig" diff --git a/apps/fuse_api/api_urls.py b/apps/fuse_api/api_urls.py new file mode 100644 index 00000000..ae529593 --- /dev/null +++ b/apps/fuse_api/api_urls.py @@ -0,0 +1,21 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .api_views import ( + FuseBrowseView, + FuseSettingsViewSet, + FuseStreamURLView, + FuseClientDownloadView, +) + +app_name = "fuse_api" + +router = DefaultRouter() +router.register(r"settings", FuseSettingsViewSet, basename="fuse-settings") + +urlpatterns = [ + path("browse//", FuseBrowseView.as_view(), name="browse"), + path("stream///", FuseStreamURLView.as_view(), name="stream-url"), + path("client-script/", FuseClientDownloadView.as_view(), name="client-script"), + path("", include(router.urls)), +] diff --git a/apps/fuse_api/api_views.py b/apps/fuse_api/api_views.py new file mode 100644 index 00000000..bd49f490 --- /dev/null +++ b/apps/fuse_api/api_views.py @@ -0,0 +1,358 @@ +import json +from urllib.parse import unquote + +from django.db.models import Prefetch +from django.http import JsonResponse, FileResponse, Http404 +from django.conf import settings +import os +from django.urls import reverse +from rest_framework import status, viewsets +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.vod.models import ( + VODCategory, + Movie, + Series, + Episode, + M3UMovieRelation, + M3USeriesRelation, + M3UEpisodeRelation, +) +from core.models import CoreSettings, FUSE_SETTINGS_KEY +from .serializers import FuseEntrySerializer, FuseSettingsSerializer + + +def _select_best_relation(relations): + """ + Pick the highest priority active relation. + """ + if relations is None: + return None + try: + iterable = list(relations.all()) if hasattr(relations, "all") else list(relations) + except TypeError: + iterable = [] + if not iterable: + return None + return sorted( + iterable, + key=lambda rel: (-getattr(rel.m3u_account, "priority", 0), rel.id), + )[0] + + +class FuseBrowseView(APIView): + """ + Read-only filesystem-style browsing for Movies and TV. + """ + + permission_classes = [AllowAny] + + def get(self, request, mode): + path = request.query_params.get("path", "/") + path = unquote(path) + # Normalize + trimmed = path.strip("/") + parts = [p for p in trimmed.split("/") if p] if trimmed else [] + + if mode not in ("movies", "tv"): + return Response({"detail": "Invalid mode"}, status=status.HTTP_400_BAD_REQUEST) + + if mode == "movies": + return Response(self._browse_movies(parts, request)) + return Response(self._browse_tv(parts, request)) + + def _browse_movies(self, parts, request): + # Root -> list categories + if len(parts) == 0: + category_ids = ( + M3UMovieRelation.objects.filter( + m3u_account__is_active=True, category__isnull=False + ) + .values_list("category_id", flat=True) + .distinct() + ) + categories = VODCategory.objects.filter( + category_type="movie", id__in=category_ids + ).order_by("name") + entries = [ + { + "name": cat.name, + "path": f"/{cat.name}", + "is_dir": True, + "content_type": "category", + "uuid": None, + } + for cat in categories + ] + return {"path": "/", "entries": FuseEntrySerializer(entries, many=True).data} + + # Category -> list movies + category_name = parts[0] + category = ( + VODCategory.objects.filter( + name=category_name, category_type="movie" + ).first() + ) + if not category: + return {"path": f"/{category_name}", "entries": []} + + movies = ( + Movie.objects.filter( + m3u_relations__category=category, + m3u_relations__m3u_account__is_active=True, + ) + .distinct() + .select_related("logo") + .prefetch_related( + Prefetch( + "m3u_relations", + queryset=M3UMovieRelation.objects.filter( + m3u_account__is_active=True + ).select_related("m3u_account"), + ) + ) + .order_by("name") + ) + + entries = [] + for movie in movies: + relation = _select_best_relation(getattr(movie, "m3u_relations", [])) + extension = getattr(relation, "container_extension", None) or "mp4" + name = f"{movie.name} ({movie.year})" if movie.year else movie.name + file_name = f"{name}.{extension}" + stream_url = None + if movie.uuid: + stream_url = request.build_absolute_uri( + reverse( + "proxy:vod_proxy:vod_stream", + kwargs={"content_type": "movie", "content_id": movie.uuid}, + ) + ) + + entries.append( + { + "name": file_name, + "path": f"/{category.name}/{file_name}", + "is_dir": False, + "content_type": "movie", + "uuid": movie.uuid, + "extension": extension, + "category": category.name, + # Report zero so clients don't prefetch/consume provider slots until a real read. + "size": 0, + # Omit stream_url to force clients to fetch it only when they actually read. + "stream_url": None, + } + ) + + return { + "path": f"/{category.name}", + "entries": FuseEntrySerializer(entries, many=True).data, + } + + def _browse_tv(self, parts, request): + # Root -> list series + if len(parts) == 0: + series = ( + Series.objects.filter( + m3u_relations__m3u_account__is_active=True, + ) + .distinct() + .order_by("name") + ) + entries = [ + { + "name": serie.name, + "path": f"/{serie.name}", + "is_dir": True, + "content_type": "series", + "uuid": None, + } + for serie in series + ] + return {"path": "/", "entries": FuseEntrySerializer(entries, many=True).data} + + # Series -> list seasons + series_name = parts[0] + series_obj = Series.objects.filter(name=series_name).first() + if not series_obj: + return {"path": f"/{series_name}", "entries": []} + + if len(parts) == 1: + seasons = ( + Episode.objects.filter(series=series_obj) + .exclude(season_number__isnull=True) + .values_list("season_number", flat=True) + .distinct() + ) + season_numbers = sorted(set(seasons)) or [0] + entries = [] + for num in season_numbers: + label = f"Season {int(num):02d}" + entries.append( + { + "name": label, + "path": f"/{series_name}/{label}", + "is_dir": True, + "content_type": "season", + "uuid": None, + "season": int(num), + } + ) + return { + "path": f"/{series_name}", + "entries": FuseEntrySerializer(entries, many=True).data, + } + + # Season -> list episodes + season_label = parts[1] + try: + season_number = int(season_label.replace("Season", "").strip()) + except Exception: + season_number = None + + episodes = ( + Episode.objects.filter( + series=series_obj, + season_number=season_number, + ) + .select_related("series") + .prefetch_related( + Prefetch( + "m3u_relations", + queryset=M3UEpisodeRelation.objects.filter( + m3u_account__is_active=True + ).select_related("m3u_account"), + ) + ) + .order_by("episode_number") + ) + + entries = [] + for ep in episodes: + relation = _select_best_relation(getattr(ep, "m3u_relations", [])) + extension = getattr(relation, "container_extension", None) or "mp4" + ep_num = ep.episode_number or 0 + season_num = ep.season_number or 0 + name = f"S{season_num:02d}E{ep_num:02d} - {ep.name}" + file_name = f"{name}.{extension}" + stream_url = None + if ep.uuid: + stream_url = request.build_absolute_uri( + reverse( + "proxy:vod_proxy:vod_stream", + kwargs={"content_type": "episode", "content_id": ep.uuid}, + ) + ) + entries.append( + { + "name": file_name, + "path": f"/{series_name}/{season_label}/{file_name}", + "is_dir": False, + "content_type": "episode", + "uuid": ep.uuid, + "extension": extension, + "season": season_num, + "episode_number": ep_num, + "size": 0, + "stream_url": None, + } + ) + + return { + "path": f"/{series_name}/{season_label}", + "entries": FuseEntrySerializer(entries, many=True).data, + } + + +class FuseSettingsViewSet(viewsets.ViewSet): + """ + Store FUSE client guidance in CoreSettings as JSON. + """ + + permission_classes = [IsAuthenticated] + serializer_class = FuseSettingsSerializer + + DEFAULTS = { + "enable_fuse": False, + "backend_base_url": "", + "movies_mount_path": "/mnt/vod_movies", + "tv_mount_path": "/mnt/vod_tv", + } + + def _get_or_create(self): + try: + obj = CoreSettings.objects.get(key=FUSE_SETTINGS_KEY) + data = json.loads(obj.value) + except (CoreSettings.DoesNotExist, json.JSONDecodeError): + data = self.DEFAULTS.copy() + obj, _ = CoreSettings.objects.get_or_create( + key=FUSE_SETTINGS_KEY, + defaults={"name": "Fuse Settings", "value": json.dumps(data)}, + ) + return obj, data + + def list(self, request): + obj, data = self._get_or_create() + serializer = FuseSettingsSerializer(data=data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + return self.list(request) + + def update(self, request, pk=None): + obj, current = self._get_or_create() + serializer = FuseSettingsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + obj.value = json.dumps(serializer.validated_data) + obj.save() + return Response(serializer.validated_data) + + +class FuseStreamURLView(APIView): + """ + Provide a stable stream URL for a given movie/episode UUID. + """ + + permission_classes = [AllowAny] + + def get(self, request, content_type, content_id): + if content_type not in ("movie", "episode"): + return Response({"detail": "Invalid content type"}, status=status.HTTP_400_BAD_REQUEST) + + try: + model = Movie if content_type == "movie" else Episode + if not model.objects.filter(uuid=content_id).exists(): + return Response({"detail": "Not found"}, status=status.HTTP_404_NOT_FOUND) + stream_url = request.build_absolute_uri( + reverse( + "proxy:vod_proxy:vod_stream", + kwargs={"content_type": content_type, "content_id": content_id}, + ) + ) + return JsonResponse({"stream_url": stream_url}) + except Exception: + return Response({"detail": "Not found"}, status=status.HTTP_404_NOT_FOUND) + + +class FuseClientDownloadView(APIView): + """ + Serve the fuse_client.py script from the local server. + """ + + permission_classes = [AllowAny] + + def get(self, request): + script_path = os.path.join(settings.BASE_DIR, "fuse_client", "fuse_client.py") + if not os.path.exists(script_path): + raise Http404("Fuse client script not found") + + return FileResponse( + open(script_path, "rb"), + as_attachment=True, + filename="fuse_client.py", + content_type="text/x-python", + ) diff --git a/apps/fuse_api/apps.py b/apps/fuse_api/apps.py new file mode 100644 index 00000000..bffb98ff --- /dev/null +++ b/apps/fuse_api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FuseApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.fuse_api" + verbose_name = "Fuse API" diff --git a/apps/fuse_api/serializers.py b/apps/fuse_api/serializers.py new file mode 100644 index 00000000..5b74faa9 --- /dev/null +++ b/apps/fuse_api/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + + +class FuseEntrySerializer(serializers.Serializer): + """Lightweight serializer for filesystem-style entries.""" + + name = serializers.CharField() + path = serializers.CharField() + is_dir = serializers.BooleanField() + content_type = serializers.CharField() + uuid = serializers.UUIDField(required=False, allow_null=True) + extension = serializers.CharField(required=False, allow_blank=True, allow_null=True) + size = serializers.IntegerField(required=False, allow_null=True) + category = serializers.CharField(required=False, allow_blank=True, allow_null=True) + season = serializers.IntegerField(required=False, allow_null=True) + episode_number = serializers.IntegerField(required=False, allow_null=True) + stream_url = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + +class FuseSettingsSerializer(serializers.Serializer): + enable_fuse = serializers.BooleanField(default=False) + backend_base_url = serializers.CharField(required=False, allow_blank=True, allow_null=True) + movies_mount_path = serializers.CharField(required=False, allow_blank=True, allow_null=True) + tv_mount_path = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + def validate_backend_base_url(self, value): + if value and not value.startswith(("http://", "https://")): + raise serializers.ValidationError("backend_base_url must start with http:// or https://") + return value diff --git a/core/models.py b/core/models.py index b9166f66..137ea982 100644 --- a/core/models.py +++ b/core/models.py @@ -152,6 +152,7 @@ PREFERRED_REGION_KEY = slugify("Preferred Region") AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files") NETWORK_ACCESS = slugify("Network Access") PROXY_SETTINGS_KEY = slugify("Proxy Settings") +FUSE_SETTINGS_KEY = slugify("Fuse Settings") DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template") DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template") DVR_SERIES_RULES_KEY = slugify("DVR Series Rules") diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 5f8c23e2..8b4c4e38 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -28,6 +28,7 @@ INSTALLED_APPS = [ "apps.output", "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", + "apps.fuse_api", "apps.vod.apps.VODConfig", "core", "daphne", diff --git a/frontend/src/api.js b/frontend/src/api.js index 7eda6a3f..9d5d9d87 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1339,6 +1339,26 @@ export default class API { } } + static async getFuseSettings() { + try { + const response = await request(`${host}/api/fuse/settings/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve FUSE settings', e); + } + } + + static async updateFuseSettings(values) { + try { + return await request(`${host}/api/fuse/settings/1/`, { + method: 'PUT', + body: values, + }); + } catch (e) { + errorNotification('Failed to update FUSE settings', e); + } + } + static async getEnvironmentSettings() { try { const response = await request(`${host}/api/core/settings/env/`); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5c25897a..71101984 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -208,6 +208,8 @@ const SettingsPage = () => { path: '', exists: false, }); + const [fuseSettingsSaved, setFuseSettingsSaved] = useState(false); + const [fuseSettingsLoading, setFuseSettingsLoading] = useState(false); // UI / local storage settings const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); @@ -309,6 +311,16 @@ const SettingsPage = () => { }, {}), }); + const fuseForm = useForm({ + mode: 'controlled', + initialValues: { + enable_fuse: false, + backend_base_url: '', + movies_mount_path: '/mnt/vod_movies', + tv_mount_path: '/mnt/vod_tv', + }, + }); + useEffect(() => { if (settings) { const formValues = Object.entries(settings).reduce( @@ -405,12 +417,35 @@ const SettingsPage = () => { loadComskipConfig(); }, []); + useEffect(() => { + const loadFuseSettings = async () => { + setFuseSettingsLoading(true); + try { + const data = await API.getFuseSettings(); + if (data) { + fuseForm.setValues({ + enable_fuse: Boolean(data.enable_fuse), + backend_base_url: data.backend_base_url || '', + movies_mount_path: data.movies_mount_path || '/mnt/vod_movies', + tv_mount_path: data.tv_mount_path || '/mnt/vod_tv', + }); + } + } catch (error) { + console.error('Failed to load FUSE settings', error); + } finally { + setFuseSettingsLoading(false); + } + }; + loadFuseSettings(); + }, []); + // Clear success states when switching accordion panels useEffect(() => { setGeneralSettingsSaved(false); setProxySettingsSaved(false); setNetworkAccessSaved(false); setRehashSuccess(false); + setFuseSettingsSaved(false); }, [accordianValue]); const onSubmit = async () => { @@ -535,6 +570,26 @@ const SettingsPage = () => { } }; + const onFuseSettingsSubmit = async () => { + setFuseSettingsSaved(false); + try { + const payload = { + enable_fuse: fuseForm.values.enable_fuse, + }; + const result = await API.updateFuseSettings(payload); + if (result) { + setFuseSettingsSaved(true); + notifications.show({ + title: 'FUSE settings saved', + message: 'Host client can use these values to mount VOD drives.', + color: 'green', + }); + } + } catch (error) { + console.error('Error saving FUSE settings:', error); + } + }; + const onComskipUpload = async () => { if (!comskipFile) { return; @@ -1306,6 +1361,61 @@ const SettingsPage = () => { + + + + FUSE / Virtual Drives + + +
+ + {fuseSettingsSaved && ( + + )} + + fuseForm.setFieldValue( + 'enable_fuse', + event.currentTarget.checked + ) + } + /> + + The host-side FUSE client runs outside Docker. Install macFUSE/libfuse/WinFsp on your machine, + then run the provided fuse_client.py script to mount Movies or TV. + + + + Example (Movies): python fuse_client.py --mode movies --backend-url http://localhost:8000 --mountpoint /mnt/vod_movies.{' '} + Example (TV): python fuse_client.py --mode tv --backend-url http://localhost:8000 --mountpoint /mnt/vod_tv. Windows: run the same command in an elevated shell and mount to a drive letter (e.g. M:\). + + + + + +
+
+
)} diff --git a/fuse_client.zip b/fuse_client.zip new file mode 100644 index 00000000..0adda8d4 Binary files /dev/null and b/fuse_client.zip differ diff --git a/fuse_client/fuse_client.py b/fuse_client/fuse_client.py new file mode 100644 index 00000000..e34cab83 --- /dev/null +++ b/fuse_client/fuse_client.py @@ -0,0 +1,402 @@ +""" +Simple read-only FUSE client for Dispatcharr VOD. + +Usage: + python fuse_client.py --mode movies --backend-url http://localhost:9191 --mountpoint /mnt/vod_movies + python fuse_client.py --mode tv --backend-url http://localhost:9191 --mountpoint /mnt/vod_tv + +Requires: fusepy (Linux/macOS) or WinFsp with fusepy on Windows. +""" +import argparse +import errno +import logging +import os +import stat +import time +from typing import Dict, Optional +from urllib.parse import urljoin + +import requests +from fuse import FUSE, FuseOSError, LoggingMixIn, Operations + +log = logging.getLogger("dispatcharr_fuse") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +# Use a generous fake size when we cannot learn the real length so players keep requesting data. +DEFAULT_FAKE_SIZE = 5 * 1024 * 1024 * 1024 # 5 GiB +# Keep sessions warm so we don't rebuild upstream sessions between reads. +SESSION_IDLE_TTL = 300 # seconds +# Ignore tiny first reads (Finder/thumbnail probes) to avoid creating upstream sessions. +DEFAULT_PROBE_READ_BYTES = 512 * 1024 # 512 KiB + + +class FuseAPIClient: + """HTTP bridge to the backend FUSE API.""" + + def __init__(self, backend_url: str, mode: str): + self.base = backend_url.rstrip("/") + self.mode = mode + self.session = requests.Session() + + def browse(self, path: str) -> Dict: + resp = self.session.get( + f"{self.base}/api/fuse/browse/{self.mode}/", params={"path": path} + ) + resp.raise_for_status() + return resp.json() + + def stream_url(self, content_type: str, content_id: str) -> str: + resp = self.session.get( + f"{self.base}/api/fuse/stream/{content_type}/{content_id}/" + ) + resp.raise_for_status() + return resp.json().get("stream_url") + + def head_stream(self, url: str) -> Dict[str, Optional[int]]: + """ + Get content length and optional session URL via HEAD. + """ + next_url = url + for _ in range(5): + resp = self.session.head(next_url, allow_redirects=False, timeout=5) + if resp.status_code in (301, 302, 303, 307, 308) and resp.headers.get("Location"): + next_url = urljoin(next_url, resp.headers["Location"]) + continue + resp.raise_for_status() + size = resp.headers.get("Content-Length") + session_url = resp.headers.get("X-Session-URL") + if session_url: + session_url = urljoin(next_url, session_url) + return { + "size": int(size) if size and str(size).isdigit() else None, + "session_url": session_url, + } + raise FuseOSError(errno.EIO) + + def ranged_get(self, session: requests.Session, url: str, offset: int, size: int): + headers = {"Range": f"bytes={offset}-{offset + size - 1}"} + next_url = url + for _ in range(5): # follow a few redirects manually to preserve Range + resp = session.get(next_url, headers=headers, stream=True, timeout=30, allow_redirects=False) + if resp.status_code in (301, 302, 303, 307, 308) and resp.headers.get("Location"): + # The proxy returns relative redirects; urljoin keeps the original host/scheme. + next_url = urljoin(next_url, resp.headers["Location"]) + continue + if resp.status_code not in (200, 206): + raise FuseOSError(errno.EIO) + total_size = None + # Parse Content-Range: bytes start-end/total + cr = resp.headers.get("Content-Range") + if cr and "/" in cr: + try: + total_size = int(cr.split("/")[-1]) + except Exception: + total_size = None + # Return both content and the final URL we ended up at (sessionized path) and optional total size + return resp.content, next_url, total_size + raise FuseOSError(errno.EIO) + + +class VODFuse(LoggingMixIn, Operations): + """Read-only filesystem exposing VOD Movies or TV.""" + + def __init__(self, api_client: FuseAPIClient, readahead_bytes: int, probe_read_bytes: int): + self.api = api_client + self.readahead_bytes = readahead_bytes + self.probe_read_bytes = probe_read_bytes + self.dir_cache: Dict[str, Dict] = {} + self.path_index: Dict[str, Dict] = {} + # shared session pool across opens of the same path to avoid repeated upstream sessions + # path -> {"session", "session_url", "size", "refcount", "last_used"} + self.session_pool: Dict[str, Dict] = {} + + # Helpers + def _get_entries(self, path: str): + if path in self.dir_cache: + return self.dir_cache[path] + data = self.api.browse(path) + self.dir_cache[path] = data + # index children + for entry in data.get("entries", []): + self.path_index[entry["path"]] = entry + return data + + def _find_entry(self, path: str) -> Optional[Dict]: + if path == "/": + return {"is_dir": True} + if path in self.path_index: + return self.path_index[path] + # Attempt to refresh parent directory + parent = "/" + "/".join([p for p in path.strip("/").split("/")[:-1]]) + if parent == "": + parent = "/" + self._get_entries(parent) + return self.path_index.get(path) + + def _ensure_file_metadata(self, entry: Dict, *, allow_head: bool): + """ + Populate size/stream_url if missing so players can stream. + allow_head=False keeps getattr fast (fallbacks to fake size). + """ + if entry.get("is_dir"): + return + + # Ensure we have a base stream URL + if not entry.get("stream_url") and entry.get("uuid"): + entry["stream_url"] = self.api.stream_url(entry["content_type"], entry["uuid"]) + + # If size already reasonable, skip + if entry.get("size") and entry["size"] > 1 and entry.get("stream_url"): + return + + url = entry.get("stream_url") + if not url: + return + + # If we're not allowed to HEAD (e.g., getattr from Finder), just set a fake size. + if not allow_head: + if not entry.get("size") or entry.get("size") <= 1: + entry["size"] = DEFAULT_FAKE_SIZE + return + + # Try to learn the true size (and session URL) via HEAD when the client is actually reading. + if not entry.get("size") or entry["size"] <= 1 or not entry.get("session_url"): + try: + info = self.api.head_stream(url) + if info.get("size"): + entry["size"] = info["size"] + if info.get("session_url"): + entry["session_url"] = info["session_url"] + except Exception as exc: # pragma: no cover + log.warning("HEAD failed for %s: %s", url, exc) + + if not entry.get("size") or entry["size"] <= 1: + entry["size"] = DEFAULT_FAKE_SIZE + + def _get_handle(self, path: str, entry: Dict): + """ + Ensure we have per-path session state without touching the upstream. + """ + now = time.time() + # Evict stale idle sessions + for stale_path, state in list(self.session_pool.items()): + if state.get("refcount", 0) <= 0 and (now - state.get("last_used", now)) > SESSION_IDLE_TTL: + sess = state.get("session") + if sess: + try: + sess.close() + except Exception: + pass + self.session_pool.pop(stale_path, None) + + if path in self.session_pool: + state = self.session_pool[path] + state["refcount"] = state.get("refcount", 0) + 1 + state["last_used"] = now + return state + + sess = requests.Session() + # propagate auth header + sess.headers.update(self.api.session.headers) + + stream_url = entry.get("stream_url") + if not stream_url and entry.get("uuid"): + stream_url = self.api.stream_url(entry["content_type"], entry["uuid"]) + entry["stream_url"] = stream_url + if not stream_url: + raise FuseOSError(errno.EIO) + + state = { + "session": sess, + "session_url": entry.get("session_url"), + "size": entry.get("size") or DEFAULT_FAKE_SIZE, + "refcount": 1, + "last_used": now, + "activated": False, # becomes True after we decide to hit upstream + "served_fake": False, # we served a fake stub read already + "buffer_offset": None, + "buffer_data": b"", + } + self.session_pool[path] = state + return state + + # FUSE operations + def getattr(self, path, fh=None): + entry = self._find_entry(path) + if not entry: + raise FuseOSError(errno.ENOENT) + + # getattr is called frequently by Finder; avoid network HEAD here. + self._ensure_file_metadata(entry, allow_head=False) + + now = time.time() + if entry.get("is_dir"): + return dict( + st_mode=(stat.S_IFDIR | 0o755), + st_nlink=2, + st_ctime=now, + st_mtime=now, + st_atime=now, + ) + + size = entry.get("size") or 0 + return dict( + st_mode=(stat.S_IFREG | 0o444), + st_nlink=1, + st_size=size, + st_ctime=now, + st_mtime=now, + st_atime=now, + ) + + def readdir(self, path, fh): + data = self._get_entries(path) + entries = [".", ".."] + [e["name"] for e in data.get("entries", [])] + for entry in entries: + yield entry + + def open(self, path, flags): + entry = self._find_entry(path) + if not entry or entry.get("is_dir"): + raise FuseOSError(errno.EISDIR if entry else errno.ENOENT) + return 0 + + def read(self, path, size, offset, fh): + entry = self._find_entry(path) + if not entry: + raise FuseOSError(errno.ENOENT) + self._ensure_file_metadata(entry, allow_head=True) + # Acquire or create per-path handle with session + session_url + handle = self._get_handle(path, entry) + handle["last_used"] = time.time() + + # If this is the very first small read (e.g., Finder thumbnail/probe), serve zeros + # and avoid triggering an upstream session. A real read will follow if the user plays. + if ( + not handle.get("activated") + and offset == 0 + and size <= self.probe_read_bytes + and not handle.get("served_fake") + ): + handle["served_fake"] = True + return b"\0" * size + + handle["activated"] = True + + url = handle.get("session_url") or entry.get("session_url") or entry.get("stream_url") + # Serve from buffer when possible + buf_offset = handle.get("buffer_offset") + buf_data = handle.get("buffer_data") or b"" + if buf_offset is not None and buf_data: + buf_end = buf_offset + len(buf_data) + if offset >= buf_offset and (offset + size) <= buf_end: + start = offset - buf_offset + end = start + size + return buf_data[start:end] + + # Align fetch to readahead boundary to maximize sequential throughput. + fetch_offset = max(0, offset - (offset % self.readahead_bytes)) + fetch_size = max(size, self.readahead_bytes) + # If we know the size, avoid requesting past EOF. + total_size = handle.get("size") + if total_size and total_size > 0: + fetch_size = min(fetch_size, max(0, total_size - fetch_offset)) + # Never issue a zero-length range. + fetch_size = max(1, fetch_size) + + try: + content, final_url, total_size = self.api.ranged_get(handle["session"], url, fetch_offset, fetch_size) + # Cache sessionized URL for future reads so we don't create new sessions each time. + handle["session_url"] = final_url + # If we learned the real size from Content-Range, update caches so seeking uses accurate length. + if total_size: + handle["size"] = total_size + entry["size"] = total_size + handle["buffer_offset"] = fetch_offset + handle["buffer_data"] = content + start = offset - fetch_offset + end = start + size + return content[start:end] + except requests.RequestException as exc: # pragma: no cover + log.error("Stream error for %s: %s", path, exc) + raise FuseOSError(errno.EIO) + + # Read-only filesystem: block writes + def write(self, path, data, offset, fh): + raise FuseOSError(errno.EROFS) + + def mkdir(self, path, mode): + raise FuseOSError(errno.EROFS) + + def rmdir(self, path): + raise FuseOSError(errno.EROFS) + + def unlink(self, path): + raise FuseOSError(errno.EROFS) + + def release(self, path, fh): + """ + Close per-path session when the file handle is released to avoid + leaving provider connections open (important when max_streams is low). + """ + state = self.session_pool.get(path) + if not state: + return 0 + state["refcount"] -= 1 + if state["refcount"] <= 0: + state["refcount"] = 0 + state["last_used"] = time.time() + # Do not immediately close to allow rapid reopen to reuse the same session URL. + # Cleanup happens opportunistically in _get_handle after SESSION_IDLE_TTL. + return 0 + + +def parse_args(): + parser = argparse.ArgumentParser(description="Dispatcharr VOD FUSE client") + parser.add_argument("--mode", choices=["movies", "tv"], required=True, help="movies or tv") + parser.add_argument("--backend-url", required=True, help="Base URL to the Dispatcharr backend (e.g., http://localhost:9191)") + parser.add_argument("--mountpoint", required=True, help="Mountpoint on the host") + parser.add_argument( + "--readahead-bytes", + type=int, + default=1 * 1024 * 1024, + help="Upstream range size to fetch and buffer per read (bytes)", + ) + parser.add_argument( + "--probe-read-bytes", + type=int, + default=DEFAULT_PROBE_READ_BYTES, + help="Serve zeros for the first small read (<= this) to avoid accidental playback from background scans", + ) + parser.add_argument( + "--max-read", + type=int, + default=4 * 1024 * 1024, + help="Max read size in bytes for FUSE (helps avoid tons of tiny range requests)", + ) + parser.add_argument( + "--foreground", + action="store_true", + help="Run in foreground (useful for debugging)", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + api_client = FuseAPIClient(args.backend_url, args.mode) + fuse = VODFuse(api_client, args.readahead_bytes, args.probe_read_bytes) + FUSE( + fuse, + args.mountpoint, + nothreads=True, + foreground=args.foreground, + ro=True, + allow_other=True, + big_writes=True, + max_read=args.max_read, + ) + + +if __name__ == "__main__": + main() diff --git a/fuse_client/mount_mac.sh b/fuse_client/mount_mac.sh new file mode 100755 index 00000000..b176aaee --- /dev/null +++ b/fuse_client/mount_mac.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Helper to mount Dispatcharr VOD via FUSE on macOS. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Prefer a stable Python for fusepy (3.11/3.12); fall back to python3 +if [[ -z "${PYTHON_BIN:-}" ]]; then + if command -v python3.12 >/dev/null 2>&1; then + PYTHON_BIN="python3.12" + elif command -v python3.11 >/dev/null 2>&1; then + PYTHON_BIN="python3.11" + else + PYTHON_BIN="python3" + fi +fi +MODE="${MODE:-movies}" # movies | tv +BACKEND_URL="${BACKEND_URL:-http://10.0.0.192:5656}" +MOUNTPOINT="${MOUNTPOINT:-$HOME/Desktop/vod_${MODE}}" +VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}" +FUSE_MAX_READ="${FUSE_MAX_READ:-8388608}" # 8 MiB +READAHEAD_BYTES="${READAHEAD_BYTES:-1048576}" # 1 MiB + +if [[ "$MODE" != "movies" && "$MODE" != "tv" ]]; then + echo "MODE must be 'movies' or 'tv'" >&2 + exit 1 +fi + +echo "==> Using Python: $PYTHON_BIN" +echo "==> Mode: $MODE" +echo "==> Backend: $BACKEND_URL" +echo "==> Mountpoint: $MOUNTPOINT" +echo "==> Venv: $VENV_PATH" + +command -v "$PYTHON_BIN" >/dev/null 2>&1 || { echo "Python not found: $PYTHON_BIN" >&2; exit 1; } + +# macFUSE detection: warn but don’t hard-exit so we can proceed if you know it’s installed +if ! kextstat 2>/dev/null | grep -q "com.github.osxfuse.filesystems.osxfuse" && ! systemextensionsctl list 2>/dev/null | grep -qi "macfuse"; then + echo "Warning: macFUSE not detected via kext/systemextension. Trying anyway." >&2 +fi + +# Prepare venv and deps +if [[ ! -d "$VENV_PATH" ]]; then + "$PYTHON_BIN" -m venv "$VENV_PATH" +fi +source "$VENV_PATH/bin/activate" +pip install --quiet --upgrade pip +pip install --quiet fusepy requests + +# Patch fusepy _wrapper to be an instance method (avoids NameError: self is not defined) +FUSE_PY=$(find "$VENV_PATH/lib" -path "*/site-packages/fuse.py" -maxdepth 4 -print -quit 2>/dev/null || true) +if [[ -n "$FUSE_PY" ]]; then + python - "$FUSE_PY" <<'PY' +import pathlib, sys, re +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +patched = re.sub(r'@staticmethod\s*\n\s*def _wrapper\(func', ' def _wrapper(self, func', text, count=1) +if text != patched: + path.write_text(patched) +PY +fi + +# Prepare mountpoint (unmount if stale) +diskutil umount force "$MOUNTPOINT" >/dev/null 2>&1 || true +rm -rf "$MOUNTPOINT" +mkdir -p "$MOUNTPOINT" +# Disable Spotlight indexing on the mount to prevent background scans/thumbnails +if command -v mdutil >/dev/null 2>&1; then + mdutil -i off "$MOUNTPOINT" >/dev/null 2>&1 || true +fi + +cleanup() { + local exit_code=$? + if [[ -n "${CHILD_PID:-}" ]] && ps -p "$CHILD_PID" >/dev/null 2>&1; then + # Politely ask the FUSE client to exit + kill "$CHILD_PID" >/dev/null 2>&1 || true + # Give it a moment, then force if needed + sleep 1 + kill -9 "$CHILD_PID" >/dev/null 2>&1 || true + fi + diskutil umount force "$MOUNTPOINT" >/dev/null 2>&1 || true + rmdir "$MOUNTPOINT" >/dev/null 2>&1 || true + exit "$exit_code" +} +trap cleanup INT TERM EXIT + +cd "$PROJECT_ROOT" +echo "==> Mounting (requires sudo for allow_other)..." +sudo "$VENV_PATH/bin/python" "$SCRIPT_DIR/fuse_client.py" \ + --mode "$MODE" \ + --backend-url "$BACKEND_URL" \ + --mountpoint "$MOUNTPOINT" \ + --max-read "$FUSE_MAX_READ" \ + --readahead-bytes "$READAHEAD_BYTES" \ + --foreground & + +CHILD_PID=$! +wait "$CHILD_PID"