working webapp with basic styling
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ __pycache__
|
|||||||
.coverage
|
.coverage
|
||||||
*.egg-info
|
*.egg-info
|
||||||
sample
|
sample
|
||||||
.tox
|
.tox
|
||||||
|
*.sqlite
|
||||||
3
static/css/yoink.css
Normal file
3
static/css/yoink.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
0
static/js/yoink.js
Normal file
0
static/js/yoink.js
Normal file
14
templates/404.html
Normal file
14
templates/404.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
{{ super() }}
|
||||||
|
<!-- Add additional meta tags here -->
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}Testing{% endblock %}
|
||||||
|
|
||||||
|
{% block page_content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Opps! File not found</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
14
templates/500.html
Normal file
14
templates/500.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
{{ super() }}
|
||||||
|
<!-- Add additional meta tags here -->
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}Testing{% endblock %}
|
||||||
|
|
||||||
|
{% block page_content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Opps! Technical difficulties</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
61
templates/base.html
Normal file
61
templates/base.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{% block meta %}
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{% endblock %}
|
||||||
|
<title>{% block title %}Y!oink Web App{% endblock %}</title>
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/yoink.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js" integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13" crossorigin="anonymous"></script>
|
||||||
|
{{ moment.include_moment() }}
|
||||||
|
{% endblock %}
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('static', filename='images/comic.png') }}" type="image/x-icon">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block navbar %}
|
||||||
|
<nav class="navbar navbar-inverse">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-header">
|
||||||
|
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
||||||
|
<span class="sr-only">Toggle Navigation</span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/" class="navbar-brand">Y!oink</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-collapse collapse">
|
||||||
|
<ul class="nav navbar-nav">
|
||||||
|
<li>
|
||||||
|
<a href="/">Y!oink</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
{% for message in get_flashed_messages() %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
{{message}}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% block page_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/yoink.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
templates/index.html
Normal file
31
templates/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import 'macros/library.html' as library %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
{{ super() }}
|
||||||
|
<!-- Add additional meta tags here -->
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}Testing{% endblock %}
|
||||||
|
|
||||||
|
{% block page_content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Y!oink Web App</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="download.disabled = true; url.readOnly = true; return true;">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
{{ form.series.label(class_='input-group-text') }}
|
||||||
|
<div class="input-group-text">
|
||||||
|
{{ form.series() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form.url(class_='form-control') }}
|
||||||
|
{{ form.download(class_='btn btn-outline-primary') }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ library.latest_downloads(latest) }}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
3
templates/macros/comments.html
Normal file
3
templates/macros/comments.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{% macro render_comment(comment) %}
|
||||||
|
<li>{{ comment }}</li>
|
||||||
|
{% endmacro %}
|
||||||
38
templates/macros/library.html
Normal file
38
templates/macros/library.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!-- latest downloads - limit to a max of 6 -->
|
||||||
|
{% macro latest_downloads(comics) %}
|
||||||
|
{% if comics %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Latest Downloads</h2>
|
||||||
|
{% for comic in comics %}
|
||||||
|
<div class="col col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<img src="{{ url_for('download_file', filename=comic['cover']) }}" alt="{{ comic['title'] }}" class="card-image-top position-relative">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title">{{ comic['title'] }}</h4>
|
||||||
|
<p class="card-text">{{ comic['title'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href="{{url_for('download_file', filename=comic.archive) }}" class="btn btn-info">Download Archive</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>Empty Comic Library</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<!-- all downloaded comics - TODO: pagination -->
|
||||||
|
{% macro all_downloads(comics) %}
|
||||||
|
{% if comics %}
|
||||||
|
{% for comic in comics %}
|
||||||
|
|
||||||
|
<h3>{{ comic['title'] }}</h3>
|
||||||
|
<img src="{{ url_for('download_file', filename=comic['cover']) }}">
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>Empty Comic Library</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
141
web.py
Normal file
141
web.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from flask import Flask, render_template, url_for, request, flash, make_response, redirect, send_from_directory
|
||||||
|
from flask_moment import Moment
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, SubmitField, BooleanField
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
from yoink.config import config
|
||||||
|
from yoink.comic import Comic
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadForm(FlaskForm):
|
||||||
|
url = StringField('Comic URL', validators=[DataRequired()])
|
||||||
|
series = BooleanField('Series? ')
|
||||||
|
download = SubmitField('Download')
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
moment = Moment(app)
|
||||||
|
app.config['SECRET_KEY'] = 'snapekilleddumpledork'
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.app_root, "data.sqlite")}'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Role(db.Model):
|
||||||
|
__tablename__ = 'roles'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(64), unique=True)
|
||||||
|
users = db.relationship('User', backref='role', lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Role {self.name!r}>'
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, index=True)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
|
||||||
|
|
||||||
|
def __repr__(self): return f'<User {self.username!r}>'
|
||||||
|
|
||||||
|
class ComicMeta(db.Model):
|
||||||
|
__tablename__ = 'comicmeta'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(256), unique=True, index=True)
|
||||||
|
issue = db.Column(db.Integer, nullable=True, index=True)
|
||||||
|
category = db.Column(db.String(128), index=True, nullable=True)
|
||||||
|
previous_issue = db.Column(db.String(256), nullable=True)
|
||||||
|
next_issue = db.Column(db.String(256), nullable=True)
|
||||||
|
cover_path = db.Column(db.String(256))
|
||||||
|
archive_path = db.Column(db.String(256))
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return render_template('404.html'), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def server_error(e):
|
||||||
|
return render_template('500.html'), 500
|
||||||
|
|
||||||
|
@app.route('/uploads/<path:filename>')
|
||||||
|
def download_file(filename):
|
||||||
|
return send_from_directory(os.path.join(config.library_path, 'comics'), filename, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cover_path(comic):
|
||||||
|
return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('000.jpg')][0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_archive_path(comic):
|
||||||
|
return [image for image in os.listdir(os.path.join(config.library_path, 'comics', comic.title)) if image.endswith('.cbr')][0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_comic_library_meta():
|
||||||
|
comic_meta = []
|
||||||
|
|
||||||
|
for comic in ComicMeta.query.all():
|
||||||
|
comic_meta.append({
|
||||||
|
'cover': comic.cover_path,
|
||||||
|
'title': comic.title,
|
||||||
|
'archive': comic.archive_path
|
||||||
|
})
|
||||||
|
|
||||||
|
return comic_meta
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/', methods=['post','get'])
|
||||||
|
def index():
|
||||||
|
url = None
|
||||||
|
series = False
|
||||||
|
form = DownloadForm()
|
||||||
|
latest = get_comic_library_meta()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
url = form.url.data.strip()
|
||||||
|
series = form.series.data
|
||||||
|
|
||||||
|
comic = Comic(url)
|
||||||
|
comic_meta = ComicMeta.query.filter_by(title=comic.title).first()
|
||||||
|
|
||||||
|
comic.archiver.download()
|
||||||
|
comic.archiver.generate_archive()
|
||||||
|
|
||||||
|
|
||||||
|
if comic_meta is None:
|
||||||
|
comic_meta = ComicMeta()
|
||||||
|
comic_meta.title = comic.title
|
||||||
|
comic_meta.category = comic.category
|
||||||
|
comic_meta.issue = comic.issue_number
|
||||||
|
comic_meta.next_issue = comic.next
|
||||||
|
comic_meta.previous_issue = comic.prev
|
||||||
|
comic_meta.cover_path = os.path.join(comic.title, get_cover_path(comic))
|
||||||
|
comic_meta.archive_path = os.path.join(comic.title, get_archive_path(comic))
|
||||||
|
|
||||||
|
db.session.add(comic_meta)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
flash(f'Comic {comic.title} exists')
|
||||||
|
|
||||||
|
latest = get_comic_library_meta()
|
||||||
|
form.url.data = ''
|
||||||
|
|
||||||
|
return render_template('index.html', form=form, url=url, series=series, latest=latest), 200
|
||||||
|
|
||||||
|
|
||||||
|
if form.series.data:
|
||||||
|
print('Download the whole damn lot')
|
||||||
|
|
||||||
|
flash(f'{comic.title} downloaded to {os.path.join(config.library_path, "comics/" + comic.title)}')
|
||||||
|
|
||||||
|
latest = get_comic_library_meta()
|
||||||
|
comic.archiver.cleanup_worktree()
|
||||||
|
form.url.data = ''
|
||||||
|
|
||||||
|
return render_template('index.html', form=form, url=url, series=series, latest=latest), 200
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ class Comic(Scrapable):
|
|||||||
|
|
||||||
if len(comics) > 0:
|
if len(comics) > 0:
|
||||||
return comics
|
return comics
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filelist(self) -> list:
|
def filelist(self) -> list:
|
||||||
comics = self.__parse_soup()
|
comics = self.__parse_soup()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class BasicTestCase(unittest.TestCase):
|
|||||||
self.test_comic = 'http://readallcomics.com/static-season-one-6-2022/'
|
self.test_comic = 'http://readallcomics.com/static-season-one-6-2022/'
|
||||||
self.test_comic_b = 'http://readallcomics.com/captain-marvel-vs-rogue-2021-part-1/'
|
self.test_comic_b = 'http://readallcomics.com/captain-marvel-vs-rogue-2021-part-1/'
|
||||||
self.comic = Comic(self.test_comic)
|
self.comic = Comic(self.test_comic)
|
||||||
self.archiver = ComicArchiver(self.comic)
|
|
||||||
self.remove_queue = []
|
self.remove_queue = []
|
||||||
self.expected_title = 'Static Season One 6 (2022)'
|
self.expected_title = 'Static Season One 6 (2022)'
|
||||||
self.expected_title_b = 'Captain Marvel vs. Rogue (2021 – Part 1)'
|
self.expected_title_b = 'Captain Marvel vs. Rogue (2021 – Part 1)'
|
||||||
@@ -46,16 +45,16 @@ class BasicTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(len(os.listdir(os.path.join(library_path, 'comics'))), 0)
|
self.assertEqual(len(os.listdir(os.path.join(library_path, 'comics'))), 0)
|
||||||
|
|
||||||
def test_004_comic_folder_created_and_populated(self):
|
def test_004_comic_folder_created_and_populated(self):
|
||||||
self.archiver.download()
|
self.comic.archiver.download()
|
||||||
self.assertTrue(os.path.exists(os.path.join(library_path, f'comics/{self.comic.title}')))
|
self.assertTrue(os.path.exists(os.path.join(library_path, f'comics/{self.comic.title}')))
|
||||||
self.assertGreater(len(os.listdir(os.path.join(library_path, f'comics/{self.comic.title}'))), 0)
|
self.assertGreater(len(os.listdir(os.path.join(library_path, f'comics/{self.comic.title}'))), 0)
|
||||||
|
|
||||||
def test_005_comic_archive_generated(self):
|
def test_005_comic_archive_generated(self):
|
||||||
self.archiver.generate_archive()
|
self.comic.archiver.generate_archive()
|
||||||
self.assertTrue(os.path.exists(os.path.join(library_path, f'comics/{self.comic.title}/{self.comic.title}.cbr')))
|
self.assertTrue(os.path.exists(os.path.join(library_path, f'comics/{self.comic.title}/{self.comic.title}.cbr')))
|
||||||
|
|
||||||
def test_006_folder_cleaned_after_archive_generation(self):
|
def test_006_folder_cleaned_after_archive_generation(self):
|
||||||
self.archiver.cleanup_worktree()
|
self.comic.archiver.cleanup_worktree()
|
||||||
self.assertLessEqual(len(os.listdir(os.path.join(library_path, f'comics/{self.comic.title}'))), 3)
|
self.assertLessEqual(len(os.listdir(os.path.join(library_path, f'comics/{self.comic.title}'))), 3)
|
||||||
|
|
||||||
def test_007_comic_instance_has_archiver(self):
|
def test_007_comic_instance_has_archiver(self):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{
|
{
|
||||||
"name": "readallcomics",
|
"name": "readallcomics",
|
||||||
"url": "http://readallcomics.com",
|
"url": "http://readallcomics.com",
|
||||||
"search": {
|
"filters": {
|
||||||
"default": {
|
"default": {
|
||||||
"element": "div",
|
"element": "div",
|
||||||
"class": "separator",
|
"class": "separator",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dragonballsupermanga",
|
"name": "dragonballsupermanga",
|
||||||
"url": "https://www.dragonballsupermanga.net",
|
"url": "https://www.dragonballsupermanga.net",
|
||||||
"search": {
|
"filters": {
|
||||||
"dbsuper": {
|
"dbsuper": {
|
||||||
"element": "meta",
|
"element": "meta",
|
||||||
"class": null,
|
"class": null,
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mangadex",
|
"name": "mangadex",
|
||||||
"url": "https://www.mangadex.tv",
|
"url": "https://www.mangadex.tv",
|
||||||
"search": {
|
"filters": {
|
||||||
"mangadex": {
|
"mangadex": {
|
||||||
"element": "img",
|
"element": "img",
|
||||||
"class": null,
|
"class": null,
|
||||||
|
|||||||
Reference in New Issue
Block a user