Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add metadata #72

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ jobs:
cognitoIdentityPoolId: "eu-west-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
cognitoUserPoolId: "eu-west-1_xxxxxxxxx",
cognitoUserPoolWebClientId: "xxxxxxxxxxxxxxxxxxxxxxxxxx",
mediaBucket: "my-media-bucket"
mediaBucket: "my-media-bucket",
metadataTableName: "my-metadata-table"
};
EOF

Expand Down
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "modificationsIfAvailable",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.generate.finalModifiers": "explicit",
"source.fixAll": "explicit"
},
}
10,522 changes: 5,588 additions & 4,934 deletions frontend/package-lock.json

Large diffs are not rendered by default.

32 changes: 17 additions & 15 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,34 @@
"deploy": "npm-run-all build cloudfront-deploy"
},
"dependencies": {
"@aws-amplify/ui-react": "^6.1.6",
"@aws-amplify/ui-react": "^6.1.9",
"@aws-sdk/client-dynamodb": "^3.574.0",
"@aws-sdk/lib-dynamodb": "^3.574.0",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.13",
"@mui/material": "^5.15.13",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.17",
"@mui/material": "^5.15.17",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
"aws-amplify": "^6.0.20",
"aws-sdk": "^2.1579.0",
"aws-amplify": "^6.3.0",
"aws-sdk": "^2.1618.0",
"eslint": "^8.57.0",
"immer": "^10.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"immer": "^10.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"typescript": "^4.9.5",
"util": "^0.12.5"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@jest/globals": "^29.7.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.28",
"@types/node": "^20.12.11",
"jest": "^29.7.0",
"npm-run-all": "^4.1.5",
"react-scripts": "^5.0.1",
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/components/AppBar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuthUser } from "@aws-amplify/auth";
import AccountCircle from "@mui/icons-material/AccountCircle";
import MenuIcon from "@mui/icons-material/Menu";
import MuiAppBar from "@mui/material/AppBar";
Expand All @@ -9,10 +10,7 @@ import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import { styled } from '@mui/system';
import React, { useEffect, useState } from "react";
import { AuthService } from "../services/AuthService";
import { AuthUser } from "@aws-amplify/auth";

const authService = new AuthService();
import { AUTH_SERVICE } from "../services/AuthService";

