mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-30 02:32:24 +01:00
* **Bug Fixes** * Playback retries can use another available provider when playback has not started, helping requests proceed if the initially selected provider is unavailable. * Once playback has started, retries continue using the same eligible provider to preserve playback continuity. * Provider selection follows the same rules for regular playback and forced reopens, reducing unnecessary provider pinning before media begins. * Series episode URLs can resolve to the correct account-specific playback address.
160 lines
6.5 KiB
Rust
160 lines
6.5 KiB
Rust
use crate::TestkitError;
|
|
use std::collections::HashMap;
|
|
|
|
/// Maps a stable synthetic-origin marker to the virtual playback URL published by Tuliprox.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct VirtualIdMap {
|
|
urls: HashMap<u32, String>,
|
|
named_urls: HashMap<String, String>,
|
|
}
|
|
|
|
impl VirtualIdMap {
|
|
pub fn from_m3u(document: &str) -> Result<Self, TestkitError> {
|
|
let mut urls = HashMap::new();
|
|
let mut named_urls = HashMap::new();
|
|
let mut pending_marker = None;
|
|
let mut pending_name = None;
|
|
for raw_line in document.lines() {
|
|
let line = raw_line.trim();
|
|
if line.starts_with("#EXTINF:") {
|
|
pending_marker = marker_from_extinf(line);
|
|
pending_name = name_from_extinf(line);
|
|
continue;
|
|
}
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some(marker) = pending_marker.take() {
|
|
if urls.insert(marker, line.to_owned()).is_some() {
|
|
return Err(TestkitError::Configuration(format!("duplicate origin marker {marker} in playlist")));
|
|
}
|
|
}
|
|
if let Some(name) = pending_name.take() {
|
|
if named_urls.insert(name.clone(), line.to_owned()).is_some() {
|
|
return Err(TestkitError::Configuration(format!("duplicate named media {name} in playlist")));
|
|
}
|
|
}
|
|
}
|
|
if urls.is_empty() && named_urls.is_empty() {
|
|
return Err(TestkitError::Configuration("playlist has no testkit marker entries".to_owned()));
|
|
}
|
|
Ok(Self { urls, named_urls })
|
|
}
|
|
|
|
pub fn playback_url(&self, marker: u32) -> Result<&str, TestkitError> {
|
|
self.urls
|
|
.get(&marker)
|
|
.map(String::as_str)
|
|
.ok_or_else(|| TestkitError::Configuration(format!("origin marker {marker} was not published by Tuliprox")))
|
|
}
|
|
|
|
pub fn named_playback_url(&self, name: &str) -> Result<&str, TestkitError> {
|
|
self.named_urls
|
|
.get(name)
|
|
.map(String::as_str)
|
|
.ok_or_else(|| TestkitError::Configuration(format!("named media {name} was not published by Tuliprox")))
|
|
}
|
|
|
|
/// Extract the numeric virtual ID from the URL stored for `marker`. The virtual ID is the last
|
|
/// path segment after stripping any query string and file extension. Returns an error if the
|
|
/// segment cannot be parsed as a `u64`.
|
|
pub fn virtual_id_from_marker(&self, marker: u32) -> Result<u64, TestkitError> {
|
|
let url = self.playback_url(marker)?;
|
|
extract_virtual_id_from_url(url)
|
|
}
|
|
}
|
|
|
|
fn marker_from_extinf(line: &str) -> Option<u32> {
|
|
let value = line.split("tvg-id=\"").nth(1)?.split('"').next()?;
|
|
value.strip_prefix("test-")?.parse().ok()
|
|
}
|
|
|
|
fn name_from_extinf(line: &str) -> Option<String> {
|
|
let value = line.split("tvg-id=\"").nth(1)?.split('"').next()?;
|
|
value.strip_prefix("test-vod-").map(str::to_owned).or_else(|| {
|
|
let title = line.split("tvg-name=\"").nth(1)?.split('"').next()?;
|
|
(title == "Test Series S01E01").then(|| "episode.mkv".to_owned())
|
|
})
|
|
}
|
|
|
|
/// Extract the numeric virtual ID from the last path segment of a URL.
|
|
/// Query strings and file extensions (e.g. `.ts`, `.m3u8`) are stripped before parsing.
|
|
fn extract_virtual_id_from_url(url: &str) -> Result<u64, TestkitError> {
|
|
// Drop query string
|
|
let path = url.split('?').next().unwrap_or(url);
|
|
// Drop file extension: find the last `.` in the last path segment only
|
|
let path = {
|
|
let last_slash = path.rfind('/').map_or(0, |i| i + 1);
|
|
if let Some(dot_pos) = path[last_slash..].rfind('.') {
|
|
&path[..last_slash + dot_pos]
|
|
} else {
|
|
path
|
|
}
|
|
};
|
|
let segment = path.rsplit('/').next().unwrap_or("");
|
|
segment.parse::<u64>().map_err(|_| {
|
|
TestkitError::Configuration(format!(
|
|
"cannot extract numeric virtual ID from URL {url}: last path segment {segment:?} is not a valid u64"
|
|
))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn discovers_synthetic_series_episode_without_tvg_id() {
|
|
let playlist = "#EXTM3U\n#EXTINF:-1 tvg-id=\"\" tvg-name=\"Test Series S01E01\" group-title=\"Series\",Test Series S01E01\nhttp://tuliprox/series/abc/def/7.mkv\n";
|
|
let map = VirtualIdMap::from_m3u(playlist).expect("series episode discovery");
|
|
assert_eq!(
|
|
map.named_playback_url("episode.mkv").expect("series episode URL"),
|
|
"http://tuliprox/series/abc/def/7.mkv"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discovers_virtual_playback_urls_without_assuming_their_ids() {
|
|
let playlist = "#EXTM3U\n#EXTINF:-1 tvg-id=\"test-17\",News\nhttp://tuliprox/m3u/abc/17\n#EXTINF:-1 tvg-id=\"test-vod-movie.mkv\" type=\"movie\",Movie\nhttp://tuliprox/movie/abc/def/1.mkv\n";
|
|
let map = VirtualIdMap::from_m3u(playlist).unwrap();
|
|
assert_eq!(map.playback_url(17).unwrap(), "http://tuliprox/m3u/abc/17");
|
|
assert_eq!(map.named_playback_url("movie.mkv").unwrap(), "http://tuliprox/movie/abc/def/1.mkv");
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_virtual_id_from_plain_m3u_url() {
|
|
let map = VirtualIdMap::from_m3u("#EXTM3U\n#EXTINF:-1 tvg-id=\"test-17\",News\nhttp://tuliprox/m3u/abc/42\n")
|
|
.unwrap();
|
|
assert_eq!(map.virtual_id_from_marker(17).unwrap(), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_query_string_before_extracting_virtual_id() {
|
|
let map = VirtualIdMap::from_m3u(
|
|
"#EXTM3U\n#EXTINF:-1 tvg-id=\"test-5\",Ch5\nhttp://tuliprox/m3u/user/99?token=abc\n",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(map.virtual_id_from_marker(5).unwrap(), 99);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_file_extension_before_extracting_virtual_id() {
|
|
let map = VirtualIdMap::from_m3u("#EXTM3U\n#EXTINF:-1 tvg-id=\"test-3\",Ch3\nhttp://tuliprox/m3u/user/77.ts\n")
|
|
.unwrap();
|
|
assert_eq!(map.virtual_id_from_marker(3).unwrap(), 77);
|
|
}
|
|
|
|
#[test]
|
|
fn non_numeric_last_segment_is_an_error() {
|
|
let map = VirtualIdMap::from_m3u("#EXTM3U\n#EXTINF:-1 tvg-id=\"test-1\",Ch1\nhttp://tuliprox/m3u/user/abc\n")
|
|
.unwrap();
|
|
assert!(map.virtual_id_from_marker(1).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_named_media_is_rejected() {
|
|
let playlist = "#EXTM3U\n#EXTINF:-1 tvg-id=\"test-vod-movie.mkv\",Movie\nhttp://tuliprox/movie/abc/def/1.mkv\n#EXTINF:-1 tvg-id=\"test-vod-movie.mkv\",Movie Again\nhttp://tuliprox/movie/abc/def/2.mkv\n";
|
|
assert!(matches!(VirtualIdMap::from_m3u(playlist), Err(TestkitError::Configuration(_))));
|
|
}
|
|
}
|