commit
d19a86ffb8
13 changed files with 554 additions and 0 deletions
@ -0,0 +1,34 @@
|
||||
#!/bin/bash |
||||
|
||||
export FLASK_APP=video_metadata |
||||
export FLASK_ENV=development |
||||
|
||||
|
||||
show_help() { |
||||
echo "Usage: $0 [OPTION]" |
||||
echo "Without options, run the flask builtin server." |
||||
echo "" |
||||
echo "-h, --help display this help and exit" |
||||
echo " --initdb (re-)initialize the the local sqlite DB from schema" |
||||
echo " --shell enter interactive python session with flask context" |
||||
echo "" |
||||
} |
||||
|
||||
case $1 in |
||||
-h|--help) |
||||
show_help |
||||
;; |
||||
--initdb) |
||||
flask init-db |
||||
;; |
||||
--shell) |
||||
flask shell |
||||
;; |
||||
"") |
||||
flask run |
||||
;; |
||||
*) |
||||
show_help |
||||
;; |
||||
esac |
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import json |
||||
import os |
||||
|
||||
from flask import Flask, render_template |
||||
|
||||
|
||||
def create_app(): |
||||
# create and configure the app |
||||
app = Flask(__name__, instance_relative_config=True) |
||||
app.config.from_mapping( |
||||
SECRET_KEY = 'dev', |
||||
DATABASE = os.path.join(app.instance_path, 'video_metadata.sqlite'), |
||||
MOVIE_DIR = os.path.join(app.instance_path, 'movie_dir'), |
||||
TMDB_API_KEY = parse_API_key(app, os.path.join(app.instance_path, 'API_KEY.json')), |
||||
) |
||||
|
||||
# ensure the instance folder exists |
||||
try: |
||||
os.makedirs(app.instance_path) |
||||
except OSError: |
||||
pass |
||||
|
||||
from . import db |
||||
db.init_app(app) |
||||
|
||||
from video_metadata.views import file_listing |
||||
app.register_blueprint(file_listing.bp) |
||||
|
||||
from video_metadata.views import tmdb |
||||
app.register_blueprint(tmdb.bp) |
||||
|
||||
app.add_url_rule('/', 'index', index) |
||||
|
||||
return app |
||||
|
||||
|
||||
def index(): |
||||
return render_template('index.html') |
||||
|
||||
def parse_API_key(app, path): |
||||
try: |
||||
fd = open(path) |
||||
except OSError as e: |
||||
app.logger.error('error opening path: %s, %s', path, e.strerror) |
||||
return '' |
||||
|
||||
j = json.load(fd) |
||||
fd.close() |
||||
|
||||
return j['key'] |
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import sqlite3 |
||||
|
||||
import click |
||||
from flask import current_app, g |
||||
from flask.cli import with_appcontext |
||||
|
||||
|
||||
def get_db(): |
||||
if 'db' not in g: |
||||
g.db = sqlite3.connect( |
||||
current_app.config['DATABASE'], |
||||
detect_types=sqlite3.PARSE_DECLTYPES |
||||
) |
||||
g.db.row_factory = sqlite3.Row |
||||
|
||||
return g.db |
||||
|
||||
def close_db(e=None): |
||||
db = g.pop('db', None) |
||||
|
||||
if db is not None: |
||||
db.close() |
||||
|
||||
def init_app(app): |
||||
app.teardown_appcontext(close_db) |
||||
app.cli.add_command(init_db_command) |
||||
|
||||
def init_db(): |
||||
db = get_db() |
||||
|
||||
with current_app.open_resource('schema.sql') as f: |
||||
db.executescript(f.read().decode('utf8')) |
||||
|
||||
@click.command('init-db') |
||||
@with_appcontext |
||||
def init_db_command(): |
||||
"""Clear the existing data and create new tables.""" |
||||
init_db() |
||||
click.echo('Initialized the database.') |
||||
|
||||
@ -0,0 +1,82 @@
|
||||
/* |
||||
* This file is part of video_metadata. |
||||
* |
||||
* video_metadata is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU General Public License as published by |
||||
* the Free Software Foundation, either version 3 of the License, or |
||||
* (at your option) any later version. |
||||
* |
||||
* video_metadata is distributed in the hope that it will be useful, |
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
* GNU General Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License |
||||
* along with video_metadata. If not, see <https://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
|
||||
DROP TABLE IF EXISTS movie_queries; |
||||
CREATE TABLE movie_queries( |
||||
query_id integer primary key autoincrement, |
||||
query text, |
||||
year integer |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS movie_details; |
||||
CREATE TABLE movie_details( |
||||
movie_id primary key not null, |
||||
title text, |
||||
overview text, |
||||
release_date date, |
||||
poster_path text |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS movie_query_mapping; |
||||
CREATE TABLE movie_query_mapping( |
||||
query text, |
||||
query_id int, |
||||
movie_id int, |
||||
foreign key(query_id) references movie_queries(query_id), |
||||
foreign key(movie_id) references movie_details(movie_id) |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS movie_genres; |
||||
CREATE TABLE movie_genres( |
||||
genre_id integer primary key not null, |
||||
genre_name |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS movie_genre_mapping; |
||||
CREATE TABLE movie_genre_mapping( |
||||
genre_id integer, |
||||
movie_id integer, |
||||
foreign key(genre_id) references movie_genres(genre_id), |
||||
foreign key(movie_id) references movie_details(movie_id) |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS actor_details; |
||||
CREATE TABLE actor_details( |
||||
actor_id integer primary key not null, |
||||
name text, |
||||
date_of_birth date, |
||||
poster_path text |
||||
); |
||||
|
||||
DROP TABLE IF EXISTS actor_movie_mapping; |
||||
CREATE TABLE actor_movie_mapping( |
||||
movie_id integer, |
||||
actor_id integer, |
||||
character_name text, |
||||
foreign key(movie_id) references movie_details(movie_id), |
||||
foreign key(actor_id) references actor_details(actor_id) |
||||
); |
||||
|
||||
|
||||
-- TODO: tv shows |
||||
|
||||
DROP TABLE IF EXISTS tv_queries; |
||||
CREATE TABLE tv_queries( |
||||
query text, |
||||
year integer |
||||
); |
||||
@ -0,0 +1,28 @@
|
||||
html { font-family: sans-serif; background: #eee; padding: 1rem; } |
||||
body { max-width: 960px; margin: 0 auto; background: white; } |
||||
h1 { font-family: serif; color: #377ba8; margin: 1rem 0; } |
||||
a { color: #377ba8; } |
||||
hr { border: none; border-top: 1px solid lightgray; } |
||||
nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; } |
||||
nav h1 { flex: auto; margin: 0; } |
||||
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; } |
||||
nav ul { display: flex; list-style: none; margin: 0; padding: 0; } |
||||
nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; } |
||||
.content { padding: 0 1rem 1rem; } |
||||
.content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; } |
||||
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } |
||||
.flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; } |
||||
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; } |
||||
.post > header > div:first-of-type { flex: auto; } |
||||
.post > header h1 { font-size: 1.5em; margin-bottom: 0; } |
||||
.post .about { color: slategray; font-style: italic; } |
||||
.post .body { white-space: pre-line; } |
||||
.content:last-child { margin-bottom: 0; } |
||||
.content form { margin: 1em 0; display: flex; flex-direction: column; } |
||||
.content label { font-weight: bold; margin-bottom: 0.5em; } |
||||
.content input, .content textarea { margin-bottom: 1em; } |
||||
.content textarea { min-height: 12em; resize: vertical; } |
||||
input { color: #333; background-color: #eee; } |
||||
input.danger { color: #cc2f2e; } |
||||
input[type=submit] { align-self: start; min-width: 10em; } |
||||
.debug { background: lightgrey; padding: 1em; overflow: auto; } |
||||
@ -0,0 +1,22 @@
|
||||
<!doctype html> |
||||
<title>{% block title %}{% endblock %} - video_metadata</title> |
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> |
||||
<nav> |
||||
<h1>video_metadata</h1> |
||||
<ul> |
||||
<li><a href="{{ url_for('index') }}">Home</a> |
||||
<li><a href="{{ url_for('file_listing.index') }}">File Listing</a> |
||||
<li><a href="{{ url_for('tmdb.query_movie') }}">Query</a> |
||||
</ul> |
||||
</nav> |
||||
<section class="content"> |
||||
<header> |
||||
{% block header %}{% endblock %} |
||||
</header> |
||||
|
||||
{% for message in get_flashed_messages() %} |
||||
<div class="flash">{{ message }}</div> |
||||
{% endfor %} |
||||
|
||||
{% block content %}{% endblock %} |
||||
</section> |
||||
@ -0,0 +1,13 @@
|
||||
{% extends 'base.html' %} |
||||
|
||||
{% block header %} |
||||
<h1>{% block title %}File Listing{% endblock %}</h1> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
<ul> |
||||
{% for file in filenames %} |
||||
<li><span>! </span><a href="{{ url_for('tmdb.query_movie') }}?query={{ file|urlencode() }}">{{ file }}</a></li> |
||||
{% endfor %} |
||||
</ul> |
||||
{% endblock %} |
||||
@ -0,0 +1,8 @@
|
||||
{% extends 'base.html' %} |
||||
|
||||
{% block header %} |
||||
<h1>{% block title %}INDEX PAGE!{% endblock %}</h1> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
{% endblock %} |
||||
@ -0,0 +1,35 @@
|
||||
{% extends 'base.html' %} |
||||
|
||||
{% block header %} |
||||
<h1>{% block title %}TMDB Query{% endblock %}</h1> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
{% if view_data.raw_query_str == '' %} |
||||
<form method="get"> |
||||
<label for="query">Search for movie</label> |
||||
<input name="query" id="query"> |
||||
<input type="submit" value="Search"> |
||||
</form> |
||||
{% endif %} |
||||
|
||||
<div id="search_results"> |
||||
<h2>Query Results</h2> |
||||
{% for r in view_data.response_object.results %} |
||||
<div> |
||||
<h3>{{ r.original_title }}</h3> |
||||
<ul> |
||||
<li>Release Date: {{ r.release_date }}</li> |
||||
<li>Description: |
||||
<p>{{ r.overview }}</p> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
<div class="debug"> |
||||
<h3>Debug Info</h3> |
||||
<pre>{{ debug_str }}</pre> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,60 @@
|
||||
|
||||
# NOTE: modified from here: https://code.activestate.com/recipes/137951-dump-all-the-attributes-of-an-object/ |
||||
# to only print attributes |
||||
def dumpObjAttr(obj): |
||||
import types |
||||
|
||||
MethodWrapperType = type(object().__hash__) |
||||
objclass = getattr(obj, '__class__') |
||||
attrs = [] |
||||
|
||||
for slot in dir(obj): |
||||
attr = getattr(obj, slot) |
||||
if slot == '__class__': |
||||
objclass = attr.__name__ |
||||
elif slot == '__doc__': |
||||
pass |
||||
elif slot == '__module__': |
||||
pass |
||||
elif slot == '__dict__': |
||||
pass |
||||
elif slot == '__weakref__': |
||||
pass |
||||
elif (isinstance(attr, types.BuiltinMethodType) or |
||||
isinstance(attr, MethodWrapperType)): |
||||
pass |
||||
elif (isinstance(attr, types.MethodType) or |
||||
isinstance(attr, types.FunctionType)): |
||||
pass |
||||
else: |
||||
attrs.append( (slot, attr) ) |
||||
|
||||
sOut = objclass + ':' |
||||
for key, value in attrs: |
||||
# NOTE: pretty print for long lists |
||||
if isinstance(value, list) or isinstance(value, tuple): |
||||
sOut += '\n\t' + key + ':' |
||||
sOut += dumpList(value, 2) |
||||
else: |
||||
sOut += '\n\t' + key + ': ' + str(value) |
||||
|
||||
return sOut |
||||
|
||||
def dumpList(value, indent): |
||||
s = '' |
||||
if len(value) > 0: |
||||
if isinstance(value[0], tuple): |
||||
for k, v in value: |
||||
s += '\n' + (indent * '\t') + k + ': ' + str(v) |
||||
elif isinstance(value, list): |
||||
for item in value: |
||||
s += '\n' + (indent * '\t') + str(item) |
||||
else: |
||||
pass |
||||
|
||||
return s |
||||
|
||||
def getFunctionName(): |
||||
import sys |
||||
return sys._getframe(1).f_code.co_name |
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import os |
||||
|
||||
from flask import Blueprint, current_app, render_template |
||||
|
||||
|
||||
bp = Blueprint('file_listing', __name__) |
||||
|
||||
@bp.route('/file_listing') |
||||
def index(): |
||||
video_path = current_app.config['MOVIE_DIR'] |
||||
filenames = os.listdir(video_path) |
||||
return render_template('file_listing/index.html', filenames=filenames) |
||||
@ -0,0 +1,167 @@
|
||||
|
||||
import cgi |
||||
from contextlib import closing |
||||
from datetime import date |
||||
from enum import Enum |
||||
import json |
||||
from pprint import pprint |
||||
import re |
||||
import ssl |
||||
from urllib.error import URLError, HTTPError |
||||
import urllib.parse |
||||
import urllib.request |
||||
|
||||
from flask import Blueprint, current_app, flash, request, render_template |
||||
|
||||
from .. import util |
||||
|
||||
|
||||
#################### |
||||
# constants |
||||
|
||||
bp = Blueprint('tmdb', __name__, url_prefix='/tmdb') |
||||
TMDB_URL = 'https://api.themoviedb.org' |
||||
TMDB_SEARCH_MOVIE = '/3/search/movie' |
||||
TMDB_SEARCH_TV = '/3/search/tv' |
||||
|
||||
|
||||
#################### |
||||
# data structures |
||||
|
||||
class query_type(Enum): |
||||
UNKNOWN = 0 |
||||
MOVIE = 1 |
||||
TV_SHOW = 2 |
||||
|
||||
class query_data(object): |
||||
def __init__(self): |
||||
self.errors = [] |
||||
self.full_url = '' |
||||
self.movie_year = 0 |
||||
self.parsed_query_str = '' |
||||
self.query_params = {} |
||||
self.query_type = query_type.UNKNOWN |
||||
self.raw_query_str = '' |
||||
self.response_body = '' |
||||
self.response_headers = [] |
||||
self.response_object = {} |
||||
self.response_status = 0 |
||||
self.tmdb_api_key = '' |
||||
|
||||
|
||||
#################### |
||||
# flask routes |
||||
|
||||
@bp.route('/query_movie') |
||||
def query_movie(): |
||||
view_data = query_data() |
||||
view_data.tmdb_api_key = current_app.config['TMDB_API_KEY'] |
||||
|
||||
# NOTE: query string can be submitted from a link or form input |
||||
if 'query' in request.form: |
||||
view_data.raw_query_str = request.form['query'] |
||||
elif 'query' in request.args: |
||||
view_data.raw_query_str = request.args['query'] |
||||
|
||||
if parseQueryStr(view_data) and makeQuery(view_data): |
||||
try: |
||||
p = view_data.response_object = json.loads(view_data.response_body) |
||||
pprint(p) |
||||
except json.JSONDecodeError as e: |
||||
view_data.errors.append(e) |
||||
else: |
||||
# display errors |
||||
pass |
||||
|
||||
if view_data.errors: |
||||
flash(view_data.errors) |
||||
|
||||
return render_template( |
||||
'tmdb/query.html', |
||||
view_data=view_data, |
||||
debug_str=util.dumpObjAttr(view_data) |
||||
) |
||||
|
||||
|
||||
################### |
||||
# helpers |
||||
|
||||
# TODO: need a way to correctly parse titles like: "2001: A Space Odyssey (1968)" |
||||
# with legitimate dates in the title |
||||
def parseQueryStr(query_obj): |
||||
if not query_obj.raw_query_str: |
||||
query_obj.errors.append('%s: raw query string is empty' % util.getFunctionName()) |
||||
return False |
||||
|
||||
s = query_obj.raw_query_str.replace('.', ' ').strip() |
||||
year = None |
||||
current_year = date.today().year |
||||
|
||||
# NOTE: find all 4 digit numbers to match the year of queries with 4 digit numbers in the title |
||||
# ex) "2036.Origin.Unknown.2018" |
||||
regex = re.compile('[0-9]{4}') |
||||
|
||||
for m in regex.finditer(s): |
||||
i = int(m[0]) |
||||
if i > 1878 and i <= current_year: |
||||
query_obj.parsed_query_str = s[0:m.start()].strip() # NOTE: slice query string to year match |
||||
query_obj.movie_year = i |
||||
query_obj.query_type = query_type.MOVIE |
||||
break |
||||
|
||||
# TODO: parse tv show titles |
||||
if query_obj.query_type == query_type.MOVIE: |
||||
return True |
||||
else: |
||||
query_obj.errors.append('%s: unable to determine query type' % util.getFunctionName()) |
||||
return False |
||||
|
||||
def makeQuery(query_obj): |
||||
if query_obj.query_type == query_type.MOVIE: |
||||
search_part = TMDB_SEARCH_MOVIE |
||||
elif query_obj.query_type == query_type.TV_SHOW: |
||||
search_part = TMDB_SEARCH_TV |
||||
else: |
||||
query_obj.errors.append('%s(), query type is not set' % util.getFunctionName()) |
||||
return False |
||||
|
||||
request_headers = {"Accept": "text/html,application/xhtml+xml,application/xml"} |
||||
query_obj.query_params = urllib.parse.urlencode( |
||||
{ |
||||
'api_key': query_obj.tmdb_api_key, |
||||
'query': query_obj.parsed_query_str, |
||||
'year': query_obj.movie_year, |
||||
} |
||||
) |
||||
|
||||
query_obj.full_url = TMDB_URL + search_part + '?' + query_obj.query_params |
||||
context = ssl.create_default_context() |
||||
req = urllib.request.Request(query_obj.full_url, None, request_headers) |
||||
ct_header = '' |
||||
|
||||
try: |
||||
response = urllib.request.urlopen(req, context=context) |
||||
except HTTPError as e: |
||||
query_obj.errors.append(e) |
||||
return False |
||||
except URLError as e: |
||||
query_obj.errors.append(e) |
||||
return False |
||||
|
||||
for k, v in response.info().items(): |
||||
query_obj.response_headers.append((k, v)) |
||||
if k == 'Content-Type': |
||||
ct_header = str(k + ':' + v) |
||||
|
||||
query_obj.response_status = response.status |
||||
|
||||
if 'charset' in ct_header: |
||||
charset = cgi.parse_header(ct_header)[1]['charset'] |
||||
query_obj.response_body = response.read().decode(charset) |
||||
else: |
||||
query_obj.errors.append('%s(), no charset key in ct_header' % util.getFunctionName()) |
||||
query_obj.response_body = response.read() |
||||
return False |
||||
|
||||
return True |
||||
|
||||
Loading…
Reference in new issue