Initial commit
This commit is contained in:
6
.editorconfig
Normal file
6
.editorconfig
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
.idea/
|
||||||
|
data/
|
||||||
|
*.iml
|
||||||
1986
Cargo.lock
generated
Normal file
1986
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "tinypod"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Manuel Vögele <develop@manuel-voegele.de>"]
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "*"
|
||||||
|
askama = "*"
|
||||||
|
askama_actix = "*"
|
||||||
|
chrono = "*"
|
||||||
|
log = { version = "*", features = ["max_level_debug"] }
|
||||||
|
serde = "*"
|
||||||
|
serde_json = "*"
|
||||||
|
simple_logger = "*"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
199
src/main.rs
Normal file
199
src/main.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use actix_web::{get, post, web, http, App, HttpResponse, HttpServer, Responder};
|
||||||
|
use askama_actix::{Template, TemplateIntoResponse};
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use simple_logger::SimpleLogger;
|
||||||
|
|
||||||
|
const DATA_DIR: &'static str = "/home/manuel/wolke/Projects/tinypod/data";
|
||||||
|
const DB_DIR: &'static str = "data";
|
||||||
|
const DB_FILE_EXT: &'static str = ".json";
|
||||||
|
|
||||||
|
mod date_format {
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde::{Serializer, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(date: &DateTime<FixedOffset>, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer {
|
||||||
|
serializer.serialize_str(&date.to_rfc3339())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D> (deserializer: D) -> Result<DateTime<FixedOffset>, D::Error>
|
||||||
|
where D: Deserializer<'de> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
let mut res = DateTime::parse_from_rfc2822(&s).map_err(serde::de::Error::custom);
|
||||||
|
if res.is_err() {
|
||||||
|
res = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom);
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Episode {
|
||||||
|
title: String,
|
||||||
|
video_url: String,
|
||||||
|
guid: String,
|
||||||
|
#[serde(with="date_format")]
|
||||||
|
pub_date: DateTime<FixedOffset>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Episode {
|
||||||
|
fn new() -> Episode {
|
||||||
|
Episode{title: String::new(), video_url: String::new(), guid: String::new(), pub_date: chrono::Utc::now().into()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Podcast {
|
||||||
|
title: String,
|
||||||
|
logo_url: Option<String>,
|
||||||
|
homepage: String,
|
||||||
|
episodes: Vec<Episode>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Podcast {
|
||||||
|
fn build_path<S: AsRef<str>>(name: S) -> PathBuf {
|
||||||
|
let filename = String::from(name.as_ref()) + DB_FILE_EXT;
|
||||||
|
let mut path = PathBuf::from(DATA_DIR);
|
||||||
|
path.push(DB_DIR);
|
||||||
|
path.push(filename);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_load<S: AsRef<str>>(name: S) -> Result<Podcast, std::io::Error> {
|
||||||
|
let file = BufReader::new(File::open(Self::build_path(name))?);
|
||||||
|
let podcast = Podcast::deserialize(serde_json::from_reader::<_, serde_json::Value>(file)?)?;
|
||||||
|
Ok(podcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_store<S: AsRef<str>>(&self, name: S) -> Result<(), std::io::Error> {
|
||||||
|
let file = BufWriter::new(File::create(Self::build_path(name))?);
|
||||||
|
serde_json::to_writer_pretty(file, &self)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "feed.xml", escape="xml")]
|
||||||
|
// TODO Change ext to rss
|
||||||
|
struct FeedTemplate<'a> {
|
||||||
|
podcast: &'a Podcast
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/feed/{name}")]
|
||||||
|
async fn feed(web::Path(name): web::Path<String>) -> impl Responder {
|
||||||
|
let podcast = match Podcast::db_load(&name) {
|
||||||
|
Ok(podcast) => podcast,
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
FeedTemplate{podcast: &podcast}.into_response().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "edit_podcast.html")]
|
||||||
|
struct EditPodcastTemplate<'a> {
|
||||||
|
podcast: &'a Podcast
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/edit/{name}/")]
|
||||||
|
async fn edit_podcast(web::Path(name): web::Path<String>) -> impl Responder {
|
||||||
|
let podcast = match Podcast::db_load(&name) {
|
||||||
|
Ok(podcast) => podcast,
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
EditPodcastTemplate{podcast: &podcast}.into_response().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "edit_episode.html")]
|
||||||
|
struct EditEpisodeTemplate<'a> {
|
||||||
|
episode: &'a Episode
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/edit/{podcast_name}/edit/{episode_number}")]
|
||||||
|
async fn edit_episode(web::Path((podcast_name, episode_number)): web::Path<(String, usize)>) -> impl Responder {
|
||||||
|
let podcast = match Podcast::db_load(&podcast_name) {
|
||||||
|
Ok(podcast) => podcast,
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// TODO Check episode_number out of bounds
|
||||||
|
EditEpisodeTemplate{episode: &podcast.episodes[episode_number]}.into_response().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/edit/{podcast_name}/edit/{episode_number}")]
|
||||||
|
async fn post_edit_episode(web::Path((podcast_name, episode_number)): web::Path<(String, usize)>, form: web::Form<Episode>) -> impl Responder {
|
||||||
|
let mut podcast = match Podcast::db_load(&podcast_name) {
|
||||||
|
Ok(podcast) => podcast,
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
podcast.episodes[episode_number] = form.into_inner();
|
||||||
|
match podcast.db_store(&podcast_name) {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||||
|
}
|
||||||
|
HttpResponse::Found().header(http::header::LOCATION, format!("/edit/{}/", &podcast_name)).finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/edit/{podcast_name}/new")]
|
||||||
|
async fn new_episode(web::Path(podcast_name): web::Path<String>) -> impl Responder {
|
||||||
|
// TODO This checks if the podcast exists. Build a slimmer test
|
||||||
|
match Podcast::db_load(&podcast_name) {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// TODO Check episode_number out of bounds
|
||||||
|
EditEpisodeTemplate{episode: &Episode::new()}.into_response().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/edit/{podcast_name}/new")]
|
||||||
|
async fn post_new_episode(web::Path(podcast_name): web::Path<String>, form: web::Form<Episode>) -> impl Responder {
|
||||||
|
let mut podcast = match Podcast::db_load(&podcast_name) {
|
||||||
|
Ok(podcast) => podcast,
|
||||||
|
Err(e) => {
|
||||||
|
// TODO Log error
|
||||||
|
return HttpResponse::InternalServerError().body(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
podcast.episodes.insert(0, form.into_inner());
|
||||||
|
match podcast.db_store(&podcast_name) {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||||
|
}
|
||||||
|
HttpResponse::Found().header(http::header::LOCATION, format!("/edit/{}/", &podcast_name)).finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
SimpleLogger::new().init().unwrap();
|
||||||
|
HttpServer::new(|| {
|
||||||
|
App::new()
|
||||||
|
.service(feed)
|
||||||
|
.service(edit_podcast)
|
||||||
|
.service(edit_episode)
|
||||||
|
.service(post_edit_episode)
|
||||||
|
.service(new_episode)
|
||||||
|
.service(post_new_episode)
|
||||||
|
})
|
||||||
|
.bind("0.0.0.0:8080")?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
45
templates/edit_episode.html
Normal file
45
templates/edit_episode.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Edit episode {{episode.title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="form" method="post">
|
||||||
|
<input type="hidden" id="pub_date" name="pub_date" value="{{episode.pub_date.to_rfc3339()}}">
|
||||||
|
<table border="0">
|
||||||
|
<tr><td>Title</td><td><input type="text" name="title" value="{{episode.title}}" required="true"></td></tr>
|
||||||
|
<tr><td>GUID</td><td><input type="text" name="guid" value="{{episode.guid}}" required="true"></td></tr>
|
||||||
|
<tr><td>Pub Date</td><td><input type="date" id="date" required="true"><input type="time" id="time" required="true"></td></tr>
|
||||||
|
<tr><td>Video URL</td><td><input type="text" name="video_url" value="{{episode.video_url}}" required="true"></td></tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
"use strict"
|
||||||
|
|
||||||
|
function leadingzero(s) {
|
||||||
|
s = s.toString()
|
||||||
|
if (s.length == 1) {
|
||||||
|
return "0" + s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcdate() {
|
||||||
|
let datestr = document.getElementById("date").value + " " + document.getElementById("time").value;
|
||||||
|
let datetime = new Date(datestr);
|
||||||
|
document.getElementById("pub_date").value = datetime.toISOString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let datetime = new Date(document.getElementById("pub_date").value)
|
||||||
|
|
||||||
|
console.log(datetime.getFullYear() + "-" + (datetime.getMonth() + 1) + "-" + datetime.getDate())
|
||||||
|
document.getElementById("date").value = datetime.getFullYear() + "-" + leadingzero(datetime.getMonth() + 1) + "-" + leadingzero(datetime.getDate())
|
||||||
|
document.getElementById("time").value = leadingzero(datetime.getHours()) + ":" + leadingzero(datetime.getMinutes())
|
||||||
|
|
||||||
|
document.getElementById("form").onsubmit = calcdate;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
templates/edit_podcast.html
Normal file
25
templates/edit_podcast.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Edit {{podcast.title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form method="post">
|
||||||
|
<table border="0">
|
||||||
|
<tr><td>Title</td><td><input type="text" name="title" value="{{podcast.title}}"></td></tr>
|
||||||
|
<tr><td>Logo URL</td><td><input type="text" name="logo_url" value="{{podcast.logo_url.as_ref().unwrap_or(String::new())}}"></td></tr>
|
||||||
|
<tr><td>Homepage</td><td><input type="text" name="homepage" value="{{podcast.homepage}}"></td></tr>
|
||||||
|
<tr><td colspan="2"><button type="submit">Save</button></td></tr>
|
||||||
|
<tr><td>Episodes</td><td><a href="./new">Add</a></td></tr>
|
||||||
|
<tr><td colspan="2">
|
||||||
|
<table border="0">
|
||||||
|
{%- for episode in podcast.episodes %}
|
||||||
|
<tr><td>{{episode.title}}</td><td>{{episode.guid}}</td><td>{{episode.pub_date}}</td><td><a href="./edit/{{loop.index}}"></a></td></tr>
|
||||||
|
{%- endfor %}
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
templates/feed.xml
Normal file
24
templates/feed.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss>
|
||||||
|
<channel>
|
||||||
|
<title>{{podcast.title}}</title>
|
||||||
|
<link>{{podcast.homepage}}</link>
|
||||||
|
{%- match podcast.logo_url %}
|
||||||
|
{%- when Some with (logo_url) %}
|
||||||
|
<image>
|
||||||
|
<title>{{podcast.title}} Logo</title>
|
||||||
|
<url>{{logo_url}}</url>
|
||||||
|
</image>
|
||||||
|
{%- when None %}
|
||||||
|
{%- endmatch %}
|
||||||
|
|
||||||
|
{%- for episode in podcast.episodes %}
|
||||||
|
<item>
|
||||||
|
<title>{{episode.title}}</title>
|
||||||
|
<enclosure url="{{episode.video_url}}" type="video/mpeg"/>
|
||||||
|
<guid>{{episode.guid}}</guid>
|
||||||
|
<pubDate>{{episode.pub_date.to_rfc2822()}}</pubDate>
|
||||||
|
</item>
|
||||||
|
{%- endfor %}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
Reference in New Issue
Block a user