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