diff --git a/Dockerfile b/Dockerfile index 153da1e..23b80d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10 +FROM python:3.10-alpine ADD . /home diff --git a/README.md b/README.md index b815eb2..dcf50f1 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,16 @@ Change your torrent client's download speed dynamically, on certain events such This script is ideal for users with limited upload speed, however anyone can use it to maximise their upload speed, whilst keeping their Plex/Jellyfin/Emby streams buffer-free! Also great to adjust the download rate during the day, in case the bandwidth is needed for something else! + ## Features - Multi-server support for Plex, Jellyfin, Emby, and Tautulli. - Supports qBittorrent and Transmission. - Multi-torrent-client support. - Bandwidth is split between them, by number of downloading/uploading torrents. - Schedule a time/day when upload speed should be lowered. +- Support for unlimited speeds in schedules (equivalent to turning off speed limits). +- Stream-based speed control: Set different upload speeds based on the number of active streams instead of bandwidth usage. + ## Setup @@ -70,6 +74,95 @@ cd /boot/config/plugins/dockerMan/templates-user && touch my-speedrr.xml && nano 5. Run `python main.py --config_path config.yaml` to start. +## Stream-Based Speed Control + +Instead of dynamically reducing upload speed based on bandwidth usage, you can configure speedrr to set specific upload speeds based on the number of active streams. + +### Why Use Stream-Based Speeds? + +Traditional bandwidth-based control is reactive—it reduces your upload speed based on how much bandwidth streams are using. Stream-based control is predictive**—you define exactly what upload speed you want for different numbers of streams. + +Benefits: +- More predictable - You control exactly what happens with 1, 2, 3+ streams +- Max out when idle - Set unlimited upload when no streams are active +- Better balance - Fine-tune the trade-off between streaming quality and torrent upload +- Easier to configure - Just count streams instead of estimating bandwidth needs + +### Quick Start + +Add `stream_based_speeds` to your media server configuration: + +```yaml +modules: + media_servers: + - type: plex + url: http://your-plex-server:32400 + token: your_token + # ... other settings ... + + stream_based_speeds: + enabled: true + speeds: + 0: unlimited # No streams = unlimited upload + 1: 10 # 1 stream = 10 Mbit/s upload + 2: 8 # 2 streams = 8 Mbit/s upload + 3: 6 # 3+ streams = 6 Mbit/s upload + default: 5 # Optional: fallback speed +``` + +### Configuration Options + +`speeds` mapping - Define upload speeds for different stream counts: +- Numbers: `10`, `15`, `20` (in your configured units) +- Percentages: `"50%"`, `"80%"` (of max_upload) +- Unlimited: `unlimited` (removes speed limit) + +`default` (optional) - Fallback speed for undefined stream counts. If omitted, uses the highest defined count's speed. + +### How It Works + +1. Stream Counting: Speedrr monitors your media server and counts active streams +2. Filtering: Local streams, paused streams, and ignored IPs are excluded from the count +3. Speed Selection: Upload speed is set based on your configured mapping +4. Dynamic Updates: Speed adjusts automatically as streams start/stop + +### Use Cases + +Scenario 1: Maximize seeding when idle +```yaml +speeds: + 0: unlimited # Full upload when not streaming + 1: 8 # Conservative when streaming +``` + +Scenario 2: Granular control for multiple users +```yaml +speeds: + 0: unlimited + 1: 12 # One user streaming + 2: 10 # Two users + 3: 8 # Three users + 4: 6 # Four or more users +``` + +Scenario 3: Using percentages +```yaml +speeds: + 0: "100%" # Full max_upload + 1: "70%" # 70% of max_upload + 2: "50%" # 50% of max_upload +``` + +### Complete Example + +See [`config.stream_based.example.yaml`](config.stream_based.example.yaml) for a fully documented configuration with detailed comments and multiple examples. + +### Backward Compatibility + +This feature is completely optional. Existing configurations without `stream_based_speeds` will continue to work with the traditional bandwidth-based speed control. + + + ## Contributing Anyone is welcome to contribute! Feel free to open pull requests. diff --git a/clients/qbittorrent.py b/clients/qbittorrent.py index 0068954..6bae422 100644 --- a/clients/qbittorrent.py +++ b/clients/qbittorrent.py @@ -47,16 +47,24 @@ def get_active_torrent_count(self) -> int: def set_upload_speed(self, speed: Union[int, float]) -> None: "Set the upload speed limit for the client, in config units." - logger.debug(f" Setting upload speed to {speed}{self._config.units}") - self._client.transfer_set_upload_limit( - max(1, int(bit_conv(speed, self._config.units, 'B'))) - ) + if speed == float('inf'): + logger.debug(f" Setting upload speed to unlimited") + self._client.transfer_set_upload_limit(0) + else: + logger.debug(f" Setting upload speed to {speed}{self._config.units}") + self._client.transfer_set_upload_limit( + max(1, int(bit_conv(speed, self._config.units, 'B'))) + ) def set_download_speed(self, speed: Union[int, float]) -> None: "Set the download speed limit for the client, in config units." - logger.debug(f" Setting dowload speed to {speed}{self._config.units}") - self._client.transfer_set_download_limit( - max(1, int(bit_conv(speed, self._config.units, 'B'))) - ) + if speed == float('inf'): + logger.debug(f" Setting download speed to unlimited") + self._client.transfer_set_download_limit(0) + else: + logger.debug(f" Setting dowload speed to {speed}{self._config.units}") + self._client.transfer_set_download_limit( + max(1, int(bit_conv(speed, self._config.units, 'B'))) + ) diff --git a/clients/transmission.py b/clients/transmission.py index c369e50..a05497b 100644 --- a/clients/transmission.py +++ b/clients/transmission.py @@ -66,15 +66,21 @@ def get_active_torrent_count(self) -> int: def set_upload_speed(self, speed: Union[int, float]) -> None: "Set the upload speed limit for the client, in config units." - logger.debug(f" Setting upload speed to {speed}{self._config.units}") - - speed_limit_up = max(1, int(bit_conv(speed, self._config.units, 'KB'))) - self._client.set_session(speed_limit_up=speed_limit_up) + if speed == float('inf'): + logger.debug(f" Setting upload speed to unlimited") + self._client.set_session(speed_limit_up_enabled=False) + else: + logger.debug(f" Setting upload speed to {speed}{self._config.units}") + speed_limit_up = max(1, int(bit_conv(speed, self._config.units, 'KB'))) + self._client.set_session(speed_limit_up_enabled=True, speed_limit_up=speed_limit_up) def set_download_speed(self, speed: Union[int, float]) -> None: "Set the download speed limit for the client, in config units." - logger.debug(f" Setting dowload speed to {speed}{self._config.units}") - - speed_limit_down = max(1, int(bit_conv(speed, self._config.units, "KB"))) - self._client.set_session(speed_limit_down=speed_limit_down) + if speed == float('inf'): + logger.debug(f" Setting download speed to unlimited") + self._client.set_session(speed_limit_down_enabled=False) + else: + logger.debug(f" Setting dowload speed to {speed}{self._config.units}") + speed_limit_down = max(1, int(bit_conv(speed, self._config.units, "KB"))) + self._client.set_session(speed_limit_down_enabled=True, speed_limit_down=speed_limit_down) diff --git a/config.stream_based.example.yaml b/config.stream_based.example.yaml new file mode 100644 index 0000000..7c07985 --- /dev/null +++ b/config.stream_based.example.yaml @@ -0,0 +1,221 @@ +################################################################################ +# STREAM-BASED SPEED CONTROL - EXAMPLE CONFIGURATION +################################################################################ +# This configuration demonstrates the stream-based upload speed control feature +# implemented for GitHub issue #33: +# https://github.com/itschasa/speedrr/issues/33 +# +# WHAT IS STREAM-BASED SPEED CONTROL? +# Instead of reducing upload speed based on bandwidth usage, you can configure +# speedrr to set SPECIFIC upload speeds based on the NUMBER of active streams. +# +# EXAMPLE: +# - 0 streams = unlimited upload (seed at full speed!) +# - 1 stream = 10 Mbit/s (plenty for one stream) +# - 2 streams = 8 Mbit/s (split between streams) +# - 3+ streams = 6 Mbit/s (conservative for multiple streams) +################################################################################ + + +# ============================================================================ +# GENERAL SPEEDRR CONFIGURATION +# ============================================================================ + +# Directory where speedrr will store log files +logs_path: ./logs/ + +# Units to use for all speed values throughout this config +# Options: bit, B, Kbit, Kibit, KB, KiB, Mbit, Mibit, MB, MiB, Gbit, Gibit, GB, GiB +# Note: Capitalization matters! Mbit = megabits, MB = megabytes +units: Mbit + +# Minimum upload speed (speedrr will never go below this) +# Set this to a reasonable minimum to keep torrents alive +min_upload: 5 + +# Maximum upload speed (your internet connection's upload limit) +# Recommended: 70-80% of your actual upload speed +max_upload: 15 + +# Minimum download speed (speedrr will never go below this) +min_download: 10 + +# Maximum download speed (your internet connection's download limit) +# Recommended: 70-100% of your actual download speed +max_download: 100 + +# Whether to use manual share allocation instead of automatic torrent counting +# false = Automatically split bandwidth based on active torrents (recommended) +# true = Use manually configured download_shares and upload_shares +manual_speed_algorithm_share: false + + +# ============================================================================ +# TORRENT CLIENT CONFIGURATION +# ============================================================================ + +clients: + - type: qbittorrent + url: http://localhost:8080 + username: admin + password: adminpass + + # Only used when manual_speed_algorithm_share is true + download_shares: 1 + upload_shares: 1 + + # Whether to verify SSL certificates (set false for self-signed certs) + https_verify: true + + +# ============================================================================ +# MODULES - WHERE THE MAGIC HAPPENS +# ============================================================================ + +modules: + + # -------------------------------------------------------------------------- + # MEDIA SERVERS MODULE - STREAM-BASED SPEED CONTROL + # -------------------------------------------------------------------------- + # This module monitors your media servers and adjusts upload speeds + # based on the NUMBER of active streams (new feature!) + + media_servers: + - type: plex # Options: plex, tautulli, jellyfin, emby + url: http://localhost:32400 + + # PLEX ONLY: Your Plex authentication token + # How to find: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ + token: your_plex_token_here + + # TAUTULLI/JELLYFIN/EMBY ONLY: API key (comment out 'token' above if using these) + # api_key: your_api_key_here + + https_verify: true + + # Multiplier for reported bandwidth (useful if Plex over-reports) + # 1.0 = use reported values, 0.8 = reduce by 20%, 1.2 = increase by 20% + bandwidth_multiplier: 1.0 + + # How often (in seconds) to check for stream updates + # Lower = more responsive, but more API calls + # Recommended: 5-10 seconds + update_interval: 5 + + # Streams to ignore (won't count toward stream-based speeds) + ignore_streams: + # Ignore all local/private IP streams (LAN users) + local: true + + # Optionally ignore specific IP addresses or networks + # Examples: 192.168.1.0/24, 10.0.0.0/8, specific IPs like 1.2.3.4 + ip_networks: [] + + # Ignore streams paused for more than X seconds + # Set to -1 to disable this feature + # Recommended: 300 (5 minutes) to avoid counting stuck streams + paused_after: 300 + + # ====================================================================== + # STREAM-BASED SPEEDS CONFIGURATION (THE NEW FEATURE!) + # ====================================================================== + stream_based_speeds: + # Set to true to enable stream-based speed control + # Set to false (or remove this section) to use bandwidth-based mode + enabled: true + + # Speed mapping: stream_count: upload_speed + # Speedrr will count active streams and set upload speed accordingly + speeds: + # VALUE TYPES: You can use any of these formats: + # - Numbers: 10, 15, 20 (in the units specified above) + # - Percentages: "50%", "80%" (of max_upload) + # - Unlimited: unlimited (removes speed limit) + + 0: unlimited # No active streams = max upload for torrents! + 1: 10 # 1 stream = 10 Mbit/s (plenty for HD streaming) + 2: 8 # 2 streams = 8 Mbit/s + 3: 6 # 3 streams = 6 Mbit/s + 4: 5 # 4+ streams = 5 Mbit/s (conservative for many streams) + + # MORE EXAMPLES (commented out): + # 0: "100%" # Use max_upload when no streams + # 1: "70%" # Use 70% of max_upload for 1 stream + # 2: "50%" # Use 50% of max_upload for 2 streams + # 5: 3 # Use 3 Mbit/s for 5+ streams + + # Optional: Fallback speed for undefined stream counts + # If a stream count isn't explicitly defined above, speedrr will: + # 1. Use the highest defined count's speed that is <= current count + # 2. If no applicable count found, use this default + # 3. If no default set, use max_upload + default: 5 + + # HOW IT WORKS: + # - Speedrr counts only NON-IGNORED streams (respects ignore_streams above) + # - Local streams, paused streams, and streams in ip_networks are NOT counted + # - Upload speed is set based on the total count across all media servers + # - Download speeds are NOT affected by stream-based mode + # - Works alongside the schedule module for combined control + + + # -------------------------------------------------------------------------- + # SCHEDULE MODULE (OPTIONAL) + # -------------------------------------------------------------------------- + # Use schedules to further adjust speeds during specific times/days + # This works ALONGSIDE stream-based speeds: + # - Stream-based speeds control the BASE upload speed + # - Schedules can apply ADDITIONAL reductions if needed + + schedule: + # Example: No additional restrictions during weekdays + - start: "08:00" + end: "22:00" + days: [mon, tue, wed, thu, fri] + upload: 0% # 0% = no reduction from stream-based speed + download: 0% # 0% = no reduction from max_download + + # ADDITIONAL SCHEDULE EXAMPLES (commented out): + + # Example: Maximize upload speed during off-peak hours + # - start: "00:00" + # end: "07:00" + # days: [all] + # upload: unlimited # Override stream-based speeds with unlimited + # download: unlimited + + # Example: Conservative during peak hours on weekends + # - start: "12:00" + # end: "23:00" + # days: [sat, sun] + # upload: "50%" # Reduce stream-based speed by 50% + # download: "30%" # Reduce max_download by 30% + + +################################################################################ +# TIPS AND BEST PRACTICES +################################################################################ +# +# 1. START CONSERVATIVE: Begin with lower speeds and increase if streams buffer +# +# 2. CONSIDER YOUR UPLOAD SPEED: +# - 1080p stream needs ~5-10 Mbit/s +# - 4K stream needs ~25-50 Mbit/s +# - Leave headroom for overhead and multiple streams +# +# 3. MONITOR LOGS: Watch speedrr's output to see: +# - " Total active streams: X" +# - "New calculated upload speed: Y Mbit" +# - Helps you fine-tune your speed mappings +# +# 4. LOCAL STREAMS: Set ignore_streams.local: true to avoid throttling +# your upload when family members stream on the same network +# +# 5. COMBINE WITH SCHEDULES: Use both features together: +# - Stream-based speeds for dynamic control +# - Schedules for time-based adjustments +# +# 6. TESTING: Start with stream_based_speeds.enabled: false to test +# your basic configuration, then enable it once everything works +# +################################################################################ diff --git a/config.yaml b/config.yaml index bd23d4a..15a012f 100644 --- a/config.yaml +++ b/config.yaml @@ -121,6 +121,21 @@ modules: # After a stream has been paused for this amount of seconds, it will be ignored from calculations # Note: To disable this feature, set to -1. paused_after: 300 + + stream_based_speeds: + enabled: true + + # Define upload speeds for different stream counts + speeds: + 0: unlimited # No streams = unlimited upload + 1: 10 # 1 stream = 10 Mbit/s upload + 2: 8 # 2 streams = 8 Mbit/s upload + 3: 6 # 3 streams = 6 Mbit/s upload + 4: 5 # 4+ streams = 5 Mbit/s upload + + # Optional: Default speed for stream counts not explicitly defined + # If not set, uses the highest defined count's speed + default: 5 # Changes the upload/download speed based on the time of day, and what day of the week @@ -141,10 +156,19 @@ modules: # The upload speed deducted in this time period. # Note: This can be a percentage of the maximum or a fixed value (uses units specified at the top of config). # Example: 50%, 10, 5, 80%, 20%, 0 + # Note: Use "unlimited" to set unlimited speed (equivalent to ∞ in qBittorrent) upload: 60% # The download speed deducted in this time period. # Note: This can be a percentage of the maximum or a fixed value (uses units specified at the top of config). - # Example: 50%, 10, 5, 80%, 20%, 0 + # Example: 50%, 10, 5, 80%, 20%, 0 + # Note: Use "unlimited" to set unlimited speed (equivalent to ∞ in qBittorrent) download: 40% + # Example: Set unlimited speeds during off-peak hours (e.g., midnight to 7am) + # - start: "00:00" + # end: "06:59" + # days: [all] + # upload: unlimited + # download: unlimited + diff --git a/helpers/config.py b/helpers/config.py index f93eb26..388458d 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -20,6 +20,12 @@ class IgnoreStreamConfig(YAMLWizard): ip_networks: Optional[tuple[str, ...]] paused_after: int +@dataclass(frozen=True) +class StreamBasedSpeedsConfig(YAMLWizard): + enabled: bool + speeds: dict[int, Union[int, float, str]] + default: Optional[Union[int, float, str]] = None + @dataclass(frozen=True) class MediaServerConfig(YAMLWizard): type: Literal['plex', 'tautulli', 'jellyfin', 'emby'] @@ -30,6 +36,7 @@ class MediaServerConfig(YAMLWizard): ignore_streams: IgnoreStreamConfig token: Optional[str] = None api_key: Optional[str] = None + stream_based_speeds: Optional[StreamBasedSpeedsConfig] = None def __hash__(self) -> int: return super().__hash__() diff --git a/main.py b/main.py index 70b6b07..bbec82d 100644 --- a/main.py +++ b/main.py @@ -90,18 +90,105 @@ ] # These are in the config's units - new_upload_speed = max( - cfg.min_upload, - (cfg.max_upload - sum(module[0] for module in module_reduction_values)) - ) - - new_download_speed = max( - cfg.min_download, - (cfg.max_download - sum(module[1] for module in module_reduction_values)) - ) + # If any module wants unlimited, set to inf for that direction + upload_reductions = [module[0] for module in module_reduction_values] + download_reductions = [module[1] for module in module_reduction_values] + + # Check if using stream-based speed mode (indicated by -inf) + using_stream_based_speeds = any(r == float('-inf') for r in upload_reductions) + + if using_stream_based_speeds: + # Stream-based mode: get BASE speed from media server module + for module in modules: + if isinstance(module, media_server.MediaServerModule): + target_speed = module.get_target_upload_speed() + + # Handle different speed value types to get base speed + if isinstance(target_speed, str): + if target_speed.lower() == "unlimited": + base_upload_speed = float('inf') + elif target_speed.endswith('%'): + percentage = int(target_speed[:-1]) / 100 + base_upload_speed = cfg.max_upload * percentage + else: + base_upload_speed = float(target_speed) + else: + base_upload_speed = target_speed + + break + else: + # Fallback if no media server module found + base_upload_speed = cfg.max_upload + + # Now apply schedule reductions to the base speed + # Filter out the stream-based indicator (-inf) from schedule reductions + schedule_reductions = [r for r in upload_reductions if r != float('-inf')] + + if base_upload_speed == float('inf'): + # Base is unlimited + if any(r == float('inf') for r in schedule_reductions): + # Schedule also wants unlimited + new_upload_speed = float('inf') + elif schedule_reductions: + # Apply reduction to max_upload (since base is unlimited) + new_upload_speed = max( + cfg.min_upload, + cfg.max_upload - sum(schedule_reductions) + ) + else: + # No schedule reductions, use unlimited + new_upload_speed = float('inf') + else: + # Base is a specific value + if any(r == float('inf') for r in schedule_reductions): + # Schedule overrides to unlimited + new_upload_speed = float('inf') + elif schedule_reductions: + # Apply reduction to the base speed + new_upload_speed = max( + cfg.min_upload, + base_upload_speed - sum(schedule_reductions) + ) + else: + # No schedule reductions, use base speed + new_upload_speed = base_upload_speed + + # Download speed calculation remains reduction-based + # Filter out stream-based indicators from download reductions + if any(r == float('inf') for r in download_reductions): + new_download_speed = float('inf') + else: + new_download_speed = max( + cfg.min_download, + (cfg.max_download - sum(download_reductions)) + ) + else: + # Bandwidth-based mode (original behavior) + if any(r == float('inf') for r in upload_reductions): + new_upload_speed = float('inf') + else: + new_upload_speed = max( + cfg.min_upload, + (cfg.max_upload - sum(upload_reductions)) + ) - logger.info(f"New calculated upload speed: {new_upload_speed}{cfg.units}") - logger.info(f"New calculated download speed: {new_download_speed}{cfg.units}") + if any(r == float('inf') for r in download_reductions): + new_download_speed = float('inf') + else: + new_download_speed = max( + cfg.min_download, + (cfg.max_download - sum(download_reductions)) + ) + + if new_upload_speed == float('inf'): + logger.info(f"New calculated upload speed: unlimited") + else: + logger.info(f"New calculated upload speed: {new_upload_speed}{cfg.units}") + + if new_download_speed == float('inf'): + logger.info(f"New calculated download speed: unlimited") + else: + logger.info(f"New calculated download speed: {new_download_speed}{cfg.units}") logger.info("Getting active torrent counts") @@ -113,13 +200,21 @@ sum_active_torrents = sum(client_active_torrent_dict.values()) for torrent_client, active_torrent_count in client_active_torrent_dict.items(): - # If there are no active torrents, set the upload speed to the new speed - if cfg.manual_speed_algorithm_share: + # If speed is unlimited, set it directly without splitting + if new_upload_speed == float('inf'): + effective_upload_speed = float('inf') + elif cfg.manual_speed_algorithm_share: effective_upload_speed = (torrent_client._client_config.download_shares / sum_client_upload_shares * new_upload_speed) - effective_download_speed = (torrent_client._client_config.upload_shares / sum_client_download_shares * new_download_speed) else: effective_upload_speed = (active_torrent_count / sum_active_torrents * new_upload_speed) if active_torrent_count > 0 else new_upload_speed + + if new_download_speed == float('inf'): + effective_download_speed = float('inf') + elif cfg.manual_speed_algorithm_share: + effective_download_speed = (torrent_client._client_config.upload_shares / sum_client_download_shares * new_download_speed) + else: effective_download_speed = (active_torrent_count / sum_active_torrents * new_download_speed) if active_torrent_count > 0 else new_download_speed + try: torrent_client.set_upload_speed(effective_upload_speed) torrent_client.set_download_speed(effective_download_speed) @@ -128,8 +223,15 @@ logger.warning(f"An error occurred while updating {torrent_client._client_config.url}, skipping:\n" + traceback.format_exc()) else: - logger.info(f"Set upload speed for {torrent_client._client_config.url} to {effective_upload_speed}{cfg.units}") - logger.info(f"Set download speed for {torrent_client._client_config.url} to {effective_download_speed}{cfg.units}") + if effective_upload_speed == float('inf'): + logger.info(f"Set upload speed for {torrent_client._client_config.url} to unlimited") + else: + logger.info(f"Set upload speed for {torrent_client._client_config.url} to {effective_upload_speed}{cfg.units}") + + if effective_download_speed == float('inf'): + logger.info(f"Set download speed for {torrent_client._client_config.url} to unlimited") + else: + logger.info(f"Set download speed for {torrent_client._client_config.url} to {effective_download_speed}{cfg.units}") logger.info("Speeds updated") diff --git a/modules/media_server.py b/modules/media_server.py index 2a63693..9367f1f 100644 --- a/modules/media_server.py +++ b/modules/media_server.py @@ -14,6 +14,7 @@ class MediaServerModule: def __init__(self, config: SpeedrrConfig, module_config: List[MediaServerConfig], update_event: threading.Event) -> None: self.reduction_value_dict: dict[MediaServerConfig, float] = {} + self.stream_count_dict: dict[MediaServerConfig, int] = {} self._config = config self._module_config = module_config @@ -44,8 +45,57 @@ def __init__(self, config: SpeedrrConfig, module_config: List[MediaServerConfig] def get_reduction_value(self) -> tuple[float, float]: "How much to reduce the speed by, in the config's units. Returns a tuple of `(upload, download)`." + # Check if any server uses stream-based speeds + stream_based_servers = [ + server for server in self._module_config + if server.stream_based_speeds and server.stream_based_speeds.enabled + ] + + if stream_based_servers: + # Use stream-based speed calculation + total_streams = sum(self.stream_count_dict.values()) + logger.info(f" Total active streams: {total_streams}") + logger.info(f" Stream counts per server = {'; '.join(f'{server.url}: {count}' for server, count in self.stream_count_dict.items())}") + + # For now, return 0 reduction - the main loop will handle stream-based speed setting + # We use a special marker (negative infinity) to signal stream-based mode + return float('-inf'), 0 + + # Use bandwidth-based calculation (original behavior) logger.info(f" Upload reduction values = {'; '.join(f'{server.url}: {reduction}' for server, reduction in self.reduction_value_dict.items())}") return sum(self.reduction_value_dict.values()), 0 + + def get_stream_count(self) -> int: + """Get the total number of active streams across all servers.""" + return sum(self.stream_count_dict.values()) + + def get_target_upload_speed(self) -> Union[int, float, str]: + """Get the target upload speed based on stream count for stream-based mode.""" + total_streams = self.get_stream_count() + + # Find the first server with stream-based speeds enabled (they should all use the same config in practice) + for server_config in self._module_config: + if server_config.stream_based_speeds and server_config.stream_based_speeds.enabled: + speeds_config = server_config.stream_based_speeds + + # Look for exact match in speeds dict + if total_streams in speeds_config.speeds: + return speeds_config.speeds[total_streams] + + # Find the highest stream count that is <= current count + applicable_counts = [count for count in speeds_config.speeds.keys() if count <= total_streams] + if applicable_counts: + highest_applicable = max(applicable_counts) + return speeds_config.speeds[highest_applicable] + + # Use default if provided + if speeds_config.default is not None: + return speeds_config.default + + # Fallback to max_upload if no default + return self._config.max_upload + + return self._config.max_upload def run(self): @@ -93,6 +143,17 @@ def set_reduction(self, reduction) -> None: self._module.reduction_value_dict[self._server_config] = reduction self._module._update_event.set() + + def set_stream_count(self, count: int) -> None: + """Set the stream count for the server and dispatch an update event.""" + old_count = self._module.stream_count_dict.get(self._server_config) + + if old_count == count: + return + + self._module.stream_count_dict[self._server_config] = count + self._module._update_event.set() + def process_session(self, bandwidth: int, paused: bool, ip_address: str, session_id: str, title: str) -> int: "Process a session and return the bandwidth usage. Returns 0 if the session should be ignored." @@ -172,23 +233,30 @@ def get_bandwidth(self) -> int: if res_json["MediaContainer"]["size"] == 0: logger.debug(f"{self._logger_prefix} No sessions found") + self.set_stream_count(0) return 0 count = 0 + stream_count = 0 session_ids: list[str] = [] for session in res_json["MediaContainer"]["Metadata"]: session_ids.append(session["Session"]["id"]) - count += self.process_session( + bandwidth = self.process_session( bandwidth = int(session["Session"]["bandwidth"]), paused = session["Player"]["state"] == "paused", ip_address = session["Player"]["address"], session_id = session["Session"]["id"], title = session["title"] ) + + count += bandwidth + if bandwidth > 0: # Only count non-ignored sessions + stream_count += 1 self.remove_old_paused(session_ids) + self.set_stream_count(stream_count) return count @@ -211,20 +279,26 @@ def get_bandwidth(self) -> int: raise Exception(f"Error from Tautulli: {res_json['response']['message']}") count = 0 + stream_count = 0 session_ids: list[str] = [] for session in res_json["response"]["data"]["sessions"]: session_ids.append(session["session_id"]) - count += self.process_session( + bandwidth = self.process_session( bandwidth = int(session["bandwidth"]), paused = session["state"] == "paused", ip_address = session["ip_address"], session_id = session["session_id"], title = session["full_title"] ) + + count += bandwidth + if bandwidth > 0: # Only count non-ignored sessions + stream_count += 1 self.remove_old_paused(session_ids) + self.set_stream_count(stream_count) return count @@ -245,6 +319,7 @@ def get_bandwidth(self) -> int: res_json: list[dict] = res.json() count = 0 + stream_count = 0 session_ids: list[str] = [] for session in res_json: @@ -261,15 +336,20 @@ def get_bandwidth(self) -> int: else: bandwidth = int(session["TranscodingInfo"]["Bitrate"]) - count += self.process_session( + processed_bandwidth = self.process_session( bandwidth = bandwidth, paused = session["PlayState"]["IsPaused"], ip_address = session["RemoteEndPoint"], session_id = session["Id"], title = session["NowPlayingItem"]["Name"] ) + + count += processed_bandwidth + if processed_bandwidth > 0: # Only count non-ignored sessions + stream_count += 1 self.remove_old_paused(session_ids) + self.set_stream_count(stream_count) return int(round(bit_conv(count, 'bit', 'Kbit'), 0)) @@ -288,6 +368,7 @@ def get_bandwidth(self) -> int: res_json: list[dict] = res.json() count = 0 + stream_count = 0 session_ids: list[str] = [] for session in res_json: @@ -304,15 +385,20 @@ def get_bandwidth(self) -> int: for stream in session["NowPlayingItem"]["MediaStreams"]: bandwidth += int(stream.get("BitRate", 0)) - count += self.process_session( + processed_bandwidth = self.process_session( bandwidth = bandwidth, paused = session["PlayState"]["IsPaused"], ip_address = session["RemoteEndPoint"], session_id = session["Id"], title = session["NowPlayingItem"]["Name"] ) + + count += processed_bandwidth + if processed_bandwidth > 0: # Only count non-ignored sessions + stream_count += 1 self.remove_old_paused(session_ids) + self.set_stream_count(stream_count) return int(round(bit_conv(count, 'bit', 'Kbit'), 0)) diff --git a/modules/schedule.py b/modules/schedule.py index 4172d52..894a97b 100644 --- a/modules/schedule.py +++ b/modules/schedule.py @@ -22,8 +22,11 @@ def __init__(self, config: SpeedrrConfig, module_configs: List[ScheduleConfig], def get_reduction_value(self) -> tuple[float, float]: "How much to reduce the speed by, in the config's units. Returns a tuple of `(upload, download)`." - logger.info(f" Upload reduction values = {'; '.join(f'{cfg.start}-{cfg.end}: {reduction[0]}' for cfg, reduction in self.reduction_value_dict.items())}") - logger.info(f" Download reduction values = {'; '.join(f'{cfg.start}-{cfg.end}: {reduction[1]}' for cfg, reduction in self.reduction_value_dict.items())}") + def format_value(val): + return "unlimited" if val == float('inf') else val + + logger.info(f" Upload reduction values = {'; '.join(f'{cfg.start}-{cfg.end}: {format_value(reduction[0])}' for cfg, reduction in self.reduction_value_dict.items())}") + logger.info(f" Download reduction values = {'; '.join(f'{cfg.start}-{cfg.end}: {format_value(reduction[1])}' for cfg, reduction in self.reduction_value_dict.items())}") return ( sum([reduction[0] for reduction in self.reduction_value_dict.values()]), @@ -63,12 +66,18 @@ def __init__(self, config: ScheduleConfig, module: ScheduleModule) -> None: self._days_as_int.append(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].index(day)) if isinstance(self._config.upload, str): - self._upload_reduce_by = int(self._config.upload[:-1]) / 100 * self._module._config.max_upload + if self._config.upload.lower() == "unlimited": + self._upload_reduce_by = float('inf') + else: + self._upload_reduce_by = int(self._config.upload[:-1]) / 100 * self._module._config.max_upload else: self._upload_reduce_by = self._config.upload if isinstance(self._config.download, str): - self._download_reduce_by = int(self._config.download[:-1]) / 100 * self._module._config.max_download + if self._config.download.lower() == "unlimited": + self._download_reduce_by = float('inf') + else: + self._download_reduce_by = int(self._config.download[:-1]) / 100 * self._module._config.max_download else: self._download_reduce_by = self._config.download