Compare commits
46 Commits
77d69221d1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8633e9a6cc | |||
| f8d0da1af4 | |||
| 625af91c20 | |||
| 7cc0af4f07 | |||
| 7d3b38af12 | |||
| 06f82cc160 | |||
| 1bca4c3642 | |||
| 337f5dfd49 | |||
| e7b29509e5 | |||
| 58064a6309 | |||
| 3402d7bcf4 | |||
| 009dd1ff19 | |||
| 639a1c7b3a | |||
| 76594dc0c1 | |||
| 80ef93f20f | |||
| a19874fe47 | |||
| 4fc0bc9d01 | |||
| 7e2cf1b956 | |||
| b61e65bd83 | |||
| 0799708109 | |||
| 24240c5f68 | |||
| f683acf0ae | |||
| f14dbff066 | |||
| ba304c85c3 | |||
| bd3aac5bc0 | |||
| 737b290cc0 | |||
| 2ee2a98c7d | |||
| a96fe1da9d | |||
| 89037b6b24 | |||
| 9e4fdf7644 | |||
| e3182d4cf2 | |||
| 9f9c3cc00c | |||
| 0dc3c5edef | |||
| 62ae230f70 | |||
| 49695dd393 | |||
| 669a129f21 | |||
| ea8f15ab23 | |||
| 407358bc43 | |||
| 01c7582ae3 | |||
| 7ec3a11037 | |||
| 792471bf00 | |||
| d0ce89b27c | |||
| 1b292b30c9 | |||
| 233d0c4883 | |||
| 1c82313c98 | |||
| 7f64a4d2f6 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
Dockerfile
|
||||
target
|
||||
*/node_modules
|
||||
*/yarn.lock
|
||||
1432
Cargo.lock
generated
1432
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@@ -9,7 +9,7 @@ edition = "2018"
|
||||
[dependencies]
|
||||
# TODO, use https://git.z.xinu.tv/wathiede/google-api-photoslibrary and figure out auth story.
|
||||
google-photoslibrary1 = { git = "https://git.z.xinu.tv/wathiede/google-api-photoslibrary" }
|
||||
google_api_auth = { git = "https://github.com/google-apis-rs/generator", features = ["with-yup-oauth2"] }
|
||||
google_api_auth = { git = "https://github.com/google-apis-rs/generator", rev="7504e31", features = ["with-yup-oauth2"] }
|
||||
hexihasher = { git = "https://git.z.xinu.tv/wathiede/hexihasher" }
|
||||
lazy_static = "1.4.0"
|
||||
log = "0.4.8"
|
||||
@@ -19,12 +19,17 @@ serde_json = "1.0.46"
|
||||
stderrlog = "0.4.3"
|
||||
structopt = "0.3.9"
|
||||
yup-oauth2 = "^3.1"
|
||||
warp = "0.1"
|
||||
serde = { version = "1.0.104", features = ["derive"] }
|
||||
image = { version = "0.23.0" } #, default-features = false, features = ["jpeg"] }
|
||||
image = { version = "0.23.2" } #, default-features = false, features = ["jpeg"] }
|
||||
rust-embed = "5.2.0"
|
||||
mime_guess = "2.0.1"
|
||||
rocksdb = "0.13.0"
|
||||
jpeg-decoder = "0.1.18"
|
||||
imageutils = { git = "https://git.z.xinu.tv/wathiede/imageutils" }
|
||||
cacher = { git = "https://git.z.xinu.tv/wathiede/cacher" }
|
||||
rocket = "0.4.5"
|
||||
thiserror = "1.0.20"
|
||||
rusoto_s3 = "0.42.0"
|
||||
rusoto_core = "0.42.0"
|
||||
|
||||
[dependencies.prometheus]
|
||||
features = ["process"]
|
||||
@@ -33,7 +38,18 @@ version = "0.7.0"
|
||||
[dev-dependencies]
|
||||
tempdir = "0.3.7"
|
||||
criterion = "0.3"
|
||||
stb_image = "0.2.2"
|
||||
load_image = "2.12.0"
|
||||
|
||||
[[bench]]
|
||||
name = "image"
|
||||
harness = false
|
||||
|
||||
# Build dependencies with release optimizations even in dev mode.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
[dependencies.rocket_contrib]
|
||||
version = "0.4.5"
|
||||
default-features = false
|
||||
features = ["json"]
|
||||
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM rustlang/rust:nightly AS build-env
|
||||
COPY ./dockerfiles/netrc /root/.netrc
|
||||
RUN mkdir /root/.cargo
|
||||
COPY ./dockerfiles/cargo-config /.cargo/config
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
|
||||
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash -
|
||||
RUN apt-get update && apt-get install -y strace build-essential clang nodejs yarn
|
||||
COPY ./ /src/
|
||||
WORKDIR /src/react-slideshow
|
||||
RUN yarn install
|
||||
RUN yarn build
|
||||
WORKDIR /src
|
||||
RUN cargo version && cargo install --path .
|
||||
|
||||
FROM rust:slim
|
||||
COPY --from=build-env /usr/local/cargo/bin/photosync /usr/bin/
|
||||
@@ -1,19 +1,44 @@
|
||||
use criterion::BenchmarkId;
|
||||
use criterion::Throughput;
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::imageops;
|
||||
use image::GenericImageView;
|
||||
use load_image as load_image_crate;
|
||||
use stb_image::image::load as stb_load;
|
||||
use stb_image::image::LoadResult;
|
||||
|
||||
use photosync::library::load_image;
|
||||
use photosync::library::resize;
|
||||
use photosync::library::save_to_jpeg_bytes;
|
||||
use photosync::library::FilterType;
|
||||
|
||||
pub fn criterion_benchmark(c: &mut Criterion) {
|
||||
const TEST_IMAGE_PATH: &'static str = "testdata/image.jpg";
|
||||
let img = load_image(TEST_IMAGE_PATH).expect("failed to load test image");
|
||||
let img = load_image(TEST_IMAGE_PATH, None, None).expect("failed to load test image");
|
||||
|
||||
c.bench_function("Load image", |b| {
|
||||
b.iter(|| black_box(load_image(TEST_IMAGE_PATH)))
|
||||
b.iter(|| black_box(load_image(TEST_IMAGE_PATH, None, None)))
|
||||
});
|
||||
|
||||
c.bench_function("Load image 256x256", |b| {
|
||||
b.iter(|| black_box(load_image(TEST_IMAGE_PATH, Some(256), Some(256))))
|
||||
});
|
||||
|
||||
c.bench_function("Load load_image", |b| {
|
||||
b.iter(|| {
|
||||
black_box(load_image_crate::load_image(TEST_IMAGE_PATH, true).expect("failed to load"))
|
||||
})
|
||||
});
|
||||
c.bench_function("Load stb_image", |b| {
|
||||
b.iter(|| match stb_load(TEST_IMAGE_PATH) {
|
||||
LoadResult::Error(err) => panic!(err),
|
||||
LoadResult::ImageU8(img) => {
|
||||
black_box(img);
|
||||
}
|
||||
LoadResult::ImageF32(img) => {
|
||||
black_box(img);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let mut group = c.benchmark_group("Resizing");
|
||||
@@ -28,9 +53,10 @@ pub fn criterion_benchmark(c: &mut Criterion) {
|
||||
{
|
||||
let (w, h) = size;
|
||||
for filter in [
|
||||
FilterType::Builtin(imageops::Nearest),
|
||||
FilterType::Builtin(imageops::CatmullRom),
|
||||
FilterType::Builtin(imageops::Lanczos3),
|
||||
FilterType::Nearest,
|
||||
FilterType::CatmullRom,
|
||||
FilterType::Lanczos3,
|
||||
]
|
||||
.iter()
|
||||
{
|
||||
@@ -62,7 +88,7 @@ pub fn criterion_benchmark(c: &mut Criterion) {
|
||||
),
|
||||
size,
|
||||
|b, size| {
|
||||
let small_img = resize(&img, *size, FilterType::Lanczos3);
|
||||
let small_img = resize(&img, *size, FilterType::Builtin(imageops::Lanczos3));
|
||||
b.iter(|| black_box(save_to_jpeg_bytes(&small_img)))
|
||||
},
|
||||
);
|
||||
@@ -79,8 +105,9 @@ pub fn criterion_benchmark(c: &mut Criterion) {
|
||||
size,
|
||||
|b, size| {
|
||||
b.iter(|| {
|
||||
let img = load_image(TEST_IMAGE_PATH).expect("failed to load test image");
|
||||
let small_img = resize(&img, *size, FilterType::Lanczos3);
|
||||
let img = load_image(TEST_IMAGE_PATH, size.0, size.1)
|
||||
.expect("failed to load test image");
|
||||
let small_img = resize(&img, *size, FilterType::Builtin(imageops::Lanczos3));
|
||||
black_box(save_to_jpeg_bytes(&small_img))
|
||||
})
|
||||
},
|
||||
|
||||
1
config.dbuild
Normal file
1
config.dbuild
Normal file
@@ -0,0 +1 @@
|
||||
package="app/photosync"
|
||||
2
dockerfiles/cargo-config
Normal file
2
dockerfiles/cargo-config
Normal file
@@ -0,0 +1,2 @@
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
1
dockerfiles/netrc
Normal file
1
dockerfiles/netrc
Normal file
@@ -0,0 +1 @@
|
||||
machine git.z.xinu.tv login wathiede password gitgit
|
||||
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"name": "react-slideshow",
|
||||
"version": "0.1.0",
|
||||
"proxy": "http://localhost:4000",
|
||||
"proxy": "http://sky.h:8000",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/jest": "^25.1.3",
|
||||
"@types/node": "^13.7.6",
|
||||
"@types/react": "^16.9.23",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/react-router": "^5.1.4",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"bootstrap": "^4.5.0",
|
||||
"react": "^16.12.0",
|
||||
"react-bootstrap": "^1.0.1",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "3.4.0"
|
||||
"react-scripts": "3.4.0",
|
||||
"typescript": "^3.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="https://static.xinu.tv/favicon/gallery.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
content="Photo gallery @ xinu.tv"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="https://static.xinu.tv/favicon/gallery.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
@@ -24,7 +24,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Xinu Slideshow</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -3,19 +3,8 @@
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"src": "https://static.xinu.tv/favicon/gallery.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
|
||||
@@ -2,41 +2,24 @@ body, html, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.App {
|
||||
.container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
#ui {
|
||||
top: 0;
|
||||
line-height: 3em;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#ui .meta {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
#slide {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
166
react-slideshow/src/App.js
vendored
166
react-slideshow/src/App.js
vendored
@@ -1,166 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Switch,
|
||||
Route,
|
||||
useParams
|
||||
} from "react-router-dom";
|
||||
|
||||
import './App.css';
|
||||
|
||||
const IMAGE_CHUNK = 256;
|
||||
const roundup = (v, mod) => {
|
||||
let r = v % mod;
|
||||
if (r === 0) {
|
||||
return v;
|
||||
} else {
|
||||
return v + (mod - r);
|
||||
}
|
||||
};
|
||||
|
||||
class Album extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: null,
|
||||
mediaItems: null,
|
||||
idx: 0,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
let {album} = this.props;
|
||||
fetch(process.env.PUBLIC_URL + `/api/album/${album}`)
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
(result) => this.setState({mediaItems: result}),
|
||||
(error) => this.setState({error}),
|
||||
);
|
||||
}
|
||||
render() {
|
||||
// TODO(wathiede): fade transition.
|
||||
// TODO(wathiede): pair-up portrait orientation images.
|
||||
// TODO(wathiede): fetch an image that maintains the originals aspect ratio
|
||||
let w = window.innerWidth * window.devicePixelRatio;
|
||||
let h = window.innerHeight * window.devicePixelRatio;
|
||||
let ratio = w/h;
|
||||
if (ratio > 1) {
|
||||
// Landscape image
|
||||
w = roundup(w, IMAGE_CHUNK);
|
||||
h = (w/ratio).toFixed();
|
||||
} else {
|
||||
// Portrait image
|
||||
h = roundup(h, IMAGE_CHUNK);
|
||||
w = (h/ratio).toFixed();
|
||||
}
|
||||
|
||||
//let w = roundup(window.innerWidth*window.devicePixelRatio, IMAGE_CHUNK);
|
||||
//let h = roundup(window.innerHeight*window.devicePixelRatio, IMAGE_CHUNK);
|
||||
let {idx, error, mediaItems} = this.state;
|
||||
if (error !== null) {
|
||||
return <h2>Error: {JSON.stringify(error)}</h2>;
|
||||
} else if (mediaItems !== null) {
|
||||
let numImages = mediaItems.length;
|
||||
let nextIdx = (idx+1)%numImages;
|
||||
let prevIdx = (numImages+idx-1)%numImages;
|
||||
let image = mediaItems[idx];
|
||||
let nextImage = mediaItems[nextIdx];
|
||||
let style = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: 'black',
|
||||
backgroundImage: `url(/api/image/${image.id}?w=${w}&h=${h})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center center',
|
||||
backgroundSize: 'cover',
|
||||
};
|
||||
let prefetchStyle = {
|
||||
// display: 'none'
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '25%',
|
||||
height: '25%',
|
||||
};
|
||||
console.log(`window.devicePixelRatio ${window.devicePixelRatio}`);
|
||||
return <div style={style} onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
this.setState({idx: nextIdx})
|
||||
}}>
|
||||
<img
|
||||
style={prefetchStyle}
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
this.setState({idx: prevIdx})
|
||||
}}
|
||||
src={`/api/image/${nextImage.id}?w=${w}&h=${h}`} alt="prefetch next" />
|
||||
</div>;
|
||||
} else {
|
||||
return <h2>Loading...</h2>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumIndex extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: null,
|
||||
albums: null,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
fetch(process.env.PUBLIC_URL + "/api/albums")
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
(result) => this.setState({albums: result}),
|
||||
(error) => this.setState({error}),
|
||||
);
|
||||
}
|
||||
render() {
|
||||
let {error, albums} = this.state;
|
||||
if (error !== null) {
|
||||
return <h2>Error: {JSON.stringify(error)}</h2>;
|
||||
} else if (albums !== null) {
|
||||
return albums.map((a) => {
|
||||
let img = <img src="https://via.placeholder.com/256x128" className="mr-3" alt="unset"/>;
|
||||
if (a.coverPhotoMediaItemId !== undefined) {
|
||||
img = <img src={ `/api/image/${a.coverPhotoMediaItemId}?w=256&h=256` } className="mr-3" alt={ a.title }/>
|
||||
}
|
||||
|
||||
let figure = <figure key={ a.id } className="figure">
|
||||
{img}
|
||||
<figcaption className="figure-caption">{ a.title || "No title" } - { a.mediaItemsCount || 0 } photos </figcaption>
|
||||
</figure>;
|
||||
return <a key={ a.id } href={ '#' + a.id }>
|
||||
{ figure }
|
||||
</a>
|
||||
});
|
||||
} else {
|
||||
return <h2>Loading...</h2>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AlbumRoute = () => {
|
||||
// We can use the `useParams` hook here to access
|
||||
// the dynamic pieces of the URL.
|
||||
let { albumId } = useParams();
|
||||
return <Album album={albumId} />;
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
return <Router>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<div className="container">
|
||||
<AlbumIndex />
|
||||
</div>
|
||||
</Route>
|
||||
<Route exact path="/:albumId">
|
||||
<AlbumRoute />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
}
|
||||
|
||||
export default App;
|
||||
359
react-slideshow/src/App.tsx
Normal file
359
react-slideshow/src/App.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Switch,
|
||||
Route,
|
||||
useParams
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import Random from './rand';
|
||||
import './App.css';
|
||||
|
||||
type Config = {
|
||||
sleepTimeSeconds: number;
|
||||
showUI: boolean;
|
||||
|
||||
}
|
||||
|
||||
let CONFIG: Config;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
CONFIG = {
|
||||
sleepTimeSeconds: 60,
|
||||
showUI: false,
|
||||
}
|
||||
} else {
|
||||
CONFIG = {
|
||||
sleepTimeSeconds: 10,
|
||||
showUI: true,
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_CHUNK = 256;
|
||||
const roundup = (v: number, mod: number) => {
|
||||
let r = v % mod;
|
||||
if (r === 0) {
|
||||
return v;
|
||||
} else {
|
||||
return v + (mod - r);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shuffles array in place. ES6 version
|
||||
* From https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
|
||||
* @param {Array} a items An array containing the items.
|
||||
*/
|
||||
function shuffle<T>(a: Array<T>) {
|
||||
let rng = new Random(new Date().getDate());
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng.nextFloat() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
class Slide {
|
||||
// One or two items. For example if display is landscape we'll try to fit
|
||||
// two portrait images and only one landscape.
|
||||
items: Array<MediaItem>;
|
||||
nextSlide?: Slide;
|
||||
prevSlide?: Slide;
|
||||
constructor(items: Array<MediaItem>) {
|
||||
this.items = items;
|
||||
}
|
||||
prefetchImages() {
|
||||
console.log(`prefetchImages, I have ${this.imageUrls.length} images`);
|
||||
this.imageUrls.map(url => new Image().src = url);
|
||||
}
|
||||
get imageUrls(): Array<string> {
|
||||
let w = window.innerWidth * window.devicePixelRatio;
|
||||
let h = window.innerHeight * window.devicePixelRatio;
|
||||
let ratio = w/h;
|
||||
if (ratio > 1) {
|
||||
// Landscape image
|
||||
w = roundup(w, IMAGE_CHUNK);
|
||||
h = Math.round(w/ratio);
|
||||
} else {
|
||||
// Portrait image
|
||||
h = roundup(h, IMAGE_CHUNK);
|
||||
w = Math.round(h/ratio);
|
||||
}
|
||||
//console.log(`Window size ${window.innerWidth}x${window.innerHeight} with a devicePixelRatio of ${window.devicePixelRatio} for a total size of ${w}x${h}`);
|
||||
return this.items.map(img => `/api/image/${img.id}?w=${w}&h=${h}`);
|
||||
}
|
||||
render() {
|
||||
let urls = this.imageUrls;
|
||||
let frac = 100 / urls.length;
|
||||
let imgs = urls.map(url => {
|
||||
// TODO(wathiede): make this landscape/portrait aware.
|
||||
let style: React.CSSProperties = {
|
||||
height: '100%',
|
||||
width: frac + '%',
|
||||
backgroundColor: 'black',
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center center',
|
||||
backgroundSize: 'cover',
|
||||
float: 'left',
|
||||
};
|
||||
return <div key={url} style={style}></div>;
|
||||
});
|
||||
// TODO(wathiede): make sure the style handles multiple items.
|
||||
return <div style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}>{imgs}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
function makePairs<T>(items: Array<T>) {
|
||||
const half = Math.floor(items.length/2);
|
||||
console.log(`items ${items.length} half ${half}`)
|
||||
let pairs = [];
|
||||
for (let i = 0; i < half; i++) {
|
||||
pairs.push([items[2*i], items[2*i+1]]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
type MediaMetadata = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
type MediaItem = {
|
||||
id: string;
|
||||
mediaMetadata: MediaMetadata;
|
||||
filename: string;
|
||||
};
|
||||
type AlbumProps = {
|
||||
album: string;
|
||||
showUI: boolean;
|
||||
sleepTimeSeconds: number;
|
||||
};
|
||||
type AlbumState = {
|
||||
error: any;
|
||||
mediaItems: Array<MediaItem> | null;
|
||||
curSlide?: Slide,
|
||||
showUI: boolean;
|
||||
timerID: any | null;
|
||||
};
|
||||
class Album extends React.Component<AlbumProps, AlbumState> {
|
||||
state: AlbumState = {
|
||||
error: null,
|
||||
mediaItems: null,
|
||||
showUI: this.props.showUI,
|
||||
timerID: null,
|
||||
};
|
||||
componentDidMount() {
|
||||
this.loadAlbum()
|
||||
}
|
||||
loadAlbum() {
|
||||
let {album} = this.props;
|
||||
fetch(process.env.PUBLIC_URL + `/api/album/${album}`)
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
(mediaItems: Array<MediaItem>) => {
|
||||
let w = window.innerWidth * window.devicePixelRatio;
|
||||
let h = window.innerHeight * window.devicePixelRatio;
|
||||
let ratio = w/h;
|
||||
let landscapes = mediaItems.filter((mi) => {
|
||||
let md = mi.mediaMetadata;
|
||||
let ratio = md.width/md.height;
|
||||
return ratio > 1;
|
||||
});
|
||||
|
||||
let portraits = mediaItems.filter((mi) => {
|
||||
let md = mi.mediaMetadata;
|
||||
let ratio = md.width/md.height;
|
||||
return ratio <= 1;
|
||||
});
|
||||
|
||||
console.log(`${landscapes.length} landscape photos`);
|
||||
console.log(`${portraits.length} portraits photos`);
|
||||
let slides: Array<Slide>;
|
||||
if (ratio > 1) {
|
||||
console.log('display in landscape mode');
|
||||
slides = landscapes.map((p)=>{
|
||||
return new Slide([p]);
|
||||
});
|
||||
let pairs = makePairs(shuffle(portraits));
|
||||
slides = slides.concat(pairs.map((p, i) => new Slide(p)));
|
||||
} else {
|
||||
console.log('display in portrait mode');
|
||||
slides = portraits.map((p)=>{
|
||||
return new Slide([p]);
|
||||
});
|
||||
// TODO(wathiede): fix Slide::render before adding landscapes
|
||||
// to slides here.
|
||||
}
|
||||
slides = shuffle(slides);
|
||||
console.log(`${slides.length} slides`);
|
||||
let numSlides = slides.length;
|
||||
slides.forEach((p, idx)=>{
|
||||
let nextIdx = (idx+1)%numSlides;
|
||||
let prevIdx = (numSlides+idx-1)%numSlides;
|
||||
p.nextSlide = slides[nextIdx];
|
||||
p.prevSlide = slides[prevIdx];
|
||||
})
|
||||
|
||||
this.setState({curSlide: slides[0]});
|
||||
let {sleepTimeSeconds} = this.props;
|
||||
let timerID = setInterval(()=>{
|
||||
let {curSlide} = this.state;
|
||||
this.setState({curSlide: curSlide?.nextSlide})
|
||||
console.log('timer fired');
|
||||
}, sleepTimeSeconds*1000);
|
||||
this.setState({timerID});
|
||||
},
|
||||
(error) => this.setState({error}),
|
||||
);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
let {timerID} = this.state;
|
||||
clearInterval(timerID);
|
||||
}
|
||||
nextPhoto() {
|
||||
}
|
||||
render() {
|
||||
// TODO(wathiede): fade transition.
|
||||
let {curSlide, error, showUI} = this.state;
|
||||
if (error !== null) {
|
||||
return <h2>Error: {JSON.stringify(error)}</h2>;
|
||||
} else if (curSlide) {
|
||||
let nextSlide = curSlide?.nextSlide;
|
||||
let prevSlide = curSlide?.prevSlide;
|
||||
let prefetchStyle: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(127, 127, 127, 0.5)',
|
||||
backgroundPosition: 'center center',
|
||||
bottom: 0,
|
||||
height: '25%',
|
||||
position: 'absolute',
|
||||
width: '25%',
|
||||
};
|
||||
let leftPrefetchStyle: React.CSSProperties = {
|
||||
left: 0,
|
||||
...prefetchStyle
|
||||
};
|
||||
let rightPrefetchStyle: React.CSSProperties = {
|
||||
right: 0,
|
||||
...prefetchStyle
|
||||
};
|
||||
let ui;
|
||||
if (showUI) {
|
||||
ui = <div id="ui">
|
||||
<div
|
||||
style={leftPrefetchStyle}
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
this.setState({curSlide: curSlide?.prevSlide})
|
||||
}}>{ prevSlide?.render() }</div>
|
||||
{/* TODO(wathiede): make this work with multiple items. */}
|
||||
<div className="meta">{curSlide?.items.map(i=>i.filename).join(' | ')}</div>
|
||||
<div
|
||||
style={rightPrefetchStyle}
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
this.setState({curSlide: curSlide?.nextSlide})
|
||||
}}>{ nextSlide?.render() }</div>
|
||||
</div>;
|
||||
}
|
||||
nextSlide?.prefetchImages();
|
||||
return <div id="slide" onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
this.setState({showUI: !showUI})
|
||||
}}>
|
||||
{ curSlide?.render() }
|
||||
{ ui }
|
||||
</div>;
|
||||
} else {
|
||||
return <h2>Loading...</h2>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AlbumIndexProps = {
|
||||
};
|
||||
type AlbumIndexState = {
|
||||
error: any | null,
|
||||
albums: Array<any> | null,
|
||||
};
|
||||
class AlbumIndex extends React.Component<AlbumIndexProps, AlbumIndexState> {
|
||||
state: AlbumIndexState = {
|
||||
error: null,
|
||||
albums: null,
|
||||
}
|
||||
componentDidMount() {
|
||||
fetch(process.env.PUBLIC_URL + "/api/albums")
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
(result) => this.setState({albums: result}),
|
||||
(error) => this.setState({error}),
|
||||
);
|
||||
}
|
||||
render() {
|
||||
let {error, albums} = this.state;
|
||||
if (error !== null) {
|
||||
return <h2>Error: {JSON.stringify(error)}</h2>;
|
||||
} else if (albums !== null) {
|
||||
return albums.map((a) => {
|
||||
let img_url = "https://via.placeholder.com/256x128";
|
||||
let img = <img src="https://via.placeholder.com/256x128" className="mr-3" alt="unset"/>;
|
||||
if (a.coverPhotoMediaItemId !== undefined) {
|
||||
img_url = `/api/image/${a.coverPhotoMediaItemId}?w=512&h=512`
|
||||
img = <img src={ `/api/image/${a.coverPhotoMediaItemId}?w=256&h=256` } className="mr-3" alt={ a.title }/>
|
||||
}
|
||||
|
||||
let figure = <figure key={ a.id } className="figure">
|
||||
{img}
|
||||
<figcaption className="figure-caption">{ a.title || "No title" } - { a.mediaItemsCount || 0 } photos </figcaption>
|
||||
</figure>;
|
||||
return <Card key={a.id} style={{width: '50%'}}>
|
||||
<Card.Img variant="top" src={img_url} />
|
||||
<Card.Body>
|
||||
<Card.Title>{a.title}</Card.Title>
|
||||
<Card.Text>
|
||||
{a.mediaItemsCount || 0} photos
|
||||
</Card.Text>
|
||||
<Button href={'#' + a.id} variant="primary" block>Slideshow</Button>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
});
|
||||
} else {
|
||||
return <h2>Loading...</h2>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AlbumRouteProps = { sleepTimeSeconds: number, showUI: boolean };
|
||||
const AlbumRoute = ({sleepTimeSeconds, showUI}: AlbumRouteProps) => {
|
||||
// We can use the `useParams` hook here to access
|
||||
// the dynamic pieces of the URL.
|
||||
let { albumId } = useParams();
|
||||
albumId = albumId || '';
|
||||
return <Album album={albumId} showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />;
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
let {showUI, sleepTimeSeconds} = CONFIG;
|
||||
return <Router>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<div className="container">
|
||||
<AlbumIndex />
|
||||
</div>
|
||||
</Route>
|
||||
<Route exact path="/lookup/:albumId">
|
||||
<AlbumRoute showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />
|
||||
</Route>
|
||||
<Route exact path="/:albumId">
|
||||
<AlbumRoute showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
}
|
||||
|
||||
export default App;
|
||||
2
react-slideshow/src/index.js
vendored
2
react-slideshow/src/index.js
vendored
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import * as serviceWorker from './serviceWorker';
|
||||
// Importing the Bootstrap CSS
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
|
||||
|
||||
30
react-slideshow/src/rand.js
vendored
Normal file
30
react-slideshow/src/rand.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// From https://gist.github.com/blixt/f17b47c62508be59987b
|
||||
/**
|
||||
* Creates a pseudo-random value generator. The seed must be an integer.
|
||||
*
|
||||
* Uses an optimized version of the Park-Miller PRNG.
|
||||
* http://www.firstpr.com.au/dsp/rand31/
|
||||
*/
|
||||
function Random(seed) {
|
||||
console.log(`Seeding prng with ${seed}`);
|
||||
this._seed = seed % 2147483647;
|
||||
if (this._seed <= 0) this._seed += 2147483646;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a pseudo-random value between 1 and 2^32 - 2.
|
||||
*/
|
||||
Random.prototype.next = function () {
|
||||
return this._seed = this._seed * 16807 % 2147483647;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns a pseudo-random floating point number in range [0, 1).
|
||||
*/
|
||||
Random.prototype.nextFloat = function () {
|
||||
// We know that result of next() will be 1 to 2147483646 (inclusive).
|
||||
return (this.next() - 1) / 2147483646;
|
||||
};
|
||||
|
||||
export default Random;
|
||||
1
react-slideshow/src/react-app-env.d.ts
vendored
Normal file
1
react-slideshow/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
25
react-slideshow/tsconfig.json
Normal file
25
react-slideshow/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,7 @@
|
||||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
pub mod library;
|
||||
pub mod web;
|
||||
pub mod rweb;
|
||||
|
||||
337
src/library.rs
337
src/library.rs
@@ -1,218 +1,133 @@
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cacher::s3::S3CacherError;
|
||||
use cacher::S3Cacher;
|
||||
use google_photoslibrary1 as photos;
|
||||
use image::imageops::FilterType;
|
||||
use image::DynamicImage;
|
||||
use image::GenericImageView;
|
||||
use image::ImageFormat;
|
||||
use image::ImageResult;
|
||||
use log::error;
|
||||
use log::info;
|
||||
use log::warn;
|
||||
use photos::schemas::Album;
|
||||
use photos::schemas::MediaItem;
|
||||
use rocksdb::Direction;
|
||||
use rocksdb::IteratorMode;
|
||||
use rocksdb::DB;
|
||||
use image::imageops;
|
||||
use imageutils::{load_image_buffer, resize, resize_to_fill, save_to_jpeg_bytes, FilterType};
|
||||
use log::{error, info};
|
||||
use photos::schemas::{Album, MediaItem};
|
||||
use rusoto_core::RusotoError;
|
||||
use rusoto_s3::GetObjectError;
|
||||
use thiserror::Error;
|
||||
|
||||
// Used to ensure DB is invalidated after schema changes.
|
||||
const LIBRARY_GENERATION: &'static str = "12";
|
||||
const LIBRARY_GENERATION: &'static str = "16";
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LibraryError {
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("s3 error: {0}")]
|
||||
S3CacherError(#[from] S3CacherError),
|
||||
#[error("json error: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Library {
|
||||
root: PathBuf,
|
||||
originals_dir: PathBuf,
|
||||
cache_db: Arc<DB>,
|
||||
}
|
||||
|
||||
pub fn load_image<P>(path: P) -> ImageResult<DynamicImage>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
image::io::Reader::open(&path)?
|
||||
.with_guessed_format()?
|
||||
.decode()
|
||||
}
|
||||
|
||||
pub fn resize(
|
||||
img: &DynamicImage,
|
||||
dimensions: (Option<u32>, Option<u32>),
|
||||
filter: FilterType,
|
||||
) -> DynamicImage {
|
||||
let (w, h) = dimensions;
|
||||
let (orig_w, orig_h) = img.dimensions();
|
||||
let (w, h) = match (w, h) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, orig_h * w / orig_w),
|
||||
(None, Some(h)) => (orig_w * h / orig_h, h),
|
||||
(None, None) => (orig_w, orig_h),
|
||||
};
|
||||
img.resize_to_fill(w, h, filter)
|
||||
}
|
||||
|
||||
pub fn save_to_jpeg_bytes(img: &DynamicImage) -> ImageResult<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut buf, ImageFormat::Jpeg)?;
|
||||
Ok(buf)
|
||||
s3: S3Cacher,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn new(root: PathBuf) -> Result<Library, Box<dyn std::error::Error>> {
|
||||
let db = DB::open_default(root.join("cache"))?;
|
||||
let cache_db = Arc::new(db);
|
||||
let lib = Library {
|
||||
originals_dir: root.join("images").join("originals"),
|
||||
cache_db,
|
||||
root,
|
||||
};
|
||||
let cnt = lib.clean_db()?;
|
||||
if cnt != 0 {
|
||||
info!("Deleted {} entries", cnt);
|
||||
}
|
||||
if !lib.originals_dir.exists() {
|
||||
info!(
|
||||
"create originals dir {}",
|
||||
&lib.originals_dir.to_string_lossy()
|
||||
);
|
||||
fs::create_dir_all(&lib.originals_dir)?;
|
||||
}
|
||||
pub fn new(s3: S3Cacher) -> Result<Library, Box<dyn std::error::Error>> {
|
||||
let lib = Library { s3 };
|
||||
Ok(lib)
|
||||
}
|
||||
// Removes all data in the database from older schema.
|
||||
pub fn clean_db(&self) -> Result<usize, rocksdb::Error> {
|
||||
Library::gc(LIBRARY_GENERATION, &self.cache_db)
|
||||
}
|
||||
fn gc(generation: &str, db: &DB) -> Result<usize, rocksdb::Error> {
|
||||
let gen = format!("{}/", generation);
|
||||
// '0' is the next character after '/', so iterator's starting there would be after the
|
||||
// last `gen` entry.
|
||||
let next_gen = format!("{}0", generation);
|
||||
let mut del_cnt = 0;
|
||||
for (k, _v) in db.iterator(IteratorMode::From(gen.as_bytes(), Direction::Reverse)) {
|
||||
if !k.starts_with(gen.as_bytes()) {
|
||||
info!("deleting stale key: {}", String::from_utf8_lossy(&k));
|
||||
db.delete(k)?;
|
||||
del_cnt += 1;
|
||||
}
|
||||
}
|
||||
for (k, _v) in db.iterator(IteratorMode::From(next_gen.as_bytes(), Direction::Forward)) {
|
||||
if !k.starts_with(gen.as_bytes()) {
|
||||
info!("deleting stale key: {}", String::from_utf8_lossy(&k));
|
||||
db.delete(k)?;
|
||||
del_cnt += 1;
|
||||
}
|
||||
}
|
||||
Ok(del_cnt)
|
||||
}
|
||||
pub fn create_album_index(&self, albums: &Vec<Album>) -> io::Result<()> {
|
||||
pub fn create_album_index(&self, albums: &Vec<Album>) -> Result<(), LibraryError> {
|
||||
// Serialize it to a JSON string.
|
||||
let j = serde_json::to_string(albums)?;
|
||||
|
||||
let path = self.root.join("albums.json");
|
||||
info!("saving {}", path.to_string_lossy());
|
||||
fs::write(path, j)
|
||||
let filename = "albums.json";
|
||||
|
||||
self.s3
|
||||
.set(&Library::generational_key(filename), j.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn create_album<P: AsRef<Path>>(
|
||||
pub fn create_album(
|
||||
&self,
|
||||
album_id: P,
|
||||
album_id: &str,
|
||||
media_items: &Vec<MediaItem>,
|
||||
) -> io::Result<()> {
|
||||
let album_dir = self.root.join(album_id);
|
||||
if !album_dir.exists() {
|
||||
info!("making album directory {}", album_dir.to_string_lossy());
|
||||
fs::create_dir_all(&album_dir)?;
|
||||
}
|
||||
) -> Result<(), LibraryError> {
|
||||
let relpath = format!("{}.json", &album_id);
|
||||
let j = serde_json::to_string(&media_items)?;
|
||||
let path = album_dir.join("album.json");
|
||||
info!("saving {}", path.to_string_lossy());
|
||||
fs::write(path, j)
|
||||
|
||||
self.s3
|
||||
.set(&Library::generational_key(&relpath), j.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn albums(&self) -> Result<Vec<Album>, Box<dyn std::error::Error>> {
|
||||
let albums_path = self.root.join("albums.json");
|
||||
info!("loading {}", albums_path.to_string_lossy());
|
||||
let bytes = fs::read(albums_path)?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
let filename = "albums.json";
|
||||
|
||||
let bytes = self.s3.get(&Library::generational_key(filename))?;
|
||||
let album: Vec<Album> = serde_json::from_slice(&bytes)?;
|
||||
Ok(album)
|
||||
}
|
||||
pub fn album(&self, album_id: &str) -> Result<Vec<MediaItem>, Box<dyn std::error::Error>> {
|
||||
let album_path = self.root.join(album_id).join("album.json");
|
||||
let bytes = fs::read(album_path)?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
let relpath = format!("{}.json", &album_id);
|
||||
let bytes = self.s3.get(&Library::generational_key(&relpath))?;
|
||||
let mis: Vec<MediaItem> = serde_json::from_slice(&bytes)?;
|
||||
Ok(mis)
|
||||
}
|
||||
pub fn download_image(
|
||||
&self,
|
||||
filename: &str,
|
||||
_filename: &str,
|
||||
media_items_id: &str,
|
||||
base_url: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
// Put images from all albums in common directory.
|
||||
let image_path = self.originals_dir.join(media_items_id);
|
||||
if image_path.exists() {
|
||||
info!(
|
||||
"Skipping already downloaded {} @ {}",
|
||||
&filename,
|
||||
image_path.to_string_lossy()
|
||||
);
|
||||
} else {
|
||||
let download_path = image_path.with_extension("download");
|
||||
let filename = Library::generational_key(&format!("images/originals/{}", media_items_id));
|
||||
if !self.s3.contains_key(&filename) {
|
||||
let url = format!("{}=d", base_url);
|
||||
let mut r = reqwest::blocking::get(&url)?;
|
||||
let mut w = File::create(&download_path)?;
|
||||
let mut buf = Vec::new();
|
||||
info!("Downloading {}", &url);
|
||||
let _n = io::copy(&mut r, &mut w)?;
|
||||
info!(
|
||||
"Rename {} -> {}",
|
||||
download_path.to_string_lossy(),
|
||||
image_path.to_string_lossy()
|
||||
);
|
||||
fs::rename(download_path, &image_path)?;
|
||||
r.read_to_end(&mut buf)?;
|
||||
self.s3.set(&filename, &buf)?;
|
||||
}
|
||||
Ok(image_path)
|
||||
Ok(filename.into())
|
||||
}
|
||||
pub fn original(&self, media_items_id: &str) -> Option<PathBuf> {
|
||||
let path = self.originals_dir.join(media_items_id);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn original_buffer(&self, media_items_id: &str) -> Result<Vec<u8>, LibraryError> {
|
||||
let filename = Library::generational_key(&format!("images/originals/{}", media_items_id));
|
||||
let bytes = self.s3.get(&filename)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
// TODO(wathiede): make this a macro like format! to skip the second string create and copy.
|
||||
fn generational_key(generation: &str, key: &str) -> String {
|
||||
format!("{}/{}", generation, key)
|
||||
fn generational_key(key: &str) -> String {
|
||||
format!("{}/{}", LIBRARY_GENERATION, key)
|
||||
}
|
||||
|
||||
pub fn generate_thumbnail(
|
||||
&self,
|
||||
media_items_id: &str,
|
||||
dimensions: (Option<u32>, Option<u32>),
|
||||
filter: FilterType,
|
||||
) -> Result<Vec<u8>, io::Error> {
|
||||
match self.original(&media_items_id) {
|
||||
None => {
|
||||
warn!("Couldn't find original {}", &media_items_id);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("{}", media_items_id),
|
||||
))
|
||||
}
|
||||
Some(path) => {
|
||||
let orig_img =
|
||||
load_image(&path).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
let img = resize(&orig_img, dimensions, filter);
|
||||
let buf = save_to_jpeg_bytes(&img)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
fill: bool,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let buf = self.original_buffer(&media_items_id)?;
|
||||
let dimension_hint = match dimensions {
|
||||
(Some(w), Some(h)) => Some((w, h)),
|
||||
// Partial dimensions should be handled by the caller of this function. So all
|
||||
// other options are None.
|
||||
_ => None,
|
||||
};
|
||||
let orig_img = load_image_buffer(buf, dimension_hint)?;
|
||||
//.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
let img = if fill {
|
||||
resize_to_fill(&orig_img, dimensions, filter)
|
||||
} else {
|
||||
resize(&orig_img, dimensions, filter)
|
||||
};
|
||||
let buf = save_to_jpeg_bytes(&img).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
Ok(buf)
|
||||
}
|
||||
pub fn thumbnail(
|
||||
&self,
|
||||
media_items_id: &str,
|
||||
dimensions: (Option<u32>, Option<u32>),
|
||||
fill: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
fn cache_key(media_items_id: &str, dimensions: (Option<u32>, Option<u32>)) -> String {
|
||||
let dim = match dimensions {
|
||||
@@ -221,77 +136,33 @@ impl Library {
|
||||
(None, Some(h)) => format!("-h={}", h),
|
||||
(None, None) => "".to_string(),
|
||||
};
|
||||
Library::generational_key(LIBRARY_GENERATION, &format!("{}{}", media_items_id, dim))
|
||||
Library::generational_key(&format!("images/thumbnails/{}-{}", media_items_id, dim))
|
||||
}
|
||||
let key = cache_key(media_items_id, dimensions);
|
||||
let db = self.cache_db.clone();
|
||||
match db.get(key.as_bytes()) {
|
||||
// Cache hit, return bytes as-is.
|
||||
Ok(Some(bytes)) => {
|
||||
info!("cache HIT {}", key);
|
||||
Some(bytes)
|
||||
}
|
||||
// Cache miss, fill cache and return.
|
||||
Ok(None) => {
|
||||
info!("cache MISS {}", key);
|
||||
let bytes =
|
||||
match self.generate_thumbnail(media_items_id, dimensions, FilterType::Lanczos3)
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to generate thumbnail for {}: {}", media_items_id, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match db.put(key.as_bytes(), &bytes) {
|
||||
Ok(_) => Some(bytes),
|
||||
Err(e) => {
|
||||
error!("Failed to put bytes to {}: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
// RocksDB error.
|
||||
match self.s3.get(&key) {
|
||||
Ok(bytes) => return Some(bytes),
|
||||
Err(S3CacherError::GetObjectError(RusotoError::Service(
|
||||
GetObjectError::NoSuchKey(msg),
|
||||
))) => info!("Missing thumbnail {} in s3: {}", key, msg),
|
||||
Err(e) => error!("Error fetching thumbnail {} from s3: {}", key, e),
|
||||
};
|
||||
|
||||
info!("cache MISS {}", key);
|
||||
let bytes = match self.generate_thumbnail(
|
||||
media_items_id,
|
||||
dimensions,
|
||||
FilterType::Builtin(imageops::FilterType::Lanczos3),
|
||||
fill,
|
||||
) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to search DB for {}: {}", key, e);
|
||||
None
|
||||
error!("Failed to generate thumbnail for {}: {}", media_items_id, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.s3.set(&key, &bytes) {
|
||||
error!("Failed to put thumbnail {}: {}", &key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use tempdir::TempDir;
|
||||
|
||||
#[test]
|
||||
fn clean_db() {
|
||||
let td = TempDir::new("photosync_test").expect("failed to create temporary directory");
|
||||
eprintln!("creating database in {}", td.path().to_string_lossy());
|
||||
let db = DB::open_default(td.path()).expect("failed to open DB");
|
||||
let keys = vec!["one", "two", "three"];
|
||||
|
||||
fn get_keys(db: &DB) -> Vec<String> {
|
||||
db.iterator(rocksdb::IteratorMode::Start)
|
||||
.map(|(k, _v)| String::from_utf8(k.to_vec()).expect("key not utf-8"))
|
||||
.collect()
|
||||
}
|
||||
for k in &keys {
|
||||
for g in vec!["1", "2", "3"] {
|
||||
db.put(Library::generational_key(g, k), k)
|
||||
.expect("failed to put");
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
get_keys(&db),
|
||||
vec![
|
||||
"1/one", "1/three", "1/two", "2/one", "2/three", "2/two", "3/one", "3/three",
|
||||
"3/two"
|
||||
]
|
||||
);
|
||||
Library::gc("2", &db).expect("failed to GC DB");
|
||||
assert_eq!(get_keys(&db), vec!["2/one", "2/three", "2/two",]);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
131
src/main.rs
131
src/main.rs
@@ -2,19 +2,46 @@ use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use cacher::S3Cacher;
|
||||
use google_api_auth;
|
||||
use google_photoslibrary1 as photos;
|
||||
use hexihasher;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{debug, info};
|
||||
use log::{debug, error, info};
|
||||
use photos::schemas::{Album, MediaItem, SearchMediaItemsRequest};
|
||||
use regex::Regex;
|
||||
use structopt::StructOpt;
|
||||
use yup_oauth2::{Authenticator, InstalledFlow};
|
||||
|
||||
use photosync::library::Library;
|
||||
use photosync::web;
|
||||
use photosync::rweb;
|
||||
|
||||
fn parse_duration(src: &str) -> Result<time::Duration, std::num::ParseIntError> {
|
||||
let secs = str::parse::<u64>(src)?;
|
||||
Ok(time::Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
struct Sync {
|
||||
#[structopt(flatten)]
|
||||
auth: Auth,
|
||||
/// Optional album title to filter. Default will mirror all albums.
|
||||
#[structopt(short, long)]
|
||||
title_filter: Option<Regex>,
|
||||
/// S3 bucket holding metadata and images.
|
||||
#[structopt(long, default_value = "photosync-dev")]
|
||||
s3_bucket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
struct Serve {
|
||||
/// HTTP address to listen for web requests.
|
||||
#[structopt(long = "addr", default_value = "0.0.0.0:0")]
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
enum Command {
|
||||
@@ -31,16 +58,22 @@ enum Command {
|
||||
},
|
||||
Sync {
|
||||
#[structopt(flatten)]
|
||||
auth: Auth,
|
||||
/// Optional album title to filter. Default will mirror all albums.
|
||||
#[structopt(short, long)]
|
||||
title_filter: Option<Regex>,
|
||||
/// Directory to store sync.
|
||||
output: PathBuf,
|
||||
sync: Sync,
|
||||
},
|
||||
Serve {
|
||||
/// Directory of data fetched by `sync`.
|
||||
root: PathBuf,
|
||||
#[structopt(flatten)]
|
||||
serve: Serve,
|
||||
/// S3 bucket holding metadata and images.
|
||||
#[structopt(default_value = "photosync-dev")]
|
||||
s3_bucket: String,
|
||||
},
|
||||
ServeAndSync {
|
||||
/// Sync albums at given interval.
|
||||
#[structopt(parse(try_from_str = parse_duration))]
|
||||
interval: time::Duration,
|
||||
|
||||
#[structopt(flatten)]
|
||||
sync: Sync,
|
||||
/// HTTP address to listen for web requests.
|
||||
#[structopt(long = "addr", default_value = "0.0.0.0:0")]
|
||||
addr: SocketAddr,
|
||||
@@ -192,10 +225,9 @@ lazy_static! {
|
||||
|
||||
fn sync_albums(
|
||||
client: &photos::Client,
|
||||
title_filter: Option<Regex>,
|
||||
output_dir: PathBuf,
|
||||
title_filter: &Option<Regex>,
|
||||
lib: &Library,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let lib = Library::new(output_dir)?;
|
||||
let albums = list_albums(client, title_filter)?;
|
||||
info!("albums {:?}", albums);
|
||||
lib.create_album_index(&albums)?;
|
||||
@@ -236,12 +268,18 @@ fn print_albums(albums: Vec<Album>) {
|
||||
|
||||
fn list_albums(
|
||||
client: &photos::Client,
|
||||
title_filter: Option<Regex>,
|
||||
title_filter: &Option<Regex>,
|
||||
) -> Result<Vec<Album>, Box<dyn Error>> {
|
||||
Ok(client
|
||||
.shared_albums()
|
||||
.albums()
|
||||
.list()
|
||||
.iter_shared_albums_with_all_fields()
|
||||
.iter_albums_with_all_fields()
|
||||
.chain(
|
||||
client
|
||||
.shared_albums()
|
||||
.list()
|
||||
.iter_shared_albums_with_all_fields(),
|
||||
)
|
||||
.filter_map(|a| a.ok())
|
||||
.filter(|a| {
|
||||
match (&title_filter, &a.title) {
|
||||
@@ -258,8 +296,23 @@ fn list_albums(
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn serve(addr: SocketAddr, root: PathBuf) -> Result<(), Box<dyn Error>> {
|
||||
web::run(addr, root)
|
||||
fn background_sync(
|
||||
client: photos::Client,
|
||||
interval: time::Duration,
|
||||
title_filter: Option<Regex>,
|
||||
lib: Library,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
thread::spawn(move || loop {
|
||||
if let Err(err) = sync_albums(&client, &title_filter, &lib) {
|
||||
error!("Error syncing: {}", err);
|
||||
}
|
||||
thread::sleep(interval);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serve(addr: SocketAddr, lib: Library) -> Result<(), Box<dyn Error>> {
|
||||
rweb::run(addr, lib)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -273,7 +326,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match opt.cmd {
|
||||
Command::ListAlbums { auth, title_filter } => {
|
||||
let client = new_client(&auth.credentials, &auth.token_cache)?;
|
||||
print_albums(list_albums(&client, title_filter)?);
|
||||
print_albums(list_albums(&client, &title_filter)?);
|
||||
Ok(())
|
||||
}
|
||||
Command::SearchMediaItems { auth, album_id } => {
|
||||
@@ -282,14 +335,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
Command::Sync {
|
||||
auth,
|
||||
title_filter,
|
||||
output,
|
||||
sync:
|
||||
Sync {
|
||||
auth,
|
||||
title_filter,
|
||||
s3_bucket,
|
||||
},
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let client = new_client(&auth.credentials, &auth.token_cache)?;
|
||||
sync_albums(&client, title_filter, output)?;
|
||||
let lib = Library::new(s3)?;
|
||||
sync_albums(&client, &title_filter, &lib)?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Serve {
|
||||
serve: Serve { addr },
|
||||
s3_bucket,
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let lib = Library::new(s3)?;
|
||||
serve(addr, lib)
|
||||
}
|
||||
Command::ServeAndSync {
|
||||
interval,
|
||||
sync:
|
||||
Sync {
|
||||
auth,
|
||||
title_filter,
|
||||
s3_bucket,
|
||||
},
|
||||
|
||||
addr,
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let client = new_client(&auth.credentials, &auth.token_cache)?;
|
||||
let lib = Library::new(s3)?;
|
||||
background_sync(client, interval, title_filter, lib.clone())?;
|
||||
serve(addr, lib)?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Serve { addr, root } => serve(addr, root),
|
||||
}
|
||||
}
|
||||
|
||||
148
src/rweb.rs
Normal file
148
src/rweb.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use std::error::Error;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use google_photoslibrary1 as photos;
|
||||
use log::error;
|
||||
use photos::schemas::{Album, MediaItem};
|
||||
use prometheus::Encoder;
|
||||
use rocket::config::{Config, Environment};
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::status::NotFound;
|
||||
use rocket::response::Content;
|
||||
use rocket::State;
|
||||
use rocket_contrib::json::Json;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
use crate::library::Library;
|
||||
|
||||
#[get("/metrics")]
|
||||
fn metrics() -> Content<Vec<u8>> {
|
||||
let mut buffer = Vec::new();
|
||||
let encoder = prometheus::TextEncoder::new();
|
||||
|
||||
// Gather the metrics.
|
||||
let metric_families = prometheus::gather();
|
||||
// Encode them to send.
|
||||
encoder.encode(&metric_families, &mut buffer).unwrap();
|
||||
// TODO(wathiede): see if there's a wrapper like html()
|
||||
Content(ContentType::Plain, buffer)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn index() -> Result<Content<Vec<u8>>, NotFound<String>> {
|
||||
file("index.html")
|
||||
}
|
||||
|
||||
// This is the catch-all handler, it has a high rank so it is the last match in any tie-breaks.
|
||||
#[get("/<path..>", rank = 99)]
|
||||
fn path(path: PathBuf) -> Result<Content<Vec<u8>>, NotFound<String>> {
|
||||
let path = path.to_str().unwrap();
|
||||
let path = if path.ends_with("/") {
|
||||
format!("{}index.html", path.to_string())
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
file(&path)
|
||||
}
|
||||
|
||||
fn file(path: &str) -> Result<Content<Vec<u8>>, NotFound<String>> {
|
||||
match Asset::get(path) {
|
||||
Some(bytes) => {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
let ct = ContentType::parse_flexible(mime.essence_str()).unwrap_or(ContentType::Binary);
|
||||
|
||||
Ok(Content(ct, bytes.into()))
|
||||
}
|
||||
None => Err(NotFound(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/albums")]
|
||||
fn albums(lib: State<Library>) -> Result<Json<Vec<Album>>, NotFound<String>> {
|
||||
let albums = lib
|
||||
.albums()
|
||||
.map_err(|e| NotFound(format!("Couldn't find albums: {}", e)))?;
|
||||
Ok(Json(albums))
|
||||
}
|
||||
|
||||
#[get("/api/album/<id>")]
|
||||
fn album(id: String, lib: State<Library>) -> Result<Json<Vec<MediaItem>>, NotFound<String>> {
|
||||
let album = lib
|
||||
.album(&id)
|
||||
.map_err(|e| NotFound(format!("Couldn't find album {}: {}", id, e)))?;
|
||||
Ok(Json(album))
|
||||
}
|
||||
|
||||
#[get("/api/image/<media_items_id>?<w>&<h>&<fill>")]
|
||||
fn image(
|
||||
media_items_id: String,
|
||||
w: Option<u32>,
|
||||
h: Option<u32>,
|
||||
fill: Option<bool>,
|
||||
lib: State<Library>,
|
||||
) -> Result<Content<Vec<u8>>, NotFound<String>> {
|
||||
// TODO(wathiede): add caching headers.
|
||||
match lib.thumbnail(&media_items_id, (w, h), fill.unwrap_or(false)) {
|
||||
None => Err(NotFound(format!(
|
||||
"Couldn't find original {}",
|
||||
&media_items_id
|
||||
))),
|
||||
Some(bytes) => Ok(Content(ContentType::JPEG, bytes.into())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "react-slideshow/build/"]
|
||||
struct Asset;
|
||||
|
||||
#[get("/embedz")]
|
||||
fn embedz() -> Content<Vec<u8>> {
|
||||
let mut w = Vec::new();
|
||||
write!(
|
||||
w,
|
||||
r#"<html><table><tbody><tr><th>size</th><th style="text-align: left;">path</th></tr>"#
|
||||
)
|
||||
.unwrap();
|
||||
for path in Asset::iter() {
|
||||
write!(
|
||||
w,
|
||||
r#"<tr><td style="text-align: right;">{0}</td><td><a href="{1}">{1}</a></td</tr>"#,
|
||||
Asset::get(&path).unwrap().len(),
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Content(ContentType::HTML, w)
|
||||
}
|
||||
|
||||
pub fn run(addr: SocketAddr, lib: Library) -> Result<(), Box<dyn Error>> {
|
||||
let config = Config::build(Environment::Development)
|
||||
.address(addr.ip().to_string())
|
||||
.port(addr.port())
|
||||
.finalize()?;
|
||||
|
||||
let e = rocket::custom(config)
|
||||
.manage(lib)
|
||||
.mount(
|
||||
"/",
|
||||
routes![album, albums, image, embedz, metrics, index, path],
|
||||
)
|
||||
.launch();
|
||||
match e.kind() {
|
||||
rocket::error::LaunchErrorKind::Collision(v) => {
|
||||
error!("Route collisions:");
|
||||
for (r1, r2) in v {
|
||||
error!(" R1 {}", r1);
|
||||
error!(" R2 {}", r2);
|
||||
}
|
||||
for (r1, r2) in v {
|
||||
error!(" R1 {:#?}", r1);
|
||||
error!(" R2 {:#?}", r2);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
return Err(e.into());
|
||||
}
|
||||
131
src/web.rs
131
src/web.rs
@@ -1,131 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::warn;
|
||||
use prometheus::Encoder;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Deserialize;
|
||||
use warp;
|
||||
use warp::http::header::{HeaderMap, HeaderValue};
|
||||
use warp::reject::Rejection;
|
||||
use warp::Filter;
|
||||
|
||||
use crate::library::Library;
|
||||
|
||||
fn metrics() -> impl Filter<Extract = (impl warp::reply::Reply,), Error = Rejection> + Clone {
|
||||
let mut text_headers = HeaderMap::new();
|
||||
text_headers.insert("content-type", HeaderValue::from_static("text/plain"));
|
||||
warp::path("metrics")
|
||||
.map(|| {
|
||||
let mut buffer = Vec::new();
|
||||
let encoder = prometheus::TextEncoder::new();
|
||||
|
||||
// Gather the metrics.
|
||||
let metric_families = prometheus::gather();
|
||||
// Encode them to send.
|
||||
encoder.encode(&metric_families, &mut buffer).unwrap();
|
||||
// TODO(wathiede): see if there's a wrapper like html()
|
||||
buffer
|
||||
})
|
||||
.with(warp::reply::with::headers(text_headers))
|
||||
}
|
||||
|
||||
// TODO(wathiede): add caching for hashed files. Add at least etag for everything.
|
||||
fn index(path: warp::path::FullPath) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let path = path.as_str();
|
||||
let path = if path.ends_with("/") {
|
||||
format!("{}index.html", path.to_string())
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
let path = &path[1..];
|
||||
|
||||
match Asset::get(path) {
|
||||
Some(bytes) => {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
|
||||
Ok(warp::http::Response::builder()
|
||||
.header("Content-Type", mime.essence_str())
|
||||
.body(bytes.into_owned()))
|
||||
}
|
||||
None => Err(warp::reject::not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
fn albums(lib: Library) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let albums = lib.albums().map_err(|e| {
|
||||
warn!("Couldn't find albums: {}", e);
|
||||
warp::reject::not_found()
|
||||
})?;
|
||||
Ok(warp::reply::json(&albums))
|
||||
}
|
||||
|
||||
fn album(lib: Library, id: String) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let album = lib.album(&id).map_err(|e| {
|
||||
warn!("Couldn't find album {}: {}", id, e);
|
||||
warp::reject::not_found()
|
||||
})?;
|
||||
Ok(warp::reply::json(&album))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImageParams {
|
||||
w: Option<u32>,
|
||||
h: Option<u32>,
|
||||
}
|
||||
|
||||
fn image(
|
||||
lib: Library,
|
||||
media_items_id: String,
|
||||
params: ImageParams,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
// TODO(wathiede): add caching headers.
|
||||
match lib.thumbnail(&media_items_id, (params.w, params.h)) {
|
||||
None => {
|
||||
warn!("Couldn't find original {}", &media_items_id);
|
||||
Err(warp::reject::not_found())
|
||||
}
|
||||
Some(bytes) => Ok(warp::http::Response::builder()
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.body(bytes)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "react-debug/build/"]
|
||||
struct Asset;
|
||||
|
||||
pub fn run(addr: SocketAddr, root: PathBuf) -> Result<(), Box<dyn Error>> {
|
||||
let lib = Library::new(root)?;
|
||||
let lib = warp::any().map(move || lib.clone());
|
||||
|
||||
let index = warp::get2().and(warp::path::full()).and_then(index);
|
||||
|
||||
let albums = warp::path("albums").and(lib.clone()).and_then(albums);
|
||||
|
||||
let album = warp::path("album")
|
||||
.and(lib.clone())
|
||||
.and(warp::path::param())
|
||||
.and_then(album);
|
||||
|
||||
let image = warp::path("image")
|
||||
.and(lib.clone())
|
||||
.and(warp::path::param())
|
||||
.and(warp::query::<ImageParams>())
|
||||
.and_then(image);
|
||||
|
||||
let api = albums.or(album).or(image);
|
||||
let api = warp::path("api").and(api);
|
||||
|
||||
// Fallback, always keep this last.
|
||||
let api = api.or(index);
|
||||
|
||||
let api = api.with(warp::log("photosync"));
|
||||
// We don't want metrics & heath checking filling up the logs, so we add this handler after
|
||||
// wrapping with the log filter.
|
||||
let routes = metrics().or(api);
|
||||
|
||||
warp::serve(routes).run(addr);
|
||||
Ok(())
|
||||
}
|
||||
BIN
testdata/image.jpg
vendored
BIN
testdata/image.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 MiB |
Reference in New Issue
Block a user