From 2b8af050a4db0354f8588a68c8cb3840b35e5b75 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 23 Jun 2025 15:53:55 -0400 Subject: [PATCH] feat(library): add library management features including create, edit, delete, and list functionalities - WIP Signed-off-by: Deluan --- .../20250701010105_add_user_library_table.go | 2 +- model/library.go | 34 ++-- persistence/library_repository.go | 41 ++-- ui/src/App.jsx | 9 +- ui/src/i18n/en.json | 41 ++++ ui/src/library/DeleteLibraryButton.jsx | 63 +++++++ ui/src/library/LibraryCreate.jsx | 58 ++++++ ui/src/library/LibraryEdit.jsx | 177 ++++++++++++++++++ ui/src/library/LibraryList.jsx | 50 +++++ ui/src/library/index.js | 9 + 10 files changed, 452 insertions(+), 32 deletions(-) create mode 100644 ui/src/library/DeleteLibraryButton.jsx create mode 100644 ui/src/library/LibraryCreate.jsx create mode 100644 ui/src/library/LibraryEdit.jsx create mode 100644 ui/src/library/LibraryList.jsx create mode 100644 ui/src/library/index.js diff --git a/db/migrations/20250701010105_add_user_library_table.go b/db/migrations/20250701010105_add_user_library_table.go index 2a7ec5824..3636ab7d3 100644 --- a/db/migrations/20250701010105_add_user_library_table.go +++ b/db/migrations/20250701010105_add_user_library_table.go @@ -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 ); diff --git a/model/library.go b/model/library.go index 621880dd1..fd0247a6e 100644 --- a/model/library.go +++ b/model/library.go @@ -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 diff --git a/persistence/library_repository.go b/persistence/library_repository.go index f59bd3208..11332805e 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -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 { diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 8469ac27e..1c613f133 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -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) => { ) : ( ), - + permissions === 'admin' ? ( + + ) : null, permissions === 'admin' ? ( { + 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 ( + + + + + ) +} + +export default DeleteLibraryButton \ No newline at end of file diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx new file mode 100644 index 000000000..94c929389 --- /dev/null +++ b/ui/src/library/LibraryCreate.jsx @@ -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 ( + } {...props}> + + + + + + ) +} + +export default LibraryCreate \ No newline at end of file diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx new file mode 100644 index 000000000..4774809a3 --- /dev/null +++ b/ui/src/library/LibraryEdit.jsx @@ -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 ( + + ) +} + +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 diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx new file mode 100644 index 000000000..3d4e88162 --- /dev/null +++ b/ui/src/library/LibraryList.jsx @@ -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 \ No newline at end of file diff --git a/ui/src/library/index.js b/ui/src/library/index.js new file mode 100644 index 000000000..b12c770dc --- /dev/null +++ b/ui/src/library/index.js @@ -0,0 +1,9 @@ +import LibraryList from './LibraryList' +import LibraryEdit from './LibraryEdit' +import LibraryCreate from './LibraryCreate' + +export default { + list: LibraryList, + edit: LibraryEdit, + create: LibraryCreate, +} \ No newline at end of file