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/') 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('/tv_show_test', methods=['GET', ]) def tv_show_test(): shows = [] errors = [] if 'query' in request.args: result = tv_shows.doTVQuery(request.args['query']) shows = result['shows'] errors = result['errors'] if (shows): pass return render_template('tmdb/tv_show_test.html', shows = shows, debug_str = f'{util.dumpObject(errors)}' ) @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', view_data = view_results, debug_str = f'{util.dumpObject(query)} \n {util.dumpObject(view_results)} \n' ) @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() 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/') 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: vr = view_result() vr.overview = r.overview vr.genre_ids = r.genre_ids vr.original_language = r.original_language vr.poster_path = r.poster_path vr.media_type = query.media_type if query.media_type == media_type.MOVIE: vr.title = r.title vr.movie_id = r.id vr.tmdb_link = TMDB_MOVIE_LINK_URL + str(r.id) if 'release_date' in r.__dict__: vr.release_date = r.release_date elif query.media_type == media_type.TV_SHOW: vr.title = r.name vr.tv_id = r.id vr.tmdb_link = TMDB_TV_LINK_URL + str(r.id) vr.release_date = r.first_air_date view_results.append(vr) 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 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 COUNT(*) FROM query_details WHERE {id_column}=?', (id_val, )) count = cur.fetchone()[0] if count > 0: 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, cur.lastrowid) ) except DatabaseError as e: query_obj.errors.append(f'database error \'{e}\'') if len(query_obj.errors) == 0: conn.commit() else: conn.rollback()