# Author: Nic Wolfe # URL: http://code.google.com/p/sickbeard/ # # This file is part of SickGear. # # SickGear 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. # # SickGear 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 SickGear. If not, see . import datetime import fnmatch import glob import os.path import re import shutil import time import sickbeard from sickbeard import helpers, logger, exceptions from sickbeard import encodingKludge as ek from sickbeard import db from sickbeard.metadata.generic import GenericMetadata from lib.hachoir.parser import createParser from lib.hachoir.metadata import extractMetadata from lib.send2trash import send2trash try: import zlib except: pass class ImageCache: def __init__(self): pass def __del__(self): pass @staticmethod def _cache_dir(): """ Builds up the full path to the image cache directory """ return ek.ek(os.path.abspath, ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images')) def _fanart_dir(self, indexer_id=None): """ Builds up the full path to the fanart image cache directory """ args = [os.path.join, self._cache_dir(), 'fanart'] + \ (None is not indexer_id and [str(indexer_id).split('.')[0]] or []) return ek.ek(os.path.abspath, ek.ek(*args)) def _thumbnails_dir(self): """ Builds up the full path to the thumbnails image cache directory """ return ek.ek(os.path.abspath, ek.ek(os.path.join, self._cache_dir(), 'thumbnails')) def poster_path(self, indexer_id): """ Builds up the path to a poster cache for a given Indexer ID returns: a full path to the cached poster file for the given Indexer ID indexer_id: ID of the show to use in the file name """ return ek.ek(os.path.join, self._cache_dir(), '%s.poster.jpg' % indexer_id) def banner_path(self, indexer_id): """ Builds up the path to a banner cache for a given Indexer ID returns: a full path to the cached banner file for the given Indexer ID indexer_id: ID of the show to use in the file name """ return ek.ek(os.path.join, self._cache_dir(), '%s.banner.jpg' % indexer_id) def fanart_path(self, indexer_id): """ Builds up the path to a fanart cache for a given Indexer ID returns: a full path to the cached fanart file for the given Indexer ID indexer_id: ID of the show to use in the file name """ return ek.ek(os.path.join, self._fanart_dir(indexer_id), '%s.fanart.jpg' % indexer_id) def poster_thumb_path(self, indexer_id): """ Builds up the path to a poster cache for a given Indexer ID returns: a full path to the cached poster file for the given Indexer ID indexer_id: ID of the show to use in the file name """ return ek.ek(os.path.join, self._thumbnails_dir(), '%s.poster.jpg' % indexer_id) def banner_thumb_path(self, indexer_id): """ Builds up the path to a poster cache for a given Indexer ID returns: a full path to the cached poster file for the given Indexer ID indexer_id: ID of the show to use in the file name """ return ek.ek(os.path.join, self._thumbnails_dir(), '%s.banner.jpg' % indexer_id) @staticmethod def has_file(image_file): """ Returns true if a image_file exists """ result = [] for filename in ek.ek(glob.glob, image_file): result.append(ek.ek(os.path.isfile, filename) and filename) logger.log(u'Found cached %s' % filename, logger.DEBUG) not any(result) and logger.log(u'No cache for %s' % image_file, logger.DEBUG) return any(result) def has_poster(self, indexer_id): """ Returns true if a cached poster exists for the given Indexer ID """ return self.has_file(self.poster_path(indexer_id)) def has_banner(self, indexer_id): """ Returns true if a cached banner exists for the given Indexer ID """ return self.has_file(self.banner_path(indexer_id)) def has_fanart(self, indexer_id): """ Returns true if a cached fanart exists for the given Indexer ID """ return self.has_file(self.fanart_path(indexer_id)) def has_poster_thumbnail(self, indexer_id): """ Returns true if a cached poster thumbnail exists for the given Indexer ID """ return self.has_file(self.poster_thumb_path(indexer_id)) def has_banner_thumbnail(self, indexer_id): """ Returns true if a cached banner exists for the given Indexer ID """ return self.has_file(self.banner_thumb_path(indexer_id)) BANNER = 1 POSTER = 2 BANNER_THUMB = 3 POSTER_THUMB = 4 FANART = 5 def which_type(self, path): """ Analyzes the image provided and attempts to determine whether it is a poster, banner or fanart. returns: BANNER, POSTER, FANART or None if image type is not detected or doesn't exist path: full path to the image """ if not ek.ek(os.path.isfile, path): logger.log(u'File does not exist to determine image type of %s' % path, logger.WARNING) return None # use hachoir to parse the image for us img_parser = createParser(path) img_metadata = extractMetadata(img_parser) if not img_metadata: logger.log(u'Unable to extract metadata from %s, not using existing image' % path, logger.DEBUG) return None img_ratio = float(img_metadata.get('width')) / float(img_metadata.get('height')) img_parser.stream._input.close() msg_success = u'Treating image as %s'\ + u' with extracted aspect ratio from %s' % path.replace('%', '%%') # most posters are around 0.68 width/height ratio (eg. 680/1000) if 0.55 < img_ratio < 0.8: logger.log(msg_success % 'poster', logger.DEBUG) return self.POSTER # most banners are around 5.4 width/height ratio (eg. 758/140) elif 5 < img_ratio < 6: logger.log(msg_success % 'banner', logger.DEBUG) return self.BANNER # most fanart are around 1.7 width/height ratio (eg. 1280/720 or 1920/1080) elif 1.7 < img_ratio < 1.8: if 500 < img_metadata.get('width'): logger.log(msg_success % 'fanart', logger.DEBUG) return self.FANART logger.log(u'Image found with fanart aspect ratio but less than 500 pixels wide, skipped', logger.WARNING) return None else: logger.log(u'Image not useful with size ratio %s, skipping' % img_ratio, logger.WARNING) return None def should_refresh(self, image_type=None, provider='local'): my_db = db.DBConnection('cache.db', row_type='dict') sql_results = my_db.select('SELECT time FROM lastUpdate WHERE provider = ?', ['imsg_%s_%s' % ((image_type, self.FANART)[None is image_type], provider)]) if sql_results: minutes_freq = 60 * 3 # daily_freq = 60 * 60 * 23 freq = minutes_freq now_stamp = int(time.mktime(datetime.datetime.today().timetuple())) the_time = int(sql_results[0]['time']) return now_stamp - the_time > freq return True def set_last_refresh(self, image_type=None, provider='local'): my_db = db.DBConnection('cache.db') my_db.upsert('lastUpdate', {'time': int(time.mktime(datetime.datetime.today().timetuple()))}, {'provider': 'imsg_%s_%s' % ((image_type, self.FANART)[None is image_type], provider)}) def _cache_image_from_file(self, image_path, img_type, indexer_id, move_file=False): """ Takes the image provided and copies or moves it to the cache folder returns: full path to cached file or None image_path: path to the image to cache img_type: BANNER, POSTER, or FANART indexer_id: id of the show this image belongs to move_file: True if action is to move the file else file should be copied """ # generate the path based on the type & indexer_id fanart_subdir = [] if img_type == self.POSTER: dest_path = self.poster_path(indexer_id) elif img_type == self.BANNER: dest_path = self.banner_path(indexer_id) elif img_type == self.FANART: with open(image_path, mode='rb') as resource: crc = '%05X' % (zlib.crc32(resource.read()) & 0xFFFFFFFF) fanart_subdir = [self._fanart_dir(indexer_id)] dest_path = self.fanart_path(indexer_id).replace('.fanart.jpg', '.%s.fanart.jpg' % crc) else: logger.log(u'Invalid cache image type: ' + str(img_type), logger.ERROR) return False for cache_dir in [self._cache_dir(), self._thumbnails_dir(), self._fanart_dir()] + fanart_subdir: helpers.make_dirs(cache_dir) logger.log(u'%sing from %s to %s' % (('Copy', 'Mov')[move_file], image_path, dest_path)) if move_file: helpers.moveFile(image_path, dest_path) else: helpers.copyFile(image_path, dest_path) return ek.ek(os.path.isfile, dest_path) and dest_path or None def _cache_image_from_indexer(self, show_obj, img_type, num_files=0, max_files=500): """ Retrieves an image of the type specified from indexer and saves it to the cache folder returns: bool representing success show_obj: TVShow object that we want to cache an image for img_type: BANNER, POSTER, or FANART """ # generate the path based on the type & indexer_id if img_type == self.POSTER: img_type_name = 'poster' dest_path = self.poster_path(show_obj.indexerid) elif img_type == self.BANNER: img_type_name = 'banner' dest_path = self.banner_path(show_obj.indexerid) elif img_type == self.FANART: img_type_name = 'fanart_all' dest_path = self.fanart_path(show_obj.indexerid).replace('fanart.jpg', '*') elif img_type == self.POSTER_THUMB: img_type_name = 'poster_thumb' dest_path = self.poster_thumb_path(show_obj.indexerid) elif img_type == self.BANNER_THUMB: img_type_name = 'banner_thumb' dest_path = self.banner_thumb_path(show_obj.indexerid) else: logger.log(u'Invalid cache image type: ' + str(img_type), logger.ERROR) return False # retrieve the image from indexer using the generic metadata class metadata_generator = GenericMetadata() if img_type == self.FANART: image_urls = metadata_generator.retrieve_show_image(img_type_name, show_obj) if None is image_urls: return False crcs = [] for cache_file_name in ek.ek(glob.glob, dest_path): with open(cache_file_name, mode='rb') as resource: crc = '%05X' % (zlib.crc32(resource.read()) & 0xFFFFFFFF) if crc not in crcs: crcs += [crc] success = 0 count_urls = len(image_urls) sources = [] for image_url in image_urls or []: img_data = helpers.getURL(image_url, nocache=True) if None is img_data: continue crc = '%05X' % (zlib.crc32(img_data) & 0xFFFFFFFF) if crc in crcs: count_urls -= 1 continue crcs += [crc] img_source = (((('', 'tvdb')['thetvdb.com' in image_url], 'tvrage')['tvrage.com' in image_url], 'fatv')['fanart.tv' in image_url], 'tmdb')['tmdb' in image_url] img_xtra = '' if 'tmdb' == img_source: match = re.search(r'(?:.*\?(\d+$))?', image_url, re.I | re.M) if match and None is not match.group(1): img_xtra = match.group(1) file_desc = '%s.%03d%s.%s' % ( show_obj.indexerid, num_files, ('.%s%s' % (img_source, img_xtra), '')['' == img_source], crc) cur_file_path = self.fanart_path(file_desc) result = metadata_generator.write_image(img_data, cur_file_path) if result and self.FANART != self.which_type(cur_file_path): try: ek.ek(os.remove, cur_file_path) except OSError as e: logger.log(u'Unable to remove %s: %s / %s' % (cur_file_path, repr(e), str(e)), logger.WARNING) continue if img_source: sources += [img_source] num_files += (0, 1)[result] success += (0, 1)[result] if num_files > max_files: break if count_urls: total = len(ek.ek(glob.glob, dest_path)) logger.log(u'Saved %s of %s fanart images%s. Cached %s of max %s fanart file%s' % (success, count_urls, ('', ' from ' + ', '.join([x for x in list(set(sources))]))[0 < len(sources)], total, sickbeard.FANART_LIMIT, helpers.maybe_plural(total))) return bool(count_urls) and not bool(count_urls - success) img_data = metadata_generator.retrieve_show_image(img_type_name, show_obj) if None is img_data: return False result = metadata_generator.write_image(img_data, dest_path) if result: logger.log(u'Saved image type %s' % img_type_name) return result def clean_fanart(self): ratings_found = False fanarts = ek.ek(glob.glob, '%s.jpg' % self._fanart_dir('*')) if fanarts: logger.log(u'Reorganising fanart cache files', logger.DEBUG) for image_path in fanarts: image_path_parts = ek.ek(os.path.basename, image_path).split('.') dest_path = self._cache_image_from_file(image_path, self.FANART, '.'.join(image_path_parts[0:-2]), True) if None is not dest_path: src_file_id = '.'.join(image_path_parts[1:-2]) rating = sickbeard.FANART_RATINGS.get(image_path_parts[0], {}).get(src_file_id, None) if None is not rating: ratings_found = True dest_file_id = str('.'.join(ek.ek(os.path.basename, dest_path).split('.')[1:-2])) sickbeard.FANART_RATINGS[image_path_parts[0]][dest_file_id] = rating del (sickbeard.FANART_RATINGS[image_path_parts[0]][src_file_id]) return ratings_found def fill_cache(self, show_obj, force=False): """ Caches all images for the given show. Copies them from the show dir if possible, or downloads them from indexer if they aren't in the show dir. show_obj: TVShow object to cache images for """ show_id = '%s' % show_obj.indexerid # check if any images are cached need_images = {self.POSTER: not self.has_poster(show_id), self.BANNER: not self.has_banner(show_id), self.FANART: 0 < sickbeard.FANART_LIMIT and (force or not self.has_fanart(show_id + '.001.*')), # use limit? shows less than a limit of say 50 would fail to fulfill images every day # '.%03d.*' % sickbeard.FANART_LIMIT self.POSTER_THUMB: not self.has_poster_thumbnail(show_id), self.BANNER_THUMB: not self.has_banner_thumbnail(show_id)} if not any(need_images.values()): logger.log(u'%s: No new cache images needed. Done.' % show_id) return void = False if not void and need_images[self.FANART]: action = ('delete', 'trash')[sickbeard.TRASH_REMOVE_SHOW] cache_path = self.fanart_path(show_id).replace('%s.fanart.jpg' % show_id, '') # num_images = len(fnmatch.filter(os.listdir(cache_path), '*.jpg')) for cache_dir in ek.ek(glob.glob, cache_path): if show_id in sickbeard.FANART_RATINGS: del (sickbeard.FANART_RATINGS[show_id]) logger.log(u'Attempt to %s purge cache file %s' % (action, cache_dir), logger.DEBUG) try: if sickbeard.TRASH_REMOVE_SHOW: send2trash(cache_dir) else: shutil.rmtree(cache_dir) except OSError as e: logger.log(u'Unable to %s %s: %s / %s' % (action, cache_dir, repr(e), str(e)), logger.WARNING) try: checked_files = [] crcs = [] for cur_provider in sickbeard.metadata_provider_dict.values(): # check the show dir for poster or banner images and use them needed = [] if any([need_images[self.POSTER], need_images[self.BANNER]]): needed += [[False, cur_provider.get_poster_path(show_obj)]] if need_images[self.FANART]: needed += [[True, cur_provider.get_fanart_path(show_obj)]] if 0 == len(needed): break logger.log(u'Checking for images from optional %s metadata' % cur_provider.name, logger.DEBUG) for all_meta_provs, path_file in needed: if path_file in checked_files: continue checked_files += [path_file] if ek.ek(os.path.isfile, path_file): cache_file_name = os.path.abspath(path_file) with open(cache_file_name, mode='rb') as resource: crc = '%05X' % (zlib.crc32(resource.read()) & 0xFFFFFFFF) if crc in crcs: continue crcs += [crc] cur_file_type = self.which_type(cache_file_name) if None is cur_file_type: continue logger.log(u'Checking if image %s (type %s needs metadata: %s)' % (cache_file_name, str(cur_file_type), ('No', 'Yes')[True is need_images[cur_file_type]]), logger.DEBUG) if need_images.get(cur_file_type): need_images[cur_file_type] = ( (need_images[cur_file_type] + 1, 1)[isinstance(need_images[cur_file_type], bool)], False)[not all_meta_provs] if self.FANART == cur_file_type and \ (not sickbeard.FANART_LIMIT or sickbeard.FANART_LIMIT < need_images[cur_file_type]): continue logger.log(u'Caching image found in the show directory to the image cache: %s, type %s' % (cache_file_name, cur_file_type), logger.DEBUG) self._cache_image_from_file(cache_file_name, cur_file_type, '%s%s' % ( show_id, ('.%03d' % need_images[cur_file_type], '')[ isinstance(need_images[cur_file_type], bool)])) except exceptions.ShowDirNotFoundException: logger.log(u'Unable to search for images in show directory because it doesn\'t exist', logger.WARNING) # download missing ones from indexer for image_type, name_type in [[self.POSTER, 'Poster'], [self.BANNER, 'Banner'], [self.FANART, 'Fanart'], [self.POSTER_THUMB, 'Poster Thumb'], [self.BANNER_THUMB, 'Banner Thumb']]: max_files = (500, sickbeard.FANART_LIMIT)[self.FANART == image_type] if not max_files or max_files < need_images[image_type]: continue logger.log(u'Seeing if we still need an image of type %s: %s' % (name_type, ('No', 'Yes')[True is need_images[image_type]]), logger.DEBUG) if need_images[image_type]: file_num = (need_images[image_type] + 1, 1)[isinstance(need_images[image_type], bool)] if file_num <= max_files: self._cache_image_from_indexer(show_obj, image_type, file_num, max_files) logger.log(u'Done cache check')