diff --git a/addon.py b/addon.py deleted file mode 100644 index f9ad481..0000000 --- a/addon.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -import sys - -import xbmc -import xbmcaddon -import xbmcgui -import soupsieve - -from imdb import getOriginalAspectRatio - -monitor = xbmc.Monitor() -capture = xbmc.RenderCapture() -player = xbmc.Player() - -CaptureWidth = 48 -CaptureHeight = 54 - - -def notify(msg): - xbmcgui.Dialog().notification("BlackBarsNever", msg, None, 1000) - - -class Player(xbmc.Player): - def __init__(self): - xbmc.Player.__init__(self) - - if "toggle" in sys.argv: - if xbmcgui.Window(10000).getProperty("blackbarsnever_status") == "on": - self.showOriginal() - else: - self.abolishBlackBars() - - def onAVStarted(self): - if xbmcaddon.Addon().getSetting("automatically_execute") == "true": - self.abolishBlackBars() - else: - self.showOriginal() - - def CaptureFrame(self): - capture.capture(CaptureWidth, CaptureHeight) - capturedImage = capture.getImage(2000) - return capturedImage - - ############## - # - # LineColorLessThan - # _bArray: byte Array that contains the data we want to test - # _lineStart: where to start testing - # _lineCount: how many lines to test - # _threshold: value to determine testing - # returns: True False - ############### - - def LineColorLessThan(self, _bArray, _lineStart, _lineCount, _threshold): - __sliceStart = _lineStart * CaptureWidth * 4 - __sliceEnd = (_lineStart + _lineCount) * CaptureWidth * 4 - - # zero out the alpha channel - i = __sliceStart + 3 - while i < __sliceEnd: - _bArray[i] &= 0x00 - i += 4 - - __imageLine = _bArray[__sliceStart:__sliceEnd] - __result = all([v < _threshold for v in __imageLine]) - - return __result - - ############### - # - # GetAspectRatioFromFrame - # - returns Aspect ratio * 100 (i.e. 2.35 = 235) - # Detects hardcoded black bars - ############### - - def GetAspectRatioFromFrame(self): - __aspect_ratio = int((capture.getAspectRatio() + 0.005) * 100) - __threshold = 25 - - line1 = "Interim Aspect Ratio = " + str(__aspect_ratio) - xbmc.log(line1, level=xbmc.LOGINFO) - - # screen capture and test for an image that is not dark in the 2.40 - # aspect ratio area. keep on capturing images until captured image - # is not dark - while True: - __myimage = self.CaptureFrame() - - xbmc.log(line1, level=xbmc.LOGINFO) - - __middleScreenDark = self.LineColorLessThan(__myimage, 7, 2, __threshold) - if __middleScreenDark == False: - # xbmc.sleep(1000) - break - else: - pass - # xbmc.sleep(1000) - - # Capture another frame. after we have waited for transitions - # __myimage = self.CaptureFrame() - __ar185 = self.LineColorLessThan(__myimage, 0, 1, __threshold) - __ar200 = self.LineColorLessThan(__myimage, 1, 3, __threshold) - __ar235 = self.LineColorLessThan(__myimage, 1, 5, __threshold) - - if __ar235 == True: - __aspect_ratio = 235 - - elif __ar200 == True: - __aspect_ratio = 200 - - elif __ar185 == True: - __aspect_ratio = 185 - - return __aspect_ratio - - def abolishBlackBars(self): - xbmcgui.Window(10000).setProperty("blackbarsnever_status", "on") - # notify(xbmcgui.Window(10000).getProperty('blackbarsnever_status')) - - original_aspect_ratio = None - android_workaround = ( - xbmcaddon.Addon().getSetting("android_workaround") == "true" - ) - - imdb_number = xbmc.getInfoLabel("VideoPlayer.IMDBNumber") - if player.getVideoInfoTag().getMediaType() == "episode": - # media is a TV show - title = player.getVideoInfoTag().getTVShowTitle() - else: - # media is probably a film - title = player.getVideoInfoTag().getTitle() - if not title: - title = player.getVideoInfoTag().getOriginalTitle() - if not title: - title = ( - os.path.basename(player.getVideoInfoTag().getFilenameAndPath()) - .split("/")[-1] - .split(".", 1)[0] - ) - - original_aspect_ratio = getOriginalAspectRatio(title, imdb_number=imdb_number) - - if isinstance(original_aspect_ratio, list): - # media has multiple aspect ratios, show unaltered and let user do manual intervention - notify("Multiple aspect ratios detected") - else: - if android_workaround and original_aspect_ratio: - aspect_ratio = int(original_aspect_ratio) - - self.doStiaff(aspect_ratio) - else: - aspect_ratio = self.GetAspectRatioFromFrame() - self.doStiaff(aspect_ratio) - - def doStiaff(self, ratio): - aspect_ratio = ratio - aspect_ratio2 = int((capture.getAspectRatio() + 0.005) * 100) - - window_id = xbmcgui.getCurrentWindowId() - line1 = ( - "Calculated Aspect Ratio = " - + str(aspect_ratio) - + " " - + "Player Aspect Ratio = " - + str(aspect_ratio2) - ) - - xbmc.log(line1, level=xbmc.LOGINFO) - - if aspect_ratio > 178: - zoom_amount = aspect_ratio / 178 - else: - zoom_amount = 1.0 - - # zoom in a sort of animated way, isn't working for now - iterations = (zoom_amount - 1) / 0.01 - # for x in range(iterations): - if (aspect_ratio > 178) and (aspect_ratio2 == 178): - # this is 16:9 and has hard coded black bars - xbmc.executeJSONRPC( - '{"jsonrpc": "2.0", "method": "Player.SetViewMode", "params": {"viewmode": {"zoom": ' - + str(zoom_amount) - + ' }}, "id": 1}' - ) - notify("Black Bars were detected. Zoomed {:0.2f}".format(zoom_amount)) - elif aspect_ratio > 178: - # this is an aspect ratio wider than 16:9, no black bars, we assume a 16:9 (1.77:1) display - xbmc.executeJSONRPC( - '{"jsonrpc": "2.0", "method": "Player.SetViewMode", "params": {"viewmode": {"zoom": ' - + str(zoom_amount) - + ' }}, "id": 1}' - ) - if zoom_amount <= 1.02: - notify( - "Wide screen was detected. Slightly zoomed {:0.2f}".format( - zoom_amount - ) - ) - elif zoom_amount > 1.02: - notify("Wide screen was detected. Zoomed {: 0.2f}".format(zoom_amount)) - - def showOriginal(self): - xbmcgui.Window(10000).setProperty("blackbarsnever_status", "off") - # notify(xbmcgui.Window(10000).getProperty('blackbarsnever_status')) - - xbmc.executeJSONRPC( - '{"jsonrpc": "2.0", "method": "Player.SetViewMode", "params": {"viewmode": {"zoom": 1.0' - + ' }}, "id": 1}' - ) - notify("Showing original aspect ratio") - - -p = Player() - -while not monitor.abortRequested(): - # Sleep/wait for abort for 10 seconds - if monitor.waitForAbort(10): - # Abort was requested while waiting. We should exit - break diff --git a/addon.xml b/addon.xml index 36133a3..de6ce24 100644 --- a/addon.xml +++ b/addon.xml @@ -1,27 +1,26 @@ - + - - - + + - - + + executable all BlackBarsNever - This addon eliminates the problem of black bars. If the black bars are hardcoded, the addon will automatically + Fork by Nircniv. This addon eliminates the problem of black bars. If the black bars are hardcoded, the addon will automatically detect this and remove them. If the video is a wide screen format, the addon will also detect this and remove the black bars too. GNU General Public License, v2 - https://github.com/osumoclement/script.black.bars.never - alkywave.com - + https://github.com/ngtawei/script.black.bars.never + + ng.ta.wei@gmail.com icon.png fanart.jpg @@ -29,6 +28,6 @@ resources/screenshot-02.jpg resources/screenshot-03.jpg - Updated the addon to use new addon.xml metadata + Updated fork of original. Improved black bars detection. Adaptive aspect ratio support. \ No newline at end of file diff --git a/fanart.jpg b/fanart.jpg index df83971..eb4f861 100644 Binary files a/fanart.jpg and b/fanart.jpg differ diff --git a/icon.png b/icon.png index 5d2a920..947a403 100644 Binary files a/icon.png and b/icon.png differ diff --git a/imdb.py b/imdb.py deleted file mode 100644 index 8a7509b..0000000 --- a/imdb.py +++ /dev/null @@ -1,80 +0,0 @@ -import requests -from bs4 import BeautifulSoup - -import xbmc -import xbmcgui - -def notify(msg): - xbmcgui.Dialog().notification("BlackBarsNever", msg, None, 1000) - -def getOriginalAspectRatio(title, imdb_number=None): - BASE_URL = "https://www.imdb.com/" - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'} - - if imdb_number and str(imdb_number).startswith("tt"): - URL = "{}/title/{}/".format(BASE_URL, imdb_number) - else: - URL = BASE_URL + "find/?q={}".format(title) - search_page = requests.get(URL, headers=HEADERS) - - # lxml parser would have been better but not currently supported in Kodi - soup = BeautifulSoup(search_page.text, 'html.parser') - - title_url_tag = soup.select_one( - '.ipc-metadata-list-summary-item__t') - if title_url_tag: - # we have matches, pick the first one - title_url = title_url_tag['href'] - imdb_number = title_url.rsplit( - '/title/', 1)[-1].split("/")[0] - # this below could have worked instead but for some reason SoupSieve not working inside Kodi - """title_url = soup.css.select( - '.ipc-metadata-list-summary-item__t')[0].get('href') - """ - - URL = BASE_URL + title_url - - title_page = requests.get(URL, headers=HEADERS) - soup = BeautifulSoup(title_page.text, 'html.parser') - - # this below could have worked instead but for some reason SoupSieve not working inside Kodi - aspect_ratio_tags = soup.find( - attrs={"data-testid": "title-techspec_aspectratio"}) - - if aspect_ratio_tags: - aspect_ratio_full = aspect_ratio_tags.select_one( - ".ipc-metadata-list-item__list-content-item").decode_contents() - - """aspect_ratio_full = soup.find( - attrs={"data-testid": "title-techspec_aspectratio"}).css.select(".ipc-metadata-list-item__list-content-item")[0].decode_contents() - """ - - if aspect_ratio_full: - aspect_ratio = aspect_ratio_full.split(':')[0].replace('.', '') - else: - # check if video has multiple aspect ratios - URL = "{}/title/{}/technical/".format(BASE_URL, imdb_number) - tech_specs_page = requests.get(URL, headers=HEADERS) - soup = BeautifulSoup(tech_specs_page.text, 'html.parser') - aspect_ratio_li = soup.select_one("#aspectratio").find_all("li") - if len(aspect_ratio_li) > 1: - aspect_ratios = [] - - for li in aspect_ratio_li: - aspect_ratio_full = li.select_one( - ".ipc-metadata-list-item__list-content-item").decode_contents() - - aspect_ratio = aspect_ratio_full.split(':')[0].replace('.', '') - sub_text = li.select_one(".ipc-metadata-list-item__list-content-item--subText").decode_contents() - - if sub_text == "(theatrical ratio)": - xbmc.log("using theatrical ratio " + str(aspect_ratio), level=xbmc.LOGINFO) - return aspect_ratio - - - aspect_ratios.append(aspect_ratio) - - return aspect_ratios - - return aspect_ratio \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7b9abb1 --- /dev/null +++ b/main.py @@ -0,0 +1,20 @@ +import sys +from src.core import core +from src.service import zoom_service + +def main(): + if "toggle" in sys.argv: + core.logger.log("Start Toggle.") + zoom_service.toggle_zoom() + core.logger.log("End Toggle.") + else: + core.logger.log("Starting service.") + while not core.monitor.abortRequested(): + zoom_service.check_toggle_service("on") + zoom_service.check_toggle_service("off") + if core.monitor.waitForAbort(1): + break + core.logger.log("Ending service.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/readme.md b/readme.md index cb706aa..9270326 100644 --- a/readme.md +++ b/readme.md @@ -1,16 +1,17 @@ # BlackBarsNever Kodi Addon - Remove black bars +Forked by Nircniv + # How it works -This is an addon that eliminates black bars on KODI, whether hardcoded or the video is just wide format +`BlackBarsNever` is a Kodi addon designed to eliminate black bars from videos, whether they are hardcoded or due to the video being in a wide format. Once installed and enabled, the addon automatically analyzes media upon playback to detect black bars. If found, it zooms into the media just enough to fill the display without distorting the picture, as the zoom is applied linearly. -With addon installed and enabled, it will automatically analyze media on playback and determine -if there are any black bars. The addon will then zoom the media exactly enough to cover the display. +# Key Features -The picture will not be distorted in any way as the zoom is linear, -however, on most media, small parts on the left and right will be cut off. Luckily, everything that's -important tends to fall in the middle of the scene 99% of the time. The advantages of experiencing an -immersive picture that fills the periphery should be enough to overweigh the disdvantage of missing sides. +- **Automatic Detection and Adjustment:** The addon automatically detects black bars and adjusts the zoom level of the media to fill the display. +- **IMDb Metadata Integration:** For media with multiple aspect ratios, the addon utilizes IMDb metadata to handle aspect ratios intelligently. +- **Customization Options:** Users can customize the behaviour of the addon through its settings, including manual trigger options and the ability to toggle the addon's functionality. +- **Support for Multiple Platforms:** The addon supports Linux, Windows, macOS, iOS, and Android & Embedded Systems (with certain limitations). # Supported platforms @@ -21,15 +22,15 @@ immersive picture that fills the periphery should be enough to overweigh the dis # Android & Embedded Systems like \*ELEC -Currently, Kodi can't capture sreenshots in Android and Embedded Systems if hardware accelertion is enabled due to some technical limitations. This may change in future and when that happens the addon will work properly like in other platforms. For now there's two options: +Currently, Kodi cannot capture screenshots in Android and Embedded Systems if hardware acceleration is enabled due to some technical limitations. This may change in future and when that happens the addon will work properly like in other platforms. For now there's two options: 1. Disable hardware acceleration (turn off MediaCodec Surface in Android). The problem with this is that Kodi will now use CPU for decoding and playback may be affected to the point of being unwatchable, especially for high bitrate media. Also in the devices I tested, HDR won't work on Android if hardware acceleration is turned on, I am not sure if this affects all of Android. -2. Enable the Android & Embedded Systems Workaround from the addon settings. This feature requires an internet connection to fetch media metadata, and works best if your library adopts a decent naming pattern i.e `Title Year`. Also works properly only if media aspect ratio is unchanged from original (i.e has not been cropped from the original) +2. Enable the Android & Embedded Systems Workaround from the addon settings. This feature requires an internet connection to fetch media metadata, and works best if your library adopts a decent naming pattern i.e Title Year. Also works properly only if media aspect ratio is unchanged from original (i.e has not been cropped from the original) # Installation -Download the zip file from [releases](https://github.com/osumoclement/script.black.bars.never/releases) +Download the zip file from [releases](https://github.com/ngtawei/script.black.bars.never/releases) Launch Kodi >> Add-ons >> Get More >> Install from zip file @@ -39,8 +40,8 @@ Feel free to ask any questions, submit feature/bug reports # Multiple Aspect Ratios in Media -For media with multiple aspect ratios, the addon will notify you of this, and will do nothing. In such cases, I recommend you watch the media as is, since if you change the aspect ratio manually, you may not know where in the media the ratio changes in order to adjust again. -This feature requires internet to work +For media with multiple aspect ratios using the Android workaround method, the addon will notify you of this, and will do nothing. In such cases, I recommend you watch the media as is, since if you change the aspect ratio manually, you may not know where in the media the ratio changes in order to adjust again. +This feature requires internet to work. # Customization @@ -51,4 +52,4 @@ To check the addon status elsewhere from Kodi, use this `xbmcgui.Window(10000).g # License -BlackBarsNever is [GPLv3 licensed](https://github.com/osumoclement/script.black.bars.never/blob/main/LICENSE). You may use, distribute and copy it under the license terms. +BlackBarsNever is [GPLv3 licensed](https://github.com/ngtawei/script.black.bars.never/blob/main/LICENSE). You may use, distribute and copy it under the license terms. diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index aeaa369..789fe45 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Black Bars Never\n" -"Report-Msgid-Bugs-To: Clement Osumo\n" +"Report-Msgid-Bugs-To: Nircniv\n" "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: 2023-06-18 10:08+0000\n" "Last-Translator: Clement Osumo\n" @@ -27,9 +27,41 @@ msgid "Automatically remove black bars" msgstr "Automatically remove black bars" msgctxt "#32003" +msgid "Show notifications" +msgstr "Show notifications" + +msgctxt "#32004" msgid "Toggle status" msgstr "Toggle status" -msgctxt "#32004" -msgid "Workaround for Android & Embedded (requires internet)" -msgstr "Workaround for Android & Embedded (requires internet)" \ No newline at end of file +msgctxt "#32005" +msgid "Workaround for Android & Embedded" +msgstr "Workaround for Android & Embedded" + +msgctxt "#32006" +msgid "Auto-Refresh Zoom for Multiple Aspect Ratios (High Resource Use)" +msgstr "Auto-Refresh Zoom for Multiple Aspect Ratios (High Resource Use)" + +msgctxt "#32007" +msgid "Auto-Refresh Interval" +msgstr "Set the interval (in seconds) for how often the addon checks and adjusts the video playback to remove black bars." + +msgctxt "#32008" +msgid "Basic Settings" +msgstr "Basic Settings" + +msgctxt "#32009" +msgid "Advanced Settings (Requires Internet)" +msgstr "Advanced Settings (Requires Internet)" + +msgctxt "#32010" +msgid "Actions" +msgstr "Actions" + +msgctxt "#32011" +msgid "Always Auto-Refresh" +msgstr "Always Auto-Refresh" + +msgctxt "#32012" +msgid "Sample resolution (Affects Performance/Accuracy)" +msgstr "Sample resolution (Affects Performance/Accuracy)" diff --git a/resources/settings.xml b/resources/settings.xml index 27873ab..38588b5 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -1,26 +1,18 @@ - - -
- - - - 0 - true - - - - 0 - RunScript(script.black.bars.never, toggle) - - true - - - - 0 - false - - - - -
+ + + + + + + + + + + + + + + + + diff --git a/src/content/__init__.py b/src/content/__init__.py new file mode 100644 index 0000000..f23f123 --- /dev/null +++ b/src/content/__init__.py @@ -0,0 +1 @@ +from .content import ContentManager \ No newline at end of file diff --git a/src/content/content.py b/src/content/content.py new file mode 100644 index 0000000..9ccf596 --- /dev/null +++ b/src/content/content.py @@ -0,0 +1,107 @@ +import xbmc +from src.scraper import imdb_scraper +from src.content.metadata import OnlineMetadataService +from src.core import core +from src.player import Player +from src.content.image import RenderImage, ImageAnalysisService + +class ContentManager: + def __init__(self, player: Player): + self.player = player + self.image = RenderImage() + self.image_analysis = ImageAnalysisService(self.image) + self.online_metadata_service = OnlineMetadataService(imdb_scraper) + self.reset_attributes() + + def reset_attributes(self): + self.content_ars = None + self.content_ar = None + self.multi_ar = False + self.online_metadata = None + + def fetch_online_metadata(self): + self.online_metadata = self.online_metadata_service.scrape_metadata(self.player.get_video_metadata()) + + if self.online_metadata is None: + return + + self.content_ars = self.online_metadata["aspect_ratios"] + self.multi_ar = len(self.content_ars) > 1 + + def get_content_ar(self): + if self.content_ar is None: + self.get_content_size() + return self.content_ar + + def _get_content_size_from_image(self): + video_w, video_h = self.player.get_video_size() + player_width, player_height = self.player.get_video_player_size() + player_ar = self.player.get_video_player_ar() + image_height = int(480 * core.addon.get_setting("sample_resolution", float)) + image_width = int(image_height*player_ar) + core.logger.log(f"Video Resolution: {video_w}x{video_h}, Video Player Aspect Ratio: {player_ar}, Image Dimensions: {image_width}x{image_height}", xbmc.LOGINFO) + + retry_delay = 5 + max_attempts = 36 + + for attempt in range(max_attempts): + self.image.capture_frame(image_width, image_height) + + if self.image_analysis.check_for_bright_frame(): + try: + content_image_width, content_image_height = self.image_analysis.calculate_content_image_size() + break + except Exception as e: + core.logger.log(f"Error occurred: {e}", xbmc.LOGERROR) + return None, None + else: + core.logger.log(f"Frame is too dark. Attempt {attempt+1} of 36. Waiting to retry...", xbmc.LOGINFO) + if core.monitor.waitForAbort(retry_delay): + return None, None + + height_scale = content_image_height / image_height + width_scale = content_image_width / image_width + content_width = float(player_width) * width_scale + content_height = float(player_height) * height_scale + return content_width, content_height + + def _get_content_size_from_data(self): + player_width, player_height = self.player.get_video_player_size() + player_ar = self.player.get_video_player_ar() + + if self.content_ars is None: + core.logger.log("Unable to get aspect ratios online.", xbmc.LOGERROR) + return None, None + + if self.multi_ar: + content_width = player_width + content_height = player_height + else: + self.content_ar = self.content_ars[0] + core.logger.log(f"Content aspect ratio from Imdb: {self.content_ar}", xbmc.LOGINFO) + + if self.content_ar > player_ar: + # Filled width, content width = player width + content_width = player_width + content_height = content_width / self.content_ar + else: + # Filled height, content height = player height + content_height = player_height + content_width = content_height * self.content_ar + return content_width, content_height + + def get_content_size(self): + content_width, content_height = None, None + + if core.addon.get_setting("android_workaround", bool): + content_width, content_height = self._get_content_size_from_data() + + if content_width is None or content_height is None: + content_width, content_height = self._get_content_size_from_image() + try: + self.content_ar = content_width / content_height + except Exception as e: + core.logger.log(e, xbmc.LOGERROR) + return None, None + + return content_width, content_height diff --git a/src/content/image.py b/src/content/image.py new file mode 100644 index 0000000..2e136af --- /dev/null +++ b/src/content/image.py @@ -0,0 +1,96 @@ +import xbmc +from src.core import core + + +class RenderImage(): + def __init__(self): + self.capture = xbmc.RenderCapture() + self.byte_array = None + self.width = None + self.height = None + + def capture_frame(self, capture_width, capture_height): + try: + self.width = capture_width + self.height = capture_height + self.capture.capture(capture_width, capture_height) + self.byte_array = self.capture.getImage(2000) + except Exception as e: + core.logger.log(f"Failed to capture frame: {e}", xbmc.LOGERROR) + + def get_image_dimensions(self): + return self.width, self.height + + +class ImageAnalysisService: + def __init__(self, image: RenderImage): + self.image = image + + def _row_color_greater_than(self, row_index, threshold): + bytesPerRow = self.image.width * 4 + totalIntensity = 0 + + startIndex = row_index * bytesPerRow + + for i in range(startIndex, min(startIndex + bytesPerRow, len(self.image.byte_array)), 4): + totalIntensity += (self.image.byte_array[i] + self.image.byte_array[i+1] + self.image.byte_array[i+2]) / 3 + + avgIntensity = totalIntensity / self.image.width + return avgIntensity > threshold + + def _col_color_greater_than(self, col_index, threshold): + bytesPerRow = self.image.width * 4 + totalIntensity = 0 + + for row in range(self.image.height): + index = (row * bytesPerRow) + (col_index * 4) + totalIntensity += (self.image.byte_array[index] + self.image.byte_array[index+1] + self.image.byte_array[index+2]) / 3 + + avgIntensity = totalIntensity / self.image.height + return avgIntensity > threshold + + def _find_boundary(self, start, end, step, check_function): + for i in range(start, end, step): + if check_function(i): + return i + return None + + def calculate_content_image_size(self): + threshold = 1 # Above this value is "non-black" + offset = 2 # Offset start pixel to fix green line rendering bug + bottom, right = self.image.height, self.image.width + + top = self._find_boundary(offset, self.image.height-1, 1, lambda i: self._row_color_greater_than(i, threshold)) + if top > offset: + bottom = self._find_boundary(self.image.height-1-offset, top-1, -1, lambda i: self._row_color_greater_than(i, threshold)) + else: + top -= offset + + left = self._find_boundary(offset, self.image.width-1, 1, lambda j: self._col_color_greater_than(j, threshold)) + if left > offset: + right = self._find_boundary(self.image.width-1-offset, left-1, -1, lambda j: self._col_color_greater_than(j, threshold)) + else: + left -= offset + + core.logger.log(f"Top/bottom/left/right: {top},{bottom},{left},{right}", xbmc.LOGINFO) + + content_image_height = bottom - top + content_image_width = right - left + + return content_image_width, content_image_height + + def get_non_black_pixels_percentage(self): + non_black_pixel_count = 0 + + for i in range(0, len(self.image.byte_array), 4): + if not (self.image.byte_array[i] == self.image.byte_array[i+1] == self.image.byte_array[i+2] == 0): + non_black_pixel_count += 1 + + total_pixel_count = self.image.width * self.image.height + non_black_pixel_percentage = non_black_pixel_count / total_pixel_count + return non_black_pixel_percentage + + def check_for_bright_frame(self, non_black_pixel_threshold=0.6): + non_black_pixel_percentage = self.get_non_black_pixels_percentage() + core.logger.log(f"Non-Black pixels Percentage: {non_black_pixel_percentage}", xbmc.LOGINFO) + return non_black_pixel_percentage >= non_black_pixel_threshold \ No newline at end of file diff --git a/src/content/metadata.py b/src/content/metadata.py new file mode 100644 index 0000000..2fe586b --- /dev/null +++ b/src/content/metadata.py @@ -0,0 +1,20 @@ +import xbmc +from src.core import core + +class OnlineMetadataService: + def __init__(self, scraper): + self._scraper = scraper + + def scrape_metadata(self, video_metadata): + try: + self._scraper.setMedia(video_metadata) + aspect_ratios = self._scraper.parse_aspect_ratios() + except Exception as e: + core.logger.log(f"Error occurred: {e}", xbmc.LOGERROR) + return None + + scraped_metadata = { + "aspect_ratios": aspect_ratios + } + + return scraped_metadata \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..f884314 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,3 @@ +from .core import CoreServices + +core = CoreServices() \ No newline at end of file diff --git a/src/core/core.py b/src/core/core.py new file mode 100644 index 0000000..ad78274 --- /dev/null +++ b/src/core/core.py @@ -0,0 +1,95 @@ +import xbmc +import xbmcgui +import xbmcaddon + + +class CoreServices: + def __init__(self): + self.addon = AddonManager(xbmcaddon.Addon()) + self.window = WindowManager(xbmcgui.Window(10000), self.addon) + self.notification = NotificationManager(self.addon) + self.logger = LogManager(self.addon) + self.monitor = xbmc.Monitor() + + +class AddonManager: + def __init__(self, addon): + self.addon = addon + + @property + def addon_name(self): + return self.addon.getAddonInfo('name') + + @property + def addon_icon(self): + return self.addon.getAddonInfo('icon') + + @property + def addon_id(self): + return self.addon.getAddonInfo('id') + + def get_setting(self, setting_id, value_type): + setting_value = self.addon.getSetting(setting_id) + + if value_type == bool: + # Kodi stores booleans as 'true' or 'false' strings. + return setting_value.lower() in ("true", "yes", "1", "on") + elif value_type == int: + return int(setting_value) + elif value_type == float: + return float(setting_value) + else: + # Return as string by default + return setting_value + + +class WindowManager: + def __init__(self, window, addon: AddonManager): + self.window = window + self.__addon = addon + + def clear_property(self, property_id): + self.window.clearProperty(self.__addon.addon_name + "_" + property_id) + + def set_property(self, property_id, is_true): + if is_true: + self.window.setProperty(self.__addon.addon_name + "_" + property_id, "true") + else: + self.window.setProperty(self.__addon.addon_name + "_" + property_id, "false") + + def get_property(self, property_id): + return self.window.getProperty(self.__addon.addon_name + "_" + property_id).lower() in ("true", "yes", "1", "on") + + +class NotificationManager: + def __init__(self, addon: AddonManager): + self.__addon = addon + self.status = True + + def on(self): + self.status = True + + def off(self): + self.status = False + + def notify(self, msg, override=False): + self.notification_status = self.__addon.get_setting("show_notification", bool) + + if (self.notification_status and self.status) or override: + xbmcgui.Dialog().notification(self.__addon.addon_name, msg, self.__addon.addon_icon, 1000) + + +class LogManager: + def __init__(self, addon: AddonManager): + self.__addon = addon + self.logging_status = True + + def on(self): + self.logging_status = True + + def off(self): + self.logging_status = False + + def log(self, msg, level=xbmc.LOGINFO): + if self.logging_status: + xbmc.log(f"{self.__addon.addon_name}: {msg}", level=level) diff --git a/src/player/__init__.py b/src/player/__init__.py new file mode 100644 index 0000000..bf1fc91 --- /dev/null +++ b/src/player/__init__.py @@ -0,0 +1 @@ +from .player import Player \ No newline at end of file diff --git a/src/player/player.py b/src/player/player.py new file mode 100644 index 0000000..443bb9b --- /dev/null +++ b/src/player/player.py @@ -0,0 +1,100 @@ +import json +import xbmc +from src.core import core + +class Player(xbmc.Player): + def __init__(self): + super().__init__() + self.onAVStarted_callbacks = [] + self.reset_attributes() + + def reset_attributes(self): + self.monitor_ar = None + + def set_onAVStarted_callback(self, callback): + self.onAVStarted_callbacks.append(callback) + + def onAVStarted(self): + for callback in self.onAVStarted_callbacks: + callback() + + def get_video_size(self): + video_w_str = xbmc.getInfoLabel('Player.Process(videowidth)') + video_h_str = xbmc.getInfoLabel('Player.Process(videoheight)') + video_w = int(video_w_str.replace(",", "")) + video_h = int(video_h_str.replace(",", "")) + return video_w, video_h + + def get_monitor_ar(self): + if self.monitor_ar is None: + self.get_monitor_size() + return self.monitor_ar + + def get_monitor_size(self): + width = int(xbmc.getInfoLabel('System.ScreenWidth')) + height = int(xbmc.getInfoLabel('System.ScreenHeight')) + + if width is None or height is None: + core.logger.log("Unable to get Monitor Size.", xbmc.LOGERROR) + return None, None + self.monitor_ar = width/ height + return width, height + + def get_video_player_ar(self): + return float(xbmc.getInfoLabel('VideoPlayer.VideoAspect')) + + def get_video_player_size(self): + if self.isPlayingVideo(): + monitor_width, monitor_height = self.get_monitor_size() + monitor_ar = monitor_width / monitor_height + + video_player_width, video_player_height = monitor_width, monitor_height + video_player_ar = self.get_video_player_ar() + + if monitor_ar > video_player_ar: + video_player_width = int(float(video_player_height) * video_player_ar) + else: + video_player_height = int(float(video_player_width) / video_player_ar) + + core.logger.log(f"Video Player Dimensions: {video_player_width}x{video_player_height}", level=xbmc.LOGINFO) + return video_player_width, video_player_height + else: + core.logger.log(f"No video is currently playing.", level=xbmc.LOGINFO) + return None, None + + def get_video_metadata(self): + if self.isPlayingVideo(): + title = xbmc.getInfoLabel('VideoPlayer.Title') or None + show_title = xbmc.getInfoLabel('VideoPlayer.TVshowtitle') or None # Show name + original_title = xbmc.getInfoLabel('VideoPlayer.OriginalTitle') + tv_show_title = show_title # Using the fetched show title + imdb_number = xbmc.getInfoLabel('VideoPlayer.IMDBNumber') or None + + # Adjust content_type based on the presence of original title or TV show title + content_type = 'tt' if original_title and not tv_show_title else 'ep' if tv_show_title else None + + season_label = xbmc.getInfoLabel('VideoPlayer.Season') + episode_label = xbmc.getInfoLabel('VideoPlayer.Episode') + + # Safely convert season and episode labels to integers + season = int(season_label) if season_label.isdigit() else None + episode = int(episode_label) if episode_label.isdigit() else None + + if season is not None and episode is not None: + content_type = 'ep' + + metadata = { + 'title': title, + 'show_title': show_title, # Include show title in metadata + 'episode_title': title if tv_show_title else None, # Episode name, if applicable + 'content_type': content_type, + 'imdb_number': imdb_number, + 'season': season, + 'episode': episode + } + + core.logger.log(json.dumps(metadata, indent=4), xbmc.LOGINFO) + return metadata + else: + core.logger.log("No video is currently playing", xbmc.LOGERROR) + return None \ No newline at end of file diff --git a/src/scraper/__init__.py b/src/scraper/__init__.py new file mode 100644 index 0000000..4a12571 --- /dev/null +++ b/src/scraper/__init__.py @@ -0,0 +1,3 @@ +from .imdbscraper import ImdbScraper + +imdb_scraper = ImdbScraper() \ No newline at end of file diff --git a/src/scraper/imdbscraper.py b/src/scraper/imdbscraper.py new file mode 100644 index 0000000..ef618ad --- /dev/null +++ b/src/scraper/imdbscraper.py @@ -0,0 +1,80 @@ +import re +import requests +from bs4 import BeautifulSoup + +class ImdbScraper: + HEADERS = {'User-Agent': 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'} + BASE_URL = "https://www.imdb.com/" + + def __init__(self): + self.session = requests.Session() + self.session.headers.update(self.HEADERS) + + def setMedia(self, video_metadata): + self.title = video_metadata.get('title') + self.show_title = video_metadata.get('show_title') + self.episode_title = video_metadata.get('episode_title') + self.media_type = video_metadata.get('content_type') + self.imdb_id = video_metadata.get('imdb_number') + + # Ensure there's enough information to proceed + if not self.title and not self.imdb_id: + raise Exception("Must have either title or imdb_id") + + # If imdb_id is not provided, attempt to parse it + if not self.imdb_id: + self.imdb_id = self._parse_imdb_id() + + def _parse_imdb_id(self): + SEARCH_URL = self.BASE_URL + "find/" + + # Determine the search query based on media type + query = self.episode_title if self.media_type == 'ep' and self.episode_title else self.title + if not query: # Safety check if query is somehow empty + raise Exception("No valid query for IMDB search.") + + params = { + 's': 'ep' if self.media_type == 'ep' else 'tt', + 'q': query + } + + search_page = self.session.get(SEARCH_URL, params=params) + + if search_page.status_code == 200: + soup = BeautifulSoup(search_page.text, 'html.parser') + title_url_tag = soup.find(class_="ipc-metadata-list-summary-item__t") + + imdb_number = None + + if title_url_tag: + title_url = title_url_tag['href'] + imdb_number = title_url.rsplit('/title/', 1)[-1].split("/")[0] + + if imdb_number: + return imdb_number + else: + raise Exception("IMDB ID not found from search.") + else: + raise Exception("Search page not successfully resolved.") + + + def parse_aspect_ratios(self): + technical_page = self.session.get(f"{self.BASE_URL}title/{self.imdb_id}/technical/") + + if technical_page.status_code == 200: + soup = BeautifulSoup(technical_page.text, 'html.parser') + aspect_ratio_element = soup.find(id="aspectratio") + aspect_ratios = aspect_ratio_element.find_all(class_="ipc-metadata-list-item__list-content-item") + aspect_ratios_text = [ratio.get_text() for ratio in aspect_ratios] + + aspect_ratios_float = [] + for text in aspect_ratios_text: + # Find all float or integer numbers in the string + numbers = re.findall(r"[\d.]+", text) + # Assuming the first number is the aspect ratio, convert to float and append + if numbers: + aspect_ratios_float.append(float(numbers[0])) + + return aspect_ratios_float + else: + raise Exception("Technical page not successfully resolved.") \ No newline at end of file diff --git a/src/service/__init__.py b/src/service/__init__.py new file mode 100644 index 0000000..0074322 --- /dev/null +++ b/src/service/__init__.py @@ -0,0 +1,3 @@ +from .zoom import ZoomService + +zoom_service = ZoomService() \ No newline at end of file diff --git a/src/service/zoom.py b/src/service/zoom.py new file mode 100644 index 0000000..12ba646 --- /dev/null +++ b/src/service/zoom.py @@ -0,0 +1,151 @@ +import xbmc +import time +from src.core import core +from src.content import ContentManager +from src.player import Player + +class ZoomService: + def __init__(self): + self.player = Player() + self.content = ContentManager(self.player) + self.player.set_onAVStarted_callback(self.start_service) + self.auto_refresh_status = False + + def reset_attributes(self): + self.auto_refresh_status = False + core.logger.on() + core.notification.on() + self.content.reset_attributes() + self.player.reset_attributes() + + def start_service(self): + if not core.addon.get_setting("automatically_execute", bool) or core.window.get_property("processing"): + return + + core.window.set_property("processing", True) + core.window.set_property("status", True) + + try: + self.reset_attributes() + + # Fetch IMDb metadata if multi-aspect ratios support is enabled or Android workaround is active + if core.addon.get_setting("multi_aspect_ratios_support", bool) or core.addon.get_setting("android_workaround", bool): + self.content.fetch_online_metadata() + + if self.content.multi_ar: + core.notification.notify("Multiple aspect ratios detected", override=True) + + self.auto_refresh_status = (self.content.multi_ar and not core.addon.get_setting("android_workaround", bool)) or core.addon.get_setting("always_refresh", bool) + + if self.auto_refresh_status: + self.auto_refresh_zoom() + else: + self.execute_zoom() + except Exception as e: + core.logger.log(e, xbmc.LOGERROR) + + core.window.clear_property("processing") + + def check_toggle_service(self, status: str): + if status == "on": + if core.window.get_property("toggle_on"): + self.start_service() + core.window.clear_property("toggle_on") + return True + elif status == "off": + if core.window.get_property("toggle_off"): + self.off_zoom() + core.window.clear_property("toggle_off") + return True + return False + + def auto_refresh_zoom(self): + refresh_interval = core.addon.get_setting("refresh_interval", int) + check_interval = 1 # How often to check the conditions in seconds + + while not core.monitor.abortRequested() and self.player.isPlayingVideo() and self.auto_refresh_status: + self.execute_zoom() + core.notification.off() + core.logger.off() + + start_time = time.time() # Record start time of the interval + + while True: + current_time = time.time() + elapsed = current_time - start_time + remaining_time = refresh_interval - elapsed + + if self.check_toggle_service("off"): + break + + if remaining_time <= 0: + break # Break immediately if the interval has completed + + # Use waitForAbort for the remaining time or the check interval, whichever is shorter + if core.monitor.waitForAbort(min(remaining_time, check_interval)): + break # Exit if waitForAbort returns True (Kodi is requesting an abort) + + if core.monitor.abortRequested(): + break + + core.logger.log("Exiting auto refresh zoom loop.", xbmc.LOGINFO) + + def execute_zoom(self): + zoom_amount = self._calculate_zoom() + + if zoom_amount is None: + core.logger.log("Unable to calculate zoom", xbmc.LOGERROR) + core.notification.notify("Unable to calculate zoom", override=True) + return + + # Execute the zoom via JSON-RPC + xbmc.executeJSONRPC( + '{"jsonrpc": "2.0", "method": "Player.SetViewMode", "params": {"viewmode": {"zoom": ' + str(zoom_amount) + ' }}, "id": 1}' + ) + + core.window.set_property("status", False) + + if zoom_amount > 1.0: + core.notification.notify(f"Adjusted zoom to {zoom_amount}") + + def off_zoom(self): + self.reset_attributes() + + xbmc.executeJSONRPC( + '{"jsonrpc": "2.0", "method": "Player.SetViewMode", "params": {"viewmode": {"zoom": 1.0' + ' }}, "id": 1}' + ) + core.window.set_property("status", False) + core.notification.notify("Showing original aspect ratio", override=True) + + def toggle_zoom(self): + if not self.player.isPlayingVideo(): + core.notification.notify("No video playing.", override=True) + return + + if core.window.get_property("status"): + core.window.set_property("toggle_off", True) + else: + core.window.set_property("toggle_on", True) + + def _calculate_zoom(self): + content_width, content_height = self.content.get_content_size() + + if content_width is None or content_height is None: + return None + + content_ar = self.content.get_content_ar() + core.logger.log(f"Content Dimension: {content_width:.2f}x{content_height:.2f}, Content Aspect Ratio: {content_ar:.2f}:1", level=xbmc.LOGINFO) + + monitor_width, monitor_height = self.player.get_monitor_size() + monitor_ar = self.player.get_monitor_ar() + core.logger.log(f"Monitor Size: {monitor_width}x{monitor_height}", level=xbmc.LOGINFO) + + if content_ar < monitor_ar: + # Fill to height + zoom_amount = round(float(monitor_height) / content_height, 2) + else: + # Fill to width + zoom_amount = round(float(monitor_width) / content_width, 2) + + core.logger.log(f"Zoom amount: {zoom_amount}", level=xbmc.LOGINFO) + return zoom_amount \ No newline at end of file