diff --git a/api/.env b/api/.env index 5da48f2d..dd906d00 100644 --- a/api/.env +++ b/api/.env @@ -3,5 +3,6 @@ NEO4J_USER=neo4j NEO4J_PASSWORD=letmein GRAPHQL_LISTEN_PORT=4001 GRAPHQL_URI=http://localhost:4001/graphql +HOST=http://localhost:4001 JWT_SECRET=topSecretEpicJWTKEYThing \ No newline at end of file diff --git a/api/src/Scanner.js b/api/src/Scanner.js index d4b56272..58616448 100644 --- a/api/src/Scanner.js +++ b/api/src/Scanner.js @@ -7,8 +7,11 @@ import { exiftool } from 'exiftool-vendored' import sharp from 'sharp' import readChunk from 'read-chunk' import imageType from 'image-type' +import { promisify } from 'util' import config from './config' +const imageSize = promisify(require('image-size')) + export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS' const isImage = async path => { @@ -125,10 +128,11 @@ class PhotoScanner { let foundAlbumIds = [] - async function scanPath(path) { + async function scanPath(path, parentAlbum) { const list = fs.readdirSync(path) let foundImage = false + let newAlbums = [] for (const item of list) { const itemPath = pathResolve(path, item) @@ -136,34 +140,39 @@ class PhotoScanner { const stat = fs.statSync(itemPath) if (stat.isDirectory()) { - // console.log(`Entering directory ${itemPath}`) - const imagesInDirectory = await scanPath(itemPath) + const session = driver.session() + let nextParentAlbum = null + + const findAlbumResult = await session.run( + 'MATCH (a:Album { path: {path} }) RETURN a', + { + path: itemPath, + } + ) + + session.close() + + if (findAlbumResult.records.length != 0) { + const album = findAlbumResult.records[0].toObject().a.properties + console.log('Found existing album', album.title) + + foundAlbumIds.push(album.id) + + nextParentAlbum = album.id + scanAlbum(album) + + continue + } + + const { + foundImage: imagesInDirectory, + newAlbums: childAlbums, + } = await scanPath(itemPath, nextParentAlbum) if (imagesInDirectory) { - console.log(`Found album at ${itemPath}`) + console.log(`Found new album at ${itemPath}`) const session = driver.session() - const findAlbumResult = await session.run( - 'MATCH (a:Album { path: {path} }) RETURN a', - { - path: itemPath, - } - ) - - console.log('FIND ALBUM RESULT', findAlbumResult.records) - - if (findAlbumResult.records.length != 0) { - console.log('Album already exists') - - const album = findAlbumResult.records[0].toObject().a.properties - - foundAlbumIds.push(album.id) - - scanAlbum(album) - - continue - } - console.log('Adding album') const albumId = uuid() const albumResult = await session.run( @@ -179,7 +188,36 @@ class PhotoScanner { } ) + foundAlbumIds.push(albumId) + newAlbums.push(albumId) const album = albumResult.records[0].toObject().a.properties + + if (parentAlbum) { + console.log('Linking parent album for', album.title) + await session.run( + `MATCH (parent:Album { id: {parentId} }) + MATCH (child:Album { id: {childId} }) + CREATE (parent)-[:SUBALBUM]->(child)`, + { + childId: albumId, + parentId: parentAlbum, + } + ) + } + + console.log(`Linking ${childAlbums.length} child albums`) + for (let childAlbum of childAlbums) { + await session.run( + `MATCH (parent:Album { id: {parentId} }) + MATCH (child:Album { id: {childId} }) + CREATE (parent)-[:SUBALBUM]->(child)`, + { + parentId: albumId, + childId: childAlbum, + } + ) + } + scanAlbum(album) session.close() @@ -193,7 +231,7 @@ class PhotoScanner { } } - return foundImage + return { foundImage, newAlbums } } await scanPath(user.rootPath) @@ -203,7 +241,7 @@ class PhotoScanner { const session = this.driver.session() const userAlbumsResult = await session.run( - 'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a return a', + 'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a, p RETURN a', { userId: user.id, foundAlbums: foundAlbumIds } ) @@ -276,22 +314,29 @@ class PhotoScanner { } async processImage(id) { - console.log('Processing image') const session = this.driver.session() - const result = await session.run('MATCH (p:Photo { id: {id} }) return p', { + const result = await session.run(`MATCH (p:Photo { id: {id} }) RETURN p`, { id, }) + + await session.run( + `MATCH (p:Photo { id: {id} })-[rel]->(url:PhotoURL) DELETE url, rel`, + { id } + ) + + console.log('PROCESS IMAGE RESULT', result) + const photo = result.records[0].get('p').properties - console.log('PHOTO', photo.path) + console.log('Processing photo', photo.path) const imagePath = path.resolve(config.cachePath, 'images', id) await fs.remove(imagePath) await fs.mkdirp(imagePath) - let resizeBaseImg = photo.path + let originalPath = photo.path if (await isRawImage(photo.path)) { console.log('Processing RAW image') @@ -299,15 +344,40 @@ class PhotoScanner { const extractedPath = path.resolve(imagePath, 'extracted.jpg') await exiftool.extractPreview(photo.path, extractedPath) - resizeBaseImg = extractedPath + originalPath = extractedPath } // Resize image - console.log('Resizing image', resizeBaseImg) - await sharp(resizeBaseImg) + const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg') + await sharp(originalPath) .jpeg({ quality: 80 }) .resize(1440, 1080, { fit: 'inside', withoutEnlargement: true }) - .toFile(path.resolve(imagePath, 'thumbnail.jpg')) + .toFile(thumbnailPath) + + const { width: originalWidth, height: originalHeight } = await imageSize( + originalPath + ) + const { width: thumbnailWidth, height: thumbnailHeight } = await imageSize( + thumbnailPath + ) + + await session.run( + `MATCH (p:Photo { id: {id} }) + CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} }) + CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} }) + CREATE (p)-[:THUMBNAIL_URL]->(thumbnail) + CREATE (p)-[:ORIGINAL_URL]->(original) + `, + { + id, + thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`, + thumbnailWidth, + thumbnailHeight, + originalUrl: `/images/${id}/${path.basename(originalPath)}`, + originalWidth, + originalHeight, + } + ) session.close() diff --git a/api/src/config.js b/api/src/config.js index c6fbf599..d708f693 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -2,4 +2,5 @@ import path from 'path' export default { cachePath: path.resolve(__dirname, 'cache'), + host: process.env.HOST || 'http://localhost:4001/', } diff --git a/api/src/index.js b/api/src/index.js index ec939d75..fadb10e6 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -126,10 +126,6 @@ app.use('/images/:id/:image', async function(req, res) { console.log('image', image) - if (image != 'original.jpg' && image != 'thumbnail.jpg') { - return res.status(404).send('Image not found') - } - let user = null try { @@ -142,7 +138,7 @@ app.use('/images/:id/:image', async function(req, res) { const session = driver.session() const result = await session.run( - 'MATCH (p:Photo { id: {id} }) MATCH (p)<-[:CONTAINS]-(:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId', + 'MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId', { id, } @@ -153,7 +149,7 @@ app.use('/images/:id/:image', async function(req, res) { } const userId = result.records[0].get('userId') - const photo = result.records[0].get('photo') + const photo = result.records[0].get('photo').properties if (userId != user.id) { return res.status(401).send(`Image not owned by you`) @@ -161,7 +157,12 @@ app.use('/images/:id/:image', async function(req, res) { session.close() - const imagePath = path.resolve(config.cachePath, 'images', id, image) + let imagePath = path.resolve(config.cachePath, 'images', id, image) + + if (image != 'extracted.jpg' && image != 'thumbnail.jpg') { + imagePath = photo.path + } + const imageFound = await fs.exists(imagePath) if (!imageFound) { diff --git a/api/src/resolvers/photos.js b/api/src/resolvers/photos.js index 20b68716..bee03eaa 100644 --- a/api/src/resolvers/photos.js +++ b/api/src/resolvers/photos.js @@ -1,11 +1,5 @@ import { cypherQuery } from 'neo4j-graphql-js' -import { promisify } from 'util' -import fs from 'fs-extra' -import path from 'path' import config from '../config' -import { isRawImage } from '../Scanner' - -const imageSize = promisify(require('image-size')) function injectAt(query, index, injection) { return query.substr(0, index) + injection + query.substr(index) @@ -130,75 +124,13 @@ const Query = { }, } -function photoResolver(image) { - return async (root, args, ctx, info) => { - const imgPath = path.resolve(config.cachePath, 'images', root.id, image) - - if (!(await fs.exists(imgPath))) { - await ctx.scanner.processImage(root.id) - } - - const { width, height } = await imageSize(imgPath) - return { - path: `${ctx.endpoint}/images/${root.id}/${image}`, - width, - height, - } - } -} - -const Photo = { - // TODO: Make original point to the right path - original: async (root, args, ctx, info) => { - async function getPath(retryAfterScan = false) { - let imgPath = path.resolve( - config.cachePath, - 'images', - root.id, - 'extracted.jpg' - ) - - if (!(await fs.exists(imgPath))) { - imgPath = root.path - - if (!imgPath) { - const session = ctx.driver.session() - - const result = await session.run( - 'MATCH (p:Photo { id: {id} }) return p.path as path', - { - id: root.id, - } - ) - - imgPath = result.get('path') - session.close() - } - - if (!(await fs.exists(imgPath)) || (await isRawImage(imgPath))) { - if (retryAfterScan) - throw new Error('Could not find image after rescan') - await ctx.scanner.processImage(root.id) - return getPath(true) - } - } - - return imgPath - } - - const imgPath = await getPath() - - const { width, height } = await imageSize(imgPath) - return { - path: `${ctx.endpoint}/images/${root.id}/${path.basename(imgPath)}`, - width, - height, - } +const PhotoURL = { + url(root, args, ctx, info) { + return new URL(root.url, config.host).href }, - thumbnail: photoResolver('thumbnail.jpg'), } export default { Query, - Photo, + PhotoURL, } diff --git a/api/src/schema.graphql b/api/src/schema.graphql index 13374c2a..a7495cb5 100644 --- a/api/src/schema.graphql +++ b/api/src/schema.graphql @@ -16,13 +16,15 @@ type Album { id: ID! title: String photos: [Photo] @relation(name: "CONTAINS", direction: "OUT") + subAlbums: [Album] @relation(name: "SUBALBUM", direction: "OUT") + parentAlbum: Album @relation(name: "SUBALBUM", direction: "IN") owner: User! @relation(name: "OWNS", direction: "IN") path: String } type PhotoURL { # URL for the image - path: String + url: String # Width of the image in pixels width: Int # Height of the image in pixels @@ -35,9 +37,9 @@ type Photo { # Local filepath for the photo path: String # URL to display the photo in full resolution - original: PhotoURL @neo4j_ignore + original: PhotoURL @relation(name: "ORIGINAL_URL", direction: "OUT") # URL to display the photo in a smaller resolution - thumbnail: PhotoURL @neo4j_ignore + thumbnail: PhotoURL @relation(name: "THUMBNAIL_URL", direction: "OUT") # The album that holds the photo album: Album! @relation(name: "CONTAINS", direction: "IN") } diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js index fd413844..47242726 100644 --- a/ui/src/Pages/AlbumPage/AlbumPage.js +++ b/ui/src/Pages/AlbumPage/AlbumPage.js @@ -9,10 +9,19 @@ const albumQuery = gql` query albumQuery($id: ID) { album(id: $id) { title + subAlbums { + id + title + photos { + thumbnail { + url + } + } + } photos { id thumbnail { - path + url width height } diff --git a/ui/src/Pages/AlbumPage/AlbumSidebar.js b/ui/src/Pages/AlbumPage/AlbumSidebar.js index 65a2a5fb..60a91d5e 100644 --- a/ui/src/Pages/AlbumPage/AlbumSidebar.js +++ b/ui/src/Pages/AlbumPage/AlbumSidebar.js @@ -8,7 +8,7 @@ const photoQuery = gql` photo(id: $id) { title original { - path + url width height } @@ -57,7 +57,7 @@ class AlbumSidebar extends Component { return (
{album.title}
+{album.title}
-