You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
550 lines
17 KiB
550 lines
17 KiB
|
|
import cgi |
|
from dataclasses import dataclass, field |
|
from datetime import date, datetime |
|
from enum import IntEnum |
|
import json |
|
import logging |
|
import os |
|
from pathlib import Path |
|
import re |
|
from sqlite3 import DatabaseError |
|
import ssl |
|
import tmdbv3api |
|
from typing import Any, Dict, List |
|
|
|
from flask import Blueprint, current_app, flash, Flask, jsonify, make_response, request, \ |
|
redirect, render_template, Response, url_for |
|
from guessit import guessit |
|
|
|
from .. import util |
|
from .. import db |
|
from . import tv_shows |
|
|
|
|
|
#################### |
|
# constants |
|
|
|
TMDB_MOVIE_LINK_URL = 'https://www.themoviedb.org/movie/' |
|
TMDB_TV_LINK_URL = 'https://www.themoviedb.org/tv/' |
|
TMDB_POSTER_PREFIX = 'https://image.tmdb.org/t/p/w154' |
|
VALID_IMAGE_EXTENSIONS = ('.jpg', '.png', '.svg') |
|
DEFAULT_POSTER_IMAGE = 'no_poster_w154.jpg' |
|
LOCAL_POSTER_PATH = 'image_cache' |
|
|
|
|
|
#################### |
|
# globals |
|
|
|
bp = Blueprint('tmdb', __name__, url_prefix='/tmdb') |
|
|
|
|
|
#################### |
|
# data structures |
|
|
|
class media_type(IntEnum): |
|
UNKNOWN = 0 |
|
MOVIE = 1 |
|
TV_SHOW = 2 |
|
|
|
@dataclass |
|
class query_data: |
|
errors: List[str] = field(default_factory=list) |
|
is_valid:bool = False |
|
raw_query_str:str = '' |
|
parsed_query_str:str = '' |
|
title:str = '' |
|
media_type:media_type = media_type.UNKNOWN |
|
rel_path:str = '' |
|
query_params:str = '' |
|
full_url:str = '' |
|
movie_year:int = 0 |
|
tv_season:str = '' |
|
tv_episode:str = '' |
|
|
|
@dataclass |
|
class view_result: |
|
detail_id:int = 0 |
|
media_type:media_type = media_type.UNKNOWN |
|
movie_id:int = 0 |
|
tv_id:int = 0 |
|
title:str = '' |
|
release_date:str = '' |
|
overview:str = '' |
|
genre_ids:List[int] = field(default_factory=list) |
|
original_language:str = '' |
|
poster_path:str = '' |
|
tmdb_link:str = '' |
|
|
|
# TODO: flask 1.1 adds support for JSON encoding dataclasses |
|
# https://palletsprojects.com/blog/flask-1-1-released/ |
|
# add flask 1.1 to requirements when alpine distro updates flask |
|
# NOTE: haven't found a better way to json encode dataclasses :( |
|
class result_encoder(json.JSONEncoder): |
|
def default(self, obj): |
|
if isinstance(obj, view_result): |
|
return obj.__dict__ |
|
if isinstance(obj, date): |
|
return obj.isoformat() |
|
if isinstance(obj, datetime): |
|
return obj.isoformat() |
|
|
|
try: |
|
ret = json.JSONEncoder.default(self, obj) |
|
except TypeError: |
|
return '' |
|
return ret |
|
|
|
|
|
#################### |
|
# flask routes |
|
|
|
@bp.route('/list_movie_test') |
|
@bp.route('/list_movie_test/<string:sorting>') |
|
def list_movie_test(sorting = 'title'): |
|
if sorting == 'add-date': |
|
orderby = 'pc.add_date' |
|
elif sorting == 'add-date-desc': |
|
orderby = 'pc.add_date DESC' |
|
elif sorting == 'release-date': |
|
orderby = 'md.release_date' |
|
elif sorting == 'release-date-desc': |
|
orderby = 'md.release_date DESC' |
|
else: |
|
orderby = 'md.title' |
|
# TODO: order by genres, unwatched folder... |
|
|
|
conn = db.get_db() |
|
cur = conn.cursor() |
|
view_results = [] |
|
query = f'''SELECT |
|
md.*, pc.path |
|
FROM |
|
query_details md |
|
JOIN path_cache pc ON pc.detail_id=md.detail_id |
|
ORDER BY |
|
{orderby}''' |
|
|
|
# TODO: should probably loop on movie_id, and add a list of paths because |
|
# there can be multiple paths associated with a movie_id |
|
for row in cur.execute(query): |
|
vr = buildViewResult(row) |
|
vr.rel_path = row['path'] |
|
view_results.append(vr) |
|
|
|
return render_template('tmdb/list_movie_test.html', |
|
view_results = view_results, |
|
url_prefix = current_app.config['VIDEO_URL_PREFIX'] |
|
) |
|
|
|
|
|
@bp.route('/manual_query', methods=['GET', 'POST']) |
|
def manual_query(): |
|
query = query_data() |
|
view_results = [] |
|
errors = [] |
|
|
|
if 'query' in request.form: |
|
query.raw_query_str = request.form['query'] |
|
q_res = doQueryAlgorithm(query) |
|
errors = q_res['errors'] |
|
view_results = q_res['results'] |
|
|
|
if errors: |
|
view_results.clear() |
|
flash(errors) |
|
for e in errors: |
|
current_app.logger.error(e) |
|
|
|
return render_template( |
|
'tmdb/query.html', |
|
media_type = media_type, |
|
query = query, |
|
view_data = view_results, |
|
debug_str = f'{util.dumpObject(query)} \n {util.dumpObject(view_results)} \n' |
|
) |
|
|
|
|
|
@bp.route('/tv_details', methods=['GET', ]) |
|
def tv_details(): |
|
tv_id = None if 'tv_id' not in request.args else request.args['tv_id'] |
|
seasons = [] |
|
episodes = [] |
|
|
|
if tv_id != None: |
|
tmdb = tmdbv3api.TMDb() |
|
tmdb.api_key = current_app.config['TMDB_API_KEY'] |
|
tv = tmdbv3api.TV() |
|
deets = tv.details(tv_id, append_to_response='') |
|
|
|
# NOTE: store cast names/images |
|
# NOTE: store season info |
|
# NOTE: request episode info |
|
for season in deets.entries['seasons']: |
|
seasons.append(season) |
|
s = tmdbv3api.Season() |
|
season_deets = s.details(tv_id, season['season_number'], append_to_response='') |
|
episodes.append(season_deets.entries['episodes']) |
|
|
|
return render_template('tmdb/tv_details.html', seasons=seasons, episodes=episodes) |
|
|
|
@bp.route('/query_media_json') |
|
def query_media_json(): |
|
query = query_data() |
|
results = [] |
|
|
|
if 'query' in request.args and 'rel_path' in request.args: |
|
query.raw_query_str = request.args['query'] |
|
query.rel_path = request.args['rel_path'] |
|
q_res = doQueryAlgorithm(query) |
|
errors = q_res['errors'] |
|
results = q_res['results'] |
|
current_app.logger.debug(f'results length: {len(results)}') |
|
else: |
|
errors = ['required parameters: query, rel_path'] |
|
|
|
if errors: |
|
for e in errors: |
|
current_app.logger.error(e) |
|
return jsonify(errors=errors) |
|
|
|
res = Response(json.dumps(results, cls=result_encoder)) |
|
res.headers.set('Content-Type', 'application/json') |
|
return res |
|
|
|
|
|
@bp.route('/query_path_json') |
|
def query_path_json(): |
|
errors = [] |
|
|
|
if not 'rel_path' in request.args: |
|
errors.append(f'required parameters: rel_path') |
|
else: |
|
path = request.args['rel_path'] |
|
cur = db.get_db().cursor() |
|
|
|
q = f'''SELECT |
|
qd.*, pc.* |
|
FROM |
|
path_cache pc |
|
JOIN query_details qd ON qd.detail_id = pc.detail_id |
|
WHERE |
|
pc.path = ? |
|
LIMIT 1''' |
|
|
|
cur.execute(q, (path, )) |
|
row = cur.fetchone() |
|
current_app.logger.debug(f'path: {path}, row: {row}') |
|
|
|
if errors: |
|
for e in errors: |
|
current_app.logger.error(e) |
|
|
|
res = Response(json.dumps(buildViewResult(row), cls=result_encoder)) |
|
res.headers.set('Content-Type', 'application/json') |
|
return res |
|
|
|
|
|
@bp.route('/create_path_mapping', methods=['POST']) |
|
def create_path_mapping(): |
|
message = '' |
|
errors = [] |
|
|
|
if not ('rel_path' in request.form |
|
and 'detail_id' in request.form |
|
): |
|
errors.append(f'required parameters: rel_path, id') |
|
elif request.form['rel_path'] is '': |
|
errors.append('rel_path is empty') |
|
elif request.form['detail_id'] is '0': |
|
errors.append('detail_id is unset') |
|
else: |
|
detail_id = request.form['detail_id'] |
|
rel_path = request.form['rel_path'] |
|
conn = db.get_db() |
|
cur = conn.cursor() |
|
current_app.logger.debug(f'mapping path "{rel_path}" for detail_id: {detail_id}') |
|
try: |
|
cur.execute(f'INSERT INTO path_cache(detail_id, path, add_date) ' |
|
f'VALUES(?,?, datetime())', |
|
(detail_id, rel_path )) |
|
except DatabaseError as e: |
|
errors.append(str(e)) |
|
|
|
if len(errors) == 0: |
|
conn.commit() |
|
message = f'Successfully added mapping for path "{rel_path}"' |
|
|
|
if errors: |
|
for e in errors: |
|
current_app.logger.error(e) |
|
|
|
return jsonify({'errors':errors, 'message':message}) |
|
|
|
|
|
@bp.route('/images') |
|
@bp.route('/images/<string:image_name>') |
|
def cache_image(image_name:str = ''): |
|
# validate image name |
|
if (image_name == '' |
|
or re.search(r'[^ a-zA-z0-9./]', image_name) |
|
or not Path(image_name).suffix in VALID_IMAGE_EXTENSIONS): |
|
current_app.logger.error(f'invalid image name: {image_name}') |
|
return redirect(url_for('static', filename=DEFAULT_POSTER_IMAGE)) |
|
|
|
# look for image in cache folder |
|
local_dir = Path(current_app.instance_path).joinpath(LOCAL_POSTER_PATH) |
|
image = Path(image_name).name |
|
p = local_dir.joinpath(image) |
|
current_app.logger.debug(f'checking for local path: {p}') |
|
|
|
if not p.exists(): |
|
# make request for remote image |
|
response = util.URLResponse |
|
|
|
if util.getURL(f'{TMDB_POSTER_PREFIX}/{image}', response, current_app.logger): |
|
# store remote image |
|
current_app.logger.debug(f'content-type for {image}: {response.content_type}') |
|
try: |
|
fp = open(p, 'wb') |
|
fp.write(response.body) |
|
fp.close() |
|
except OSError as e: |
|
current_app.logger.error(e) |
|
return redirect(url_for('static', filename=DEFAULT_POSTER_IMAGE)) |
|
|
|
# redirect to saved image |
|
try: |
|
fp = open(p, 'rb') |
|
image_bytes = fp.read() |
|
except OSError as e: |
|
current_app.logger.error(e) |
|
return redirect(url_for('static', filename=DEFAULT_POSTER_IMAGE)) |
|
|
|
def getCType(s): |
|
if s is '.jpg': |
|
return 'image/jpeg' |
|
elif s is '.png': |
|
return 'image/png' |
|
elif s is '.svg': |
|
return 'image/svg+xml' |
|
|
|
return make_response(tuple([image_bytes, {'Content-Type': getCType(p.suffix)} ])) |
|
|
|
|
|
################### |
|
# helpers |
|
|
|
def doQueryAlgorithm(query:query_data) -> dict: |
|
errors, view_results = [], [] |
|
query = guessMedia(query) |
|
|
|
if query.is_valid: |
|
if isQueryCached(query.parsed_query_str, query.movie_year): |
|
view_results = makeLocalQuery(query) |
|
current_app.logger.debug(f'view_results length: {len(view_results)}') |
|
else: |
|
tmdb = tmdbv3api.TMDb() |
|
tmdb.api_key = current_app.config['TMDB_API_KEY'] |
|
res = [] |
|
|
|
if query.media_type == media_type.MOVIE: |
|
movie = tmdbv3api.Movie() |
|
res = movie.search(query.parsed_query_str) |
|
elif query.media_type == media_type.TV_SHOW: |
|
tv = tmdbv3api.TV() |
|
res = tv.search(query.parsed_query_str) |
|
|
|
# TODO: it's possible for the query results to span multiple 'pages' |
|
# see) https://developers.themoviedb.org/3/search/search-movies |
|
for r in res: |
|
view_results.append(buildViewResultFromAPI(r, query.media_type)) |
|
|
|
storeTMDBQueryResults(query, view_results) |
|
|
|
errors.extend(query.errors) |
|
|
|
return {'errors': errors, 'results': view_results} |
|
|
|
|
|
def guessMedia(query:query_data) -> query_data: |
|
# NOTE: don't log debug messages for rebulk, which are excessive |
|
logging.disable(logging.DEBUG) |
|
guess = guessit(query.raw_query_str) |
|
logging.disable(logging.NOTSET) |
|
|
|
for k, v in guess.items(): |
|
if k == 'type': |
|
if v == 'movie': query.media_type = media_type.MOVIE |
|
elif v == 'episode': query.media_type = media_type.TV_SHOW |
|
elif k == 'title': |
|
query.title = v |
|
query.parsed_query_str = v.lower() |
|
elif k == 'year': |
|
query.movie_year = v |
|
elif k == 'season': |
|
query.tv_season = v |
|
elif k == 'episode': |
|
query.tv_episode = v |
|
|
|
if query.title != '' and query.media_type != media_type.UNKNOWN: |
|
query.is_valid = True |
|
|
|
return query |
|
|
|
|
|
def isQueryCached(query_str:str, year) -> bool: |
|
conn = db.get_db() |
|
cur = conn.cursor() |
|
cur.execute('SELECT COUNT(*) FROM query_cache WHERE query=? AND year=?', (query_str, year)) |
|
count = cur.fetchone()[0] |
|
current_app.logger.debug(f'query_cache count: {count}, query: "{query_str}", year: {year}') |
|
|
|
return (count > 0) |
|
|
|
|
|
def getIDColumn(mediaType:int) -> str: |
|
if int(mediaType) == media_type.MOVIE: |
|
return 'movie_id' |
|
elif int(mediaType) == media_type.TV_SHOW: |
|
return 'tv_id' |
|
|
|
return 'unknown' |
|
|
|
|
|
# NOTE: returns list of 'view_result' objects that match the query_obj.parsed_query_str |
|
# stored in the local db cache |
|
def makeLocalQuery(query_obj): |
|
conn = db.get_db() |
|
cur = conn.cursor() |
|
results = [] |
|
id_column = getIDColumn(query_obj.media_type) |
|
|
|
query = f'''SELECT |
|
qd.* |
|
FROM |
|
query_cache qc |
|
JOIN query_mapping map ON qc.query_id = map.query_id |
|
JOIN query_details qd ON qd.detail_id = map.detail_id |
|
WHERE |
|
qc.query = ?''' |
|
|
|
for row in cur.execute(query, (query_obj.parsed_query_str, )): |
|
results.append(buildViewResult(row)) |
|
|
|
return results |
|
|
|
|
|
def buildViewResultFromAPI(api_result:dict, mediaType:int) -> view_result: |
|
vr = view_result() |
|
vr.media_type = mediaType |
|
|
|
for k, v in api_result.__dict__.items(): |
|
if k == 'overview': vr.overview = v |
|
elif k == 'genre_ids': vr.genre_ids = v |
|
elif k == 'original_language': vr.original_language = v |
|
elif k == 'poster_path': vr.poster_path = v |
|
elif k == 'media_type': vr.media_type = v |
|
|
|
elif vr.media_type == media_type.MOVIE: |
|
if k == 'title': |
|
vr.title = v |
|
elif k == 'id': |
|
vr.movie_id = v |
|
vr.tmdb_link = TMDB_MOVIE_LINK_URL + str(v) |
|
elif k == 'release_date': |
|
vr.release_date = v |
|
elif vr.media_type == media_type.TV_SHOW: |
|
if k == 'name': |
|
vr.title = v |
|
elif k == 'id': |
|
vr.tv_id = v |
|
vr.tmdb_link = TMDB_TV_LINK_URL + str(v) |
|
elif k == 'first_air_date': |
|
vr.release_date = v |
|
|
|
return vr |
|
|
|
def buildViewResult(db_row): |
|
if not db_row: |
|
current_app.logger.error('empty db_row parameter') |
|
return None |
|
|
|
vr = view_result() |
|
vr.detail_id = db_row['detail_id'] |
|
vr.media_type = db_row['media_type'] |
|
vr.title = db_row['title'] |
|
vr.overview = db_row['overview'] |
|
vr.release_date = db_row['release_date'] |
|
vr.original_language = db_row['original_language'] |
|
|
|
if vr.media_type == media_type.MOVIE: |
|
vr.movie_id = db_row['movie_id'] |
|
vr.tmdb_link = f'{TMDB_MOVIE_LINK_URL}{vr.movie_id}' |
|
elif vr.media_type == media_type.TV_SHOW: |
|
vr.tv_id = db_row['tv_id'] |
|
vr.tmdb_link = f'{TMDB_TV_LINK_URL}{vr.tv_id}' |
|
|
|
if db_row['poster_path'] != None: |
|
vr.poster_path = db_row['poster_path'] |
|
|
|
return vr |
|
|
|
|
|
def storeTMDBQueryResults(query_obj, result_list): |
|
conn = db.get_db() |
|
cur = conn.cursor() |
|
query_id = 0 |
|
|
|
current_app.logger.debug(f'adding query string ' |
|
f'"{query_obj.parsed_query_str}" into local cache') |
|
|
|
try: |
|
cur.execute('BEGIN TRANSACTION') |
|
cur.execute('INSERT INTO query_cache(query, year) VALUES (?, ?)', |
|
(query_obj.parsed_query_str, query_obj.movie_year) |
|
) |
|
query_id = cur.lastrowid |
|
except DatabaseError as e: |
|
query_obj.errors.append(e) |
|
|
|
for r in result_list: |
|
id_column = getIDColumn(r.media_type) |
|
id_val = r.movie_id if (r.media_type == media_type.MOVIE) else r.tv_id |
|
|
|
# NOTE: check stored details before trying to add to local db to avoid unique key |
|
# constraint error |
|
cur.execute(f'SELECT * FROM query_details WHERE {id_column}=?', (id_val, )) |
|
match = cur.fetchone() |
|
if match is not None: |
|
cur.execute(f'INSERT INTO query_mapping(query_id, detail_id)' |
|
+ ' VALUES (?,?)', (query_id, match['detail_id']) |
|
) |
|
|
|
current_app.logger.debug( |
|
f'skipping result that is already saved. {id_column}: {id_val}, title: {r.title}' |
|
) |
|
break |
|
|
|
try: |
|
current_app.logger.debug(f'adding query details for "{r.title}"') |
|
cur.execute(f'INSERT INTO query_details({id_column}, ' |
|
+ 'media_type, title, overview, release_date, original_language, poster_path)' |
|
+ ' VALUES (?,?,?,?,?,?,?)', |
|
(id_val, r.media_type, r.title, r.overview, r.release_date, |
|
r.original_language, r.poster_path) |
|
) |
|
|
|
# NOTE: need to set detail_id here to avoid a second query in create_path_mapping |
|
r.detail_id = cur.lastrowid |
|
|
|
cur.execute(f'INSERT INTO query_mapping(query_id, detail_id)' |
|
+ ' VALUES (?,?)', (query_id, r.detail_id) |
|
) |
|
except DatabaseError as e: |
|
query_obj.errors.append(f'database error \'{e}\'') |
|
|
|
if len(query_obj.errors) == 0: |
|
conn.commit() |
|
else: |
|
conn.rollback()
|
|
|