const AppBar: React.FC<unknown> = () => {

Expand All @@ -23,7 +21,7 @@ const AppBar: React.FC<unknown> = () => {
useEffect(() => {
(async function () {
try {
const currentUser = await authService.currentAuthenticatedUser();
const currentUser = await AUTH_SERVICE.currentAuthenticatedUser();
setUser(currentUser);
} catch (error) {
console.warn("Error getting current user", error);
Expand All @@ -37,7 +35,7 @@ const AppBar: React.FC<unknown> = () => {

const handleLogout = () => {
handleClose();
authService.signOut();
AUTH_SERVICE.signOut();
};

const handleClose = () => {
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/components/ListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AudiotrackIcon from '@mui/icons-material/Audiotrack';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { ListItemButton } from "@mui/material";
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import React from "react";
import { Link } from "react-router-dom";
import useMusicPlayer from "../hooks/useMusicPlayer";
import { S3Object } from "../services/MediaService";
import { MetadataItem } from '../services/MetadataService';
import { PlaylistItem } from "../services/PlaylistService";
import { EditMetadataButton, MetadataChangeCallback, MetadataView } from './MetadataView';

export const FolderListItem: React.FC<{ folder: S3Object }> = ({ folder }) => {
return (<ListItemButton component={Link} to={`/${folder.key}`}>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText primary={folder.fileName} />
</ListItemButton>);
}

export const OtherFileItem: React.FC<{ file: PlaylistItem, metadata?: MetadataItem }> = ({ file, metadata }) => {
return (<ListItem>
<ListItemIcon>
<InsertDriveFileIcon />
</ListItemIcon>
<ListItemText primary={file.track.fileName} />
<MetadataView metadata={metadata} />
</ListItem>);
}

export const AudioFileItem: React.FC<{ file: PlaylistItem, metadata?: MetadataItem, metadataChangeCallback: MetadataChangeCallback }> = ({ file, metadata, metadataChangeCallback }) => {
const { currentTrack, isPlaying } = useMusicPlayer();
const isCurrentTrack = file.equals(currentTrack);
const playingState = isPlaying ? 'playing' : 'paused';
const state = isCurrentTrack ? playingState : '';
return (
<ListItem key={file.track.fileName} disablePadding={false} secondaryAction={
<EditMetadataButton file={file} metadata={metadata} changeCallback={metadataChangeCallback} />}>
<ListItemButton component={Link} to={`/${file.track.key}`}>
<ListItemIcon>
<AudiotrackIcon />
</ListItemIcon>
<ListItemText primary={file.track.fileName} secondary={state} />
<MetadataView metadata={metadata} />
</ListItemButton>
</ListItem>
);
}
112 changes: 66 additions & 46 deletions frontend/src/components/MediaList.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,30 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import Container from "@mui/material/Container";
import KeyboardReturnIcon from '@mui/icons-material/KeyboardReturn';
import { ListItemButton, Typography } from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
import { S3Service, S3Object } from "../services/S3Service";
import Container from "@mui/material/Container";
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import FolderIcon from '@mui/icons-material/Folder';
import AudiotrackIcon from '@mui/icons-material/Audiotrack';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import KeyboardReturnIcon from '@mui/icons-material/KeyboardReturn';
import ListItemText from '@mui/material/ListItemText';
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import useMusicPlayer from "../hooks/useMusicPlayer";
import { MediaService, S3Object } from "../services/MediaService";
import { FolderMetadata, MetadataItem, MetadataService } from '../services/MetadataService';
import { Playlist, PlaylistItem, PlaylistService } from "../services/PlaylistService";
import { ListItemButton, Typography } from "@mui/material";
import { AudioFileItem, FolderListItem, OtherFileItem } from './ListItem';

const s3 = new S3Service();
const s3 = new MediaService();
const metadataService = new MetadataService();
const playlistService = new PlaylistService();

const FolderListItem: React.FC<{ folder: S3Object }> = ({ folder }) => {
return (<ListItemButton component={Link} to={`/${folder.key}`}>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText primary={folder.fileName} />
</ListItemButton>);
}

const OtherFileItem: React.FC<{ file: PlaylistItem }> = ({ file }) => {
return (<ListItem>
<ListItemIcon>
<InsertDriveFileIcon />
</ListItemIcon>
<ListItemText primary={file.track.fileName} />
</ListItem>);
}

const AudioFileItem: React.FC<{ file: PlaylistItem }> = ({ file }) => {
const { currentTrack, isPlaying } = useMusicPlayer();
const isCurrentTrack = file.equals(currentTrack);
const state = isCurrentTrack ? (isPlaying ? 'playing' : 'paused') : '';
return (<ListItemButton component={Link} to={`/${file.track.key}`}>
<ListItemIcon>
<AudiotrackIcon />
</ListItemIcon>
<ListItemText primary={file.track.fileName} secondary={state} />
</ListItemButton>);
}

type GenericError = any;

const MediaList: React.FC<{ bucket: string, path: string, time?: number }> = ({ bucket, path, time }) => {
const [playlist, setPlaylist] = useState<Playlist | undefined>(undefined);
const [error, setError] = useState<any | undefined>(undefined);
const [folderMetadata, setFolderMetadata] = useState<FolderMetadata | undefined>(undefined);
const [error, setError] = useState<GenericError | undefined>(undefined);
const [metadataError, setMetadataError] = useState<GenericError | undefined>(undefined);
const { playerControl } = useMusicPlayer();

const isFolder = path === '' || path.indexOf('/') < 0 || path.endsWith('/');
Expand All @@ -72,6 +46,28 @@ const MediaList: React.FC<{ bucket: string, path: string, time?: number }> = ({
})();
}, [bucket, currentFolder.key]);

useEffect(() => {
(async function fetchMetadata() {
if (folderMetadata?.path === folderPath) {
console.log("Metadata already loaded for ", folderPath);
return;
}
await updateMetadata();
})();
}, [folderMetadata, folderPath]);

async function updateMetadata() {
console.log("Getting metadata for ", folderPath);
try {
const metadata = await metadataService.getMetadata(folderPath);
console.log("Metadata", metadata);
setFolderMetadata(metadata);
} catch (e) {
console.error("Error getting metadata", e);
setMetadataError(e)
}
}

useEffect(() => {
if(!playlist) {
return;
Expand All @@ -93,14 +89,25 @@ const MediaList: React.FC<{ bucket: string, path: string, time?: number }> = ({

const isAudioFile = (object: S3Object) => object.key.toLowerCase().endsWith('.mp3');


function getMetadata(key: string): MetadataItem | undefined {
if (!folderMetadata) {
return undefined;
}
return folderMetadata.items.find(item => item.key === key);
}



function renderItem(object: PlaylistItem) {
if (object.track.isFolder) {
return <FolderListItem key={object.track.key} folder={object.track} />;
}
const metadata = getMetadata(object.track.key);
if (isAudioFile(object.track)) {
return <AudioFileItem key={object.track.key} file={object} />
return <AudioFileItem key={object.track.key} file={object} metadata={metadata} metadataChangeCallback={updateMetadata} />
}
return <OtherFileItem key={object.track.key} file={object} />
return <OtherFileItem key={object.track.key} file={object} metadata={metadata} />
}

const parentPath = `/${currentFolder.getParentFolder().key}`;
Expand All @@ -112,13 +119,26 @@ const MediaList: React.FC<{ bucket: string, path: string, time?: number }> = ({
<ListItemText primary={`Up one level to ${parentPath}`} />
</ListItemButton>);

const loadingState = error ? `Error loading list: ${error}` : <CircularProgress />;

function getMetadataInfo() {
if (metadataError) {
return `Error loading metadata: ${metadataError}`;

}
if (folderMetadata) {
return `Metadata loaded for ${folderMetadata.items.length} items`;
}
return "Loading metadata..."
}

return (
<Container>
<Typography variant="h6">Current directory: {'/' + currentFolder.key}</Typography>
<Typography variant="h6">Current directory: {'/' + currentFolder.key}. {getMetadataInfo()}</Typography>
<div>
{
(playlist === undefined)
? (error ? `Error loading list: ${error}` : <CircularProgress />)
? loadingState
: <List dense={true}>
{upOneLevel}
{playlist.items.map(renderItem)}
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/components/MetadataView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import EditIcon from '@mui/icons-material/Edit';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, ListItemText, TextField } from "@mui/material";
import { useState } from 'react';
import { MetadataItem, MetadataService } from "../services/MetadataService";
import { PlaylistItem } from '../services/PlaylistService';

const metadataService = new MetadataService();

export type MetadataChangeCallback = (metadata: MetadataItem) => void;

export const MetadataView: React.FC<{ metadata?: MetadataItem }> = ({ metadata }) => {
const view = metadata ? metadata.note : '';
return (<ListItemText primary={view} />);
}

export const EditMetadataButton: React.FC<{ file: PlaylistItem, metadata?: MetadataItem, changeCallback: MetadataChangeCallback }> = ({ file, metadata, changeCallback }) => {
const [dialogOpen, setDialogOpen] = useState(false);
function editMetadata() {
setDialogOpen(true);
}
function handleClose() {
setDialogOpen(false);
}
return <>
<IconButton edge="start" aria-label="metadata" onClick={editMetadata}>
<EditIcon />
</IconButton>
<EditMetadataDialog open={dialogOpen} handleClose={handleClose} file={file} metadata={metadata} changeCallback={changeCallback} />
</>;
}

const EditMetadataDialog: React.FC<{ open: boolean, handleClose: () => void, file: PlaylistItem, metadata?: MetadataItem, changeCallback: MetadataChangeCallback }> = ({ open, handleClose, file, metadata, changeCallback }) => {
async function saveMetadata(formData: any) {
console.log("Save metadata", formData, file);
const newMetadata: MetadataItem = {
key: file.track.key,
note: formData.note
};
await metadataService.insert(newMetadata);
changeCallback(newMetadata);
}
return (
<Dialog
open={open}
onClose={handleClose}
PaperProps={{
component: 'form',
onSubmit: (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const formJson = Object.fromEntries((formData as any).entries()) as any;
saveMetadata(formJson);
handleClose();
},
}}
>
<DialogTitle>Edit Metadata</DialogTitle>
<DialogContent>
<DialogContentText>
Edit metadata for {file.track.fileName}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="note"
name="note"
label="Note"
type="text"
fullWidth
variant="standard"
defaultValue={metadata?.note}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button type="submit">Save</Button>
</DialogActions>
</Dialog>
);
}
Loading
Loading