adapter-local-files: media provider, index, transcoder
This commit is contained in:
200
crates/adapters/local-files/src/provider.rs
Normal file
200
crates/adapters/local-files/src/provider.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
|
||||
|
||||
use crate::config::LocalFilesConfig;
|
||||
use crate::index::{decode_id, LocalIndex};
|
||||
use crate::scanner::LocalFileItem;
|
||||
use crate::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 < SHORT_DURATION_SECS {
|
||||
ContentType::Short
|
||||
} else {
|
||||
ContentType::Movie
|
||||
};
|
||||
MediaItem::from_persistence(
|
||||
id,
|
||||
item.title.clone(),
|
||||
content_type,
|
||||
item.duration_secs,
|
||||
None, // description
|
||||
vec![], // genres
|
||||
item.year,
|
||||
item.tags.clone(),
|
||||
None, // series_name
|
||||
None, // season_number
|
||||
None, // episode_number
|
||||
None, // thumbnail_url
|
||||
None, // collection_id
|
||||
)
|
||||
}
|
||||
|
||||
#[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 {
|
||||
if &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 {
|
||||
if item.duration_secs < min {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(max) = filter.max_duration_secs {
|
||||
if item.duration_secs > max {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// search_term: case-insensitive substring in title
|
||||
if let Some(ref q) = filter.search_term {
|
||||
if !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))
|
||||
}
|
||||
Reference in New Issue
Block a user