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:
Deluan 2025-06-23 15:53:55 -04:00
commit 2b8af050a4
10 changed files with 452 additions and 32 deletions

View file

@ -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
);

View file

@ -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

View file

@ -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 {

View file

@ -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"

View file

@ -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": {

View 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

View 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

View 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

View 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
View 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,
}