mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-27 02:37:21 +01:00
feat(library): add library management features including create, edit, delete, and list functionalities - WIP
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e9fb649e9e
commit
2b8af050a4
10 changed files with 452 additions and 32 deletions
|
|
@ -32,7 +32,7 @@ func upAddUserLibraryTable(ctx context.Context, tx *sql.Tx) error {
|
|||
FROM user u;
|
||||
|
||||
-- Add total_duration column to library table
|
||||
ALTER TABLE library ADD COLUMN total_duration INTEGER DEFAULT 0;
|
||||
ALTER TABLE library ADD COLUMN total_duration real DEFAULT 0;
|
||||
UPDATE library SET total_duration = (
|
||||
SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,23 +5,23 @@ import (
|
|||
)
|
||||
|
||||
type Library struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
RemotePath string `json:"remotePath"`
|
||||
LastScanAt time.Time `json:"lastScanAt"`
|
||||
LastScanStartedAt time.Time `json:"lastScanStartedAt"`
|
||||
FullScanInProgress bool `json:"fullScanInProgress"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
TotalSongs int `json:"totalSongs"`
|
||||
TotalAlbums int `json:"totalAlbums"`
|
||||
TotalArtists int `json:"totalArtists"`
|
||||
TotalFolders int `json:"totalFolders"`
|
||||
TotalFiles int `json:"totalFiles"`
|
||||
TotalMissingFiles int `json:"totalMissingFiles"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TotalDuration int `json:"totalDuration"`
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Path string `json:"path" db:"path"`
|
||||
RemotePath string `json:"remotePath" db:"remote_path"`
|
||||
LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"`
|
||||
LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"`
|
||||
FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
TotalSongs int `json:"totalSongs" db:"total_songs"`
|
||||
TotalAlbums int `json:"totalAlbums" db:"total_albums"`
|
||||
TotalArtists int `json:"totalArtists" db:"total_artists"`
|
||||
TotalFolders int `json:"totalFolders" db:"total_folders"`
|
||||
TotalFiles int `json:"totalFiles" db:"total_files"`
|
||||
TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
|
||||
TotalSize int64 `json:"totalSize" db:"total_size"`
|
||||
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
||||
}
|
||||
|
||||
type Libraries []Library
|
||||
|
|
|
|||
|
|
@ -71,20 +71,31 @@ func (r *libraryRepository) GetPath(id int) (string, error) {
|
|||
}
|
||||
|
||||
func (r *libraryRepository) Put(l *model.Library) error {
|
||||
cols := map[string]any{
|
||||
"name": l.Name,
|
||||
"path": l.Path,
|
||||
"remote_path": l.RemotePath,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if l.ID != 0 {
|
||||
cols["id"] = l.ID
|
||||
if l.ID == 1 {
|
||||
currentLib, err := r.Get(1)
|
||||
// if we are creating it, it's ok.
|
||||
if err == nil { // it exists, so we are updating it
|
||||
if currentLib.Path != l.Path {
|
||||
return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sq := Insert(r.tableName).SetMap(cols).
|
||||
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
|
||||
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
|
||||
_, err := r.executeSQL(sq)
|
||||
var err error
|
||||
if l.ID == 0 {
|
||||
l.CreatedAt = time.Now()
|
||||
l.UpdatedAt = time.Now()
|
||||
err = r.db.Model(l).Insert()
|
||||
} else {
|
||||
cols := map[string]any{
|
||||
"name": l.Name,
|
||||
"path": l.Path,
|
||||
"remote_path": l.RemotePath,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
|
||||
_, err = r.executeSQL(sq)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -167,7 +178,8 @@ func (r *libraryRepository) ScanInProgress() (bool, error) {
|
|||
|
||||
func (r *libraryRepository) RefreshStats(id int) error {
|
||||
var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
|
||||
var sizeRes, durationRes struct{ Sum int64 }
|
||||
var sizeRes struct{ Sum int64 }
|
||||
var durationRes struct{ Sum float64 }
|
||||
|
||||
err := run.Parallel(
|
||||
func() error {
|
||||
|
|
@ -225,6 +237,9 @@ func (r *libraryRepository) Delete(id int) error {
|
|||
if !isAdmin(r.ctx) {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if id == 1 {
|
||||
return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation)
|
||||
}
|
||||
|
||||
err := r.delete(Eq{"id": id})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import artist from './artist'
|
|||
import playlist from './playlist'
|
||||
import radio from './radio'
|
||||
import share from './share'
|
||||
import library from './library'
|
||||
import { Player } from './audioplayer'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
|
|
@ -122,7 +123,13 @@ const Admin = (props) => {
|
|||
) : (
|
||||
<Resource name="transcoding" />
|
||||
),
|
||||
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="library"
|
||||
{...library}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="missing"
|
||||
|
|
|
|||
|
|
@ -263,6 +263,47 @@
|
|||
"notifications": {
|
||||
"removed": "Missing file(s) removed"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "Library |||| Libraries",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"path": "Path",
|
||||
"remotePath": "Remote Path",
|
||||
"lastScanAt": "Last Scan",
|
||||
"totalSongs": "Songs",
|
||||
"totalAlbums": "Albums",
|
||||
"totalArtists": "Artists",
|
||||
"totalFolders": "Folders",
|
||||
"totalFiles": "Files",
|
||||
"totalMissingFiles": "Missing Files",
|
||||
"totalSize": "Total Size",
|
||||
"totalDuration": "Duration",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scan Library",
|
||||
"manageUsers": "Manage User Access",
|
||||
"viewDetails": "View Details"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Library created successfully",
|
||||
"updated": "Library updated successfully",
|
||||
"deleted": "Library deleted successfully",
|
||||
"scanStarted": "Library scan started",
|
||||
"scanCompleted": "Library scan completed"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Library name is required",
|
||||
"pathRequired": "Library path is required",
|
||||
"pathInvalid": "Invalid library path"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
|
||||
"scanInProgress": "Scan in progress...",
|
||||
"noLibrariesAssigned": "No libraries assigned to this user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
|
|
|||
63
ui/src/library/DeleteLibraryButton.jsx
Normal file
63
ui/src/library/DeleteLibraryButton.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React, { Fragment, useState } from 'react'
|
||||
import {
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useDelete,
|
||||
Button,
|
||||
Confirm,
|
||||
useTranslate,
|
||||
useRedirect,
|
||||
} from 'react-admin'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
|
||||
const DeleteLibraryButton = ({ record }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const redirect = useRedirect()
|
||||
const [deleteOne, { loading }] = useDelete()
|
||||
|
||||
const handleClick = () => setOpen(true)
|
||||
const handleDialogClose = () => setOpen(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await deleteOne('library', record.id, record)
|
||||
notify('resources.library.notifications.deleted', 'info', {
|
||||
smart_count: 1,
|
||||
})
|
||||
redirect('/library')
|
||||
} catch (error) {
|
||||
notify(
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error.message || 'ra.notification.http_error',
|
||||
'warning',
|
||||
)
|
||||
} finally {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
label="ra.action.delete"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
loading={loading}
|
||||
title={translate('resources.library.name', { smart_count: 1 })}
|
||||
content={translate('resources.library.messages.deleteConfirm')}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteLibraryButton
|
||||
58
ui/src/library/LibraryCreate.jsx
Normal file
58
ui/src/library/LibraryCreate.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
Create,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
required,
|
||||
useTranslate,
|
||||
useMutation,
|
||||
useNotify,
|
||||
useRedirect,
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
|
||||
const LibraryCreate = (props) => {
|
||||
const translate = useTranslate()
|
||||
const [mutate] = useMutation()
|
||||
const notify = useNotify()
|
||||
const redirect = useRedirect()
|
||||
const resourceName = translate('resources.library.name', { smart_count: 1 })
|
||||
const title = translate('ra.page.create', {
|
||||
name: `${resourceName}`,
|
||||
})
|
||||
|
||||
const save = useCallback(
|
||||
async (values) => {
|
||||
try {
|
||||
await mutate(
|
||||
{
|
||||
type: 'create',
|
||||
resource: 'library',
|
||||
payload: { data: values },
|
||||
},
|
||||
{ returnPromise: true },
|
||||
)
|
||||
notify('resources.library.notifications.created', 'info', {
|
||||
smart_count: 1,
|
||||
})
|
||||
redirect('/library')
|
||||
} catch (error) {
|
||||
if (error.body.errors) {
|
||||
return error.body.errors
|
||||
}
|
||||
}
|
||||
},
|
||||
[mutate, notify, redirect],
|
||||
)
|
||||
|
||||
return (
|
||||
<Create title={<Title subTitle={title} />} {...props}>
|
||||
<SimpleForm save={save} variant={'outlined'}>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput source="path" validate={[required()]} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
|
||||
export default LibraryCreate
|
||||
177
ui/src/library/LibraryEdit.jsx
Normal file
177
ui/src/library/LibraryEdit.jsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Edit,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
required,
|
||||
Toolbar,
|
||||
SaveButton,
|
||||
NumberField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Divider, Typography } from '@material-ui/core'
|
||||
import DeleteLibraryButton from './DeleteLibraryButton'
|
||||
import { Title } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
divider: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
stats: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '0 1em',
|
||||
'& > *': {
|
||||
padding: '0.2em 0',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const LibraryTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.library.name', { smart_count: 1 })
|
||||
return (
|
||||
<Title subTitle={`${resourceName} ${record ? `"${record.name}"` : ''}`} />
|
||||
)
|
||||
}
|
||||
|
||||
const LibraryToolbar = (props) => (
|
||||
<Toolbar {...props} classes={useStyles()}>
|
||||
<SaveButton />
|
||||
{props.record && props.record.id !== 1 && <DeleteLibraryButton {...props} />}
|
||||
</Toolbar>
|
||||
)
|
||||
|
||||
const formatDuration = (totalSeconds) => {
|
||||
if (totalSeconds == null || totalSeconds < 0) {
|
||||
return '0s'
|
||||
}
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts = []
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(`${seconds}s`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const LibraryEdit = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const isFirstLibrary = props.id === '1'
|
||||
|
||||
return (
|
||||
<Edit title={<LibraryTitle />} undoable={false} {...props}>
|
||||
<SimpleForm variant={'outlined'} toolbar={<LibraryToolbar />}>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput source="path" validate={[required()]} fullWidth disabled={isFirstLibrary} />
|
||||
|
||||
<Divider className={classes.divider} fullWidth />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Statistics
|
||||
</Typography>
|
||||
<div className={classes.stats} fullWidth>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalSongs')}
|
||||
</Typography>
|
||||
<NumberField source="totalSongs" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalAlbums')}
|
||||
</Typography>
|
||||
<NumberField source="totalAlbums" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalArtists')}
|
||||
</Typography>
|
||||
<NumberField source="totalArtists" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalFolders')}
|
||||
</Typography>
|
||||
<NumberField source="totalFolders" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalFiles')}
|
||||
</Typography>
|
||||
<NumberField source="totalFiles" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalMissingFiles')}
|
||||
</Typography>
|
||||
<NumberField source="totalMissingFiles" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalSize')}
|
||||
</Typography>
|
||||
<FunctionField render={(record) => formatBytes(record.totalSize)} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalDuration')}
|
||||
</Typography>
|
||||
<FunctionField
|
||||
render={(record) => formatDuration(record.totalDuration)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={classes.divider} fullWidth />
|
||||
<div className={classes.stats} fullWidth>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.lastScanAt')}
|
||||
</Typography>
|
||||
<DateField source="lastScanAt" showTime />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.createdAt')}
|
||||
</Typography>
|
||||
<DateField source="createdAt" showTime />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.updatedAt')}
|
||||
</Typography>
|
||||
<DateField source="updatedAt" showTime />
|
||||
</div>
|
||||
</div>
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
|
||||
export default LibraryEdit
|
||||
50
ui/src/library/LibraryList.jsx
Normal file
50
ui/src/library/LibraryList.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
Filter,
|
||||
SearchInput,
|
||||
SimpleList,
|
||||
TextField,
|
||||
NumberField,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { List, DateField } from '../common'
|
||||
|
||||
const LibraryFilter = (props) => (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput source="name" alwaysOn />
|
||||
</Filter>
|
||||
)
|
||||
|
||||
const LibraryList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<LibraryFilter />}
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
primaryText={(record) => record.name}
|
||||
secondaryText={(record) => record.path}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid rowClick="edit">
|
||||
<TextField source="id" />
|
||||
<TextField source="name" />
|
||||
<TextField source="path" />
|
||||
<NumberField source="totalSongs" label="Songs" />
|
||||
<NumberField source="totalAlbums" label="Albums" />
|
||||
<NumberField source="totalMissingFiles" label="Missing Files" />
|
||||
<DateField source="lastScanAt" label="Last Scan" sortByOrder={'DESC'} />
|
||||
</Datagrid>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default LibraryList
|
||||
9
ui/src/library/index.js
Normal file
9
ui/src/library/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import LibraryList from './LibraryList'
|
||||
import LibraryEdit from './LibraryEdit'
|
||||
import LibraryCreate from './LibraryCreate'
|
||||
|
||||
export default {
|
||||
list: LibraryList,
|
||||
edit: LibraryEdit,
|
||||
create: LibraryCreate,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue