Skip to content

fix(download): remove deleted/cancelled items from the Downloads list in real time (#1227)#2598

Open
LeC-D wants to merge 4 commits into
recloudstream:masterfrom
LeC-D:fix/download-button-delete-event
Open

fix(download): remove deleted/cancelled items from the Downloads list in real time (#1227)#2598
LeC-D wants to merge 4 commits into
recloudstream:masterfrom
LeC-D:fix/download-button-delete-event

Conversation

@LeC-D

@LeC-D LeC-D commented Apr 2, 2026

Copy link
Copy Markdown

Fixes #1227.

Problem

When a download is cancelled or deleted, VideoDownloadManager.downloadDeleteEvent fires — but until now only the download button (BaseFetchButton) listened to it. The Downloads page itself never reacted, so a deleted episode/movie card stayed on screen until the user navigated away and back. That stale card is the actual bug reported in #1227 ("UI still showing episodes/movies that were deleted").

Fix

DownloadViewModel — the core #1227 fix. Subscribes to downloadDeleteEvent at the activity-scoped (shared) ViewModel level and removes the deleted id from the header + child lists live, so both DownloadFragment (headers) and DownloadChildFragment (children) refresh with no manual reload. It also clears the id from multi-select state. Uses postValue only, since the event fires on the downloader's background thread. This hooks the event "to update the UI in case of deletion from any source" (result page, queue, notification, …), as the issue asks.

BaseFetchButton — resets the button via resetView() on a matching persistentId; subscription wired in onAttached/DetachedFromWindow, mirroring the existing downloadStatusEvent handling. This keeps an on-screen download button from showing a stale state after a deletion elsewhere.

How to reproduce (the #1227 symptom)

  1. Download one or more episodes/movies.
  2. Open the Downloads page.
  3. Cancel/delete an item from another surface (result page button, download queue, or the download notification).
  4. Before: the deleted card stays visible in the Downloads list until you leave and re-enter the page. After: it disappears immediately.

I'm happy to attach a before/after screen recording if useful.

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LeC-D

LeC-D commented Apr 2, 2026

Copy link
Copy Markdown
Author

Hi @fire-light42, thanks for pointing to the AI policy.

To be transparent: this fix was developed with AI assistance (GitHub Copilot / Claude for context exploration). I'm fully responsible for understanding and verifying the code — the approach directly mirrors the existing downloadStatusEvent and downloadProgressEvent patterns already in BaseFetchButton, so the change is straightforward and intentional. I've reviewed and tested the logic manually.

Let me know if you have any concerns about the implementation itself!

@LeC-D

LeC-D commented Apr 2, 2026

Copy link
Copy Markdown
Author

Updated the PR description to include the AI usage disclosure directly in the body, as required by the AI Policy. Thanks for flagging this, @fire-light42!

@LeC-D

LeC-D commented Apr 3, 2026

Copy link
Copy Markdown
Author

Rebased on upstream master (2026-04-03) to bring the branch back up to date.

@fire-light42

Copy link
Copy Markdown
Collaborator

Thank you for your contribution!

I will test and review soon. The code looks good, but getting these things right can be tricky.

Do not concern yourself with merge conflicts or keeping the branch up to date, I will resolve any conflicts if necessary 👍

@fire-light43

Copy link
Copy Markdown
Contributor

I can not replicate the stale download state.
Can you please show a video of it before and after?

…ecloudstream#1227)

Issue recloudstream#1227 reports that cancelled downloads keep showing as episode/movie
cards on the Downloads page. The previous change only hooked
downloadDeleteEvent in BaseFetchButton, which resets the download *button*
on the result page but never touches the Downloads list itself.

Observe VideoDownloadManager.downloadDeleteEvent at the activity-scoped
DownloadViewModel so both DownloadFragment (headers) and
DownloadChildFragment (children) drop the deleted item in real time, with
no manual refresh. The handler also keeps multi-select state consistent.
Uses thread-safe LiveData postValue since the event fires on the
downloader's background thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LeC-D

LeC-D commented Jun 28, 2026

Copy link
Copy Markdown
Author

Quick status update to clear up confusion about what this PR actually does — the original title/description undersold its scope.

The code already covers the full #1227 fix (2 files, the diff hasn't changed):

  • DownloadViewModel — the actual Download does not get removed when cancelling from a notification. #1227 fix: subscribes to downloadDeleteEvent at the activity-scoped (shared) ViewModel level and removes the deleted id from the header + child lists live, so both DownloadFragment and DownloadChildFragment refresh with no manual reload. Also clears it from multi-select state. postValue only (the event fires on the downloader's background thread).
  • BaseFetchButton — resets the button via resetView() on a matching persistentId; subscription wired in onAttached/DetachedFromWindow, mirroring the existing downloadStatusEvent handling.

Together this hooks downloadDeleteEvent "to update the UI in case of deletion from any source," as the issue asks.

@fire-light43 — I think the earlier difficulty reproducing came from the old description, which pointed at the button rather than the Downloads list. The #1227 symptom is on the list. Repro:

  1. Download some episodes/movies.
  2. Open the Downloads page.
  3. Cancel/delete an item from another surface (result page, queue, or notification).
  4. Before: the deleted card stays in the list until you leave and re-enter. After: it disappears immediately.

I've updated the title and description to match. Happy to attach a before/after recording if that helps.

@LeC-D LeC-D changed the title fix(download): hook downloadDeleteEvent in BaseFetchButton to reset UI on cancel fix(download): remove deleted/cancelled items from the Downloads list in real time (#1227) Jun 28, 2026
// filter() returns null only while the list is still Loading; postHeaders/postChildren
// treat null as "no change", so there is nothing to update in that case.
postHeaders(_headerCards.success?.filter { it.data.id != id })
postChildren(_childCards.success?.filter { it.data.id != id })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not work for TV shows because _childCards are 'Loading' when this gets triggered.

…episodes vanish (recloudstream#1227)

The previous real-time refresh filtered the in-memory header/child lists by id.
That fails for TV shows, as the maintainer noted: _childCards is often in the
Loading state when the delete event arrives (deletes triggered from outside the
child screen), so filtering a null success list is a no-op. Filtering also can't
update a series header when one of its episodes is deleted, since the event
carries the child id, not the header id.

Instead, rebuild the affected lists from disk in onDownloadDeleted. After a delete
the file (and KEY_DOWNLOAD_INFO) is already gone, so getDownloadFileInfo returns
null and updateChildList/updateHeaderList naturally drop the episode/movie and
recompute header aggregates (count, size). updateChildList gains a pushLoading
flag so the live refresh doesn't flicker the list. The application context and the
current child folder are retained for the reload; currentChildFolder is cleared
when the child screen closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LeC-D

LeC-D commented Jul 2, 2026

Copy link
Copy Markdown
Author

Thanks for taking the time to reproduce this and for the precise pointer — you were right. Filtering the in-memory lists by id was the wrong approach for TV shows, for the two reasons your comment exposed:

  1. _childCards is often still in Loading when downloadDeleteEvent fires, so filtering .success was a no-op.
  2. Deleting an episode emits the event with the child id, never the header id — so the series header could never be updated by an id filter.

I've reworked onDownloadDeleted (commit b0513631) to rebuild the lists from disk instead of filtering the cache: it re-runs updateHeaderList + updateChildList. After a deletion the KEY_DOWNLOAD_INFO entry is gone (or the file is removed), so getDownloadFileInfo returns null, the item drops out, and the header aggregates are recomputed. This works the same for a normal delete and for cancelling an in-progress download.

Implementation notes: I retain the application appContext (no Activity reference → no leak) and currentChildFolder, and added a pushLoading=false flag so the live refresh doesn't flash a Loading state.

Ready for another look when you have a moment 🙏

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be mergeable when the comments are addressed.

If you have time it would be very nice if you could add a corresponding feature for when new downloads are started.

Comment on lines +445 to +449
val currentSelection = selectedItemIds.value
if (currentSelection?.contains(id) == true) {
_selectedItemIds.postValue(currentSelection - id)
updateSelectedBytes()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val currentSelection = selectedItemIds.value
if (currentSelection?.contains(id) == true) {
_selectedItemIds.postValue(currentSelection - id)
updateSelectedBytes()
}
updateSelectedItems { it?.minus(id) }

Same logic, much cleaner.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 60cfebd — collapsed to updateSelectedItems { it?.minus(id) }.

Comment on lines +454 to +458
// Refresh the child screen too, without flashing a loading state. currentChildFolder is
// cleared once the child screen closes (clearChildren), so this is a no-op afterwards.
currentChildFolder?.let { folder ->
updateChildList(context, folder, pushLoading = false)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Refresh the child screen too, without flashing a loading state. currentChildFolder is
// cleared once the child screen closes (clearChildren), so this is a no-op afterwards.
currentChildFolder?.let { folder ->
updateChildList(context, folder, pushLoading = false)
}
postChildren(_childCards.success?.filterNot { it.data.id == id })

Unnecessary to save a folder or re-fetch children if you just need to delete ids.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 60cfebd — the child list now just drops the deleted id via postChildren(_childCards.success?.filterNot { it.data.id == id }), and the pushLoading parameter on updateChildList is reverted since nothing re-fetches the folder anymore. The header list is still rebuilt from disk so series counts/sizes stay correct when an episode (child id) is deleted.

Comment on lines +451 to +453
// Nothing has been shown yet (no list ever loaded), so there is nothing to refresh.
val context = appContext ?: return
updateHeaderList(context)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Nothing has been shown yet (no list ever loaded), so there is nothing to refresh.
val context = appContext ?: return
updateHeaderList(context)
val context = CloudStreamApp.context ?: return
updateHeaderList(context)

We use the CloudStreamApp global context here, otherwise the context variable will cause memory leaks.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 60cfebd — now uses CloudStreamApp.context, and the retained appContext/currentChildFolder fields are gone entirely.

…mpler selection and child updates

- Use CloudStreamApp.context in onDownloadDeleted instead of retaining a
  context reference in the ViewModel, avoiding any leak risk. Drops the
  appContext/currentChildFolder fields.
- Collapse the selection cleanup to updateSelectedItems { it?.minus(id) }.
- Remove the deleted id from the child list directly via postChildren
  instead of re-fetching the folder from disk; the header list is still
  rebuilt from disk so series aggregates stay correct.
- Revert the pushLoading parameter on updateChildList, no longer needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LeC-D

LeC-D commented Jul 3, 2026

Copy link
Copy Markdown
Author

All three suggestions applied in 60cfebd (net effect: −31 lines vs the previous revision — CloudStreamApp.context, updateSelectedItems { it?.minus(id) }, and in-memory child filtering instead of the folder re-fetch, which also let me revert the pushLoading parameter).

On the follow-up idea — live-adding items when a new download starts: agreed it would round this out nicely. I'd rather do it as a separate PR than grow this one, for two reasons: it needs its own event wiring (downloadStatusEvent rather than downloadDeleteEvent, with care not to rebuild on every pause/resume transition), and I'd like this fix to stay small and easy to verify against #1227. Happy to open that follow-up once this lands, if that works for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Download does not get removed when cancelling from a notification.

3 participants