mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
FUSE Testing
Exploratory branch for FUSE testing
This commit is contained in:
parent
514e7e06e4
commit
ce0acca8bc
13 changed files with 1051 additions and 0 deletions
|
|
@ -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')),
|
||||
|
|
|
|||
1
apps/fuse_api/__init__.py
Normal file
1
apps/fuse_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
default_app_config = "apps.fuse_api.apps.FuseApiConfig"
|
||||
21
apps/fuse_api/api_urls.py
Normal file
21
apps/fuse_api/api_urls.py
Normal 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
358
apps/fuse_api/api_views.py
Normal 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
7
apps/fuse_api/apps.py
Normal 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"
|
||||
29
apps/fuse_api/serializers.py
Normal file
29
apps/fuse_api/serializers.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
|
|
@ -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
BIN
fuse_client.zip
Normal file
Binary file not shown.
402
fuse_client/fuse_client.py
Normal file
402
fuse_client/fuse_client.py
Normal 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
100
fuse_client/mount_mac.sh
Executable 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 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue