feat(ui): add remove all missing files functionality (#4108)
* Add remove all missing files feature * test: update mediafile_repository tests for missing files deletion Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
@@ -44,14 +45,39 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
|
|
||||||
It("delete tracks by id", func() {
|
It("delete tracks by id", func() {
|
||||||
newID := id.NewRandom()
|
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)
|
_, err := mr.Get(newID)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
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() {
|
Context("Annotations", func() {
|
||||||
It("increments play count when the tracks does not have annotations", func() {
|
It("increments play count when the tracks does not have annotations", func() {
|
||||||
id := "incplay.firsttime"
|
id := "incplay.firsttime"
|
||||||
|
|||||||
@@ -63,25 +63,29 @@ func (r *missingRepository) EntityName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||||
repo := ds.MediaFile(r.Context())
|
ctx := r.Context()
|
||||||
p := req.Params(r)
|
p := req.Params(r)
|
||||||
ids, _ := p.Strings("id")
|
ids, _ := p.Strings("id")
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
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) {
|
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)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = ds.GC(r.Context())
|
err = ds.GC(ctx)
|
||||||
if err != nil {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ const mapResource = (resource, params) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callDeleteMany = (resource, params) => {
|
const callDeleteMany = (resource, params) => {
|
||||||
const ids = params.ids.map((id) => `id=${id}`)
|
const ids = (params.ids || []).map((id) => `id=${id}`)
|
||||||
const idsParam = ids.join('&')
|
const query = ids.length > 0 ? `?${ids.join('&')}` : ''
|
||||||
return httpClient(`${REST_URL}/${resource}?${idsParam}`, {
|
return httpClient(`${REST_URL}/${resource}${query}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}).then((response) => ({ data: response.json.ids || [] }))
|
}).then((response) => ({ data: response.json.ids || [] }))
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -241,7 +241,8 @@
|
|||||||
"updatedAt": "Disappeared on"
|
"updatedAt": "Disappeared on"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"remove": "Remove"
|
"remove": "Remove",
|
||||||
|
"remove_all": "Remove All"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"removed": "Missing file(s) removed"
|
"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)?",
|
"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_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_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_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",
|
"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",
|
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ const useStyles = makeStyles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const DeleteMissingFilesButton = (props) => {
|
const DeleteMissingFilesButton = (props) => {
|
||||||
const { selectedIds, className } = props
|
const { selectedIds, className, deleteAll = false } = props
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const unselectAll = useUnselectAll()
|
const unselectAll = useUnselectAll()
|
||||||
const refresh = useRefresh()
|
const refresh = useRefresh()
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
|
||||||
const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, {
|
const ids = deleteAll ? [] : selectedIds
|
||||||
|
const [deleteMany, { loading }] = useDeleteMany('missing', ids, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notify('resources.missing.notifications.removed')
|
notify('resources.missing.notifications.removed')
|
||||||
refresh()
|
refresh()
|
||||||
@@ -57,7 +58,11 @@ const DeleteMissingFilesButton = (props) => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
label="ra.action.remove"
|
label={
|
||||||
|
deleteAll
|
||||||
|
? 'resources.missing.actions.remove_all'
|
||||||
|
: 'ra.action.remove'
|
||||||
|
}
|
||||||
key="button"
|
key="button"
|
||||||
className={clsx('ra-delete-button', classes.deleteButton, className)}
|
className={clsx('ra-delete-button', classes.deleteButton, className)}
|
||||||
>
|
>
|
||||||
@@ -66,8 +71,16 @@ const DeleteMissingFilesButton = (props) => {
|
|||||||
<Confirm
|
<Confirm
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
title="message.remove_missing_title"
|
title={
|
||||||
content="message.remove_missing_content"
|
deleteAll
|
||||||
|
? 'message.remove_all_missing_title'
|
||||||
|
: 'message.remove_missing_title'
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
deleteAll
|
||||||
|
? 'message.remove_all_missing_content'
|
||||||
|
: 'message.remove_missing_content'
|
||||||
|
}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={handleDialogClose}
|
onClose={handleDialogClose}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
|
||||||
|
import * as RA from 'react-admin'
|
||||||
|
|
||||||
|
vi.mock('react-admin', async () => {
|
||||||
|
const actual = await vi.importActual('react-admin')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
Button: ({ children, onClick, label }) => (
|
||||||
|
<button onClick={onClick}>{label || children}</button>
|
||||||
|
),
|
||||||
|
Confirm: ({ isOpen }) => (isOpen ? <div data-testid="confirm" /> : 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(<DeleteMissingFilesButton deleteAll />)
|
||||||
|
expect(getByRole('button').textContent).toBe(
|
||||||
|
'resources.missing.actions.remove_all',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls useDeleteMany with empty ids when deleteAll is true', () => {
|
||||||
|
render(<DeleteMissingFilesButton deleteAll />)
|
||||||
|
expect(RA.useDeleteMany).toHaveBeenCalledWith(
|
||||||
|
'missing',
|
||||||
|
[],
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import jsonExport from 'jsonexport/dist'
|
import jsonExport from 'jsonexport/dist'
|
||||||
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
|
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
|
||||||
|
import MissingListActions from './MissingListActions.jsx'
|
||||||
|
|
||||||
const exporter = (files) => {
|
const exporter = (files) => {
|
||||||
const filesToExport = files.map((file) => {
|
const filesToExport = files.map((file) => {
|
||||||
@@ -35,6 +36,7 @@ const MissingFilesList = (props) => {
|
|||||||
{...props}
|
{...props}
|
||||||
sort={{ field: 'updated_at', order: 'DESC' }}
|
sort={{ field: 'updated_at', order: 'DESC' }}
|
||||||
exporter={exporter}
|
exporter={exporter}
|
||||||
|
actions={<MissingListActions />}
|
||||||
bulkActionButtons={<BulkActionButtons />}
|
bulkActionButtons={<BulkActionButtons />}
|
||||||
perPage={50}
|
perPage={50}
|
||||||
pagination={<MissingPagination />}
|
pagination={<MissingPagination />}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { TopToolbar, ExportButton } from 'react-admin'
|
||||||
|
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
|
||||||
|
|
||||||
|
const MissingListActions = (props) => (
|
||||||
|
<TopToolbar {...props}>
|
||||||
|
<ExportButton />
|
||||||
|
<DeleteMissingFilesButton deleteAll />
|
||||||
|
</TopToolbar>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MissingListActions
|
||||||
Reference in New Issue
Block a user