/
/
/
1"""HLS seek optimizer for nicovideo provider.
2
3This module implements a workaround for FFmpeg's seeking limitations with fragmented MP4
4HLS playlists (see https://trac.ffmpeg.org/ticket/7359).
5
6NOTE: This entire module can be removed once Music Assistant requires FFmpeg 8.0+,
7 which fixes the input-side -ss seeking issue (commit 380a518c, 2024-11-10).
8"""
9
10from __future__ import annotations
11
12import logging
13from dataclasses import dataclass
14from typing import TYPE_CHECKING
15
16from music_assistant.helpers.hls import HLSMediaPlaylist
17from music_assistant.providers.nicovideo.constants import (
18 DOMAND_BID_COOKIE_NAME,
19 NICOVIDEO_USER_AGENT,
20)
21from music_assistant.providers.nicovideo.helpers.utils import log_verbose
22
23if TYPE_CHECKING:
24 from music_assistant.providers.nicovideo.converters.stream import NicovideoStreamData
25
26LOGGER = logging.getLogger(__name__)
27
28
29@dataclass
30class SeekOptimizedStreamContext:
31 """Context for seek-optimized HLS streaming.
32
33 Contains all information needed to set up streaming with fast seeking:
34 - Dynamic playlist content to serve
35 - FFmpeg extra input arguments (headers, seeking)
36 """
37
38 dynamic_playlist_text: str
39 extra_input_args: list[str]
40
41
42class HLSSeekOptimizer:
43 """Optimizes HLS streaming with fast seeking support.
44
45 Generates dynamic HLS playlists and FFmpeg arguments for efficient
46 seeking by calculating optimal segment start positions.
47
48 This eliminates the need to decode all segments before the target position,
49 enabling instant seeking in long nicovideo streams.
50 """
51
52 def __init__(
53 self,
54 hls_data: NicovideoStreamData,
55 ) -> None:
56 """Initialize seek optimizer with HLS data.
57
58 Args:
59 hls_data: HLS streaming data containing parsed playlist and authentication info
60 """
61 self.parsed_playlist: HLSMediaPlaylist = hls_data.parsed_hls_playlist
62 self.domand_bid = hls_data.domand_bid
63
64 def _calculate_start_segment(self, seek_position: int) -> tuple[int, float]:
65 """Calculate which segment to start from based on seek position.
66
67 Args:
68 seek_position: Desired seek position in seconds
69
70 Returns:
71 Tuple of (segment_index, offset_within_segment)
72 - segment_index: Index of the segment to start from
73 - offset_within_segment: Seconds to skip within that segment
74 """
75 if seek_position <= 0:
76 return (0, 0.0)
77
78 accumulated_time = 0.0
79 for idx, segment in enumerate(self.parsed_playlist.segments):
80 segment_duration = segment.duration
81 if segment_duration > 0:
82 if accumulated_time + segment_duration > seek_position:
83 # Found the segment containing seek_position
84 offset = seek_position - accumulated_time
85 return (idx, offset)
86 accumulated_time += segment_duration
87
88 # If seek position is beyond total duration, start from last segment
89 return (max(0, len(self.parsed_playlist.segments) - 1), 0.0)
90
91 def _generate_dynamic_playlist(self, start_segment_idx: int) -> str:
92 """Generate dynamic HLS playlist with segments from start_segment_idx onward.
93
94 Args:
95 start_segment_idx: Index to start from
96
97 Returns:
98 Dynamic HLS playlist text
99 """
100 lines = []
101
102 # Add header lines
103 lines.extend(self.parsed_playlist.header_lines)
104
105 # Add segments from start_segment_idx onward
106 # Track previous segment's key_line and map_line to emit only when changed
107 prev_key_line: str | None = None
108 prev_map_line: str | None = None
109
110 for segment in self.parsed_playlist.segments[start_segment_idx:]:
111 # Add discontinuity marker if present
112 if segment.discontinuity:
113 lines.append("#EXT-X-DISCONTINUITY")
114
115 # Add program date/time if present
116 if segment.program_date_time:
117 lines.append(segment.program_date_time)
118
119 # Add map line only if it changed from previous segment
120 # Note: MAP must come before KEY according to RFC 8216
121 if segment.map_line and segment.map_line != prev_map_line:
122 lines.append(segment.map_line)
123 prev_map_line = segment.map_line
124
125 # Add key line only if it changed from previous segment
126 if segment.key_line and segment.key_line != prev_key_line:
127 lines.append(segment.key_line)
128 prev_key_line = segment.key_line
129
130 # Add segment info and URL
131 lines.append(segment.extinf_line)
132
133 # Add byte range if present
134 if segment.byterange_line:
135 lines.append(segment.byterange_line)
136
137 lines.append(segment.segment_url)
138
139 # Add end tag
140 lines.extend(self.parsed_playlist.footer_lines)
141
142 return "\n".join(lines)
143
144 def create_stream_context(self, seek_position: int) -> SeekOptimizedStreamContext:
145 """Create seek-optimized streaming context.
146
147 This method combines segment calculation, playlist generation,
148 and FFmpeg arguments preparation for fast seeking.
149
150 Args:
151 seek_position: Position to seek to in seconds
152
153 Returns:
154 SeekOptimizedStreamContext with all streaming setup information
155 """
156 # Stage 1: Calculate which segment contains the seek position (coarse seek)
157 # This avoids processing unnecessary segments before the target position
158 start_segment_idx, offset_within_segment = self._calculate_start_segment(seek_position)
159 if seek_position > 0:
160 log_verbose(
161 LOGGER,
162 "HLS seek: position=%ds â segment %d/%d (offset %.2fs)",
163 seek_position,
164 start_segment_idx,
165 len(self.parsed_playlist.segments),
166 offset_within_segment,
167 )
168
169 # Generate HLS playlist starting from the calculated segment
170 dynamic_playlist_text = self._generate_dynamic_playlist(start_segment_idx)
171
172 # Build FFmpeg extra input arguments
173 headers = (
174 f"User-Agent: {NICOVIDEO_USER_AGENT}\r\n"
175 f"Cookie: {DOMAND_BID_COOKIE_NAME}={self.domand_bid}\r\n"
176 )
177 extra_input_args = ["-headers", headers]
178
179 # Stage 2: Apply input-side -ss for fine-tuning within the segment
180 if offset_within_segment > 0:
181 extra_input_args.extend(["-ss", str(offset_within_segment)])
182
183 return SeekOptimizedStreamContext(
184 dynamic_playlist_text=dynamic_playlist_text,
185 extra_input_args=extra_input_args,
186 )
187