From df0f2e9140f1b84c24729d86dc17ce650b725fb6 Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Sun, 21 Jul 2019 22:51:51 +0200
Subject: [PATCH] Refactor scanner into multiple files

---
 api/src/Scanner.js                         | 398 ---------------------
 api/src/index.js                           |   4 +-
 api/src/resolvers/scanner.js               |   2 +-
 api/src/scanner/Scanner.js                 | 114 ++++++
 api/src/scanner/processImage.js            |  79 ++++
 api/src/scanner/scanAlbum.js               |  71 ++++
 api/src/scanner/scanAll.js                 |  38 ++
 api/src/scanner/scanUser.js                | 144 ++++++++
 api/src/scanner/utils.js                   |  33 ++
 ui/src/Pages/AlbumPage/AlbumPage.js        |  19 +-
 ui/src/Pages/AllAlbumsPage/AlbumGallery.js |   4 +-
 ui/src/PhotoGallery.js                     |   9 +-
 12 files changed, 501 insertions(+), 414 deletions(-)
 delete mode 100644 api/src/Scanner.js
 create mode 100644 api/src/scanner/Scanner.js
 create mode 100644 api/src/scanner/processImage.js
 create mode 100644 api/src/scanner/scanAlbum.js
 create mode 100644 api/src/scanner/scanAll.js
 create mode 100644 api/src/scanner/scanUser.js
 create mode 100644 api/src/scanner/utils.js

