Compare commits
No commits in common. "7cc0af4f077644d6619a4a9774f693d289a48a17" and "7e2cf1b956c888ec8ba3fbe470906cd8e93fd901" have entirely different histories.
7cc0af4f07
...
7e2cf1b956
1277
Cargo.lock
generated
1277
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@ -19,17 +19,14 @@ 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.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"]
|
||||
@ -48,8 +45,3 @@ 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"]
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
FROM rustlang/rust:nightly AS build-env
|
||||
FROM rust:latest AS build-env
|
||||
COPY ./ /src/
|
||||
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 curl -sL https://deb.nodesource.com/setup_13.x | bash -
|
||||
RUN apt-get update && apt-get install -y strace build-essential clang nodejs yarn
|
||||
WORKDIR /src/react-slideshow
|
||||
RUN yarn install
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "react-slideshow",
|
||||
"version": "0.1.0",
|
||||
"proxy": "http://sky.h:8000",
|
||||
"proxy": "http://localhost:4000",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
@ -13,9 +13,7 @@
|
||||
"@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",
|
||||
|
||||
@ -2,10 +2,6 @@ body, html, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
#ui {
|
||||
top: 0;
|
||||
line-height: 3em;
|
||||
|
||||
@ -5,10 +5,6 @@ import {
|
||||
Route,
|
||||
useParams
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import Random from './rand';
|
||||
import './App.css';
|
||||
@ -300,10 +296,8 @@ class AlbumIndex extends React.Component<AlbumIndexProps, AlbumIndexState> {
|
||||
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 }/>
|
||||
}
|
||||
|
||||
@ -311,16 +305,9 @@ class AlbumIndex extends React.Component<AlbumIndexProps, AlbumIndexState> {
|
||||
{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>
|
||||
return <a key={ a.id } href={ '#' + a.id }>
|
||||
{ figure }
|
||||
</a>
|
||||
});
|
||||
} else {
|
||||
return <h2>Loading...</h2>;
|
||||
@ -346,9 +333,6 @@ const App = () => {
|
||||
<AlbumIndex />
|
||||
</div>
|
||||
</Route>
|
||||
<Route exact path="/lookup/:albumId">
|
||||
<AlbumRoute showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />
|
||||
</Route>
|
||||
<Route exact path="/:albumId">
|
||||
<AlbumRoute showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />
|
||||
</Route>
|
||||
|
||||
2
react-slideshow/src/index.js
vendored
2
react-slideshow/src/index.js
vendored
@ -3,8 +3,6 @@ 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'));
|
||||
|
||||
|
||||
2
react-slideshow/src/rand.js
vendored
2
react-slideshow/src/rand.js
vendored
@ -22,7 +22,7 @@ Random.prototype.next = function () {
|
||||
/**
|
||||
* Returns a pseudo-random floating point number in range [0, 1).
|
||||
*/
|
||||
Random.prototype.nextFloat = function () {
|
||||
Random.prototype.nextFloat = function (opt_minOrMax, opt_max) {
|
||||
// We know that result of next() will be 1 to 2147483646 (inclusive).
|
||||
return (this.next() - 1) / 2147483646;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,2 @@
|
||||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
pub mod library;
|
||||
pub mod rweb;
|
||||
pub mod web;
|
||||
|
||||
263
src/library.rs
263
src/library.rs
@ -1,102 +1,155 @@
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cacher::s3::S3CacherError;
|
||||
use cacher::S3Cacher;
|
||||
use google_photoslibrary1 as photos;
|
||||
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;
|
||||
use imageutils::{load_image, resize, resize_to_fill, save_to_jpeg_bytes, FilterType};
|
||||
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;
|
||||
|
||||
// Used to ensure DB is invalidated after schema changes.
|
||||
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),
|
||||
}
|
||||
const LIBRARY_GENERATION: &'static str = "14";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Library {
|
||||
s3: S3Cacher,
|
||||
root: PathBuf,
|
||||
originals_dir: PathBuf,
|
||||
cache_db: Arc<DB>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn new(s3: S3Cacher) -> Result<Library, Box<dyn std::error::Error>> {
|
||||
let lib = Library { s3 };
|
||||
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)?;
|
||||
}
|
||||
Ok(lib)
|
||||
}
|
||||
pub fn create_album_index(&self, albums: &Vec<Album>) -> Result<(), LibraryError> {
|
||||
// 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<()> {
|
||||
// Serialize it to a JSON string.
|
||||
let j = serde_json::to_string(albums)?;
|
||||
|
||||
let filename = "albums.json";
|
||||
|
||||
self.s3
|
||||
.set(&Library::generational_key(filename), j.as_ref())?;
|
||||
Ok(())
|
||||
let path = self.root.join("albums.json");
|
||||
info!("saving {}", path.to_string_lossy());
|
||||
fs::write(path, j)
|
||||
}
|
||||
pub fn create_album(
|
||||
pub fn create_album<P: AsRef<Path>>(
|
||||
&self,
|
||||
album_id: &str,
|
||||
album_id: P,
|
||||
media_items: &Vec<MediaItem>,
|
||||
) -> Result<(), LibraryError> {
|
||||
let relpath = format!("{}.json", &album_id);
|
||||
) -> 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)?;
|
||||
}
|
||||
let j = serde_json::to_string(&media_items)?;
|
||||
|
||||
self.s3
|
||||
.set(&Library::generational_key(&relpath), j.as_ref())?;
|
||||
Ok(())
|
||||
let path = album_dir.join("album.json");
|
||||
info!("saving {}", path.to_string_lossy());
|
||||
fs::write(path, j)
|
||||
}
|
||||
pub fn albums(&self) -> Result<Vec<Album>, Box<dyn std::error::Error>> {
|
||||
let filename = "albums.json";
|
||||
|
||||
let bytes = self.s3.get(&Library::generational_key(filename))?;
|
||||
let album: Vec<Album> = serde_json::from_slice(&bytes)?;
|
||||
Ok(album)
|
||||
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)?)
|
||||
}
|
||||
pub fn album(&self, album_id: &str) -> Result<Vec<MediaItem>, Box<dyn std::error::Error>> {
|
||||
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)
|
||||
let album_path = self.root.join(album_id).join("album.json");
|
||||
let bytes = fs::read(album_path)?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
pub fn download_image(
|
||||
&self,
|
||||
_filename: &str,
|
||||
filename: &str,
|
||||
media_items_id: &str,
|
||||
base_url: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let filename = Library::generational_key(&format!("images/originals/{}", media_items_id));
|
||||
if !self.s3.contains_key(&filename) {
|
||||
// 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 url = format!("{}=d", base_url);
|
||||
let mut r = reqwest::blocking::get(&url)?;
|
||||
let mut buf = Vec::new();
|
||||
let mut w = File::create(&download_path)?;
|
||||
info!("Downloading {}", &url);
|
||||
r.read_to_end(&mut buf)?;
|
||||
self.s3.set(&filename, &buf)?;
|
||||
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)?;
|
||||
}
|
||||
Ok(filename.into())
|
||||
Ok(image_path)
|
||||
}
|
||||
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(key: &str) -> String {
|
||||
format!("{}/{}", LIBRARY_GENERATION, key)
|
||||
fn generational_key(generation: &str, key: &str) -> String {
|
||||
format!("{}/{}", generation, key)
|
||||
}
|
||||
|
||||
pub fn generate_thumbnail(
|
||||
@ -106,23 +159,25 @@ impl Library {
|
||||
filter: FilterType,
|
||||
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)?;
|
||||
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)).into())
|
||||
}
|
||||
Some(path) => {
|
||||
let orig_img = load_image(&path, dimensions.0, dimensions.1)?;
|
||||
//.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))?;
|
||||
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,
|
||||
@ -136,17 +191,15 @@ impl Library {
|
||||
(None, Some(h)) => format!("-h={}", h),
|
||||
(None, None) => "".to_string(),
|
||||
};
|
||||
Library::generational_key(&format!("images/thumbnails/{}-{}", media_items_id, dim))
|
||||
Library::generational_key(LIBRARY_GENERATION, &format!("{}{}", media_items_id, dim))
|
||||
}
|
||||
let key = cache_key(media_items_id, dimensions);
|
||||
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),
|
||||
};
|
||||
|
||||
let db = self.cache_db.clone();
|
||||
match db.get(key.as_bytes()) {
|
||||
// Cache hit, return bytes as-is.
|
||||
Ok(Some(bytes)) => Some(bytes),
|
||||
// Cache miss, fill cache and return.
|
||||
Ok(None) => {
|
||||
info!("cache MISS {}", key);
|
||||
let bytes = match self.generate_thumbnail(
|
||||
media_items_id,
|
||||
@ -160,9 +213,55 @@ impl Library {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.s3.set(&key, &bytes) {
|
||||
error!("Failed to put thumbnail {}: {}", &key, e);
|
||||
}
|
||||
Some(bytes)
|
||||
match db.put(key.as_bytes(), &bytes) {
|
||||
Ok(_) => Some(bytes),
|
||||
Err(e) => {
|
||||
error!("Failed to put bytes to {}: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
// RocksDB error.
|
||||
Err(e) => {
|
||||
error!("Failed to search DB for {}: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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",]);
|
||||
}
|
||||
}
|
||||
|
||||
32
src/main.rs
32
src/main.rs
@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use cacher::S3Cacher;
|
||||
use google_api_auth;
|
||||
use google_photoslibrary1 as photos;
|
||||
use hexihasher;
|
||||
@ -17,7 +16,7 @@ use structopt::StructOpt;
|
||||
use yup_oauth2::{Authenticator, InstalledFlow};
|
||||
|
||||
use photosync::library::Library;
|
||||
use photosync::rweb;
|
||||
use photosync::web;
|
||||
|
||||
fn parse_duration(src: &str) -> Result<time::Duration, std::num::ParseIntError> {
|
||||
let secs = str::parse::<u64>(src)?;
|
||||
@ -31,13 +30,14 @@ struct Sync {
|
||||
/// 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,
|
||||
/// Directory to store sync.
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
struct Serve {
|
||||
/// Directory of data fetched by `sync`.
|
||||
root: PathBuf,
|
||||
/// HTTP address to listen for web requests.
|
||||
#[structopt(long = "addr", default_value = "0.0.0.0:0")]
|
||||
addr: SocketAddr,
|
||||
@ -63,9 +63,6 @@ enum Command {
|
||||
Serve {
|
||||
#[structopt(flatten)]
|
||||
serve: Serve,
|
||||
/// S3 bucket holding metadata and images.
|
||||
#[structopt(default_value = "photosync-dev")]
|
||||
s3_bucket: String,
|
||||
},
|
||||
ServeAndSync {
|
||||
/// Sync albums at given interval.
|
||||
@ -312,7 +309,7 @@ fn background_sync(
|
||||
}
|
||||
|
||||
pub fn serve(addr: SocketAddr, lib: Library) -> Result<(), Box<dyn Error>> {
|
||||
rweb::run(addr, lib)
|
||||
web::run(addr, lib)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@ -339,21 +336,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Sync {
|
||||
auth,
|
||||
title_filter,
|
||||
s3_bucket,
|
||||
root,
|
||||
},
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let client = new_client(&auth.credentials, &auth.token_cache)?;
|
||||
let lib = Library::new(s3)?;
|
||||
let lib = Library::new(root)?;
|
||||
sync_albums(&client, &title_filter, &lib)?;
|
||||
Ok(())
|
||||
}
|
||||
Command::Serve {
|
||||
serve: Serve { addr },
|
||||
s3_bucket,
|
||||
serve: Serve { addr, root },
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let lib = Library::new(s3)?;
|
||||
let lib = Library::new(root)?;
|
||||
serve(addr, lib)
|
||||
}
|
||||
Command::ServeAndSync {
|
||||
@ -362,14 +356,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Sync {
|
||||
auth,
|
||||
title_filter,
|
||||
s3_bucket,
|
||||
root,
|
||||
},
|
||||
|
||||
addr,
|
||||
} => {
|
||||
let s3 = S3Cacher::new(s3_bucket.clone())?;
|
||||
let client = new_client(&auth.credentials, &auth.token_cache)?;
|
||||
let lib = Library::new(s3)?;
|
||||
let lib = Library::new(root)?;
|
||||
background_sync(client, interval, title_filter, lib.clone())?;
|
||||
serve(addr, lib)?;
|
||||
Ok(())
|
||||
|
||||
148
src/rweb.rs
148
src/rweb.rs
@ -1,148 +0,0 @@
|
||||
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());
|
||||
}
|
||||
159
src/web.rs
Normal file
159
src/web.rs
Normal file
@ -0,0 +1,159 @@
|
||||
use std::error::Error;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
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>,
|
||||
fill: Option<bool>,
|
||||
}
|
||||
|
||||
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),
|
||||
params.fill.unwrap_or(false),
|
||||
) {
|
||||
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-slideshow/build/"]
|
||||
struct Asset;
|
||||
|
||||
fn embedz() -> Result<impl warp::Reply, warp::Rejection> {
|
||||
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();
|
||||
}
|
||||
Ok(warp::http::Response::builder()
|
||||
.header("Content-Type", "text/html")
|
||||
.body(w))
|
||||
}
|
||||
|
||||
pub fn run(addr: SocketAddr, lib: Library) -> Result<(), Box<dyn Error>> {
|
||||
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 embedz = warp::path("embedz").and_then(embedz);
|
||||
|
||||
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);
|
||||
let api = api.or(embedz);
|
||||
|
||||
// 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(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user