feat: Add torrent and SMB file deletion with confirmation and batch delete

This commit is contained in:
david
2026-08-17 10:26:07 -04:00
parent 504d3ef620
commit 057800f0b2
2 changed files with 339 additions and 8 deletions
+237 -5
View File
@@ -1171,13 +1171,14 @@
</button> </button>
<button class="btn btn-secondary" id="btn-stop-analysis" style="display: none; background: #7f1d1d; border-color: #991b1b; color: #fff;"> <button class="btn btn-secondary" id="btn-stop-analysis" style="display: none; background: #7f1d1d; border-color: #991b1b; color: #fff;">
⛔ Stop Analysis ⛔ Stop Analy <button class="btn btn-success" id="btn-batch-relocate" disabled>
</button>
<button class="btn btn-success" id="btn-batch-relocate" disabled>
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
Approve & Move Selected Torrents Approve & Move Selected Torrents
</button> </button>
<button class="btn btn-secondary" id="btn-batch-delete-torrents" disabled style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Delete selected torrents and delete their downloaded files from disk">
🗑️ Delete Selected
</button>
</div> </div>
<div class="search-filter"> <div class="search-filter">
@@ -1251,7 +1252,7 @@
</div> </div>
</div> </div>
<!-- SMB Actions Control Bar --> <!-- Actions Control Bar -->
<div class="controls-bar"> <div class="controls-bar">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-primary" id="btn-fetch-smb"> <button class="btn btn-primary" id="btn-fetch-smb">
@@ -1280,6 +1281,11 @@
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
Approve & Move Selected Files Approve & Move Selected Files
</button> </button>
<button class="btn btn-secondary" id="btn-smb-batch-delete" disabled style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Permanently delete selected files from SMB share">
🗑️ Delete Selected
</button>
</div> </button>
</div> </div>
<div class="search-filter"> <div class="search-filter">
@@ -1593,6 +1599,7 @@
const btnAnalyzeAll = document.getElementById('btn-ai-analyze-all'); const btnAnalyzeAll = document.getElementById('btn-ai-analyze-all');
const btnStopAnalysis = document.getElementById('btn-stop-analysis'); const btnStopAnalysis = document.getElementById('btn-stop-analysis');
const btnBatchRelocate = document.getElementById('btn-batch-relocate'); const btnBatchRelocate = document.getElementById('btn-batch-relocate');
const btnBatchDeleteTorrents = document.getElementById('btn-batch-delete-torrents');
const progressBarContainer = document.getElementById('global-progress-container'); const progressBarContainer = document.getElementById('global-progress-container');
const progressBarFill = document.getElementById('global-progress-fill'); const progressBarFill = document.getElementById('global-progress-fill');
@@ -1610,6 +1617,7 @@
const btnSmbAnalyzeAll = document.getElementById('btn-smb-analyze-all'); const btnSmbAnalyzeAll = document.getElementById('btn-smb-analyze-all');
const btnSmbStopAnalysis = document.getElementById('btn-smb-stop-analysis'); const btnSmbStopAnalysis = document.getElementById('btn-smb-stop-analysis');
const btnSmbBatchRelocate = document.getElementById('btn-smb-batch-relocate'); const btnSmbBatchRelocate = document.getElementById('btn-smb-batch-relocate');
const btnSmbBatchDelete = document.getElementById('btn-smb-batch-delete');
const smbProgressBarContainer = document.getElementById('smb-progress-container'); const smbProgressBarContainer = document.getElementById('smb-progress-container');
const smbProgressBarFill = document.getElementById('smb-progress-fill'); const smbProgressBarFill = document.getElementById('smb-progress-fill');
@@ -1845,6 +1853,22 @@
} }
} }
async function deleteItemFromDb(itemKey) {
delete dbCache.all[itemKey];
dbStatusText.textContent = `SQLite: ${Object.keys(dbCache.all).length} Cached`;
try {
const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000';
const endpoint = isServingFromProxy ? '/api/db/delete' : 'http://localhost:8000/api/db/delete';
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_key: itemKey })
});
} catch (err) {
console.warn('Failed deleting item from SQLite DB:', err);
}
}
// Load saved settings // Load saved settings
function loadConfig() { function loadConfig() {
const raw = localStorage.getItem(STORAGE_KEY) || localStorage.getItem('qb_performer_sorter_cfg_v3'); const raw = localStorage.getItem(STORAGE_KEY) || localStorage.getItem('qb_performer_sorter_cfg_v3');
@@ -3006,6 +3030,87 @@ Return ONLY valid JSON matching:
updateButtonStates(); updateButtonStates();
}); });
async function deleteTorrent(torrent, confirmPrompt = true) {
if (confirmPrompt) {
const ok = confirm(`⚠️ PERMANENT DELETE:\n\nAre you sure you want to permanently delete "${torrent.name}" AND ALL ITS DOWNLOADED FILES from disk?`);
if (!ok) return;
}
torrent.moveStatus = 'moving';
renderRow(torrent.hash);
const params = new URLSearchParams();
params.append('hashes', torrent.hash);
params.append('deleteFiles', 'true');
const res = await qbRequest('/api/v2/torrents/delete', { method: 'POST', body: params });
if (!res.ok) {
const text = await res.text();
torrent.moveStatus = 'error';
torrent.errorMessage = 'Failed delete: ' + text;
renderRow(torrent.hash);
throw new Error('Failed to delete torrent: ' + text);
}
await deleteItemFromDb(torrent.hash);
qbState.torrents = qbState.torrents.filter(t => t.hash !== torrent.hash);
qbState.selectedHashes.delete(torrent.hash);
qbTabBadge.textContent = qbState.torrents.length;
renderTable();
updateStats();
showToast(`Deleted ${torrent.name} and downloaded files from disk`, 'success');
}
if (btnBatchDeleteTorrents) {
btnBatchDeleteTorrents.addEventListener('click', async () => {
const selected = qbState.torrents.filter(t => qbState.selectedHashes.has(t.hash));
if (selected.length === 0) {
showToast('Please select at least one torrent checkbox to delete', 'warning');
return;
}
const ok = confirm(`⚠️ PERMANENT BATCH DELETE:\n\nAre you sure you want to permanently delete ${selected.length} selected torrent(s) AND ALL THEIR DOWNLOADED FILES from disk?\n\nThis cannot be undone.`);
if (!ok) return;
btnBatchDeleteTorrents.disabled = true;
btnBatchDeleteTorrents.textContent = 'Deleting...';
progressBarContainer.style.display = 'block';
progressBarFill.style.width = '0%';
try {
const hashes = selected.map(t => t.hash).join('|');
const params = new URLSearchParams();
params.append('hashes', hashes);
params.append('deleteFiles', 'true');
const res = await qbRequest('/api/v2/torrents/delete', { method: 'POST', body: params });
if (!res.ok) {
const text = await res.text();
throw new Error('Failed deleting torrents from qBittorrent: ' + text);
}
for (const t of selected) {
await deleteItemFromDb(t.hash);
}
const deletedHashSet = new Set(selected.map(t => t.hash));
qbState.torrents = qbState.torrents.filter(t => !deletedHashSet.has(t.hash));
qbState.selectedHashes.clear();
qbTabBadge.textContent = qbState.torrents.length;
renderTable();
updateStats();
showToast(`Permanently deleted ${selected.length} torrent(s) and files from disk`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btnBatchDeleteTorrents.disabled = false;
btnBatchDeleteTorrents.textContent = '🗑️ Delete Selected';
setTimeout(() => { progressBarContainer.style.display = 'none'; }, 1000);
updateButtonStates();
}
});
}
function updateStats() { function updateStats() {
statTotal.textContent = qbState.torrents.length; statTotal.textContent = qbState.torrents.length;
statAnalyzed.textContent = qbState.torrents.filter(t => t.analysis).length; statAnalyzed.textContent = qbState.torrents.filter(t => t.analysis).length;
@@ -3024,6 +3129,7 @@ Return ONLY valid JSON matching:
btnAnalyzeSelected.disabled = !hasSelection || qbState.isAnalyzing; btnAnalyzeSelected.disabled = !hasSelection || qbState.isAnalyzing;
btnAnalyzeAll.disabled = !hasTorrents || qbState.isAnalyzing; btnAnalyzeAll.disabled = !hasTorrents || qbState.isAnalyzing;
btnBatchRelocate.disabled = !hasReadySelected || qbState.isMoving; btnBatchRelocate.disabled = !hasReadySelected || qbState.isMoving;
if (btnBatchDeleteTorrents) btnBatchDeleteTorrents.disabled = !hasSelection || qbState.isMoving;
} }
function renderTable() { function renderTable() {
@@ -3150,6 +3256,7 @@ Return ONLY valid JSON matching:
${t.analysis && t.moveStatus !== 'moved' ? ` ${t.analysis && t.moveStatus !== 'moved' ? `
<button class="btn btn-secondary btn-sm btn-single-analyze" data-hash="${t.hash}" title="Re-run AI analysis">Re-run</button> <button class="btn btn-secondary btn-sm btn-single-analyze" data-hash="${t.hash}" title="Re-run AI analysis">Re-run</button>
` : ''} ` : ''}
<button type="button" class="btn btn-secondary btn-sm btn-single-delete-torrent" data-hash="${t.hash}" title="Permanently delete torrent and downloaded files from disk" style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.3); padding: 0.25rem 0.5rem;">🗑️</button>
</div> </div>
</td> </td>
<td style="text-align: center; white-space: nowrap; min-width: 105px;"> <td style="text-align: center; white-space: nowrap; min-width: 105px;">
@@ -3258,6 +3365,14 @@ Return ONLY valid JSON matching:
}); });
}); });
root.querySelectorAll('.btn-single-delete-torrent').forEach(btn => {
btn.addEventListener('click', async () => {
const hash = btn.getAttribute('data-hash');
const t = qbState.torrents.find(item => item.hash === hash);
if (t) await deleteTorrent(t, true);
});
});
root.querySelectorAll('.view-files-link').forEach(link => { root.querySelectorAll('.view-files-link').forEach(link => {
link.addEventListener('click', async (e) => { link.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
@@ -3754,6 +3869,95 @@ Return ONLY a valid JSON object with an array named "results":
updateSmbButtonStates(); updateSmbButtonStates();
}); });
async function deleteSmbFile(fileItem, confirmPrompt = true) {
if (confirmPrompt) {
const ok = confirm(`⚠️ PERMANENT DELETE:\n\nAre you sure you want to permanently delete "${fileItem.name}" from the SMB share?`);
if (!ok) return;
}
fileItem.moveStatus = 'moving';
renderSmbRow(fileItem.path);
const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000';
const endpoint = isServingFromProxy ? '/api/smb/delete' : 'http://localhost:8000/api/smb/delete';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: fileItem.path })
});
if (!res.ok) {
const errData = await res.json();
fileItem.moveStatus = 'error';
fileItem.errorMessage = 'Failed delete: ' + (errData.error || `HTTP ${res.status}`);
renderSmbRow(fileItem.path);
throw new Error(errData.error || `HTTP ${res.status}`);
}
await deleteItemFromDb(fileItem.path);
smbState.files = smbState.files.filter(f => f.path !== fileItem.path);
smbState.selectedPaths.delete(fileItem.path);
smbTabBadge.textContent = smbState.files.length;
renderSmbTable();
updateSmbStats();
showToast(`Deleted ${fileItem.name} from SMB share`, 'success');
}
if (btnSmbBatchDelete) {
btnSmbBatchDelete.addEventListener('click', async () => {
const selected = smbState.files.filter(f => smbState.selectedPaths.has(f.path));
if (selected.length === 0) {
showToast('Please select at least one SMB file to delete', 'warning');
return;
}
const ok = confirm(`⚠️ PERMANENT BATCH DELETE:\n\nAre you sure you want to permanently delete ${selected.length} selected video file(s) from the SMB share?\n\nThis cannot be undone.`);
if (!ok) return;
btnSmbBatchDelete.disabled = true;
btnSmbBatchDelete.textContent = 'Deleting...';
smbProgressBarContainer.style.display = 'block';
smbProgressBarFill.style.width = '0%';
try {
const paths = selected.map(f => f.path);
const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000';
const endpoint = isServingFromProxy ? '/api/smb/delete' : 'http://localhost:8000/api/smb/delete';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: paths })
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || `HTTP ${res.status}`);
}
for (const f of selected) {
await deleteItemFromDb(f.path);
}
const deletedPathSet = new Set(paths);
smbState.files = smbState.files.filter(f => !deletedPathSet.has(f.path));
smbState.selectedPaths.clear();
smbTabBadge.textContent = smbState.files.length;
renderSmbTable();
updateSmbStats();
showToast(`Permanently deleted ${selected.length} video file(s) from SMB share`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btnSmbBatchDelete.disabled = false;
btnSmbBatchDelete.textContent = '🗑️ Delete Selected';
setTimeout(() => { smbProgressBarContainer.style.display = 'none'; }, 1000);
updateSmbButtonStates();
}
});
}
function updateSmbStats() { function updateSmbStats() {
smbStatTotal.textContent = smbState.files.length; smbStatTotal.textContent = smbState.files.length;
smbStatAnalyzed.textContent = smbState.files.filter(f => f.analysis).length; smbStatAnalyzed.textContent = smbState.files.filter(f => f.analysis).length;
@@ -3772,6 +3976,7 @@ Return ONLY a valid JSON object with an array named "results":
btnSmbAnalyzeSelected.disabled = !hasSelection || smbState.isAnalyzing; btnSmbAnalyzeSelected.disabled = !hasSelection || smbState.isAnalyzing;
btnSmbAnalyzeAll.disabled = !hasFiles || smbState.isAnalyzing; btnSmbAnalyzeAll.disabled = !hasFiles || smbState.isAnalyzing;
btnSmbBatchRelocate.disabled = !hasReadySelected || smbState.isMoving; btnSmbBatchRelocate.disabled = !hasReadySelected || smbState.isMoving;
if (btnSmbBatchDelete) btnSmbBatchDelete.disabled = !hasSelection || smbState.isMoving;
} }
function renderSmbTable() { function renderSmbTable() {
@@ -3893,6 +4098,7 @@ Return ONLY a valid JSON object with an array named "results":
${f.analysis && f.moveStatus !== 'moved' ? ` ${f.analysis && f.moveStatus !== 'moved' ? `
<button class="btn btn-secondary btn-sm btn-smb-single-analyze" data-path="${escapeHtml(f.path)}" title="Re-run AI analysis">Re-run</button> <button class="btn btn-secondary btn-sm btn-smb-single-analyze" data-path="${escapeHtml(f.path)}" title="Re-run AI analysis">Re-run</button>
` : ''} ` : ''}
<button type="button" class="btn btn-secondary btn-sm btn-smb-single-delete" data-path="${escapeHtml(f.path)}" title="Permanently delete file from SMB share" style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.3); padding: 0.25rem 0.5rem;">🗑️</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -3924,6 +4130,14 @@ Return ONLY a valid JSON object with an array named "results":
}); });
}); });
root.querySelectorAll('.btn-smb-single-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const path = btn.getAttribute('data-path');
const f = smbState.files.find(item => item.path === path);
if (f) await deleteSmbFile(f, true);
});
});
// Video Preview Button / Title Click // Video Preview Button / Title Click
root.querySelectorAll('.btn-open-video-preview').forEach(el => { root.querySelectorAll('.btn-open-video-preview').forEach(el => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
@@ -4153,6 +4367,9 @@ Return ONLY a valid JSON object with an array named "results":
` : ` ` : `
<span class="badge badge-success" style="font-size: 0.85rem; padding: 0.4rem 0.8rem;">✓ Moved Successfully</span> <span class="badge badge-success" style="font-size: 0.85rem; padding: 0.4rem 0.8rem;">✓ Moved Successfully</span>
`} `}
<button type="button" class="btn btn-secondary" id="btn-modal-delete-now" style="color: var(--accent-rose); border-color: rgba(244,63,94,0.4); background: rgba(244,63,94,0.08);" title="Permanently delete this video and files from disk">
🗑️ Delete Video
</button>
</div> </div>
</div> </div>
@@ -4172,6 +4389,21 @@ Return ONLY a valid JSON object with an array named "results":
`; `;
// Event listeners inside modal // Event listeners inside modal
const btnDeleteNow = videoPerformerBox.querySelector('#btn-modal-delete-now');
if (btnDeleteNow) {
btnDeleteNow.addEventListener('click', async () => {
try {
if (type === 'smb') {
await deleteSmbFile(item, true);
} else {
await deleteTorrent(item, true);
}
closeVideoPreview();
} catch (e) {
showToast(`Delete failed: ${e.message}`, 'error');
}
});
}
videoPerformerBox.querySelectorAll('.modal-performer-pill').forEach(pill => { videoPerformerBox.querySelectorAll('.modal-performer-pill').forEach(pill => {
pill.addEventListener('click', (e) => { pill.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
+102 -3
View File
@@ -583,22 +583,25 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
else: else:
super().do_GET() super().do_GET()
def do_POST(self): # Database Cache Save/Delete APIs
# Database Cache Save APIs
if self.path == '/api/db/save': if self.path == '/api/db/save':
self.handle_db_save() self.handle_db_save()
elif self.path == '/api/db/batch-save': elif self.path == '/api/db/batch-save':
self.handle_db_batch_save() self.handle_db_batch_save()
elif self.path == '/api/db/delete':
self.handle_db_delete()
# Stash Lookup APIs # Stash Lookup APIs
elif self.path == '/api/stash/lookup': elif self.path == '/api/stash/lookup':
self.handle_stash_lookup() self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup': elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup() self.handle_stash_batch_lookup()
# SMB API Moves # SMB API Moves & Deletes
elif self.path == '/api/smb/move': elif self.path == '/api/smb/move':
self.handle_smb_move() self.handle_smb_move()
elif self.path == '/api/smb/batch-move': elif self.path == '/api/smb/batch-move':
self.handle_smb_batch_move() self.handle_smb_batch_move()
elif self.path == '/api/smb/delete':
self.handle_smb_delete()
elif self.path.startswith('/qb-proxy/'): elif self.path.startswith('/qb-proxy/'):
self.proxy_qb_request('POST') self.proxy_qb_request('POST')
elif self.path.startswith('/anthropic-proxy/'): elif self.path.startswith('/anthropic-proxy/'):
@@ -783,6 +786,43 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"error": f"Failed batch saving to DB: {str(e)}"}).encode('utf-8')) self.wfile.write(json.dumps({"error": f"Failed batch saving to DB: {str(e)}"}).encode('utf-8'))
def handle_db_delete(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
item_key = payload.get('item_key')
item_keys = payload.get('item_keys', [])
if item_key and item_key not in item_keys:
item_keys.append(item_key)
if not item_keys:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": "Missing item_key or item_keys"}).encode('utf-8'))
return
conn = get_db_connection()
cursor = conn.cursor()
for key in item_keys:
cursor.execute("DELETE FROM performer_analysis_cache WHERE item_key = ? OR name = ?", (key, key))
conn.commit()
conn.close()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"success": True, "deleted_keys": item_keys}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": f"Failed deleting from DB: {str(e)}"}).encode('utf-8'))
# ==================== Video Streaming Handlers (Range 206) ==================== # ==================== Video Streaming Handlers (Range 206) ====================
def handle_stream_video(self): def handle_stream_video(self):
head_only = False head_only = False
@@ -1217,6 +1257,65 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"error": f"Batch move failed: {str(e)}"}).encode('utf-8')) self.wfile.write(json.dumps({"error": f"Batch move failed: {str(e)}"}).encode('utf-8'))
def handle_smb_delete(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
path = payload.get('path')
paths = payload.get('paths', [])
if path and path not in paths:
paths.append(path)
if not paths:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": "Missing path or paths"}).encode('utf-8'))
return
deleted = []
errors = []
conn = get_db_connection()
cursor = conn.cursor()
for p in paths:
try:
resolved = resolve_stream_video_path(p) or p
if os.path.exists(resolved):
if os.path.isdir(resolved):
shutil.rmtree(resolved)
else:
os.remove(resolved)
deleted.append(p)
cursor.execute("DELETE FROM performer_analysis_cache WHERE item_key = ? OR name = ?", (p, os.path.basename(p)))
else:
errors.append(f"File not found: {p}")
except Exception as del_err:
errors.append(f"Failed deleting {p}: {str(del_err)}")
conn.commit()
conn.close()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({
"success": len(deleted) > 0,
"deleted": deleted,
"deleted_count": len(deleted),
"errors": errors
}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": f"Delete request failed: {str(e)}"}).encode('utf-8'))
# ==================== External API Proxies ==================== # ==================== External API Proxies ====================
def proxy_external_api(self, upstream_base, proxy_prefix, method='POST'): def proxy_external_api(self, upstream_base, proxy_prefix, method='POST'):
upstream_base = upstream_base.rstrip('/') upstream_base = upstream_base.rstrip('/')