187 lines
5.9 KiB
Rust
187 lines
5.9 KiB
Rust
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::{
|
|
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
|
MediaItemId, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
|
};
|
|
|
|
use super::config::LocalFilesConfig;
|
|
use super::index::{LocalIndex, decode_id};
|
|
use super::scanner::LocalFileItem;
|
|
use super::transcoder::TranscodeManager;
|
|
|
|
pub struct LocalFilesProvider {
|
|
pub index: Arc<LocalIndex>,
|
|
base_url: String,
|
|
transcode_manager: Option<Arc<TranscodeManager>>,
|
|
}
|
|
|
|
const SHORT_DURATION_SECS: u32 = 1200; // 20 minutes
|
|
|
|
impl LocalFilesProvider {
|
|
pub fn new(
|
|
index: Arc<LocalIndex>,
|
|
config: LocalFilesConfig,
|
|
transcode_manager: Option<Arc<TranscodeManager>>,
|
|
) -> Self {
|
|
Self {
|
|
index,
|
|
base_url: config.base_url.trim_end_matches('/').to_string(),
|
|
transcode_manager,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
|
let content_type = if item.duration_secs < 1200 {
|
|
ContentType::Short
|
|
} else {
|
|
ContentType::Movie
|
|
};
|
|
MediaItem {
|
|
id,
|
|
title: item.title.clone(),
|
|
content_type,
|
|
duration_secs: item.duration_secs,
|
|
description: None,
|
|
genres: vec![],
|
|
year: item.year,
|
|
tags: item.tags.clone(),
|
|
series_name: None,
|
|
season_number: None,
|
|
episode_number: None,
|
|
thumbnail_url: None,
|
|
collection_id: None,
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IMediaProvider for LocalFilesProvider {
|
|
fn capabilities(&self) -> ProviderCapabilities {
|
|
ProviderCapabilities {
|
|
collections: true,
|
|
series: false,
|
|
genres: false,
|
|
tags: true,
|
|
decade: true,
|
|
search: true,
|
|
streaming_protocol: if self.transcode_manager.is_some() {
|
|
StreamingProtocol::Hls
|
|
} else {
|
|
StreamingProtocol::DirectFile
|
|
},
|
|
rescan: true,
|
|
transcode: self.transcode_manager.is_some(),
|
|
}
|
|
}
|
|
|
|
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
|
let all = self.index.get_all().await;
|
|
|
|
let results = all
|
|
.into_iter()
|
|
.filter_map(|(id, item)| {
|
|
// content_type: derive heuristically, then filter
|
|
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
|
|
ContentType::Short
|
|
} else {
|
|
ContentType::Movie
|
|
};
|
|
if let Some(ref ct) = filter.content_type && &content_type != ct {
|
|
return None;
|
|
}
|
|
|
|
// collections: match against top_dir
|
|
if !filter.collections.is_empty() && !filter.collections.contains(&item.top_dir) {
|
|
return None;
|
|
}
|
|
|
|
// tags: OR — item must have at least one matching tag
|
|
if !filter.tags.is_empty() {
|
|
let has = filter
|
|
.tags
|
|
.iter()
|
|
.any(|tag| item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)));
|
|
if !has {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// decade: year in [decade, decade+9]
|
|
if let Some(decade) = filter.decade {
|
|
match item.year {
|
|
Some(y) if y >= decade && y <= decade + 9 => {}
|
|
_ => return None,
|
|
}
|
|
}
|
|
|
|
// duration bounds
|
|
if let Some(min) = filter.min_duration_secs && item.duration_secs < min {
|
|
return None;
|
|
}
|
|
if let Some(max) = filter.max_duration_secs && item.duration_secs > max {
|
|
return None;
|
|
}
|
|
|
|
// search_term: case-insensitive substring in title
|
|
if let Some(ref q) = filter.search_term && !item.title.to_lowercase().contains(&q.to_lowercase()) {
|
|
return None;
|
|
}
|
|
|
|
Some(to_media_item(id, &item))
|
|
})
|
|
.collect();
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
|
Ok(self
|
|
.index
|
|
.get(item_id)
|
|
.await
|
|
.map(|item| to_media_item(item_id.clone(), &item)))
|
|
}
|
|
|
|
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
|
match quality {
|
|
StreamQuality::Transcode(_) if self.transcode_manager.is_some() => {
|
|
let tm = self.transcode_manager.as_ref().unwrap();
|
|
let rel = decode_id(item_id).ok_or_else(|| {
|
|
DomainError::InfrastructureError("invalid item id encoding".into())
|
|
})?;
|
|
let src = self.index.root_dir.join(&rel);
|
|
tm.ensure_transcoded(item_id.as_ref(), &src).await?;
|
|
Ok(format!(
|
|
"{}/api/v1/files/transcode/{}/playlist.m3u8",
|
|
self.base_url,
|
|
item_id.as_ref()
|
|
))
|
|
}
|
|
_ => Ok(format!(
|
|
"{}/api/v1/files/stream/{}",
|
|
self.base_url,
|
|
item_id.as_ref()
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
|
let dirs = self.index.collections().await;
|
|
Ok(dirs
|
|
.into_iter()
|
|
.map(|d| Collection {
|
|
id: d.clone(),
|
|
name: d,
|
|
collection_type: None,
|
|
})
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
/// Decode an encoded ID from a URL path segment to its relative path string.
|
|
pub fn decode_stream_id(encoded: &str) -> Option<String> {
|
|
decode_id(&MediaItemId::new(encoded))
|
|
}
|