FUSE Testing

Exploratory branch for FUSE testing
This commit is contained in:
Dispatcharr 2025-12-11 19:15:52 -06:00
parent 514e7e06e4
commit ce0acca8bc
13 changed files with 1051 additions and 0 deletions

View file

@ -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')),

View file

@ -0,0 +1 @@
default_app_config = "apps.fuse_api.apps.FuseApiConfig"

21
apps/fuse_api/api_urls.py Normal file
View file

@ -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/<str:mode>/", FuseBrowseView.as_view(), name="browse"),
path("stream/<str:content_type>/<uuid:content_id>/", FuseStreamURLView.as_view(), name="stream-url"),
path("client-script/", FuseClientDownloadView.as_view(), name="client-script"),
path("", include(router.urls)),
]

358
apps/fuse_api/api_views.py Normal file
View file

@ -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",
)

7
apps/fuse_api/apps.py Normal file
View file

@ -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"

View file

@ -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

View file

@ -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")

View file

@ -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",

View file

@ -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/`);

View file

@ -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 = () => {
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="fuse-settings">
<Accordion.Control>
<Box>FUSE / Virtual Drives</Box>
</Accordion.Control>
<Accordion.Panel>
<form onSubmit={fuseForm.onSubmit(onFuseSettingsSubmit)}>
<Stack gap="sm">
{fuseSettingsSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
/>
)}
<Switch
label="Enable FUSE integration"
description="Exposes Movies/TV as read-only virtual drives via the host-side FUSE client."
checked={fuseForm.values.enable_fuse}
onChange={(event) =>
fuseForm.setFieldValue(
'enable_fuse',
event.currentTarget.checked
)
}
/>
<Text size="sm" c="dimmed">
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.
</Text>
<Button
component="a"
variant="light"
href="/api/fuse/client-script/"
download="fuse_client.py"
>
Download fuse_client.py
</Button>
<Text size="sm" c="dimmed">
Example (Movies): <code>python fuse_client.py --mode movies --backend-url http://localhost:8000 --mountpoint /mnt/vod_movies</code>.{' '}
Example (TV): <code>python fuse_client.py --mode tv --backend-url http://localhost:8000 --mountpoint /mnt/vod_tv</code>. Windows: run the same command in an elevated shell and mount to a drive letter (e.g. <code>M:\</code>).
</Text>
<Flex justify="flex-end">
<Button
type="submit"
disabled={fuseSettingsLoading}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
</>
)}
</Accordion>

BIN
fuse_client.zip Normal file

Binary file not shown.

402
fuse_client/fuse_client.py Normal file
View file

@ -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()

100
fuse_client/mount_mac.sh Executable file
View file

@ -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 dont hard-exit so we can proceed if you know its 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"