diff --git a/api/src/Scanner.js b/api/src/Scanner.js
deleted file mode 100644
index 5861644..0000000
--- a/api/src/Scanner.js
+++ /dev/null
@@ -1,398 +0,0 @@
-import fs from 'fs-extra'
-import path from 'path'
-import { resolve as pathResolve, basename as pathBasename } from 'path'
-import { PubSub } from 'apollo-server'
-import uuid from 'uuid'
-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 => {
-  const buffer = await readChunk(path, 0, 12)
-  const type = imageType(buffer)
-
-  return type != null
-}
-
-export const isRawImage = async path => {
-  const buffer = await readChunk(path, 0, 12)
-  const { ext } = imageType(buffer)
-
-  const rawTypes = ['cr2', 'arw', 'crw', 'dng']
-
-  return rawTypes.includes(ext)
-}
-
-class PhotoScanner {
-  constructor(driver) {
-    this.driver = driver
-    this.isRunning = false
-    this.pubsub = new PubSub()
-
-    this.scanAll = this.scanAll.bind(this)
-    this.scanAlbum = this.scanAlbum.bind(this)
-    this.scanUser = this.scanUser.bind(this)
-    this.processImage = this.processImage.bind(this)
-
-    this.imagesToProgress = 0
-    this.finishedImages = 0
-  }
-
-  async scanAll() {
-    this.isRunning = true
-
-    this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
-      scannerStatusUpdate: {
-        progress: 0,
-        finished: false,
-        error: false,
-        errorMessage: '',
-      },
-    })
-
-    let session = this.driver.session()
-
-    let allUserScans = []
-
-    session.run('MATCH (u:User) return u').subscribe({
-      onNext: record => {
-        const user = record.toObject().u.properties
-
-        console.log('USER', user)
-
-        if (!user.rootPath) {
-          console.log(`User ${user.username}, has no root path, skipping`)
-          return
-        }
-
-        console.log(`Scanning ${user.username}...`)
-        allUserScans.push(this.scanUser(user))
-      },
-      onCompleted: () => {
-        session.close()
-        this.isRunning = false
-
-        Promise.all(allUserScans)
-          .then(() => {
-            console.log(
-              `Done scanning ${this.finishedImages} of ${this.imagesToProgress}`
-            )
-            this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
-              scannerStatusUpdate: {
-                progress: 100,
-                finished: true,
-                error: false,
-                errorMessage: '',
-              },
-            })
-          })
-          .catch(error => {
-            console.log('SYNC ERROR', JSON.stringify(error))
-            this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
-              scannerStatusUpdate: {
-                progress: 0,
-                finished: false,
-                error: true,
-                errorMessage: error.message,
-              },
-            })
-          })
-      },
-      onError: error => {
-        console.error(error)
-
-        this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
-          scannerStatusUpdate: {
-            progress: 0,
-            finished: false,
-            error: true,
-            errorMessage: error.message,
-          },
-        })
-      },
-    })
-  }
-
-  async scanUser(user) {
-    console.log('Scanning path', user.rootPath)
-
-    const driver = this.driver
-    const scanAlbum = this.scanAlbum
-
-    let foundAlbumIds = []
-
-    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)
-        // console.log(`Scanning item ${itemPath}...`)
-        const stat = fs.statSync(itemPath)
-
-        if (stat.isDirectory()) {
-          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 new album at ${itemPath}`)
-            const session = driver.session()
-
-            console.log('Adding album')
-            const albumId = uuid()
-            const albumResult = await session.run(
-              `MATCH (u:User { id: {userId} })
-              CREATE (a:Album { id: {id}, title: {title}, path: {path} })
-              CREATE (u)-[own:OWNS]->(a)
-              RETURN a`,
-              {
-                id: albumId,
-                userId: user.id,
-                title: item,
-                path: itemPath,
-              }
-            )
-
-            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()
-          }
-
-          continue
-        }
-
-        if (!foundImage && (await isImage(itemPath))) {
-          foundImage = true
-        }
-      }
-
-      return { foundImage, newAlbums }
-    }
-
-    await scanPath(user.rootPath)
-
-    console.log('Found album ids', foundAlbumIds)
-
-    const session = this.driver.session()
-
-    const userAlbumsResult = await session.run(
-      '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 }
-    )
-
-    console.log(
-      `Deleted ${userAlbumsResult.records.length} albums from ${user.username} that was not found locally`
-    )
-
-    session.close()
-
-    console.log('User scan complete')
-  }
-
-  async scanAlbum(album) {
-    const { title, path, id } = album
-    console.log('Scanning album', title)
-
-    const list = fs.readdirSync(path)
-
-    for (const item of list) {
-      const itemPath = pathResolve(path, item)
-
-      if (await isImage(itemPath)) {
-        const session = this.driver.session()
-
-        this.imagesToProgress++
-
-        const photoResult = await session.run(
-          `MATCH (p:Photo {path: {imgPath} })<--(a:Album {id: {albumId}}) RETURN p`,
-          {
-            imgPath: itemPath,
-            albumId: id,
-          }
-        )
-
-        if (photoResult.records.length != 0) {
-          console.log(`Photo already exists ${item}`)
-
-          const id = photoResult.records[0].get('p').properties.id
-
-          const thumbnailPath = pathResolve(
-            config.cachePath,
-            id,
-            'thumbnail.jpg'
-          )
-
-          if (!(await fs.exists(thumbnailPath))) {
-            this.processImage(id)
-          } else {
-            this.finishedImages++
-          }
-        } else {
-          console.log(`Found new image at ${itemPath}`)
-          const imageId = uuid()
-          await session.run(
-            `MATCH (a:Album { id: {albumId} })
-            CREATE (p:Photo {id: {id}, path: {path}, title: {title} })
-            CREATE (a)-[:CONTAINS]->(p)`,
-            {
-              id: imageId,
-              path: itemPath,
-              title: item,
-              albumId: id,
-            }
-          )
-
-          this.processImage(imageId)
-        }
-      }
-    }
-  }
-
-  async processImage(id) {
-    const session = this.driver.session()
-
-    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('Processing photo', photo.path)
-
-    const imagePath = path.resolve(config.cachePath, 'images', id)
-
-    await fs.remove(imagePath)
-    await fs.mkdirp(imagePath)
-
-    let originalPath = photo.path
-
-    if (await isRawImage(photo.path)) {
-      console.log('Processing RAW image')
-
-      const extractedPath = path.resolve(imagePath, 'extracted.jpg')
-      await exiftool.extractPreview(photo.path, extractedPath)
-
-      originalPath = extractedPath
-    }
-
-    // Resize image
-    const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
-    await sharp(originalPath)
-      .jpeg({ quality: 80 })
-      .resize(1440, 1080, { fit: 'inside', withoutEnlargement: true })
-      .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()
-
-    console.log('Processing done')
-    this.finishedImages++
-
-    this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
-      scannerStatusUpdate: {
-        progress: this.finishedImages / this.imagesToProgress,
-        finished: false,
-        error: false,
-        errorMessage: '',
-      },
-    })
-  }
-}
-
-export default PhotoScanner
diff --git a/api/src/index.js b/api/src/index.js
index fadb10e..b54e3b1 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -8,7 +8,7 @@ import { v1 as neo4j } from 'neo4j-driver'
 import { makeAugmentedSchema } from 'neo4j-graphql-js'
 import dotenv from 'dotenv'
 import http from 'http'
-import PhotoScanner from './Scanner'
+import PhotoScanner from './scanner/Scanner'
 import _ from 'lodash'
 import config from './config'
 
@@ -124,8 +124,6 @@ server.applyMiddleware({ app, graphPath })
 app.use('/images/:id/:image', async function(req, res) {
   const { id, image } = req.params
 
-  console.log('image', image)
-
   let user = null
 
   try {
diff --git a/api/src/resolvers/scanner.js b/api/src/resolvers/scanner.js
index 08adda1..74d19bf 100644
--- a/api/src/resolvers/scanner.js
+++ b/api/src/resolvers/scanner.js
@@ -1,4 +1,4 @@
-import { EVENT_SCANNER_PROGRESS } from '../scanner'
+import { EVENT_SCANNER_PROGRESS } from '../scanner/Scanner'
 
 const Mutation = {
   async scanAll(root, args, ctx, info) {
diff --git a/api/src/scanner/Scanner.js b/api/src/scanner/Scanner.js
new file mode 100644
index 0000000..2046001
--- /dev/null
+++ b/api/src/scanner/Scanner.js
@@ -0,0 +1,114 @@
+import fs from 'fs-extra'
+import path from 'path'
+import { resolve as pathResolve, basename as pathBasename } from 'path'
+import { PubSub } from 'apollo-server'
+import uuid from 'uuid'
+import { exiftool } from 'exiftool-vendored'
+import sharp from 'sharp'
+import { isImage, isRawImage } from './utils'
+import { promisify } from 'util'
+import config from '../config'
+import _scanUser from './scanUser'
+import _scanAlbum from './scanAlbum'
+import _processImage from './processImage'
+import _scanAll from './scanAll'
+
+export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
+
+class PhotoScanner {
+  constructor(driver) {
+    this.driver = driver
+    this.isRunning = false
+    this.pubsub = new PubSub()
+
+    this.processImage = this.processImage.bind(this)
+    this.scanAlbum = this.scanAlbum.bind(this)
+    this.scanUser = this.scanUser.bind(this)
+    this.scanAll = this.scanAll.bind(this)
+
+    this.imagesToProgress = 0
+    this.finishedImages = 0
+
+    this.addImageToProgress = () => {
+      this.imagesToProgress++
+    }
+
+    this.addFinishedImage = () => {
+      this.finishedImages++
+    }
+  }
+
+  async scanUser(user) {
+    await _scanUser({ driver: this.driver, scanAlbum: this.scanAlbum }, user)
+  }
+
+  async scanAlbum(album) {
+    await _scanAlbum(
+      {
+        driver: this.driver,
+        addImageToProgress: this.addImageToProgress,
+        addFinishedImage: this.addFinishedImage,
+        processImage: this.processImage,
+      },
+      album
+    )
+
+    this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
+      scannerStatusUpdate: {
+        progress: this.finishedImages / this.imagesToProgress,
+        finished: false,
+        error: false,
+        errorMessage: '',
+      },
+    })
+  }
+
+  async processImage(id) {
+    await _processImage(
+      {
+        driver: this.driver,
+        addFinishedImage: this.addFinishedImage,
+      },
+      id
+    )
+  }
+
+  async scanAll() {
+    this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
+      scannerStatusUpdate: {
+        progress: 0,
+        finished: false,
+        error: false,
+        errorMessage: '',
+      },
+    })
+
+    try {
+      await _scanAll({ driver: this.driver, scanUser: this.scanUser })
+    } catch (error) {
+      this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
+        scannerStatusUpdate: {
+          progress: 0,
+          finished: false,
+          error: true,
+          errorMessage: error.message,
+        },
+      })
+      throw error
+    }
+
+    console.log(
+      `Done scanning ${this.finishedImages} of ${this.imagesToProgress}`
+    )
+    this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
+      scannerStatusUpdate: {
+        progress: 100,
+        finished: true,
+        error: false,
+        errorMessage: '',
+      },
+    })
+  }
+}
+
+export default PhotoScanner
diff --git a/api/src/scanner/processImage.js b/api/src/scanner/processImage.js
new file mode 100644
index 0000000..da400e0
--- /dev/null
+++ b/api/src/scanner/processImage.js
@@ -0,0 +1,79 @@
+import fs from 'fs-extra'
+import path from 'path'
+import { exiftool } from 'exiftool-vendored'
+import sharp from 'sharp'
+import { isRawImage, imageSize } from './utils'
+import config from '../config'
+
+export default async function processImage({ driver, addFinishedImage }, id) {
+  const session = driver.session()
+
+  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 }
+  )
+
+  const photo = result.records[0].get('p').properties
+
+  console.log('Processing photo', photo.path)
+
+  const imagePath = path.resolve(config.cachePath, 'images', id)
+
+  await fs.remove(imagePath)
+  await fs.mkdirp(imagePath)
+
+  let originalPath = photo.path
+
+  if (await isRawImage(photo.path)) {
+    // console.log('Processing RAW image')
+
+    const extractedPath = path.resolve(imagePath, 'extracted.jpg')
+    await exiftool.extractPreview(photo.path, extractedPath)
+
+    originalPath = extractedPath
+  }
+
+  // Resize image
+  const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
+  await sharp(originalPath)
+    .jpeg({ quality: 80 })
+    .resize(1440, 1080, { fit: 'inside', withoutEnlargement: true })
+    .toFile(thumbnailPath)
+
+  try {
+    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,
+      }
+    )
+  } catch (e) {
+    console.log('Create photo url failed', e)
+  }
+
+  session.close()
+
+  addFinishedImage()
+}
diff --git a/api/src/scanner/scanAlbum.js b/api/src/scanner/scanAlbum.js
new file mode 100644
index 0000000..6121e10
--- /dev/null
+++ b/api/src/scanner/scanAlbum.js
@@ -0,0 +1,71 @@
+import fs from 'fs-extra'
+import path from 'path'
+import uuid from 'uuid'
+import { isImage } from './utils'
+import config from '../config'
+
+export default async function scanAlbum(
+  { driver, addImageToProgress, addFinishedImage, processImage },
+  album
+) {
+  const { title, path: albumPath, id } = album
+  console.log('Scanning album', title)
+
+  const list = await fs.readdir(albumPath)
+  let processingImagePromises = []
+
+  for (const item of list) {
+    const itemPath = path.resolve(albumPath, item)
+
+    if (await isImage(itemPath)) {
+      const session = driver.session()
+
+      addImageToProgress()
+
+      const photoResult = await session.run(
+        `MATCH (p:Photo {path: {imgPath} })<--(a:Album {id: {albumId}}) RETURN p`,
+        {
+          imgPath: itemPath,
+          albumId: id,
+        }
+      )
+
+      if (photoResult.records.length != 0) {
+        // console.log(`Photo already exists ${item}`)
+
+        const id = photoResult.records[0].get('p').properties.id
+
+        const thumbnailPath = path.resolve(
+          config.cachePath,
+          id,
+          'thumbnail.jpg'
+        )
+
+        if (!(await fs.exists(thumbnailPath))) {
+          processingImagePromises.push(processImage(id))
+        } else {
+          addFinishedImage()
+        }
+      } else {
+        console.log(`Found new image at ${itemPath}`)
+        const imageId = uuid()
+        await session.run(
+          `MATCH (a:Album { id: {albumId} })
+          CREATE (p:Photo {id: {id}, path: {path}, title: {title} })
+          CREATE (a)-[:CONTAINS]->(p)`,
+          {
+            id: imageId,
+            path: itemPath,
+            title: item,
+            albumId: id,
+          }
+        )
+
+        processingImagePromises.push(processImage(imageId))
+      }
+    }
+  }
+
+  await Promise.all(processingImagePromises)
+  console.log('Done processing album', album.title)
+}
diff --git a/api/src/scanner/scanAll.js b/api/src/scanner/scanAll.js
new file mode 100644
index 0000000..e9ea2a2
--- /dev/null
+++ b/api/src/scanner/scanAll.js
@@ -0,0 +1,38 @@
+export default function scanAll({ driver, scanUser }) {
+  return new Promise((resolve, reject) => {
+    let session = driver.session()
+
+    let allUserScans = []
+
+    session.run('MATCH (u:User) return u').subscribe({
+      onNext: record => {
+        const user = record.toObject().u.properties
+
+        if (!user.rootPath) {
+          console.log(`User ${user.username}, has no root path, skipping`)
+          return
+        }
+
+        allUserScans.push(
+          scanUser(user).catch(reason => {
+            console.log(
+              `User scan exception for user ${user.username} ${reason}`
+            )
+            reject(reason)
+          })
+        )
+      },
+      onCompleted: () => {
+        session.close()
+
+        Promise.all(allUserScans).then(() => {
+          resolve()
+        })
+      },
+      onError: error => {
+        session.close()
+        reject(error)
+      },
+    })
+  })
+}
diff --git a/api/src/scanner/scanUser.js b/api/src/scanner/scanUser.js
new file mode 100644
index 0000000..150b97a
--- /dev/null
+++ b/api/src/scanner/scanUser.js
@@ -0,0 +1,144 @@
+import fs from 'fs-extra'
+import { resolve as pathResolve } from 'path'
+import uuid from 'uuid'
+import { isImage } from './utils'
+
+export default async function scanUser({ driver, scanAlbum }, user) {
+  console.log('Scanning user', user.username, 'at', user.path)
+
+  let foundAlbumIds = []
+  let albumScanPromises = []
+
+  async function scanPath(path, parentAlbum) {
+    console.log('SCAN PATH', path)
+    const list = fs.readdirSync(path)
+
+    let foundImage = false
+    let newAlbums = []
+
+    for (const item of list) {
+      const itemPath = pathResolve(path, item)
+      // console.log(`Scanning item ${itemPath}...`)
+      let stat = null
+
+      try {
+        stat = await fs.stat(itemPath)
+      } catch {
+        console.log('ERROR reading file stat for item:', itemPath)
+      }
+
+      if (stat && stat.isDirectory()) {
+        const session = driver.session()
+        let nextParentAlbum = null
+
+        const findAlbumResult = await session.run(
+          'MATCH (a:Album { path: {path} }) RETURN a',
+          {
+            path: itemPath,
+          }
+        )
+
+        session.close()
+
+        const {
+          foundImage: imagesInDirectory,
+          newAlbums: childAlbums,
+        } = await scanPath(itemPath, nextParentAlbum)
+
+        if (findAlbumResult.records.length > 0) {
+          const album = findAlbumResult.records[0].toObject().a.properties
+          console.log('Found existing album', album.title)
+
+          nextParentAlbum = album.id
+          foundAlbumIds.push(album.id)
+          albumScanPromises.push(scanAlbum(album))
+
+          continue
+        }
+
+        if (imagesInDirectory) {
+          console.log(`Found new album at ${itemPath}`)
+          const session = driver.session()
+
+          console.log('Adding album')
+          const albumId = uuid()
+          const albumResult = await session.run(
+            `MATCH (u:User { id: {userId} })
+            CREATE (a:Album { id: {id}, title: {title}, path: {path} })
+            CREATE (u)-[own:OWNS]->(a)
+            RETURN a`,
+            {
+              id: albumId,
+              userId: user.id,
+              title: item,
+              path: itemPath,
+            }
+          )
+
+          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} })
+              MERGE (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,
+              }
+            )
+          }
+
+          foundAlbumIds.push(album.id)
+          albumScanPromises.push(scanAlbum(album))
+
+          session.close()
+        }
+
+        continue
+      }
+
+      if (!foundImage && (await isImage(itemPath))) {
+        foundImage = true
+      }
+    }
+
+    return { foundImage, newAlbums }
+  }
+
+  await scanPath(user.rootPath)
+
+  console.log('Found album ids', foundAlbumIds)
+
+  const session = driver.session()
+
+  const userAlbumsResult = await session.run(
+    '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 }
+  )
+
+  console.log(
+    `Deleted ${userAlbumsResult.records.length} albums from ${user.username} that was not found locally`
+  )
+
+  session.close()
+
+  await Promise.all(albumScanPromises)
+
+  console.log('User scan complete')
+}
diff --git a/api/src/scanner/utils.js b/api/src/scanner/utils.js
new file mode 100644
index 0000000..9bada1d
--- /dev/null
+++ b/api/src/scanner/utils.js
@@ -0,0 +1,33 @@
+import fs from 'fs-extra'
+import readChunk from 'read-chunk'
+import imageType from 'image-type'
+import { promisify } from 'util'
+
+export const isImage = async path => {
+  if ((await fs.stat(path)).isDirectory()) {
+    return false
+  }
+
+  try {
+    const buffer = await readChunk(path, 0, 12)
+    const type = imageType(buffer)
+    return type != null
+  } catch (e) {
+    throw new Error(`isImage error at ${path}: ${JSON.stringify(e)}`)
+  }
+}
+
+export const isRawImage = async path => {
+  try {
+    const buffer = await readChunk(path, 0, 12)
+    const { ext } = imageType(buffer)
+
+    const rawTypes = ['cr2', 'arw', 'crw', 'dng']
+
+    return rawTypes.includes(ext)
+  } catch (e) {
+    throw new Error(`isRawImage error at ${path}: ${JSON.stringify(e)}`)
+  }
+}
+
+export const imageSize = promisify(require('image-size'))
diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js
index 4724272..385179f 100644
--- a/ui/src/Pages/AlbumPage/AlbumPage.js
+++ b/ui/src/Pages/AlbumPage/AlbumPage.js
@@ -4,6 +4,7 @@ import { Query } from 'react-apollo'
 import Layout from '../../Layout'
 import AlbumSidebar from './AlbumSidebar'
 import PhotoGallery from '../../PhotoGallery'
+import AlbumGallery from '../AllAlbumsPage/AlbumGallery'
 
 const albumQuery = gql`
   query albumQuery($id: ID) {
@@ -85,15 +86,31 @@ class AlbumPage extends Component {
           {({ loading, error, data }) => {
             if (error) return <div>Error</div>
 
+            let subAlbumElement = null
+
             if (data.album) {
               this.photoAmount = data.album.photos.length
+
+              if (data.album.subAlbums.length > 0) {
+                subAlbumElement = (
+                  <AlbumGallery
+                    loading={loading}
+                    error={error}
+                    albums={data.album.subAlbums}
+                  />
+                )
+              }
             }
 
             return (
               <div>
+                <h1>{data.album && data.album.title}</h1>
+                {subAlbumElement}
+                {data.album && data.album.subAlbums.length > 0 && (
+                  <h2>Images</h2>
+                )}
                 <PhotoGallery
                   loading={loading}
-                  title={data.album && data.album.title}
                   photos={data.album && data.album.photos}
                   activeIndex={this.state.activeImage}
                   onSelectImage={index => {
diff --git a/ui/src/Pages/AllAlbumsPage/AlbumGallery.js b/ui/src/Pages/AllAlbumsPage/AlbumGallery.js
index b10b081..b1b1439 100644
--- a/ui/src/Pages/AllAlbumsPage/AlbumGallery.js
+++ b/ui/src/Pages/AllAlbumsPage/AlbumGallery.js
@@ -4,10 +4,8 @@ import { Loader } from 'semantic-ui-react'
 import { AlbumBox } from './AlbumBox'
 
 const Container = styled.div`
-  margin: -10px;
-  margin-top: 20px;
+  margin: 20px -10px;
   position: relative;
-  min-height: 500px;
 `
 
 const AlbumGallery = ({ loading, error, albums }) => {
diff --git a/ui/src/PhotoGallery.js b/ui/src/PhotoGallery.js
index 4821989..9aa81b7 100644
--- a/ui/src/PhotoGallery.js
+++ b/ui/src/PhotoGallery.js
@@ -60,13 +60,7 @@ const PhotoFiller = styled.div`
   flex-grow: 999999;
 `
 
-const PhotoGallery = ({
-  activeIndex = -1,
-  photos,
-  loading,
-  title,
-  onSelectImage,
-}) => {
+const PhotoGallery = ({ activeIndex = -1, photos, loading, onSelectImage }) => {
   let photoElements = null
   if (photos) {
     photoElements = photos.map((photo, index) => {
@@ -96,7 +90,6 @@ const PhotoGallery = ({
 
   return (
     <div>
-      <h1>{title}</h1>
       <Gallery>
         <Loader active={loading}>Loading images</Loader>
         {photoElements}
