diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 41b48c0c6..c17bc595b 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -44,14 +45,39 @@ var _ = Describe("MediaRepository", func() { It("delete tracks by id", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) - Expect(mr.Delete(newID)).To(BeNil()) + Expect(mr.Delete(newID)).To(Succeed()) _, err := mr.Get(newID) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("deletes all missing files", func() { + new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} + new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} + Expect(mr.Put(&new1)).To(Succeed()) + Expect(mr.Put(&new2)).To(Succeed()) + Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) + + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + + // Ensure the files are marked as missing and we have 2 of them + count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) + Expect(count).To(BeNumerically("==", 2)) + Expect(err).ToNot(HaveOccurred()) + + count, err = adminRepo.DeleteAllMissing() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + + _, err = mr.Get(new1.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = mr.Get(new2.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 74e645248..5ccc15f55 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -63,25 +63,29 @@ func (r *missingRepository) EntityName() string { } func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) { - repo := ds.MediaFile(r.Context()) + ctx := r.Context() p := req.Params(r) ids, _ := p.Strings("id") err := ds.WithTx(func(tx model.DataStore) error { - return repo.DeleteMissing(ids) + if len(ids) == 0 { + _, err := tx.MediaFile(ctx).DeleteAllMissing() + return err + } + return tx.MediaFile(ctx).DeleteMissing(ids) }) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { - log.Warn(r.Context(), "Missing file not found", "id", ids[0]) + log.Warn(ctx, "Missing file not found", "id", ids[0]) http.Error(w, "not found", http.StatusNotFound) return } if err != nil { - log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err) + log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - err = ds.GC(r.Context()) + err = ds.GC(ctx) if err != nil { - log.Error(r.Context(), "Error running GC after deleting missing tracks", err) + log.Error(ctx, "Error running GC after deleting missing tracks", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index f387136cb..bf487dc7c 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -36,9 +36,9 @@ const mapResource = (resource, params) => { } const callDeleteMany = (resource, params) => { - const ids = params.ids.map((id) => `id=${id}`) - const idsParam = ids.join('&') - return httpClient(`${REST_URL}/${resource}?${idsParam}`, { + const ids = (params.ids || []).map((id) => `id=${id}`) + const query = ids.length > 0 ? `?${ids.join('&')}` : '' + return httpClient(`${REST_URL}/${resource}${query}`, { method: 'DELETE', }).then((response) => ({ data: response.json.ids || [] })) } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 5ccebf115..aed8796c8 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -241,7 +241,8 @@ "updatedAt": "Disappeared on" }, "actions": { - "remove": "Remove" + "remove": "Remove", + "remove_all": "Remove All" }, "notifications": { "removed": "Missing file(s) removed" @@ -403,6 +404,8 @@ "delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?", "remove_missing_title": "Remove missing files", "remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.", + "remove_all_missing_title": "Remove all missing files", + "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", diff --git a/ui/src/missing/DeleteMissingFilesButton.jsx b/ui/src/missing/DeleteMissingFilesButton.jsx index 7b4aae875..9cd622826 100644 --- a/ui/src/missing/DeleteMissingFilesButton.jsx +++ b/ui/src/missing/DeleteMissingFilesButton.jsx @@ -29,13 +29,14 @@ const useStyles = makeStyles( ) const DeleteMissingFilesButton = (props) => { - const { selectedIds, className } = props + const { selectedIds, className, deleteAll = false } = props const [open, setOpen] = useState(false) const unselectAll = useUnselectAll() const refresh = useRefresh() const notify = useNotify() - const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, { + const ids = deleteAll ? [] : selectedIds + const [deleteMany, { loading }] = useDeleteMany('missing', ids, { onSuccess: () => { notify('resources.missing.notifications.removed') refresh() @@ -57,7 +58,11 @@ const DeleteMissingFilesButton = (props) => { <> + ), + Confirm: ({ isOpen }) => (isOpen ?
: null), + useNotify: vi.fn(), + useDeleteMany: vi.fn(() => [vi.fn(), { loading: false }]), + useRefresh: vi.fn(), + useUnselectAll: vi.fn(), + } +}) + +describe('DeleteMissingFilesButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses remove_all label when deleteAll is true', () => { + const { getByRole } = render() + expect(getByRole('button').textContent).toBe( + 'resources.missing.actions.remove_all', + ) + }) + + it('calls useDeleteMany with empty ids when deleteAll is true', () => { + render() + expect(RA.useDeleteMany).toHaveBeenCalledWith( + 'missing', + [], + expect.any(Object), + ) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 8f73023fa..42b0a90e5 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -8,6 +8,7 @@ import { } from 'react-admin' import jsonExport from 'jsonexport/dist' import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' +import MissingListActions from './MissingListActions.jsx' const exporter = (files) => { const filesToExport = files.map((file) => { @@ -35,6 +36,7 @@ const MissingFilesList = (props) => { {...props} sort={{ field: 'updated_at', order: 'DESC' }} exporter={exporter} + actions={} bulkActionButtons={} perPage={50} pagination={} diff --git a/ui/src/missing/MissingListActions.jsx b/ui/src/missing/MissingListActions.jsx new file mode 100644 index 000000000..4bbf77115 --- /dev/null +++ b/ui/src/missing/MissingListActions.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { TopToolbar, ExportButton } from 'react-admin' +import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' + +const MissingListActions = (props) => ( + + + + +) + +export default MissingListActions