From d19a86ffb85a3fa478fcda2a2cd0c562fcdd90fd Mon Sep 17 00:00:00 2001 From: cinnaboot Date: Sun, 21 Jul 2019 02:12:37 -0400 Subject: [PATCH] Initial commit --- .gitignore | 2 + run.sh | 34 ++++ video_metadata/__init__.py | 51 ++++++ video_metadata/db.py | 40 +++++ video_metadata/schema.sql | 82 +++++++++ video_metadata/static/style.css | 28 +++ video_metadata/templates/base.html | 22 +++ .../templates/file_listing/index.html | 13 ++ video_metadata/templates/index.html | 8 + video_metadata/templates/tmdb/query.html | 35 ++++ video_metadata/util.py | 60 +++++++ video_metadata/views/file_listing.py | 12 ++ video_metadata/views/tmdb.py | 167 ++++++++++++++++++ 13 files changed, 554 insertions(+) create mode 100644 .gitignore create mode 100755 run.sh create mode 100644 video_metadata/__init__.py create mode 100644 video_metadata/db.py create mode 100644 video_metadata/schema.sql create mode 100644 video_metadata/static/style.css create mode 100644 video_metadata/templates/base.html create mode 100644 video_metadata/templates/file_listing/index.html create mode 100644 video_metadata/templates/index.html create mode 100644 video_metadata/templates/tmdb/query.html create mode 100644 video_metadata/util.py create mode 100644 video_metadata/views/file_listing.py create mode 100644 video_metadata/views/tmdb.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4af3ae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +instance/* +__pycache__/ diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..62fe9c6 --- /dev/null +++ b/run.sh @@ -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 + diff --git a/video_metadata/__init__.py b/video_metadata/__init__.py new file mode 100644 index 0000000..9998e45 --- /dev/null +++ b/video_metadata/__init__.py @@ -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'] + diff --git a/video_metadata/db.py b/video_metadata/db.py new file mode 100644 index 0000000..d32987e --- /dev/null +++ b/video_metadata/db.py @@ -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.') + diff --git a/video_metadata/schema.sql b/video_metadata/schema.sql new file mode 100644 index 0000000..edc1b95 --- /dev/null +++ b/video_metadata/schema.sql @@ -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 . + */ + + +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 +); diff --git a/video_metadata/static/style.css b/video_metadata/static/style.css new file mode 100644 index 0000000..492bca9 --- /dev/null +++ b/video_metadata/static/style.css @@ -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; } diff --git a/video_metadata/templates/base.html b/video_metadata/templates/base.html new file mode 100644 index 0000000..364fa3b --- /dev/null +++ b/video_metadata/templates/base.html @@ -0,0 +1,22 @@ + +{% block title %}{% endblock %} - video_metadata + + +
+
+ {% block header %}{% endblock %} +
+ + {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + + {% block content %}{% endblock %} +
diff --git a/video_metadata/templates/file_listing/index.html b/video_metadata/templates/file_listing/index.html new file mode 100644 index 0000000..55c277c --- /dev/null +++ b/video_metadata/templates/file_listing/index.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}File Listing{% endblock %}

+{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/video_metadata/templates/index.html b/video_metadata/templates/index.html new file mode 100644 index 0000000..e8351d9 --- /dev/null +++ b/video_metadata/templates/index.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}INDEX PAGE!{% endblock %}

+{% endblock %} + +{% block content %} +{% endblock %} diff --git a/video_metadata/templates/tmdb/query.html b/video_metadata/templates/tmdb/query.html new file mode 100644 index 0000000..c053013 --- /dev/null +++ b/video_metadata/templates/tmdb/query.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}TMDB Query{% endblock %}

+{% endblock %} + +{% block content %} + {% if view_data.raw_query_str == '' %} +
+ + + +
+ {% endif %} + +
+

Query Results

+ {% for r in view_data.response_object.results %} +
+

{{ r.original_title }}

+
    +
  • Release Date: {{ r.release_date }}
  • +
  • Description: +

    {{ r.overview }}

    +
  • +
+
+ {% endfor %} +
+ +
+

Debug Info

+
{{ debug_str }}
+
+{% endblock %} diff --git a/video_metadata/util.py b/video_metadata/util.py new file mode 100644 index 0000000..a822a4d --- /dev/null +++ b/video_metadata/util.py @@ -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 + diff --git a/video_metadata/views/file_listing.py b/video_metadata/views/file_listing.py new file mode 100644 index 0000000..60c632d --- /dev/null +++ b/video_metadata/views/file_listing.py @@ -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) diff --git a/video_metadata/views/tmdb.py b/video_metadata/views/tmdb.py new file mode 100644 index 0000000..a64df50 --- /dev/null +++ b/video_metadata/views/tmdb.py @@ -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 +