1
Fork 0

Move photourl to database

This commit is contained in:
viktorstrate 2019-07-21 17:16:43 +02:00
parent 208cbda19a
commit 7a54fc9bc9
14 changed files with 239 additions and 217 deletions

View File

@ -3,5 +3,6 @@ NEO4J_USER=neo4j
NEO4J_PASSWORD=letmein NEO4J_PASSWORD=letmein
GRAPHQL_LISTEN_PORT=4001 GRAPHQL_LISTEN_PORT=4001
GRAPHQL_URI=http://localhost:4001/graphql GRAPHQL_URI=http://localhost:4001/graphql
HOST=http://localhost:4001
JWT_SECRET=topSecretEpicJWTKEYThing JWT_SECRET=topSecretEpicJWTKEYThing

View File

@ -7,8 +7,11 @@ import { exiftool } from 'exiftool-vendored'
import sharp from 'sharp' import sharp from 'sharp'
import readChunk from 'read-chunk' import readChunk from 'read-chunk'
import imageType from 'image-type' import imageType from 'image-type'
import { promisify } from 'util'
import config from './config' import config from './config'
const imageSize = promisify(require('image-size'))
export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS' export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
const isImage = async path => { const isImage = async path => {
@ -125,10 +128,11 @@ class PhotoScanner {
let foundAlbumIds = [] let foundAlbumIds = []
async function scanPath(path) { async function scanPath(path, parentAlbum) {
const list = fs.readdirSync(path) const list = fs.readdirSync(path)
let foundImage = false let foundImage = false
let newAlbums = []
for (const item of list) { for (const item of list) {
const itemPath = pathResolve(path, item) const itemPath = pathResolve(path, item)
@ -136,34 +140,39 @@ class PhotoScanner {
const stat = fs.statSync(itemPath) const stat = fs.statSync(itemPath)
if (stat.isDirectory()) { if (stat.isDirectory()) {
// console.log(`Entering directory ${itemPath}`) const session = driver.session()
const imagesInDirectory = await scanPath(itemPath) 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) { if (imagesInDirectory) {
console.log(`Found album at ${itemPath}`) console.log(`Found new album at ${itemPath}`)
const session = driver.session() 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') console.log('Adding album')
const albumId = uuid() const albumId = uuid()
const albumResult = await session.run( 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 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) scanAlbum(album)
session.close() session.close()
@ -193,7 +231,7 @@ class PhotoScanner {
} }
} }
return foundImage return { foundImage, newAlbums }
} }
await scanPath(user.rootPath) await scanPath(user.rootPath)
@ -203,7 +241,7 @@ class PhotoScanner {
const session = this.driver.session() const session = this.driver.session()
const userAlbumsResult = await session.run( 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 } { userId: user.id, foundAlbums: foundAlbumIds }
) )
@ -276,22 +314,29 @@ class PhotoScanner {
} }
async processImage(id) { async processImage(id) {
console.log('Processing image')
const session = this.driver.session() 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, 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 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) const imagePath = path.resolve(config.cachePath, 'images', id)
await fs.remove(imagePath) await fs.remove(imagePath)
await fs.mkdirp(imagePath) await fs.mkdirp(imagePath)
let resizeBaseImg = photo.path let originalPath = photo.path
if (await isRawImage(photo.path)) { if (await isRawImage(photo.path)) {
console.log('Processing RAW image') console.log('Processing RAW image')
@ -299,15 +344,40 @@ class PhotoScanner {
const extractedPath = path.resolve(imagePath, 'extracted.jpg') const extractedPath = path.resolve(imagePath, 'extracted.jpg')
await exiftool.extractPreview(photo.path, extractedPath) await exiftool.extractPreview(photo.path, extractedPath)
resizeBaseImg = extractedPath originalPath = extractedPath
} }
// Resize image // Resize image
console.log('Resizing image', resizeBaseImg) const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
await sharp(resizeBaseImg) await sharp(originalPath)
.jpeg({ quality: 80 }) .jpeg({ quality: 80 })
.resize(1440, 1080, { fit: 'inside', withoutEnlargement: true }) .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() session.close()

View File

@ -2,4 +2,5 @@ import path from 'path'
export default { export default {
cachePath: path.resolve(__dirname, 'cache'), cachePath: path.resolve(__dirname, 'cache'),
host: process.env.HOST || 'http://localhost:4001/',
} }

View File

@ -126,10 +126,6 @@ app.use('/images/:id/:image', async function(req, res) {
console.log('image', image) console.log('image', image)
if (image != 'original.jpg' && image != 'thumbnail.jpg') {
return res.status(404).send('Image not found')
}
let user = null let user = null
try { try {
@ -142,7 +138,7 @@ app.use('/images/:id/:image', async function(req, res) {
const session = driver.session() const session = driver.session()
const result = await session.run( 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, id,
} }
@ -153,7 +149,7 @@ app.use('/images/:id/:image', async function(req, res) {
} }
const userId = result.records[0].get('userId') 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) { if (userId != user.id) {
return res.status(401).send(`Image not owned by you`) 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() 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) const imageFound = await fs.exists(imagePath)
if (!imageFound) { if (!imageFound) {

View File

@ -1,11 +1,5 @@
import { cypherQuery } from 'neo4j-graphql-js' import { cypherQuery } from 'neo4j-graphql-js'
import { promisify } from 'util'
import fs from 'fs-extra'
import path from 'path'
import config from '../config' import config from '../config'
import { isRawImage } from '../Scanner'
const imageSize = promisify(require('image-size'))
function injectAt(query, index, injection) { function injectAt(query, index, injection) {
return query.substr(0, index) + injection + query.substr(index) return query.substr(0, index) + injection + query.substr(index)
@ -130,75 +124,13 @@ const Query = {
}, },
} }
function photoResolver(image) { const PhotoURL = {
return async (root, args, ctx, info) => { url(root, args, ctx, info) {
const imgPath = path.resolve(config.cachePath, 'images', root.id, image) return new URL(root.url, config.host).href
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,
}
}, },
thumbnail: photoResolver('thumbnail.jpg'),
} }
export default { export default {
Query, Query,
Photo, PhotoURL,
} }

View File

@ -16,13 +16,15 @@ type Album {
id: ID! id: ID!
title: String title: String
photos: [Photo] @relation(name: "CONTAINS", direction: "OUT") 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") owner: User! @relation(name: "OWNS", direction: "IN")
path: String path: String
} }
type PhotoURL { type PhotoURL {
# URL for the image # URL for the image
path: String url: String
# Width of the image in pixels # Width of the image in pixels
width: Int width: Int
# Height of the image in pixels # Height of the image in pixels
@ -35,9 +37,9 @@ type Photo {
# Local filepath for the photo # Local filepath for the photo
path: String path: String
# URL to display the photo in full resolution # 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 # 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 # The album that holds the photo
album: Album! @relation(name: "CONTAINS", direction: "IN") album: Album! @relation(name: "CONTAINS", direction: "IN")
} }

View File

@ -9,10 +9,19 @@ const albumQuery = gql`
query albumQuery($id: ID) { query albumQuery($id: ID) {
album(id: $id) { album(id: $id) {
title title
subAlbums {
id
title
photos {
thumbnail {
url
}
}
}
photos { photos {
id id
thumbnail { thumbnail {
path url
width width
height height
} }

View File

@ -8,7 +8,7 @@ const photoQuery = gql`
photo(id: $id) { photo(id: $id) {
title title
original { original {
path url
width width
height height
} }
@ -57,7 +57,7 @@ class AlbumSidebar extends Component {
return ( return (
<div> <div>
<PreviewImage src={photo.original.path} /> <PreviewImage src={photo.original.url} />
<Name>{photo.title}</Name> <Name>{photo.title}</Name>
</div> </div>
) )

View File

@ -0,0 +1,39 @@
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
const AlbumBoxLink = styled(Link)`
width: 240px;
height: 240px;
display: inline-block;
text-align: center;
color: #222;
`
const AlbumBoxImage = styled.div`
width: 220px;
height: 220px;
margin: auto;
border-radius: 4%;
background-image: url('${props => props.image}');
background-color: #eee;
background-size: cover;
background-position: center;
`
export const AlbumBox = ({ album, ...props }) => {
if (!album) {
return (
<AlbumBoxLink {...props} to="#">
<AlbumBoxImage />
</AlbumBoxLink>
)
}
return (
<AlbumBoxLink {...props} to={`/album/${album.id}`}>
<AlbumBoxImage image={album.photos[0] && album.photos[0].thumbnail.url} />
<p>{album.title}</p>
</AlbumBoxLink>
)
}

View File

@ -0,0 +1,36 @@
import React from 'react'
import styled from 'styled-components'
import { Loader } from 'semantic-ui-react'
import { AlbumBox } from './AlbumBox'
const Container = styled.div`
margin: -10px;
margin-top: 20px;
position: relative;
min-height: 500px;
`
const AlbumGallery = ({ loading, error, albums }) => {
if (error) return <div>Error {error.message}</div>
let albumElements = []
if (albums) {
albumElements = albums.map(album => (
<AlbumBox key={album.id} album={album} />
))
} else {
for (let i = 0; i < 8; i++) {
albumElements.push(<AlbumBox key={i} />)
}
}
return (
<Container>
<Loader active={loading}>Loading albums</Loader>
{albumElements}
</Container>
)
}
export default AlbumGallery

View File

@ -1,93 +0,0 @@
import React, { Component } from 'react'
import gql from 'graphql-tag'
import { Query } from 'react-apollo'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import { Loader } from 'semantic-ui-react'
const getAlbumsQuery = gql`
query getMyAlbums {
myAlbums {
id
title
photos {
thumbnail {
path
}
}
}
}
`
const Container = styled.div`
margin: -10px;
margin-top: 20px;
position: relative;
min-height: 500px;
`
const AlbumBoxLink = styled(Link)`
width: 240px;
height: 240px;
display: inline-block;
text-align: center;
color: #222;
`
const AlbumBoxImage = styled.div`
width: 220px;
height: 220px;
margin: auto;
border-radius: 4%;
background-image: url('${props => props.image}');
background-color: #eee;
background-size: cover;
background-position: center;
`
class Albums extends Component {
render() {
return (
<Container>
<Query query={getAlbumsQuery}>
{({ loading, error, data }) => {
// if (loading) return <Loader active />
if (error) return <div>Error {error.message}</div>
let albums
if (data && data.myAlbums) {
albums = data.myAlbums.map(album => (
<AlbumBoxLink key={album.id} to={`/album/${album.id}`}>
<AlbumBoxImage
image={album.photos[0] && album.photos[0].thumbnail.path}
/>
<p>{album.title}</p>
</AlbumBoxLink>
))
} else {
albums = []
for (let i = 0; i < 8; i++) {
albums.push(
<AlbumBoxLink key={i} to="#">
<AlbumBoxImage />
</AlbumBoxLink>
)
}
}
return (
<div>
{' '}
<Loader active={loading}>Loading images</Loader>
{albums}
</div>
)
}}
</Query>
</Container>
)
}
}
export default Albums

View File

@ -1,13 +1,37 @@
import React, { Component } from 'react' import React, { Component } from 'react'
import Albums from './Albums' import AlbumGallery from './AlbumGallery'
import Layout from '../../Layout' import Layout from '../../Layout'
import gql from 'graphql-tag'
import { Query } from 'react-apollo'
const getAlbumsQuery = gql`
query getMyAlbums {
myAlbums {
id
title
photos {
thumbnail {
url
}
}
}
}
`
class AlbumsPage extends Component { class AlbumsPage extends Component {
render() { render() {
return ( return (
<Layout> <Layout>
<h1>Albums</h1> <h1>Albums</h1>
<Albums /> <Query query={getAlbumsQuery}>
{({ loading, error, data }) => (
<AlbumGallery
loading={loading}
error={error}
albums={data && data.myAlbums}
/>
)}
</Query>
</Layout> </Layout>
) )
} }

View File

@ -10,7 +10,7 @@ const photoQuery = gql`
id id
title title
thumbnail { thumbnail {
path url
width width
height height
} }

View File

@ -87,7 +87,7 @@ const PhotoGallery = ({
onSelectImage && onSelectImage(index) onSelectImage && onSelectImage(index)
}} }}
> >
<Photo src={photo.thumbnail.path} /> <Photo src={photo.thumbnail.url} />
<PhotoOverlay active={active} /> <PhotoOverlay active={active} />
</PhotoContainer> </PhotoContainer>
) )