-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin.py
More file actions
275 lines (216 loc) · 10.2 KB
/
plugin.py
File metadata and controls
275 lines (216 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
"""
Dispatcharr VOD Fix Plugin
==========================
This plugin fixes VOD (Video on Demand) playback issues with TiviMate and other
Android IPTV clients by properly handling multiple simultaneous HTTP Range requests.
Problem Description
-------------------
TiviMate and similar Android clients make multiple simultaneous HTTP Range requests
when playing MKV/VOD files:
1. Initial request (no Range header) - to probe file size and metadata
2. Range: bytes=X- (request for metadata at end of file, e.g., MKV seek index)
3. Range: bytes=Y- (actual playback start position)
Dispatcharr's default behavior counts each HTTP request as a separate provider
connection, incrementing the `profile_connections:{profile_id}` counter in Redis.
With `max_streams=1`, the 2nd and 3rd requests are rejected with
"All profiles at capacity" before the first request even completes.
Solution
--------
This plugin tracks VOD connections by **client IP + content UUID** instead of
per-HTTP-request. Multiple Range requests from the same client for the same
content share a single "connection slot" in Redis.
Architecture
------------
The plugin uses monkey-patching to intercept key functions in Dispatcharr's
VOD proxy system:
- VODStreamView._get_m3u_profile() - Check for existing client slots
- MultiWorkerVODConnectionManager._increment_profile_connections() - Skip if already counted
- MultiWorkerVODConnectionManager._decrement_profile_connections() - Smart cleanup
- MultiWorkerVODConnectionManager.stream_content_with_session() - Context tracking
Auto-Install on Startup
-----------------------
This module auto-installs hooks when loaded. Dispatcharr's PluginManager imports
this module on startup, triggering the auto-install code at the bottom of this file.
IMPORTANT - uWSGI Multi-Worker Architecture:
Dispatcharr runs with multiple uWSGI workers (separate processes).
Each worker has its own memory space, so hooks must be installed
in EACH worker independently. The auto-install mechanism handles this
by installing hooks when Django signals that a worker is ready.
Usage
-----
Simply place this plugin in the Dispatcharr plugins directory and restart:
/data/plugins/dispatcharr_vod_fix/
The plugin will auto-install and start working immediately.
Author: Cedric Marcoux
Version: 1.4.0
License: MIT
"""
import logging
# Configure logger with plugin namespace for easy filtering in logs
# All log messages will be prefixed with "plugins.dispatcharr_vod_fix"
logger = logging.getLogger("plugins.dispatcharr_vod_fix")
# Track if hooks are installed in THIS worker process
# Each uWSGI worker is a separate Python process with its own memory space,
# so this flag is worker-specific and prevents double-installation
_hooks_installed = False
def _auto_install_hooks():
"""
Install hooks automatically when Django starts up.
This function is called either:
1. Immediately if Django apps are already ready (django.apps.apps.ready == True)
2. On the first HTTP request via Django's request_finished signal
Hooks are ALWAYS installed regardless of plugin enabled state in the database.
The hooks themselves check the enabled state at runtime, allowing the plugin
to be enabled/disabled without requiring a restart.
Returns:
None
Side Effects:
- Sets global _hooks_installed to True on success
- Logs success/failure messages
- Installs monkey-patches on Dispatcharr's VOD proxy classes
"""
global _hooks_installed
# Prevent double-installation in this worker
if _hooks_installed:
return
try:
# Import and execute hook installation
from .hooks import install_hooks
if install_hooks():
_hooks_installed = True
logger.info("[VOD-Fix] Hooks installed (will check enabled state at runtime)")
else:
logger.error("[VOD-Fix] Hook installation returned False")
except Exception as e:
logger.error(f"[VOD-Fix] Auto-install error: {e}")
import traceback
traceback.print_exc()
class Plugin:
"""
Main plugin class for Dispatcharr VOD Fix.
This class follows Dispatcharr's plugin interface specification.
The PluginManager instantiates this class and calls run() with
various actions based on user interaction in the UI.
Attributes:
name (str): Human-readable plugin name displayed in UI
version (str): Semantic version string (MAJOR.MINOR.PATCH)
description (str): Brief description shown in plugin list
author (str): Plugin author name
fields (list): Configuration fields (empty - no config needed)
actions (list): Custom action buttons (empty - no custom actions)
Example:
>>> plugin = Plugin()
>>> plugin.run("enable")
{'status': 'ok', 'message': 'VOD Fix plugin enabled'}
"""
def __init__(self):
"""
Initialize plugin metadata.
All attributes are used by Dispatcharr's PluginManager to display
plugin information in the admin UI and handle plugin lifecycle.
"""
# Display name shown in the Dispatcharr plugins list
self.name = "Dispatcharr VOD Fix"
# Semantic version: MAJOR.MINOR.PATCH
# - MAJOR: Breaking changes
# - MINOR: New features, backwards compatible
# - PATCH: Bug fixes
self.version = "1.4.0"
# Description shown in plugin details
self.description = (
"Fixes VOD playback for TiviMate and series playback for iPlayTV. "
"Tracks connections by client+content instead of per-request. "
"Fixes series episode IDs and null values for iPlayTV compatibility."
)
# Plugin author
self.author = "Cedric Marcoux"
# Configuration fields - empty list means no configuration UI
# If fields were needed, they would be dicts with:
# {"id": "field_name", "type": "string|select|boolean", "label": "...", "default": "..."}
self.fields = []
# Custom action buttons - empty list means no custom actions
# If actions were needed, they would be dicts with:
# {"id": "action_name", "label": "Button Text", "confirm": "Are you sure?"}
self.actions = []
def run(self, action=None, params=None, context=None):
"""
Execute a plugin action.
This method is the main entry point called by Dispatcharr's PluginManager
when the user interacts with the plugin (enable, disable, custom actions).
Args:
action (str, optional): The action to perform. Standard actions:
- "enable": User enabled the plugin in UI
- "disable": User disabled the plugin in UI
- Custom action IDs defined in self.actions
params (dict, optional): Parameters passed with the action.
For custom actions, contains user input from action form.
context (dict, optional): Execution context including:
- "user": The Django user who triggered the action
- "request": The HTTP request object
Returns:
dict: Result dictionary with keys:
- "status": "ok" or "error"
- "message": Human-readable result message
Example:
>>> plugin.run("enable")
{'status': 'ok', 'message': 'VOD Fix plugin enabled'}
>>> plugin.run("disable")
{'status': 'ok', 'message': 'VOD Fix plugin disabled'}
>>> plugin.run("unknown")
{'status': 'error', 'message': 'Unknown action: unknown'}
"""
# Ensure context is always a dict
context = context or {}
if action == "enable":
# User enabled the plugin via UI
logger.info("[VOD-Fix] Enabling plugin...")
from .hooks import install_hooks
if install_hooks():
return {"status": "ok", "message": "VOD Fix plugin enabled"}
return {"status": "error", "message": "Failed to install hooks"}
elif action == "disable":
# User disabled the plugin via UI
logger.info("[VOD-Fix] Disabling plugin...")
from .hooks import uninstall_hooks
uninstall_hooks()
return {"status": "ok", "message": "VOD Fix plugin disabled"}
# Unknown action - return error
return {"status": "error", "message": f"Unknown action: {action}"}
# =============================================================================
# AUTO-INSTALL ON MODULE IMPORT
# =============================================================================
# This code runs when Python imports this module. Since Dispatcharr's
# PluginManager imports all discovered plugins on startup, this effectively
# runs on application startup.
#
# The try/except ensures that any import errors don't crash Dispatcharr.
# =============================================================================
try:
import django
# Check if Django apps registry is ready
# This is True after Django has finished loading all apps
if django.apps.apps.ready:
# Django is ready, install hooks immediately
_auto_install_hooks()
else:
# Django is still starting up, defer hook installation
# Use Django's request_finished signal to install on first request
from django.core.signals import request_finished
def _on_first_request(sender, **kwargs):
"""
Signal handler to install hooks on first HTTP request.
This is a one-shot handler that disconnects itself after running.
It's used when Django isn't fully ready at module import time.
Args:
sender: The signal sender (usually the WSGI handler)
**kwargs: Additional signal arguments (ignored)
"""
_auto_install_hooks()
# Disconnect to prevent running on every request
request_finished.disconnect(_on_first_request)
# Connect the signal handler
request_finished.connect(_on_first_request)
except Exception:
# Silently ignore any errors during auto-install setup
# The plugin will still work if manually enabled via UI
pass