Compare commits

...

14 Commits

14 changed files with 2678 additions and 3171 deletions

1277
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,14 +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.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"]
@ -45,3 +48,8 @@ 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"]

View File

@ -1,11 +1,11 @@
FROM rust:latest AS build-env
FROM rustlang/rust:nightly 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_13.x | bash -
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
WORKDIR /src/react-slideshow
RUN yarn install

View File

@ -1,7 +1,7 @@
{
"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",
@ -13,7 +13,9 @@
"@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",

View File

@ -2,6 +2,10 @@ body, html, #root {
height: 100%;
}
.container {
margin-top: 2em;
}
#ui {
top: 0;
line-height: 3em;

View File

@ -5,6 +5,10 @@ import {
Route,
useParams
} from "react-router-dom";
import {
Button,
Card
} from 'react-bootstrap';
import Random from './rand';
import './App.css';
@ -296,8 +300,10 @@ 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 }/>
}
@ -305,9 +311,16 @@ class AlbumIndex extends React.Component<AlbumIndexProps, AlbumIndexState> {
{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>
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>;
@ -333,6 +346,9 @@ 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>

View File

@ -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'));

View File

@ -22,7 +22,7 @@ Random.prototype.next = function () {
/**
* Returns a pseudo-random floating point number in range [0, 1).
*/
Random.prototype.nextFloat = function (opt_minOrMax, opt_max) {
Random.prototype.nextFloat = function () {
// 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

View File

@ -1,2 +1,7 @@
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
pub mod library;
pub mod web;
pub mod rweb;

View File

@ -1,155 +1,102 @@
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;
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;
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 = "14";
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>,
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(
@ -159,24 +106,22 @@ impl Library {
filter: FilterType,
fill: bool,
) -> Result<Vec<u8>, Box<dyn std::error::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)).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))?;
Ok(buf)
}
}
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,
@ -191,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)) => 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::Builtin(imageops::FilterType::Lanczos3),
fill,
) {
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)
}
}

View File

@ -5,6 +5,7 @@ use std::path::PathBuf;
use std::thread;
use std::time;
use cacher::S3Cacher;
use google_api_auth;
use google_photoslibrary1 as photos;
use hexihasher;
@ -16,7 +17,7 @@ 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)?;
@ -30,14 +31,13 @@ struct Sync {
/// Optional album title to filter. Default will mirror all albums.
#[structopt(short, long)]
title_filter: Option<Regex>,
/// Directory to store sync.
root: PathBuf,
/// S3 bucket holding metadata and images.
#[structopt(long, default_value = "photosync-dev")]
s3_bucket: String,
}
#[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,6 +63,9 @@ 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.
@ -309,7 +312,7 @@ fn background_sync(
}
pub fn serve(addr: SocketAddr, lib: Library) -> Result<(), Box<dyn Error>> {
web::run(addr, lib)
rweb::run(addr, lib)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -336,18 +339,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Sync {
auth,
title_filter,
root,
s3_bucket,
},
} => {
let s3 = S3Cacher::new(s3_bucket.clone())?;
let client = new_client(&auth.credentials, &auth.token_cache)?;
let lib = Library::new(root)?;
let lib = Library::new(s3)?;
sync_albums(&client, &title_filter, &lib)?;
Ok(())
}
Command::Serve {
serve: Serve { addr, root },
serve: Serve { addr },
s3_bucket,
} => {
let lib = Library::new(root)?;
let s3 = S3Cacher::new(s3_bucket.clone())?;
let lib = Library::new(s3)?;
serve(addr, lib)
}
Command::ServeAndSync {
@ -356,12 +362,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Sync {
auth,
title_filter,
root,
s3_bucket,
},
addr,
} => {
let s3 = S3Cacher::new(s3_bucket.clone())?;
let client = new_client(&auth.credentials, &auth.token_cache)?;
let lib = Library::new(root)?;
let lib = Library::new(s3)?;
background_sync(client, interval, title_filter, lib.clone())?;
serve(addr, lib)?;
Ok(())

148
src/rweb.rs Normal file
View 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());
}

View File

@ -1,159 +0,0 @@
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(())
}