aede_core/playlist/
files.rs

1//! Filesystem playlists are shared library files, independent of private exports.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8mod metadata;
9use metadata::metadata;
10mod read;
11pub use read::{Document, read};
12
13/// A complete inspection refuses capacity/deadline limits instead of hiding files.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Error {
16    /// The requested identity is malformed.
17    InvalidIdentity,
18    /// No currently permitted file has this identity.
19    NotFound,
20    /// A path or opened file changed during inspection.
21    Changed,
22    /// A filesystem operation failed.
23    Unavailable,
24    /// A path cannot be represented losslessly as bounded UTF-8.
25    InvalidPath,
26    /// Traversal, file content or entry count exceeds its limit.
27    TooLarge,
28    /// The cooperative inspection deadline was reached.
29    Timeout,
30    /// Content is not UTF-8 M3U text.
31    InvalidText,
32}
33
34/// Metadata only: discovery never opens playlist content.
35#[derive(Clone, Debug)]
36pub struct File {
37    /// Opaque path selector; authorization always comes from the current roots.
38    pub id: String,
39    /// Original filename without its extension.
40    pub name: String,
41    /// Original filename, including its `.m3u` or `.m3u8` extension.
42    pub filename: String,
43    /// Canonical path, deduplicated across overlapping roots.
44    pub path: String,
45}
46
47/// Sorted files plus explicit diagnostics for inaccessible parts of the roots.
48pub struct Discovery {
49    /// Files in deterministic canonical path order.
50    pub files: Vec<File>,
51    /// Watched roots that cannot currently be inspected.
52    pub unavailable_roots: usize,
53    /// Child directories or directory entries that could not be inspected.
54    pub unreadable_directories: usize,
55}
56
57#[derive(Clone, Copy)]
58struct Limits {
59    files: usize,
60    entries: usize,
61    depth: usize,
62    elapsed: Duration,
63}
64const LIMITS: Limits = Limits {
65    files: 10_000,
66    entries: 250_000,
67    depth: 64,
68    elapsed: Duration::from_secs(3),
69};
70
71struct Inspection {
72    roots: Vec<PathBuf>,
73    excluded: Vec<PathBuf>,
74    result: Discovery,
75    entries: usize,
76    started: Instant,
77    limits: Limits,
78    seen_files: BTreeMap<String, Vec<usize>>,
79}
80
81impl Inspection {
82    fn new(roots: &[String], excluded: &[String], limits: Limits) -> Result<Self, Error> {
83        let mut inspection = Self {
84            roots: Vec::new(),
85            excluded: Vec::new(),
86            result: Discovery {
87                files: Vec::new(),
88                unavailable_roots: 0,
89                unreadable_directories: 0,
90            },
91            entries: 0,
92            started: Instant::now(),
93            limits,
94            seen_files: BTreeMap::new(),
95        };
96        for path in excluded {
97            inspection.check()?;
98            let path = PathBuf::from(path);
99            inspection
100                .excluded
101                .push(path.canonicalize().unwrap_or(path));
102        }
103        for root in roots {
104            inspection.check()?;
105            match Path::new(root).canonicalize() {
106                Ok(root) if root.is_dir() => inspection.roots.push(root),
107                _ => inspection.result.unavailable_roots += 1,
108            }
109        }
110        inspection.roots.sort();
111        inspection.roots.dedup();
112        Ok(inspection)
113    }
114
115    fn check(&self) -> Result<(), Error> {
116        if self.started.elapsed() >= self.limits.elapsed {
117            Err(Error::Timeout)
118        } else {
119            Ok(())
120        }
121    }
122
123    fn excluded(&self, path: &Path) -> bool {
124        self.excluded
125            .iter()
126            .any(|excluded| path.starts_with(excluded))
127    }
128
129    fn walk(&mut self, folder: &Path, depth: usize) -> Result<(), Error> {
130        self.check()?;
131        if self.excluded(folder) {
132            return Ok(());
133        }
134        if depth > self.limits.depth {
135            return Err(Error::TooLarge);
136        }
137        // Root aliases are canonicalized once; no symlink beneath them is admitted.
138        let directory_metadata = match fs::symlink_metadata(folder) {
139            Ok(metadata) => metadata,
140            Err(_) => {
141                self.result.unreadable_directories += 1;
142                return Ok(());
143            }
144        };
145        let canonical = match folder.canonicalize() {
146            Ok(canonical) => canonical,
147            Err(_) => {
148                self.result.unreadable_directories += 1;
149                return Ok(());
150            }
151        };
152        if !directory_metadata.is_dir()
153            || directory_metadata.file_type().is_symlink()
154            || canonical != folder
155        {
156            return Err(Error::Changed);
157        }
158        let entries = match fs::read_dir(folder) {
159            Ok(entries) => entries,
160            Err(_) => {
161                self.result.unreadable_directories += 1;
162                return Ok(());
163            }
164        };
165        for entry in entries {
166            self.check()?;
167            self.entries += 1;
168            if self.entries > self.limits.entries {
169                return Err(Error::TooLarge);
170            }
171            let entry = match entry {
172                Ok(entry) => entry,
173                Err(_) => {
174                    self.result.unreadable_directories += 1;
175                    continue;
176                }
177            };
178            let path = entry.path();
179            if self.excluded(&path) {
180                continue;
181            }
182            let kind = match entry.file_type() {
183                Ok(kind) => kind,
184                Err(_) => {
185                    self.result.unreadable_directories += 1;
186                    continue;
187                }
188            };
189            if kind.is_dir() {
190                self.walk(&path, depth + 1)?;
191            }
192            if kind.is_file() && playlist_path(&path) {
193                let canonical = match path.canonicalize() {
194                    Ok(canonical) => canonical,
195                    Err(_) => {
196                        self.result.unreadable_directories += 1;
197                        continue;
198                    }
199                };
200                if canonical != path {
201                    return Err(Error::Changed);
202                }
203                let file = metadata(&path)?;
204                if self.seen_files.get(&file.id).is_some_and(|indices| {
205                    indices
206                        .iter()
207                        .any(|&index| self.result.files[index].path == file.path)
208                }) {
209                    continue;
210                }
211                if self.result.files.len() >= self.limits.files {
212                    return Err(Error::TooLarge);
213                }
214                self.seen_files
215                    .entry(file.id.clone())
216                    .or_default()
217                    .push(self.result.files.len());
218                self.result.files.push(file);
219            }
220        }
221        self.check()
222    }
223}
224
225fn playlist_path(path: &Path) -> bool {
226    path.extension()
227        .and_then(|extension| extension.to_str())
228        .is_some_and(|extension| {
229            extension.eq_ignore_ascii_case("m3u") || extension.eq_ignore_ascii_case("m3u8")
230        })
231}
232
233/// Discover all regular M3U files, including folders without catalogued audio.
234/// A filesystem syscall may outlast the cooperative three-second deadline.
235pub fn discover(roots: &[String], excluded: &[String]) -> Result<Discovery, Error> {
236    discover_with_limits(roots, excluded, LIMITS)
237}
238
239fn discover_with_limits(
240    roots: &[String],
241    excluded: &[String],
242    limits: Limits,
243) -> Result<Discovery, Error> {
244    let mut inspection = Inspection::new(roots, excluded, limits)?;
245    let mut covered = Vec::<PathBuf>::new();
246    for root in inspection.roots.clone() {
247        if covered.iter().any(|parent| root.starts_with(parent)) {
248            continue;
249        }
250        let unreadable = inspection.result.unreadable_directories;
251        inspection.walk(&root, 0)?;
252        // An inaccessible parent cannot cover an explicitly watched readable
253        // child. Revisit such roots; only distinct file paths consume slots.
254        if unreadable == inspection.result.unreadable_directories {
255            covered.push(root);
256        }
257    }
258    inspection
259        .result
260        .files
261        .sort_by(|left, right| left.path.cmp(&right.path));
262    Ok(inspection.result)
263}
264
265#[cfg(test)]
266#[path = "files_test_support.rs"]
267mod test_support;
268
269#[cfg(test)]
270#[path = "files_tests.rs"]
271mod tests;