Play and delete a torrent that came as a folder of files (#158)

A torrent of several files is recorded as its folder. /media could not serve a folder, and the
reaper's remove_file failed on it, warned "could not delete" on every pass and freed nothing, so it
counted against the quota for good; Delete in the page failed the same way.

download::remove deletes a file or a whole folder, for the reaper and Delete alike, and the reaper
counts a folder by its recorded size, as a directory's own size is not its contents'.
download::playable answers the path itself, or for a folder the largest file in it, which is the
episode, and /media serves that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:49:03 +00:00
parent 33e031dd13
commit ebecc12193
4 changed files with 50 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one. - Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good. - Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds. - An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.

View File

@@ -207,6 +207,33 @@ pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
Ok(dest) Ok(dest)
} }
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
/// and Delete failed (#158).
pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }
}
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
/// the largest file in its folder, which is the episode (#158).
pub fn playable(path: &Path) -> PathBuf {
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => largest(&e.path(), best),
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
_ => {}
}
}
}
let mut best = None;
if path.is_dir() {
largest(path, &mut best);
}
best.map_or_else(|| path.to_owned(), |(_, p)| p)
}
/// Advertised as a torrent, before spending any bandwidth on it. /// Advertised as a torrent, before spending any bandwidth on it.
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool { pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent")) mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
@@ -415,6 +442,20 @@ mod tests {
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show"); assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
} }
#[test]
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("show/extras")).unwrap();
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
remove(&d.join("show")).unwrap();
assert!(!d.join("show").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test] #[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() { fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()]; let kws = vec!["deep dive".to_string(), "interview".to_string()];

View File

@@ -96,9 +96,13 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
if dry_run { if dry_run {
return Ok(c.bytes.max(0) as u64); return Ok(c.bytes.max(0) as u64);
} }
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(c.bytes.max(0) as u64); // A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
let size = std::fs::metadata(&c.path)
.ok()
.filter(|m| m.is_file())
.map_or(c.bytes.max(0) as u64, |m| m.len());
// Missing is fine: the point is that it is gone. // Missing is fine: the point is that it is gone.
if let Err(e) = std::fs::remove_file(&c.path) if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound && e.kind() != std::io::ErrorKind::NotFound
{ {
tracing::warn!(path = c.path, error = %e, "could not delete"); tracing::warn!(path = c.path, error = %e, "could not delete");

View File

@@ -1758,7 +1758,7 @@ async fn delete_file(
} }
} }
if let Some(path) = &enc.path if let Some(path) = &enc.path
&& let Err(e) = std::fs::remove_file(path) && let Err(e) = crate::download::remove(std::path::Path::new(path))
&& e.kind() != std::io::ErrorKind::NotFound && e.kind() != std::io::ErrorKind::NotFound
{ {
return Err(e.into()); return Err(e.into());
@@ -1863,7 +1863,7 @@ async fn media(
let Some(path) = enc.path else { let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response(); return (StatusCode::NOT_FOUND, "not downloaded").into_response();
}; };
match ServeFile::new(path).oneshot(req).await { match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(), Ok(r) => r.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }