Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "symlinkarr"
version = "1.1.0-rc.17"
version = "1.1.0-rc.18"
edition = "2021"
authors = ["Lenny", "Gemini CLI", "Antigravity"]
description = "Intelligent symlink manager for Real-Debrid-backed media libraries"
Expand Down
15 changes: 14 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@

## Release Target

- package version for this push: `1.1.0-rc.17`
- package version for this push: `1.1.0-rc.18`
- posture: `v1.1 release candidate with Arr backfill, fail-closed acquisition ranking, current dependencies, synchronized Docker release channels, and updated operator documentation`
- intended use: local-first host or Docker installs, with Windows 11 users running through WSL2 or a Linux container

## 2026-09-20 - v1.1.0-rc.18 Backfill-Buddy Ecosystem & Dynamic Dead-Link Management

### Code Changes

- **Dynamic Dead Links UX & Safe Pruning**: `/links/dead` now uses live background polling with auto-reload, transparent repair/prune outcome metric cards, paginated listing (50 items/page default) with library tabs and instant search, safe dead-link pruning with Tautulli streaming guards and Arr rescan triggers, and Backfill-Buddy wanted export/download (`wanted.json` and marker creation).
- **Backfill-Buddy Ecosystem & Handoff Watcher**: Added real-time handoff-queue marker watcher in daemon mode, single-folder targeted scan API/CLI, source pins management UI (`/pins`) and API (`/api/v1/pins`) with "Import & Link Now", unlinked library items report CLI/API, and Decypharr Quarantine Inspector (`/quarantine`).
- **WebDAV Readability Diagnostics**: Elevated WebDAV readability probe failure to `warn!` with actionable troubleshooting guidance.
- **Doctor Healthcheck**: Added validation for companion handoff marker directories in `symlinkarr doctor`.

### Validation

- `cargo test` 966 passed, clippy `-D warnings`, fmt, `cargo audit` 0 vulnerabilities.

## 2026-09-19 - v1.1.0-rc.17 Flattened Torrent Paths

### Code Changes
Expand Down
1 change: 1 addition & 0 deletions src/auto_acquire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,7 @@ async fn run_relink_scans(
false,
crate::OutputFormat::Text,
filter.as_deref(),
None,
))
.await?;
}
Expand Down
35 changes: 27 additions & 8 deletions src/commands/backfill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,23 @@ async fn run_backfill_link_only(
.map(|candidate| candidate.item.clone())
.collect::<Vec<_>>();

if let Some(dir) = cfg.handoff.markers_dir.as_deref() {
let import = crate::handoff::import_markers(db, dir, cfg.handoff.consume_markers).await;
if emit_text && (import.imported > 0 || import.without_id > 0 || import.unreadable > 0) {
println!(" 📌 Handoff markers: {}", import.summary_line());
}
}
let source_pins = crate::commands::scan::load_source_pins(db).await;

let matcher = Matcher::new(
tmdb.clone(),
tvdb,
cfg.matching.mode,
cfg.matching.metadata_mode,
cfg.matching.metadata_concurrency,
)
.with_multi_version(cfg.symlink.multi_version);
.with_multi_version(cfg.symlink.multi_version)
.with_source_pins(source_pins);

let mut match_output = matcher
.find_matches_with_telemetry(&library_items, &source_items, db)
Expand Down Expand Up @@ -380,14 +389,24 @@ async fn run_backfill_link_only(
}

let linked_total = link_summary.created + link_summary.updated;
if linked_total > 0 && !effective_dry_run && has_configured_invalidation_server(cfg) {
if let Err(err) =
invalidate_after_mutation(cfg, &selected, &link_summary.refresh_paths, emit_text).await
if linked_total > 0 && !effective_dry_run {
if has_configured_invalidation_server(cfg) {
if let Err(err) =
invalidate_after_mutation(cfg, &selected, &link_summary.refresh_paths, emit_text)
.await
{
warn!("Arr backfill media-server refresh failed: {}", err);
summary
.warnings
.push(format!("media-server refresh failed: {}", err));
}
}
for line in
crate::commands::scan::rescan_arrs_for_touched(cfg, &link_summary.touched_media).await
{
warn!("Arr backfill media-server refresh failed: {}", err);
summary
.warnings
.push(format!("media-server refresh failed: {}", err));
if emit_text {
println!(" {}", line);
}
}
}

Expand Down
180 changes: 180 additions & 0 deletions src/commands/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,186 @@ async fn run_cleanup_dead(
Ok(dead.removed as i64)
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeadLinkWantedItem {
pub media_id: String,
pub title: String,
pub media_type: String,
pub library: String,
pub dead_count: usize,
pub sample_files: Vec<String>,
}

pub(crate) async fn execute_prune_dead_links(
cfg: &Config,
db: &Database,
library_filter: Option<&str>,
dry_run: bool,
emit_text: bool,
) -> Result<crate::db::DeadLinkPruneOutcome> {
let selected = selected_libraries(cfg, library_filter)?;
let library_roots: Vec<PathBuf> = selected.iter().map(|l| l.path.clone()).collect();

ensure_runtime_directories_healthy(&selected, &cfg.sources, "cleanup dead-link pruning")
.await?;

if cfg.backup.enabled {
if dry_run {
if emit_text {
println!(" ℹ️ Skipping safety snapshot in --dry-run mode");
}
} else {
if emit_text {
println!(" 🛡️ Creating safety snapshot before dead-link prune...");
}
let bm = crate::backup::BackupManager::new(&cfg.backup);
bm.create_safety_snapshot(db, "dead_link_prune").await?;
}
}

let skip_paths = if cfg.has_tautulli() {
let tautulli = TautulliClient::new(&cfg.tautulli);
match tautulli.get_active_file_paths().await {
Ok(paths) => {
if !paths.is_empty() && emit_text {
println!(
" 🎬 Tautulli: {} active streams detected — protecting those files",
paths.len()
);
}
paths
}
Err(e) => {
if emit_text {
println!(
" ⚠️ Tautulli query failed ({}), proceeding without guard",
e
);
}
vec![]
}
}
} else {
vec![]
};

let outcome = db
.prune_dead_links_scoped(Some(&library_roots), dry_run, &skip_paths)
.await?;

if !dry_run && outcome.removed > 0 {
let servers = configured_refresh_backends(cfg);
if emit_text && !servers.is_empty() {
println!(
" 📺 Post-prune: refreshing affected library roots in {}...",
display_server_list(&servers)
);
}
if let Err(err) =
invalidate_after_mutation(cfg, &selected, &outcome.affected_paths, emit_text).await
{
if emit_text {
println!(" ⚠️ Post-prune media-server refresh failed: {}", err);
}
tracing::warn!("Post-prune media-server refresh failed: {}", err);
}
}

if !dry_run && !outcome.touched_media.is_empty() {
for line in
crate::commands::scan::rescan_arrs_for_touched(cfg, &outcome.touched_media).await
{
if emit_text {
println!(" {}", line);
}
}
}

Ok(outcome)
}

pub(crate) async fn group_dead_links_wanted(
cfg: &Config,
db: &Database,
library_filter: Option<&str>,
) -> Result<Vec<DeadLinkWantedItem>> {
let selected = selected_libraries(cfg, library_filter)?;
let library_roots: Vec<PathBuf> = selected.iter().map(|l| l.path.clone()).collect();
let dead_links = db
.get_links_by_status_scoped(crate::models::LinkStatus::Dead, Some(&library_roots))
.await?;

use std::collections::BTreeMap;
let mut map: BTreeMap<String, (String, String, String, usize, Vec<String>)> = BTreeMap::new();

for link in dead_links {
let media_type_str = match link.media_type {
crate::models::MediaType::Movie => "movie",
crate::models::MediaType::Tv => "tv",
};
let lib_name = selected
.iter()
.find(|l| link.target_path.starts_with(&l.path))
.map(|l| l.name.clone())
.unwrap_or_else(|| "Unknown".to_string());

let folder_title = link
.target_path
.parent()
.and_then(|p| {
let file_name = p.file_name()?.to_string_lossy();
let lower = file_name.to_lowercase();
if lower.starts_with("season ") || lower.starts_with("specials") {
p.parent()?
.file_name()
.map(|s| s.to_string_lossy().to_string())
} else {
Some(file_name.to_string())
}
})
.unwrap_or_else(|| link.media_id.clone());

let filename = link
.target_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();

let entry = map.entry(link.media_id.clone()).or_insert_with(|| {
(
folder_title,
media_type_str.to_string(),
lib_name,
0,
Vec::new(),
)
});
entry.3 += 1;
if entry.4.len() < 5 {
entry.4.push(filename);
}
}

let mut items: Vec<DeadLinkWantedItem> = map
.into_iter()
.map(
|(media_id, (title, media_type, library, dead_count, sample_files))| {
DeadLinkWantedItem {
media_id,
title,
media_type,
library,
dead_count,
sample_files,
}
},
)
.collect();

items.sort_by_key(|a| std::cmp::Reverse(a.dead_count));
Ok(items)
}

async fn run_cleanup_audit(
cfg: &Config,
db: &Database,
Expand Down
Loading
Loading