├── .github └── FUNDING.yml ├── .gitignore ├── .vscode ├── launch.json ├── ltex.dictionary.en-US.txt └── settings.json ├── LICENSE ├── README.md ├── favicon.ico ├── kamyroll-gui.png ├── kamyroll_gui ├── __init__.py ├── __main__.py ├── data_types │ ├── __init__.py │ ├── channel.py │ ├── locale.py │ ├── metadata.py │ ├── resolution.py │ ├── stream.py │ ├── stream_response.py │ ├── stream_type.py │ └── subtitle.py ├── download_dialog │ ├── __init__.py │ ├── argument_helper.py │ ├── download_dialog.py │ ├── download_selector.py │ ├── ffmpeg.py │ └── login_dialog.py ├── main_widget.py ├── settings.py ├── settings_dialog │ ├── __init__.py │ ├── filename_widget.py │ ├── settings_dialog.py │ ├── subtitle_widget.py │ └── video_widget.py ├── utils │ ├── __init__.py │ ├── api.py │ ├── blocking.py │ ├── filename.py │ ├── m3u8.py │ └── web_manager.py └── validated_url_input_dialog.py └── requirements.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: Grub4K 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | pyrightconfig.json 131 | 132 | /settings.json 133 | tempCodeRunnerFile.py -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Main Window", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "kamyroll_gui", 9 | "console": "integratedTerminal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/ltex.dictionary.en-US.txt: -------------------------------------------------------------------------------- 1 | Kamyroll-GUI 2 | Kamyroll-API 3 | ffmpeg 4 | reencode 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "editor.formatOnSave": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Simon Sawicki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kamyroll-GUI 2 | 3 | ![Kamyroll-GUI](kamyroll-gui.png) 4 | 5 | A GUI frontend for the Kamyroll-API using Python and PySide6 6 | 7 | ## Usage 8 | 9 | When starting the application you will be presented with a list and some buttons on the right. 10 | If you are starting it for the first time it will set up some default settings. 11 | You can change them by clicking the `Settings` button and changing the values there. 12 | 13 | After you are done with settings, you can add links by clicking the `+ Add` button. 14 | It will open a dialog box where you can paste a link. 15 | If the link is supported it will show a green message. 16 | Click `OK` to add the link to the list. 17 | 18 | After adding all your links you can click: 19 | 20 | - The `Download Subtitles` to only download subtitles 21 | - The `Download All` button to download 22 | 23 | While the download window is active you might get prompted for alternative settings 24 | or if a file should be overwritten. 25 | 26 | After the download is finished, there will be a popup. 27 | You can now close the download window. 28 | 29 | ## Settings 30 | 31 | Output directory is the base directory into which the files will be written. 32 | Click the `Browse` button to change the parameter. 33 | 34 | ### Filename format 35 | 36 | The settings menu has two fields where a "filename format" is accepted, 37 | `Episode filename format` and `Movie fiename format` 38 | These use python string formatting, everything inside curly braces (`{}`) 39 | will be replaced with a value, if it is supported. 40 | For example `{series} - {episode}` will become `One Piece - 1`. 41 | Use `{{` and `}}` if you want to use `{` or `}` literally. 42 | For more information read the [Python documentation](https://docs.python.org/3/library/string.html#format-string-syntax). 43 | 44 | These values are available for formatting: 45 | 46 | - `title`: The title of the media 47 | - `duration`: The duration of the video in milliseconds 48 | - `description`: A description 49 | - `year`: The release year 50 | 51 | In addition, for an episode these values are available: 52 | 53 | - `series`: The series the episode is from 54 | - `season`: The number of the season 55 | - `season_name`: The name of the season 56 | - `episode`: The number of the episode 57 | - `episode_disp`: A string value representing the number 58 | - For something like specials it might show `Special 1` 59 | - `date`: The release date 60 | 61 | ### Write separate subtitle files 62 | 63 | This option will enable you to write a `.mp4` file and many `.ass` files 64 | instead of a single `.mkv` file. 65 | To help structure it clearly, there is also a field called `Subtitle prefix`. 66 | If used the file will be prefixed with that name. 67 | 68 | If the movie file was `One Piece/One Piece - 01.mp4` 69 | and the subtitle prefix was `subtitles`, 70 | then the output filename for the subtitle would be 71 | `One Piece/subtitles/One Piece - 01.eng.ass` 72 | 73 | ### Write metadata 74 | 75 | This will write metadata like episode title or the cover picture to the file. 76 | 77 | ### Compress streams 78 | 79 | This will make ffmpeg reencode the video. 80 | Use this only if you know what you are doing. 81 | Checking this will slow down the download. 82 | 83 | ### Use own login credentials 84 | 85 | If you don't want to use the bypasses available 86 | you can also provide your own login data. 87 | If this is checked it will prompt you for 88 | your email and password on download. 89 | 90 | ### Use strict matching 91 | 92 | Sometimes some subtitles or resolutions might not be available. 93 | If you don't check this box subtitles that are not available will be ignored and 94 | if a resolution is not available it will automatically select a lower resolution. 95 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grub4K/kamyroll-gui/c6e9875d8da82d65a51b81a9cf533b33822b33de/favicon.ico -------------------------------------------------------------------------------- /kamyroll-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grub4K/kamyroll-gui/c6e9875d8da82d65a51b81a9cf533b33822b33de/kamyroll-gui.png -------------------------------------------------------------------------------- /kamyroll_gui/__init__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import logging 3 | import sys 4 | 5 | from pathlib import Path 6 | from datetime import datetime 7 | 8 | from PySide6.QtGui import QIcon 9 | from PySide6 import __version__ as pyside_version 10 | from PySide6.QtCore import __version__ as qt_version 11 | from PySide6.QtWidgets import QApplication 12 | 13 | from kamyroll_gui.main_widget import MainWidget 14 | 15 | 16 | 17 | filename = datetime.now().strftime("logs/kamyroll_%Y-%m-%d_%H-%M-%S.log") 18 | logfile = Path(filename) 19 | logfile.parent.mkdir(exist_ok=True) 20 | 21 | logging.basicConfig(level=logging.DEBUG, style='{', 22 | format='{asctime} | {name:<90} | {levelname:<8} | {message}', 23 | filename=str(logfile), filemode='w') 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | logger.info("Python version: %s", sys.version) 28 | logger.info("PySide version: %s (Qt v%s)", pyside_version, qt_version) 29 | 30 | app = QApplication([]) 31 | app_icon = QIcon() 32 | app_icon.addFile("favicon.ico") 33 | app.setWindowIcon(app_icon) 34 | 35 | widget = MainWidget() 36 | widget.show() 37 | sys.exit(app.exec()) 38 | -------------------------------------------------------------------------------- /kamyroll_gui/__main__.py: -------------------------------------------------------------------------------- 1 | from kamyroll_gui import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .locale import Locale 2 | from .stream import Stream, StreamType 3 | from .subtitle import Subtitle 4 | from .channel import Channel 5 | from .stream_response import StreamResponse, StreamResponseType 6 | from .resolution import Resolution 7 | from .metadata import EpisodeMetadata, MovieMetadata 8 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/channel.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | 5 | class Channel(Enum): 6 | CRUNCHYROLL = "crunchyroll" 7 | FUNIMATION = "funimation" 8 | ADN = "adn" 9 | 10 | def __str__(self, /): 11 | return _CLEAR_NAME_LOOKUP[self] 12 | 13 | _CLEAR_NAME_LOOKUP = { 14 | Channel.CRUNCHYROLL: "Crunchyroll", 15 | Channel.FUNIMATION: "Funimation", 16 | Channel.ADN: "Anime Digital Network", 17 | } 18 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/locale.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | 5 | class Locale(Enum): 6 | """Represents a Locale.""" 7 | #: Explicitly no locale 8 | NONE = "" 9 | #: Unknown locale 10 | UNDEFINED = "und" 11 | ENGLISH_US = "en-US" 12 | PORTUGUESE_BR = "pt-BR" 13 | PORTUGUESE_PT = "pt-PT" 14 | SPANISH_419 = "es-419" 15 | SPANISH_ES = "es-ES" 16 | FRENCH_FR = "fr-FR" 17 | ARABIC_ME = "ar-ME" 18 | ARABIC_SA = "ar-SA" 19 | ITALIAN_IT = "it-IT" 20 | GERMAN_DE = "de-DE" 21 | RUSSIAN_RU = "ru-RU" 22 | TURKISH_TR = "tr-TR" 23 | JAPANESE_JP = "ja-JP" 24 | CHINESE_CN = "zh-CN" 25 | 26 | def to_iso_639_2(self, /): 27 | """Get the ISO-639-2 language code.""" 28 | return _TO_ISO_639_2[self] 29 | 30 | def __str__(self, /): 31 | """Get the clear name for a locale""" 32 | return _TO_CLEAR_NAME[self] 33 | 34 | def __repr__(self, /): 35 | return f'<{self.__class__.__name__}.{self.name}>' 36 | 37 | _TO_CLEAR_NAME = { 38 | Locale.NONE: "None", 39 | Locale.UNDEFINED: "Undefined", 40 | Locale.ENGLISH_US: "English (USA)", 41 | Locale.PORTUGUESE_BR: "Portuguese (Brazil)", 42 | Locale.PORTUGUESE_PT: "Portuguese (Portugal)", 43 | Locale.SPANISH_419: "Spanish (Latinoamerica)", 44 | Locale.SPANISH_ES: "Spanish (Spain)", 45 | Locale.FRENCH_FR: "French", 46 | Locale.ARABIC_ME: "Arabic (Montenegro)", 47 | Locale.ARABIC_SA: "Arabic (Saudi Arabia)", 48 | Locale.ITALIAN_IT: "Italian", 49 | Locale.GERMAN_DE: "German", 50 | Locale.RUSSIAN_RU: "Russian", 51 | Locale.TURKISH_TR: "Turkish", 52 | Locale.JAPANESE_JP: "Japanese", 53 | Locale.CHINESE_CN: "Chinese", 54 | } 55 | 56 | _TO_ISO_639_2 = { 57 | Locale.NONE: "", 58 | Locale.ENGLISH_US: "eng", 59 | Locale.PORTUGUESE_BR: "por", 60 | Locale.PORTUGUESE_PT: "por", 61 | Locale.SPANISH_419: "spa", 62 | Locale.SPANISH_ES: "spa", 63 | Locale.FRENCH_FR: "fra", 64 | Locale.ARABIC_ME: "ara", 65 | Locale.ARABIC_SA: "ara", 66 | Locale.ITALIAN_IT: "ita", 67 | Locale.GERMAN_DE: "deu", 68 | Locale.RUSSIAN_RU: "rus", 69 | Locale.TURKISH_TR: "tur", 70 | Locale.JAPANESE_JP: "jpn", 71 | Locale.CHINESE_CN: "zho", 72 | } 73 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime, timedelta 3 | 4 | 5 | 6 | @dataclass 7 | class Metadata: 8 | title: str 9 | duration: timedelta 10 | description: str 11 | year: int 12 | 13 | 14 | @dataclass 15 | class EpisodeMetadata(Metadata): 16 | series: str 17 | season: int 18 | season_name: str 19 | episode: int 20 | episode_disp: str 21 | date: datetime 22 | 23 | 24 | @dataclass 25 | class MovieMetadata(Metadata): 26 | pass 27 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/resolution.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | 5 | class Resolution(IntEnum): 6 | R1080 = 1080 7 | R720 = 720 8 | R540 = 540 9 | R480 = 480 10 | R432 = 432 11 | R360 = 360 12 | R240 = 240 13 | R234 = 234 14 | R80 = 80 15 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/stream.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .locale import Locale 4 | from .stream_type import StreamType 5 | 6 | 7 | 8 | @dataclass 9 | class Stream: 10 | type: StreamType 11 | type_name: str 12 | audio_locale: Locale 13 | hardsub_locale: Locale 14 | url: str 15 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/stream_response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | from .channel import Channel 5 | from .stream import Stream 6 | from .subtitle import Subtitle 7 | from .metadata import Metadata 8 | 9 | 10 | 11 | class StreamResponseType(Enum): 12 | EPISODE = "episode" 13 | MOVIE = "movie" 14 | 15 | 16 | @dataclass 17 | class StreamResponse: 18 | type: StreamResponseType 19 | channel: Channel 20 | metadata: Metadata 21 | images: dict[str, str] 22 | streams: list[Stream] 23 | subtitles: list[Subtitle] 24 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/stream_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | 5 | class StreamType(Enum): 6 | ADAPTIVE_HLS = "adaptive_hls" 7 | MOBILE_MP4 = "mobile_mp4" 8 | 9 | def __str__(self, /): 10 | return _CLEAR_NAME_LOOKUP[self] 11 | 12 | def __repr__(self, /): 13 | return f'<{self.__class__.__name__}.{self.name}>' 14 | 15 | 16 | _CLEAR_NAME_LOOKUP = { 17 | StreamType.ADAPTIVE_HLS: "Adaptive, HLS", 18 | StreamType.MOBILE_MP4: "Mobile, mp4, HLS", 19 | } 20 | -------------------------------------------------------------------------------- /kamyroll_gui/data_types/subtitle.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .locale import Locale 3 | 4 | 5 | @dataclass 6 | class Subtitle: 7 | locale: Locale 8 | url: str 9 | format: str 10 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from .download_dialog import DownloadDialog 2 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/argument_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import dataclasses 3 | import tempfile 4 | 5 | from ..utils.filename import format_name 6 | from ..utils.web_manager import web_manager 7 | from ..data_types.metadata import EpisodeMetadata 8 | 9 | 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_arguments(settings, selection, metadata, images, subtitles_only, /): 15 | output_path = _get_output_path(settings, metadata) 16 | output_path.parent.mkdir(parents=True, exist_ok=True) 17 | 18 | arguments = _get_input_args(selection, subtitles_only) 19 | 20 | if not subtitles_only: 21 | image_mapping_args = [] 22 | if settings.write_metadata: 23 | poster = images.get("poster_tall") 24 | if poster is not None: 25 | position = len(arguments) // 2 26 | image_input_args, image_mapping_args = _get_image_args( 27 | poster, position, settings.separate_subtitles) 28 | arguments += image_input_args 29 | 30 | if not settings.compress_streams: 31 | arguments.extend(["-c:a", "copy", "-c:v", "copy"]) 32 | 33 | if settings.write_metadata: 34 | arguments += _get_metadata_args(selection, metadata) 35 | 36 | arguments += _get_video_mapping_args(selection) 37 | if not settings.separate_subtitles: 38 | arguments += _get_subtitle_mapping_args(selection) 39 | if settings.write_metadata: 40 | arguments += image_mapping_args 41 | 42 | arguments.append(str(output_path)) 43 | 44 | if settings.separate_subtitles or subtitles_only: 45 | subtitle_path = output_path.parent.joinpath( 46 | settings.subtitle_prefix, output_path.name) 47 | subtitle_path.parent.mkdir(parents=True, exist_ok=True) 48 | 49 | arguments += _get_separate_subtitle_args(selection, 50 | subtitle_path, subtitles_only) 51 | 52 | _logger.debug("Constructed ffmpeg arguments: %s", arguments) 53 | return arguments 54 | 55 | 56 | def _get_output_path(settings, metadata): 57 | format_data = dataclasses.asdict(metadata) 58 | if isinstance(metadata, EpisodeMetadata): 59 | filename = format_name(settings.episode_format, format_data) 60 | else: # elif isinstance(metadata, MovieMetadata): 61 | filename = format_name(settings.movie_format, format_data) 62 | 63 | if settings.separate_subtitles: 64 | return settings.download_path.joinpath(filename + ".mp4") 65 | 66 | return settings.download_path.joinpath(filename + ".mkv") 67 | 68 | 69 | def _get_input_args(download_selection, subtitles_only): 70 | input_args = [] 71 | 72 | if not subtitles_only: 73 | input_args.extend(["-i", download_selection.url]) 74 | for subtitle in download_selection.subtitles: 75 | input_args.extend(["-i", subtitle.url]) 76 | 77 | return input_args 78 | 79 | 80 | def _get_video_mapping_args(download_selection): 81 | mapping_args = [] 82 | 83 | for program_id in download_selection.program_ids: 84 | mapping_args.extend(["-map", f"0:p:{program_id}:v?"]) 85 | mapping_args.extend(["-map", f"0:p:{program_id}:a?"]) 86 | 87 | hardsub_info = download_selection.hardsub_info 88 | if not hardsub_info.is_native: 89 | mapping_args.extend(['-vf', f'subtitles={hardsub_info.url}']) 90 | 91 | return mapping_args 92 | 93 | 94 | def _get_subtitle_mapping_args(download_selection): 95 | arguments = [] 96 | 97 | # We have one prior argument, so start from 1 98 | index_range = range(1, len(download_selection.subtitles)+1) 99 | for index in index_range: 100 | arguments.extend(["-map", str(index)]) 101 | 102 | return arguments 103 | 104 | 105 | def _get_separate_subtitle_args(download_selection, base_path, subtitles_only): 106 | arguments = [] 107 | 108 | start = 0 if subtitles_only else 1 109 | for index, subtitle in enumerate(download_selection.subtitles, start): 110 | arguments.extend(["-map", str(index)]) 111 | 112 | subtitle_language = subtitle.locale.to_iso_639_2() 113 | suffix = f".{subtitle_language}.ass" 114 | sub_output_path = base_path.with_suffix(suffix) 115 | arguments.append(str(sub_output_path)) 116 | 117 | return arguments 118 | 119 | 120 | def _get_metadata_args(download_selection, metadata, /): 121 | arguments = [] 122 | 123 | audio_language = download_selection.audio_locale.to_iso_639_2() 124 | arguments.extend(["-metadata:s:a:0", f"language={audio_language}"]) 125 | 126 | hardsub_language = download_selection.hardsub_info.locale.to_iso_639_2() 127 | if hardsub_language: 128 | arguments.extend(["-metadata:s:v:0", f"language={hardsub_language}"]) 129 | 130 | for index, subtitle in enumerate(download_selection.subtitles): 131 | sublang = subtitle.locale.to_iso_639_2() 132 | arguments.extend([f"-metadata:s:s:{index}", f"language={sublang}"]) 133 | 134 | arguments.extend([ 135 | "-metadata", f"title={metadata.title}", 136 | "-metadata", f"year={metadata.year}", 137 | "-metadata", f"description={metadata.description}", 138 | ]) 139 | 140 | if isinstance(metadata, EpisodeMetadata): 141 | arguments.extend([ 142 | "-metadata", f"show={metadata.series}", 143 | "-metadata", f"season_number={metadata.season}", 144 | "-metadata", f"episode_sort={metadata.episode}", 145 | "-metadata", f"episode_id={metadata.episode_disp}", 146 | "-metadata", f"date={metadata.date}", 147 | ]) 148 | 149 | return arguments 150 | 151 | def _get_image_args(image, position, is_mp4): 152 | filename = _get_temp_image_file(image) 153 | if is_mp4: 154 | return ([ 155 | "-i", filename 156 | ], [ 157 | "-map", str(position), 158 | # "-c:v:1", "mjpeg", 159 | f"-disposition:v:1", "attached_pic", 160 | ]) 161 | 162 | return ([ 163 | ], [ 164 | "-attach", filename, 165 | "-metadata:s:t", "mimetype=image/jpeg", 166 | ]) 167 | 168 | def _get_temp_image_file(image): 169 | data = web_manager.get(image) 170 | with tempfile.NamedTemporaryFile("wb", prefix="kamyroll_", 171 | suffix=".jpeg", delete=False) as file: 172 | _logger.debug("Created tempfile: %s", file.name) 173 | file.write(data) 174 | 175 | return file.name 176 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/download_dialog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import asdict 3 | 4 | from PySide6.QtCore import QTimer, Qt 5 | from PySide6.QtGui import QFont 6 | from PySide6.QtWidgets import ( 7 | QDialog, 8 | QLabel, 9 | QMessageBox, 10 | QProgressBar, 11 | QTextEdit, 12 | QVBoxLayout, 13 | ) 14 | 15 | from ..settings_dialog.settings_dialog import SettingsDialog 16 | from ..data_types import StreamResponseType 17 | from ..settings import manager 18 | from ..utils import api 19 | 20 | from .argument_helper import get_arguments 21 | from .login_dialog import LoginDialog 22 | from .ffmpeg import FFmpeg 23 | from .download_selector import ( 24 | SelectionError, 25 | selection_from_stream_response, 26 | selection_from_subtitle_list, 27 | subtitles_from_stream_response, 28 | ) 29 | 30 | 31 | 32 | TOTAL_BASE_FORMAT = "Downloading {type} {index} of {total}:" 33 | EPISODE_BASE_FORMAT = "{series} Season {season} Episode {episode_disp}" 34 | MOVIE_BASE_FORMAT = "{title}" 35 | 36 | class DownloadDialog(QDialog): 37 | _logger = logging.getLogger(__name__).getChild(__qualname__) 38 | 39 | def __init__(self, parent, links, /, subtitle_only=False, strict=True): 40 | super().__init__(parent) 41 | self.setWindowTitle("Download - Kamyroll") 42 | self.setFixedSize(600, 400) 43 | self.setAttribute(Qt.WA_DeleteOnClose) 44 | 45 | self.credentials = {} 46 | self.settings = manager.settings 47 | 48 | self.halt_execution = False 49 | self.links = links 50 | self.length = len(links) 51 | self.subtitle_only = subtitle_only 52 | self.type_name = "subtitle" if subtitle_only else "item" 53 | self.position = 0 54 | self.is_running = False 55 | self.strict = strict 56 | self.ask_login = self.settings.use_own_credentials 57 | self.successful_items = [] 58 | 59 | layout = QVBoxLayout() 60 | self.setLayout(layout) 61 | 62 | self.progress_label = QLabel() 63 | layout.addWidget(self.progress_label) 64 | 65 | self.overall_progress = QProgressBar() 66 | self.overall_progress.setMaximum(len(links)) 67 | self.overall_progress.setValue(self.position) 68 | layout.addWidget(self.overall_progress) 69 | 70 | self.ffmpeg_progress_label = QLabel() 71 | layout.addWidget(self.ffmpeg_progress_label) 72 | 73 | self.ffmpeg_progress = QProgressBar() 74 | self.ffmpeg_progress.setMaximum(1) 75 | self.ffmpeg_progress.setValue(0) 76 | layout.addWidget(self.ffmpeg_progress) 77 | 78 | self.text_edit = QTextEdit() 79 | text_font = QFont("Monospace") 80 | text_font.setStyleHint(QFont.TypeWriter) 81 | self.text_edit.setFont(text_font) 82 | self.text_edit.setReadOnly(True) 83 | self.text_edit.setLineWrapMode(QTextEdit.NoWrap) 84 | layout.addWidget(self.text_edit) 85 | 86 | self.ffmpeg = FFmpeg(self, self.ffmpeg_progress, self.text_edit, 87 | self.ffmpeg_success, self.ffmpeg_fail) 88 | self.is_running = True 89 | QTimer.singleShot(0, self.enqueue_next_download) 90 | 91 | def enqueue_next_download(self, /): 92 | if self.position >= self.length: 93 | return 94 | 95 | self.ffmpeg_progress.setValue(0) 96 | self.ffmpeg_progress.setMaximum(0) 97 | self.ffmpeg_progress_label.setText("Querying api") 98 | current_item = self.links[self.position] 99 | self.progress_label.setText(TOTAL_BASE_FORMAT.format( 100 | type=self.type_name, index=self.position+1, total=self.length)) 101 | self.overall_progress.setValue(self.position) 102 | parsed_data = api.parse_url(current_item) 103 | if parsed_data is None: 104 | raise ValueError("Somehow the url is not a valid one") 105 | channel_id, params = parsed_data 106 | 107 | settings = self.settings 108 | 109 | username = None 110 | password = None 111 | if self.ask_login: 112 | if channel_id in self.credentials: 113 | username, password = self.credentials[channel_id] 114 | else: 115 | dialog = LoginDialog(self) 116 | if self.halt_execution: 117 | return 118 | if dialog.exec() == QDialog.Accepted: 119 | data = dialog.get_data() 120 | username, password = data 121 | self.credentials[channel_id] = data 122 | else: 123 | # Disable the dialog next time 124 | self.ask_login = False 125 | 126 | try: 127 | stream_response = api.get_media(channel_id, params, username, password) 128 | except api.ApiError as error: 129 | message = f"The api call failed:\n{error}" 130 | QMessageBox.critical(self, "Error - Kamyroll", message) 131 | if self.halt_execution: 132 | return 133 | QTimer.singleShot(0, self.safe_enqueue_next) 134 | return 135 | 136 | format_data = asdict(stream_response.metadata) 137 | if stream_response.type is StreamResponseType.EPISODE: 138 | info_text = EPISODE_BASE_FORMAT.format(**format_data) 139 | else: # elif stream_response.type is StreamResponseType.MOVIE: 140 | info_text = MOVIE_BASE_FORMAT.format(**format_data) 141 | 142 | sub_label_text = f"Downloading {info_text}:" 143 | self.ffmpeg_progress_label.setText(sub_label_text) 144 | 145 | try: 146 | try: 147 | if self.halt_execution: 148 | return 149 | selection = self.get_selection(stream_response, settings) 150 | except SelectionError as error: 151 | dialog = SettingsDialog(self, settings, stream_response, 152 | self.subtitle_only) 153 | if self.halt_execution: 154 | return 155 | if dialog.exec() != QDialog.Accepted: 156 | QTimer.singleShot(0, self.safe_enqueue_next) 157 | return 158 | 159 | new_settings = dialog.settings 160 | if dialog.apply_to_all: 161 | self.settings = new_settings 162 | if self.halt_execution: 163 | return 164 | selection = self.get_selection(stream_response, new_settings) 165 | except Exception as error: 166 | self._logger.error("Error during selection: %s", error) 167 | QMessageBox.critical(self, "Download Error - Kamyroll", str(error)) 168 | if self.halt_execution: 169 | return 170 | QTimer.singleShot(0, self.safe_enqueue_next) 171 | return 172 | 173 | arguments = get_arguments(settings, selection, stream_response.metadata, 174 | stream_response.images, self.subtitle_only) 175 | self.ffmpeg.start(arguments, stream_response.metadata.duration) 176 | 177 | def get_selection(self, stream_response, settings, /): 178 | if self.subtitle_only: 179 | selected_subtitles = subtitles_from_stream_response( 180 | stream_response, settings) 181 | selection = selection_from_subtitle_list(selected_subtitles) 182 | else: 183 | selection = selection_from_stream_response(stream_response, 184 | settings) 185 | return selection 186 | 187 | def ffmpeg_fail(self, /): 188 | self.ffmpeg.stop() 189 | QMessageBox.critical(self, "Error - Kamyroll", "The download failed.") 190 | self.safe_enqueue_next() 191 | 192 | def ffmpeg_success(self, /): 193 | self.successful_items.append(self.position) 194 | self.safe_enqueue_next() 195 | 196 | def safe_enqueue_next(self, /): 197 | if self.halt_execution: 198 | return 199 | 200 | self.position += 1 201 | 202 | if self.position < self.length: 203 | QTimer.singleShot(0, self.enqueue_next_download) 204 | return 205 | 206 | self.is_running = False 207 | self.progress_label.setText(TOTAL_BASE_FORMAT.format( 208 | type=self.type_name, index=self.length, total=self.length)) 209 | self.overall_progress.setValue(self.length) 210 | self.ffmpeg_progress.setMaximum(1) 211 | self.ffmpeg_progress.setValue(1) 212 | if self.successful_items: 213 | QMessageBox.information(self, "Info - Kamyroll", "The download is finished.") 214 | else: 215 | QMessageBox.information(self, "Info - Kamyroll", "No items were downloaded.") 216 | 217 | def reject(self): 218 | if not self.is_running: 219 | self.halt_execution = True 220 | self.ffmpeg.stop() 221 | return super().accept() 222 | 223 | response = QMessageBox.question(self, "Terminate download? - Kamyroll", 224 | "A Download is in progress. Exiting now will terminate the download progess.\n\nAre you sure you want to quit?") 225 | if response == QMessageBox.Yes: 226 | self.ffmpeg.stop() 227 | return super().reject() 228 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/download_selector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dataclasses import dataclass 4 | 5 | from ..utils import m3u8 6 | from ..utils.web_manager import web_manager 7 | from ..data_types import ( 8 | Locale, 9 | Subtitle, 10 | ) 11 | 12 | 13 | 14 | _logger = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass 18 | class HardsubInfo: 19 | is_native: bool 20 | locale: Locale 21 | url: str 22 | 23 | 24 | @dataclass 25 | class DownloadSelection: 26 | url: str 27 | audio_locale: Locale 28 | program_ids: list[int] 29 | hardsub_info: HardsubInfo 30 | subtitles: list[Subtitle] 31 | 32 | 33 | class SelectionError(Exception): 34 | pass 35 | 36 | 37 | def selection_from_stream_response(stream_response, settings): 38 | # Match on subtitle settings 39 | selected_subtitles = subtitles_from_stream_response( 40 | stream_response, settings) 41 | 42 | # match on video settings 43 | audio_matching_streams = [ 44 | stream 45 | for stream in stream_response.streams 46 | if stream.audio_locale == settings.audio_locale 47 | ] 48 | if not audio_matching_streams: 49 | raise SelectionError("Could not find matching audio locale") 50 | 51 | matching_streams = [ 52 | stream 53 | for stream in audio_matching_streams 54 | if stream.hardsub_locale == settings.hardsub_locale 55 | ] 56 | 57 | if matching_streams: 58 | hardsub_is_native = True 59 | hardsub_url = "" 60 | else: 61 | # We can bake softsubs in as a replacement for native hardsubs 62 | hardsub_is_native = False 63 | matching_streams = [ 64 | stream 65 | for stream in audio_matching_streams 66 | if stream.hardsub_locale == Locale.NONE 67 | ] 68 | matching_subtitles = [ 69 | subtitle 70 | for subtitle in stream_response.subtitles 71 | if subtitle.locale == settings.hardsub_locale 72 | ] 73 | if not bool(matching_streams) or not bool(matching_subtitles): 74 | raise SelectionError("Could not find matching hardsub locale") 75 | 76 | hardsub_url = matching_subtitles[0].url 77 | 78 | hardsub_info = HardsubInfo(is_native=hardsub_is_native, 79 | locale=settings.hardsub_locale, url=hardsub_url) 80 | 81 | # Get program ids to select the correct resolution 82 | program_url = matching_streams[0].url 83 | data = web_manager.get(program_url).decode() 84 | resolutions = m3u8.get_resolutions(data) 85 | if settings.video_height in resolutions: 86 | program_ids = resolutions[settings.video_height] 87 | else: 88 | if settings.strict_matching: 89 | raise SelectionError("Desired resolution not available") 90 | suitable_resolutions = [ 91 | resolution 92 | for resolution in sorted(resolutions, reverse=True) 93 | if resolution <= settings.video_height 94 | ] 95 | if not suitable_resolutions: 96 | raise SelectionError("Desired resolution or smaller not available") 97 | program_ids = resolutions[suitable_resolutions[0]] 98 | 99 | return DownloadSelection(url=program_url, 100 | audio_locale=settings.audio_locale, hardsub_info=hardsub_info, 101 | subtitles=selected_subtitles, program_ids=program_ids) 102 | 103 | 104 | def subtitles_from_stream_response(stream_response, settings): 105 | desired_subtitles = set(settings.subtitle_locales) 106 | 107 | selected_subtitles = [] 108 | for subtitle in stream_response.subtitles: 109 | # Take only the first subtitle of a matching locale 110 | if subtitle.locale in desired_subtitles: 111 | selected_subtitles.append(subtitle) 112 | desired_subtitles.remove(subtitle.locale) 113 | 114 | if desired_subtitles: 115 | missing_locale_names = ", ".join(map(str, desired_subtitles)) 116 | message = f"Missing subtitle locale(s): {missing_locale_names}" 117 | if settings.strict_matching: 118 | raise SelectionError(message) 119 | 120 | _logger.warning(message) 121 | 122 | return selected_subtitles 123 | 124 | 125 | def selection_from_subtitle_list(subtitles): 126 | hardsub_info = HardsubInfo(is_native=True, locale=Locale.NONE, url="") 127 | return DownloadSelection(url="", audio_locale=Locale.NONE, 128 | hardsub_info=hardsub_info, program_ids=[], subtitles=subtitles) 129 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from datetime import timedelta 5 | 6 | from PySide6.QtCore import QProcess 7 | from PySide6.QtWidgets import QMessageBox 8 | 9 | 10 | 11 | PROGRESS_REGEX = re.compile(r"" 12 | # output file size 13 | + r"size=(?:(?:N/A)|(?:\s*?(?P-?\d+)kB)) " 14 | # output file length as time 15 | + r"time=(?:(?:N/A)|(?:(?P-?\d+):(?P\d+):(?P\d+\.\d+))) " 16 | ) 17 | 18 | 19 | class FFmpeg: 20 | _logger = logging.getLogger(__name__).getChild(__qualname__) 21 | 22 | def __init__(self, /, parent, progress, text_edit, 23 | success_callback, fail_callback): 24 | self.parent = parent 25 | self.success_callback = success_callback 26 | self.fail_callback = fail_callback 27 | self.is_stopped = True 28 | self.first_update = True 29 | self.max_time: timedelta 30 | 31 | self.leftover_bytes = b"" 32 | 33 | self.progress = progress 34 | self.text_edit = text_edit 35 | 36 | self.process = QProcess() 37 | self.process.readyReadStandardError.connect(self.readAll) 38 | self.process.finished.connect(self.finished) 39 | 40 | def start(self, arguments, max_time, /): 41 | self.max_time = max_time 42 | self.progress.setMaximum(0) 43 | 44 | prepended_args = [ 45 | "-hide_banner", 46 | "-stats", 47 | "-loglevel", "error", 48 | "-reconnect", "1", 49 | #"-reconnect_at_eof", "1", 50 | "-reconnect_streamed", "1", 51 | "-reconnect_on_network_error", "1", 52 | "-user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36 Edg/94.0.992.50", 53 | ] 54 | arguments = prepended_args + arguments 55 | 56 | self.is_stopped = False 57 | self.first_update = True 58 | self._logger.info("Started ffmpeg process with arguments: %r", arguments) 59 | self.process.start("ffmpeg", arguments) 60 | 61 | def stop(self, /): 62 | self.is_stopped = True 63 | self.process.kill() 64 | self._logger.info("FFmpeg process stopped") 65 | 66 | def readAll(self, /): 67 | data = bytes(self.process.readAllStandardError()) 68 | self._logger.debug("Read data: %s", data) 69 | self._process_data(data) 70 | 71 | def _process_data(self, chunk, /): 72 | if self.is_stopped: 73 | return 74 | # newline translation 75 | chunk = chunk.replace(b"\r\n", b"\n") 76 | chunk = chunk.replace(b"\r", b"\n") 77 | if b"\n" in chunk: 78 | split_chunk = chunk.split(b"\n") 79 | split_chunk[0] = self.leftover_bytes + split_chunk[0] 80 | self.leftover_bytes = split_chunk.pop() 81 | 82 | for line in map(bytes.decode, split_chunk): 83 | if not line: 84 | continue 85 | self.text_edit.insertPlainText(line) 86 | self.text_edit.insertPlainText("\n") 87 | self._process_line(line) 88 | else: 89 | self.leftover_bytes += chunk 90 | 91 | if self.leftover_bytes.endswith(b"[y/N] "): 92 | line = self.leftover_bytes.decode() 93 | self.text_edit.insertPlainText(line) 94 | question = line.rstrip("[y/N] ") 95 | self.ask_question(question) 96 | self.leftover_bytes = b"" 97 | 98 | def ask_question(self, question, /): 99 | reply = QMessageBox.question(self.parent, 100 | "Question - FFmpeg - Kamyroll", question) 101 | response = "y\n" if reply == QMessageBox.Yes else "n\n" 102 | self.text_edit.insertPlainText(response) 103 | self.process.write(response.encode()) 104 | 105 | def _process_line(self, line, /): 106 | if self.is_stopped: 107 | return 108 | 109 | if line.startswith("["): 110 | self.is_stopped = True 111 | self.process.kill() 112 | 113 | module, _, message = line.partition("] ") 114 | module, _, offset = module[1:].partition("@") 115 | module = module.strip() 116 | offset = offset.strip() 117 | message, _, data = message.partition(": ") 118 | info_line = "\n".join([ 119 | f"Module: {module}", 120 | f"Offset: {offset}", 121 | f"Message: {message}", 122 | f"Data: {data}" 123 | ]) 124 | QMessageBox.critical(self.parent, "Error - FFmpeg - Kamyroll", info_line) 125 | self._logger.error("FFmpeg error: %s", line) 126 | 127 | self.fail_callback() 128 | return 129 | 130 | match = PROGRESS_REGEX.search(line) 131 | if match: 132 | try: 133 | hours = int(match["hours"]) 134 | minutes = int(match["minutes"]) 135 | seconds = float(match["seconds"]) 136 | parsed_time = timedelta(hours=hours, minutes=minutes, seconds=seconds) 137 | except ValueError: 138 | return 139 | if self.first_update: 140 | maximum = int(self.max_time.total_seconds()) 141 | self.progress.setMaximum(maximum) 142 | 143 | if parsed_time > self.max_time: 144 | self.progress.setMaximum(0) 145 | self.progress.setValue(0) 146 | return 147 | 148 | self.progress.setValue(int(parsed_time.total_seconds())) 149 | return 150 | 151 | QMessageBox.information(self.parent, "Info - FFmpeg - Kamyroll", line) 152 | 153 | def finished(self, exit_code, status: QProcess.ExitStatus, /): 154 | if status == QProcess.ExitStatus.CrashExit: 155 | if not self.is_stopped: 156 | self._logger.info("FFmpeg process crashed (%s)", exit_code) 157 | self.fail_callback() 158 | return 159 | 160 | # status == NormalExit 161 | self.progress.setMaximum(1) 162 | self.progress.setValue(1) 163 | self._logger.info("FFmpeg process exited successfully (%s)", exit_code) 164 | self.success_callback() 165 | -------------------------------------------------------------------------------- /kamyroll_gui/download_dialog/login_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import ( 2 | QDialog, 3 | QDialogButtonBox, 4 | QFormLayout, 5 | QLabel, 6 | QLineEdit, 7 | QVBoxLayout, 8 | ) 9 | 10 | 11 | 12 | class LoginDialog(QDialog): 13 | def __init__(self, parent, /): 14 | super().__init__(parent) 15 | self.setWindowTitle("Input Login data - Kamyroll") 16 | 17 | self.form_layout = QFormLayout() 18 | self.setLayout(self.form_layout) 19 | 20 | self.username = QLineEdit() 21 | self.username.setTextMargins(5, 5, 5, 5) 22 | self.username.setPlaceholderText("Enter E-Mail") 23 | self.username.textEdited.connect(self.validate) 24 | self.add_input_row("E-Mail", self.username) 25 | 26 | self.password = QLineEdit() 27 | self.password.setTextMargins(5, 5, 5, 5) 28 | self.password.setEchoMode(QLineEdit.Password) 29 | self.password.setPlaceholderText("Enter Password") 30 | self.password.textEdited.connect(self.validate) 31 | self.add_input_row("Password", self.password) 32 | 33 | buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 34 | self.ok_button = buttonBox.button(QDialogButtonBox.Ok) 35 | self.form_layout.addWidget(buttonBox) 36 | 37 | buttonBox.accepted.connect(self.accept) 38 | buttonBox.rejected.connect(self.reject) 39 | 40 | def add_input_row(self, name, widget, /): 41 | name_label = QLabel(name) 42 | temp_layout = QVBoxLayout() 43 | temp_layout.addWidget(widget) 44 | 45 | self.form_layout.addRow(name_label, temp_layout) 46 | 47 | def validate(self, /): 48 | username = self.username.text() 49 | password = self.password.text() 50 | is_valid = bool(username and password) 51 | self.ok_button.setEnabled(is_valid) 52 | 53 | def get_data(self, /): 54 | username = self.username.text() 55 | password = self.password.text() 56 | return username, password 57 | -------------------------------------------------------------------------------- /kamyroll_gui/main_widget.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import logging 3 | 4 | from PySide6.QtCore import Qt 5 | 6 | from PySide6.QtWidgets import ( 7 | QAbstractItemView, 8 | QDialog, 9 | QGridLayout, 10 | QListWidget, 11 | QListWidgetItem, 12 | QMessageBox, 13 | QWidget, 14 | QPushButton, 15 | ) 16 | 17 | from .settings_dialog import SettingsDialog 18 | from .download_dialog import DownloadDialog 19 | from .validated_url_input_dialog import ValidatedUrlInputDialog 20 | from .settings import manager 21 | 22 | 23 | ABOUT_TEXT = """ 24 | Kamyroll is written in Python 3 using PySide (Qt)
25 | and was developed by Grub4K
26 | The source is available on GitHub

27 | It uses the Kamyroll API developed by hyugogirubato 28 | """ 29 | 30 | class MainWidget(QWidget): 31 | _logger = logging.getLogger(__name__).getChild(__qualname__) 32 | 33 | def __init__(self, /): 34 | super().__init__() 35 | self.setWindowTitle('Kamyroll') 36 | self.setMinimumSize(700, 500) 37 | 38 | self.url_correct = False 39 | 40 | layout = QGridLayout() 41 | self.setLayout(layout) 42 | layout.setAlignment(Qt.AlignTop) 43 | 44 | layout.setColumnStretch(0, 10) 45 | layout.setColumnStretch(1, 1) 46 | layout.setColumnStretch(2, 1) 47 | 48 | for row in range(10): 49 | layout.setRowStretch(row, 1) 50 | 51 | self.list_widget = QListWidget() 52 | self.list_widget.setDragEnabled(True) 53 | self.list_widget.viewport().setAcceptDrops(True) 54 | self.list_widget.setDragDropMode(QAbstractItemView.InternalMove) 55 | self.list_widget.setSelectionMode(QAbstractItemView.ExtendedSelection) 56 | self.list_widget.addItem("https://beta.crunchyroll.com/de/watch/GR3VWXP96/im-luffy-the-man-whos-gonna-be-king-of-the-pirates") 57 | self.list_widget.itemSelectionChanged.connect(self.check_selection) 58 | self.list_widget.itemDoubleClicked.connect(self.edit_item) 59 | layout.addWidget(self.list_widget, 0, 0, 10, 1) 60 | 61 | self.add_item_button = QPushButton("+ Add") 62 | self.add_item_button.clicked.connect(self.add_item) 63 | layout.addWidget(self.add_item_button, 0, 1) 64 | 65 | self.remove_item_button = QPushButton("- Remove") 66 | self.remove_item_button.setDisabled(True) 67 | self.remove_item_button.clicked.connect(self.remove_item) 68 | layout.addWidget(self.remove_item_button, 0, 2) 69 | 70 | about_button = QPushButton("About...") 71 | about_function = partial(QMessageBox.about, self, "About - Kamyroll", 72 | ABOUT_TEXT) 73 | about_button.clicked.connect(about_function) 74 | layout.addWidget(about_button, 5, 1, 1, 2) 75 | 76 | self.settings_button = QPushButton("Settings") 77 | self.settings_button.clicked.connect(self.create_settings) 78 | layout.addWidget(self.settings_button, 7, 1, 1, 2) 79 | 80 | self.download_subs_button = QPushButton("Download Subtitles") 81 | self.download_subs_button.clicked.connect(self.create_subtitle_download_dialog) 82 | layout.addWidget(self.download_subs_button, 8, 1, 1, 2) 83 | 84 | self.download_button = QPushButton("Download All") 85 | self.download_button.clicked.connect(self.create_download_dialog) 86 | layout.addWidget(self.download_button, 9, 1, 1, 2) 87 | 88 | self._set_button_states() 89 | 90 | def edit_item(self, item: QListWidgetItem, /): 91 | dialog = ValidatedUrlInputDialog(self, item.text()) 92 | if dialog.exec() == QDialog.Accepted: 93 | value = dialog.line_edit.text() 94 | item.setText(value) 95 | 96 | def check_selection(self, /): 97 | selection = self.list_widget.selectedIndexes() 98 | self.remove_item_button.setEnabled(bool(selection)) 99 | 100 | def create_settings(self, /): 101 | dialog = SettingsDialog(self, manager.settings) 102 | if dialog.exec() == QDialog.Accepted: 103 | manager.settings = dialog.settings 104 | manager.save() 105 | 106 | def remove_item(self, /): 107 | for item in self.list_widget.selectedItems(): 108 | row = self.list_widget.row(item) 109 | self.list_widget.takeItem(row) 110 | del item 111 | 112 | self._set_button_states() 113 | 114 | def add_item(self, /): 115 | dialog = ValidatedUrlInputDialog(self) 116 | if dialog.exec() == QDialog.Accepted: 117 | value = dialog.line_edit.text() 118 | self.list_widget.addItem(value) 119 | 120 | self._set_button_states() 121 | 122 | def create_subtitle_download_dialog(self, /): 123 | if not manager.settings.subtitle_locales: 124 | QMessageBox.information(self, "Info - Kamyroll", 125 | "No subtitles are selected.\nSelect subtitles in the settings and try again.") 126 | return 127 | self._real_create_download_dialog(True) 128 | 129 | def create_download_dialog(self, /): 130 | self._real_create_download_dialog(False) 131 | 132 | def _set_button_states(self, /): 133 | disable_buttons = not self.list_widget.count() 134 | self.download_button.setDisabled(disable_buttons) 135 | self.download_subs_button.setDisabled(disable_buttons) 136 | 137 | def _real_create_download_dialog(self, subtitle_only, /): 138 | items = self._get_items() 139 | dialog = DownloadDialog(self, items, subtitle_only=subtitle_only) 140 | if dialog.exec() == QDialog.Accepted: 141 | self._logger.info("Finished downloading sequence") 142 | else: 143 | self._logger.error("Failed download") 144 | 145 | items_to_remove = [ 146 | self.list_widget.item(row) 147 | for row in dialog.successful_items 148 | ] 149 | self._logger.debug("Removing %s (%s)", dialog.successful_items, items_to_remove) 150 | for item in items_to_remove: 151 | row = self.list_widget.row(item) 152 | self.list_widget.takeItem(row) 153 | 154 | def _get_items(self, /): 155 | items = [] 156 | for row in range(self.list_widget.count()): 157 | item = self.list_widget.item(row).text() 158 | items.append(item) 159 | return items 160 | -------------------------------------------------------------------------------- /kamyroll_gui/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import typing 4 | 5 | from dataclasses import ( 6 | asdict, 7 | dataclass, 8 | field, 9 | fields, 10 | is_dataclass, 11 | ) 12 | from enum import Enum 13 | from pathlib import Path 14 | from types import GenericAlias 15 | 16 | from .data_types import ( 17 | Locale, 18 | Resolution, 19 | ) 20 | 21 | 22 | 23 | _logger = logging.getLogger(__name__) 24 | 25 | 26 | @dataclass 27 | class Settings: 28 | audio_locale: Locale = Locale.JAPANESE_JP 29 | hardsub_locale: Locale = Locale.NONE 30 | subtitle_locales: list[Locale] = field(default_factory=list) 31 | video_height: Resolution = Resolution.R1080 32 | episode_format: str = "{series}/{series}.S{season}.E{episode}" 33 | subtitle_prefix: str = "subtitles" 34 | movie_format: str = "{title}" 35 | download_path: Path = Path("downloads") 36 | write_metadata: bool = False 37 | separate_subtitles: bool = False 38 | compress_streams: bool = False 39 | use_own_credentials: bool = False 40 | strict_matching: bool = False 41 | 42 | 43 | class SettingsManager: 44 | def __init__(self, path, /): 45 | self.path = Path(path) 46 | self.load() 47 | 48 | def load(self, /): 49 | data = {} 50 | if self.path.exists(): 51 | with self.path.open("rb") as file: 52 | try: 53 | data = json.load(file) 54 | except ValueError as e: 55 | _logger.warning("Error parsing settings json: %s", e) 56 | 57 | self.settings: Settings = self._parse_value(data, Settings) 58 | 59 | if not data: 60 | self.save() 61 | 62 | def save(self, /): 63 | data = self._dump_value(self.settings) 64 | 65 | with self.path.open("w") as file: 66 | json.dump(data, file, indent=4) 67 | 68 | @classmethod 69 | def _dump_value(cls, data): 70 | if isinstance(data, Enum): 71 | return data.value 72 | 73 | if is_dataclass(data): 74 | return cls._dump_value(asdict(data)) 75 | 76 | if isinstance(data, dict): 77 | return { 78 | key: cls._dump_value(value) 79 | for key, value in data.items() 80 | } 81 | 82 | if isinstance(data, list): 83 | return [ 84 | cls._dump_value(value) 85 | for value in data 86 | ] 87 | 88 | if isinstance(data, Path): 89 | return str(data.absolute().resolve().as_posix()) 90 | 91 | for data_type in [int, str]: 92 | if isinstance(data, data_type): 93 | return data 94 | 95 | @classmethod 96 | def _parse_value(cls, data, field_type: type): 97 | if isinstance(field_type, GenericAlias): 98 | type_origin = typing.get_origin(field_type) 99 | if type_origin is list: 100 | if not isinstance(data, list): 101 | return 102 | 103 | sub_type, = typing.get_args(field_type) 104 | constructed_list = [] 105 | for value in data: 106 | parsed_sub_value = cls._parse_value(value, sub_type) 107 | if parsed_sub_value is not None: 108 | constructed_list.append(parsed_sub_value) 109 | return constructed_list 110 | 111 | if type_origin is dict: 112 | if not isinstance(data, dict): 113 | return 114 | 115 | _, sub_type = typing.get_args(field_type) 116 | constructed_dict = {} 117 | for key, value in data.items(): 118 | parsed_sub_value = cls._parse_value(value, sub_type) 119 | if parsed_sub_value is not None: 120 | constructed_dict[key] = parsed_sub_value 121 | return constructed_dict 122 | 123 | if type(data) is field_type: 124 | return data 125 | 126 | if issubclass(field_type, Enum): 127 | try: 128 | parsed_value = field_type(data) 129 | except ValueError: 130 | _logger.warning("%r is not of type %s", data, field_type) 131 | return 132 | 133 | return parsed_value 134 | 135 | if is_dataclass(field_type): 136 | rebuilt_data = {} 137 | for field in fields(field_type): 138 | value = data.get(field.name) 139 | if value is None: 140 | continue 141 | 142 | parsed_value = cls._parse_value(value, field.type) 143 | if parsed_value is None: 144 | continue 145 | 146 | rebuilt_data[field.name] = parsed_value 147 | 148 | return field_type(**rebuilt_data) 149 | 150 | if issubclass(field_type, Path): 151 | if not isinstance(data, str): 152 | return 153 | return Path(data) 154 | 155 | _logger.warning("Cannot parse value %r, type %s is unknown", data, field_type) 156 | 157 | 158 | manager = SettingsManager("settings.json") 159 | -------------------------------------------------------------------------------- /kamyroll_gui/settings_dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings_dialog import SettingsDialog 2 | -------------------------------------------------------------------------------- /kamyroll_gui/settings_dialog/filename_widget.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import ( 3 | fields, 4 | MISSING, 5 | ) 6 | 7 | from pathlib import Path 8 | 9 | from PySide6.QtCore import Qt 10 | from PySide6.QtWidgets import ( 11 | QCheckBox, 12 | QFileDialog, 13 | QGridLayout, 14 | QLabel, 15 | QLineEdit, 16 | QMessageBox, 17 | QPushButton, 18 | QWidget, 19 | ) 20 | 21 | from ..data_types.metadata import ( 22 | EpisodeMetadata, 23 | MovieMetadata, 24 | ) 25 | 26 | 27 | 28 | class FilenameWidget(QWidget): 29 | _logger = logging.getLogger(__name__).getChild(__qualname__) 30 | 31 | def __init__(self, settings, /): 32 | super().__init__() 33 | 34 | self.settings = settings 35 | 36 | layout = QGridLayout() 37 | layout.setAlignment(Qt.AlignTop) 38 | layout.setColumnStretch(0, 10) 39 | layout.setColumnStretch(1, 1) 40 | self.setLayout(layout) 41 | 42 | path_label = QLabel("Output directory:") 43 | layout.addWidget(path_label, 0, 0, 1, 2) 44 | 45 | self.path_edit = QLineEdit() 46 | self.path_edit.setReadOnly(True) 47 | self.path_edit.setTextMargins(5, 5, 5, 5) 48 | absolute_path = settings.download_path.resolve().absolute() 49 | self.path_edit.setText(str(absolute_path)) 50 | layout.addWidget(self.path_edit, 1, 0) 51 | 52 | self.path_button = QPushButton("Browse...") 53 | self.path_button.clicked.connect(self.get_output_path) 54 | self.path_button.setStyleSheet("padding: 8px;") 55 | layout.addWidget(self.path_button, 1, 1) 56 | 57 | episode_filename_label = QLabel("Episode filename format:") 58 | layout.addWidget(episode_filename_label, 2, 0, 1, 2) 59 | 60 | self.episode_filename = QLineEdit() 61 | self.episode_filename.setTextMargins(5, 5, 5, 5) 62 | self.episode_filename.setStyleSheet("border: 1px solid black;") 63 | self.episode_filename.setPlaceholderText("Episode filename format") 64 | self.episode_filename.setText(settings.episode_format) 65 | self.episode_filename.editingFinished.connect(self.validate_episode) 66 | layout.addWidget(self.episode_filename, 3, 0, 1, 2) 67 | 68 | movie_filename_label = QLabel("Movie filename format:") 69 | layout.addWidget(movie_filename_label, 4, 0, 1, 2) 70 | 71 | self.movie_filename = QLineEdit() 72 | self.movie_filename.setTextMargins(5, 5, 5, 5) 73 | self.movie_filename.setPlaceholderText("Movie filename format") 74 | self.movie_filename.setText(settings.movie_format) 75 | self.episode_filename.editingFinished.connect(self.validate_movie) 76 | layout.addWidget(self.movie_filename, 5, 0, 1, 2) 77 | 78 | self.separate_subtitles_box = QCheckBox("Write separate subtitle files") 79 | self.separate_subtitles_box.setChecked(settings.separate_subtitles) 80 | self.separate_subtitles_box.stateChanged.connect(self.swap_sub_state) 81 | layout.addWidget(self.separate_subtitles_box, 6, 0, 1, 2) 82 | 83 | self.subtitle_prefix_label = QLabel("Subtitle prefix:") 84 | layout.addWidget(self.subtitle_prefix_label, 7, 0, 1, 2) 85 | 86 | self.subtitle_prefix = QLineEdit() 87 | self.subtitle_prefix.setTextMargins(5, 5, 5, 5) 88 | self.subtitle_prefix.setPlaceholderText("Subtitle prefix") 89 | self.subtitle_prefix.setText(settings.subtitle_prefix) 90 | self.subtitle_prefix.editingFinished.connect(self.set_subtitle_prefix) 91 | layout.addWidget(self.subtitle_prefix, 8, 0, 1, 2) 92 | 93 | self.swap_sub_state(settings.separate_subtitles) 94 | 95 | def validate_episode(self, /): 96 | example_data = self._default_dict_from_dataclass(EpisodeMetadata) 97 | self._validate_format(self.episode_filename, example_data) 98 | 99 | def validate_movie(self, /): 100 | example_data = self._default_dict_from_dataclass(MovieMetadata) 101 | self._validate_format(self.movie_filename, example_data) 102 | 103 | @staticmethod 104 | def _default_dict_from_dataclass(data, /): 105 | generated_dict = {} 106 | for field in fields(data): 107 | if field.default_factory is not MISSING: 108 | value = field.default_factory() 109 | else: 110 | value = field.type() 111 | generated_dict[field.name] = value 112 | return generated_dict 113 | 114 | def _validate_format(self, line_edit, example_data, /): 115 | value = line_edit.text() 116 | try: 117 | value.format(**example_data) 118 | except ValueError: 119 | self._logger.debug("Invalid format string") 120 | line_edit.setStyleSheet("border: 1px solid red;") 121 | line_edit.setToolTip("Invalid format string") 122 | except KeyError: 123 | self._logger.debug("Incorrect key used in format") 124 | line_edit.setStyleSheet("border: 1px solid red;") 125 | line_edit.setToolTip("Incorrect key used in format") 126 | else: 127 | self._logger.debug("Correct format") 128 | line_edit.setStyleSheet("border: 1px solid black;") 129 | line_edit.setToolTip("") 130 | 131 | def swap_sub_state(self, state, /): 132 | checked = bool(state) 133 | self.subtitle_prefix_label.setEnabled(checked) 134 | self.subtitle_prefix.setEnabled(checked) 135 | self.settings.separate_subtitles = checked 136 | 137 | def set_subtitle_prefix(self, /): 138 | self.settings.subtitle_prefix = self.subtitle_prefix.text() 139 | 140 | def get_output_path(self, /): 141 | title = "Select output path" 142 | base_path = str(self.settings.download_path) 143 | 144 | path = QFileDialog.getExistingDirectory(self, title, base_path, 145 | QFileDialog.ShowDirsOnly) 146 | if not path: 147 | return 148 | self._logger.info("Selected File: %s", path) 149 | 150 | download_path = Path(path) 151 | if download_path.is_reserved(): 152 | QMessageBox.information(self, "Directory is reserved", 153 | "The directory you selected is unavailable, please select another one.") 154 | return 155 | 156 | self.settings.download_path = download_path 157 | self.path_edit.setText(str(download_path)) 158 | 159 | def is_valid(self, /): 160 | return not (self.episode_filename.toolTip() 161 | or self.movie_filename.toolTip()) 162 | -------------------------------------------------------------------------------- /kamyroll_gui/settings_dialog/settings_dialog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import replace 3 | 4 | from PySide6.QtWidgets import ( 5 | QCheckBox, 6 | QDialog, 7 | QMessageBox, 8 | QPushButton, 9 | QGridLayout, 10 | QVBoxLayout, 11 | QWidget, 12 | ) 13 | 14 | from kamyroll_gui.settings import Settings 15 | from ..data_types import StreamResponse 16 | 17 | from .subtitle_widget import SubtitleWidget 18 | from .video_widget import VideoWidget 19 | from .filename_widget import FilenameWidget 20 | 21 | 22 | 23 | class SettingsDialog(QDialog): 24 | _logger = logging.getLogger(__name__).getChild(__qualname__) 25 | 26 | def __init__(self, parent, settings: Settings, /, stream_response: StreamResponse = None, 27 | subtitle_only=False): 28 | super().__init__(parent) 29 | self.setWindowTitle("Settings - Kamyroll") 30 | 31 | self.stream_response = stream_response 32 | self.is_constrained = stream_response is not None 33 | self.apply_to_all = False 34 | 35 | # copy data, we dont want to affect actual preference until apply 36 | self.settings = replace(settings) 37 | 38 | self.main_layout = QGridLayout() 39 | self.main_layout.setColumnStretch(0, 2) 40 | self.main_layout.setColumnStretch(1, 1) 41 | self.setLayout(self.main_layout) 42 | 43 | if not self.is_constrained: 44 | self.filename_widget = FilenameWidget(self.settings) 45 | self.main_layout.addWidget(self.filename_widget, 0, 0, 1, 2) 46 | 47 | boxes_width = 2 if subtitle_only else 1 48 | self.subtitle_widget = SubtitleWidget(self.settings, stream_response) 49 | self.main_layout.addWidget(self.subtitle_widget, 1, 0, 2, boxes_width) 50 | 51 | if not subtitle_only: 52 | self.video_widget = VideoWidget(self.settings, stream_response) 53 | self.main_layout.addWidget(self.video_widget, 1, 1) 54 | 55 | if not self.is_constrained: 56 | self._create_checkboxes() 57 | 58 | self.apply_button = QPushButton("Apply") 59 | self.apply_button.clicked.connect(self.apply) 60 | self.main_layout.addWidget(self.apply_button, 3, 1) 61 | 62 | if self.is_constrained: 63 | self.apply_all_button = QPushButton("Apply to all") 64 | self.apply_all_button.clicked.connect(self.apply_all) 65 | self.main_layout.addWidget(self.apply_all_button, 4, 1) 66 | 67 | def _check_valid(self, /): 68 | if self.stream_response is None: 69 | if not self.filename_widget.is_valid(): 70 | QMessageBox.warning(self, "Invalid filename format - Kamyroll", 71 | "The filename format provided is invalid. " + 72 | "Please provide a valid filename format. " + 73 | "For more infos read the help on formatting the filename.", 74 | QMessageBox.Ok) 75 | return False 76 | return True 77 | 78 | def apply(self, /): 79 | if not self._check_valid(): 80 | return 81 | self.accept() 82 | 83 | def apply_all(self, /): 84 | if not self._check_valid(): 85 | return 86 | self.apply_to_all = True 87 | self.accept() 88 | 89 | def _create_checkboxes(self, /): 90 | _checkbox_widget = QWidget() 91 | self.main_layout.addWidget(_checkbox_widget, 2, 1) 92 | 93 | _checkbox_layout = QVBoxLayout() 94 | _checkbox_widget.setLayout(_checkbox_layout) 95 | 96 | self.write_metadata = QCheckBox("Write Metadata") 97 | self.write_metadata.setToolTip("Write metadata like title and series into the file") 98 | self.write_metadata.stateChanged.connect(self.update_metadata) 99 | self.write_metadata.setChecked(self.settings.write_metadata) 100 | _checkbox_layout.addWidget(self.write_metadata) 101 | 102 | self.compress_streams = QCheckBox("Compress streams") 103 | self.compress_streams.setToolTip("Reencode the video and audio stream with the ffmpeg defaults") 104 | self.compress_streams.stateChanged.connect(self.update_compress_streams) 105 | self.compress_streams.setChecked(self.settings.compress_streams) 106 | _checkbox_layout.addWidget(self.compress_streams) 107 | 108 | self.use_own_credentials = QCheckBox("Use own login credentials") 109 | self.use_own_credentials.setToolTip("Force the use of self provided login credentials") 110 | self.use_own_credentials.stateChanged.connect(self.update_use_own_credentials) 111 | self.use_own_credentials.setChecked(self.settings.use_own_credentials) 112 | _checkbox_layout.addWidget(self.use_own_credentials) 113 | 114 | self.use_strict_matching_box = QCheckBox("Use strict matching") 115 | self.use_strict_matching_box.setToolTip("Fail if the desired resolution or subtitles are not available") 116 | self.use_strict_matching_box.stateChanged.connect(self.update_strict_matching) 117 | self.use_strict_matching_box.setChecked(self.settings.strict_matching) 118 | _checkbox_layout.addWidget(self.use_strict_matching_box) 119 | 120 | def update_metadata(self, state, /): 121 | self.settings.write_metadata = bool(state) 122 | 123 | def update_compress_streams(self, state, /): 124 | self.settings.compress_streams = bool(state) 125 | 126 | def update_use_own_credentials(self, state, /): 127 | self.settings.use_own_credentials = bool(state) 128 | 129 | def update_strict_matching(self, state, /): 130 | self.settings.strict_matching = bool(state) 131 | -------------------------------------------------------------------------------- /kamyroll_gui/settings_dialog/subtitle_widget.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | 4 | from PySide6.QtCore import Qt 5 | from PySide6.QtWidgets import ( 6 | QCheckBox, 7 | QGridLayout, 8 | QLabel, 9 | QPushButton, 10 | QWidget, 11 | ) 12 | 13 | from kamyroll_gui.data_types.stream_response import StreamResponse 14 | from kamyroll_gui.settings import Settings 15 | 16 | from ..data_types import Locale 17 | 18 | 19 | 20 | class SubtitleWidget(QWidget): 21 | _logger = logging.getLogger(__name__).getChild(__qualname__) 22 | 23 | def __init__(self, settings: Settings, stream_response: StreamResponse = None, /): 24 | super().__init__() 25 | 26 | self.settings = settings 27 | self.check_boxes = [] 28 | 29 | self.selected = set() 30 | 31 | layout = QGridLayout() 32 | layout.setAlignment(Qt.AlignTop) 33 | self.setLayout(layout) 34 | self.setHidden(False) 35 | 36 | subtitle_language_label = QLabel("Subtitle languages:") 37 | layout.addWidget(subtitle_language_label, 0, 0, 1, 2) 38 | 39 | select_all_button = QPushButton("Select all") 40 | select_all_button.clicked.connect(lambda: self.set_all_state(True)) 41 | layout.addWidget(select_all_button, 1, 0) 42 | 43 | select_all_button = QPushButton("Deselect all") 44 | select_all_button.clicked.connect(lambda: self.set_all_state(False)) 45 | layout.addWidget(select_all_button, 1, 1) 46 | 47 | index = 0 48 | total_rows = (len(Locale) // 2) - 1 49 | enabled_locale = self.settings.subtitle_locales 50 | 51 | if stream_response is not None: 52 | self.settings.subtitle_locales = [] 53 | 54 | for locale in Locale: 55 | if locale in [Locale.NONE, Locale.UNDEFINED]: 56 | continue 57 | 58 | column, row = divmod(index, total_rows) 59 | index += 1 60 | 61 | check_box = QCheckBox(str(locale)) 62 | 63 | if stream_response is not None: 64 | self._logger.debug("Stream response subs: %s in %s", 65 | locale, stream_response.subtitles) 66 | # FIXME: Dirty fix for checking locale 67 | response_locales = {subtitle.locale for subtitle in stream_response.subtitles} 68 | if locale in response_locales: 69 | if locale in enabled_locale: 70 | check_box.setChecked(True) 71 | self.settings.subtitle_locales.append(locale) 72 | check_box.setEnabled(True) 73 | else: 74 | check_box.setEnabled(False) 75 | else: 76 | if locale in enabled_locale: 77 | check_box.setChecked(True) 78 | check_box.setEnabled(True) 79 | 80 | check_box.stateChanged.connect(partial(self.select_subtitle, locale)) 81 | 82 | self.check_boxes.append(check_box) 83 | layout.addWidget(check_box, row+2, column) 84 | 85 | def select_subtitle(self, locale, state, /): 86 | selected = bool(state) 87 | subtitle_locales = self.settings.subtitle_locales 88 | 89 | if selected and locale not in subtitle_locales: 90 | subtitle_locales.append(locale) 91 | 92 | elif locale in subtitle_locales: 93 | subtitle_locales.remove(locale) 94 | 95 | def set_all_state(self, state, /): 96 | for check_box in self.check_boxes: 97 | if check_box.isEnabled(): 98 | check_box.setChecked(state) 99 | -------------------------------------------------------------------------------- /kamyroll_gui/settings_dialog/video_widget.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | import logging 3 | 4 | from PySide6.QtCore import Qt 5 | 6 | from PySide6.QtWidgets import ( 7 | QVBoxLayout, 8 | QComboBox, 9 | QWidget, 10 | QLabel, 11 | ) 12 | 13 | from kamyroll_gui.data_types.stream_response import StreamResponse 14 | from kamyroll_gui.settings import Settings 15 | 16 | from ..utils import m3u8 17 | from ..utils.web_manager import web_manager 18 | from ..data_types import ( 19 | Locale, 20 | Resolution, 21 | ) 22 | 23 | 24 | 25 | class VideoWidget(QWidget): 26 | _logger = logging.getLogger(__name__).getChild(__qualname__) 27 | 28 | def __init__(self, settings: Settings, stream_response: StreamResponse = None, /): 29 | super().__init__() 30 | 31 | self.settings = settings 32 | self.stream_response = stream_response 33 | 34 | layout = QVBoxLayout() 35 | layout.setAlignment(Qt.AlignTop) 36 | self.setLayout(layout) 37 | 38 | 39 | 40 | self.audio_locale = QComboBox() 41 | for locale in self.get_audio_locales(): 42 | if locale in [Locale.NONE, Locale.UNDEFINED]: 43 | continue 44 | self.audio_locale.addItem(str(locale), locale) 45 | 46 | audio_locale_label = QLabel("Audio language:") 47 | audio_locale_label.setBuddy(self.audio_locale) 48 | layout.addWidget(audio_locale_label) 49 | layout.addWidget(self.audio_locale) 50 | 51 | self.hardsub_locale = QComboBox() 52 | for locale in self.get_hardsub_locales(): 53 | if locale is Locale.UNDEFINED: 54 | continue 55 | self.hardsub_locale.addItem(str(locale), locale) 56 | 57 | hardsub_language_label = QLabel("Hardsub language:") 58 | hardsub_language_label.setBuddy(self.hardsub_locale) 59 | layout.addWidget(hardsub_language_label) 60 | layout.addWidget(self.hardsub_locale) 61 | 62 | self.video_height = QComboBox() 63 | for resolution in Resolution: 64 | self.video_height.addItem(f"{resolution}p", resolution.value) 65 | self.video_width_label = QLabel("Maximum Resolution:") 66 | self.video_width_label.setBuddy(self.video_height) 67 | layout.addWidget(self.video_width_label) 68 | layout.addWidget(self.video_height) 69 | 70 | selected_audio_index = self.audio_locale.findData(settings.audio_locale) 71 | self.audio_locale.setCurrentIndex(selected_audio_index) 72 | selected_hardsub_index = self.hardsub_locale.findData(settings.hardsub_locale) 73 | self.hardsub_locale.setCurrentIndex(selected_hardsub_index) 74 | selected_resolution_index = self.video_height.findData(settings.video_height.value) 75 | self.video_height.setCurrentIndex(selected_resolution_index) 76 | 77 | self.set_audio_locale() 78 | self.set_hardsub_locale() 79 | self.set_video_height() 80 | 81 | self.audio_locale.currentIndexChanged.connect(self.set_audio_locale) 82 | self.hardsub_locale.currentIndexChanged.connect(self.set_hardsub_locale) 83 | self.video_height.currentIndexChanged.connect(self.set_video_height) 84 | 85 | def get_audio_locales(self, /): 86 | if self.stream_response is None: 87 | return Locale 88 | audio_locales = set() 89 | for stream in self.stream_response.streams: 90 | audio_locales.add(stream.audio_locale) 91 | return list(audio_locales) 92 | 93 | def get_hardsub_locales(self, /): 94 | if self.stream_response is None: 95 | return Locale 96 | 97 | selected_audio_locale = self.audio_locale.currentData() 98 | hardsub_locales = set() 99 | for stream in self.stream_response.streams: 100 | if stream.audio_locale != selected_audio_locale: 101 | continue 102 | hardsub_locales.add(stream.hardsub_locale) 103 | # Add bakeable hardsubs 104 | if Locale.NONE in hardsub_locales: 105 | for subtitle in self.stream_response.subtitles: 106 | hardsub_locales.add(subtitle.locale) 107 | return list(hardsub_locales) 108 | 109 | def set_audio_locale(self, /): 110 | self.settings.audio_locale = self.audio_locale.currentData() 111 | if self.stream_response is None: 112 | return 113 | 114 | self.hardsub_locale.clear() 115 | for locale in self.get_hardsub_locales(): 116 | if locale == Locale.UNDEFINED: 117 | continue 118 | self.hardsub_locale.addItem(str(locale), locale) 119 | selected_hardsub_index = self.hardsub_locale.findData( 120 | self.settings.hardsub_locale) 121 | if selected_hardsub_index != -1: 122 | self.hardsub_locale.setCurrentIndex(selected_hardsub_index) 123 | else: 124 | self.hardsub_locale.setCurrentIndex(0) 125 | 126 | def set_hardsub_locale(self, /): 127 | selected_hardsub_locale = self.hardsub_locale.currentData() 128 | self.settings.hardsub_locale = selected_hardsub_locale 129 | if self.stream_response is None: 130 | return 131 | 132 | selected_audio_locale = self.audio_locale.currentData() 133 | 134 | suitable_streams = set() 135 | for stream in self.stream_response.streams: 136 | if stream.audio_locale != selected_audio_locale: 137 | continue 138 | if stream.hardsub_locale != selected_hardsub_locale: 139 | continue 140 | 141 | suitable_streams.add(stream.url) 142 | 143 | if not suitable_streams: 144 | # We need baked subs, use Locale.NONE 145 | for stream in self.stream_response.streams: 146 | if stream.audio_locale != selected_audio_locale: 147 | continue 148 | if stream.hardsub_locale != Locale.NONE: 149 | continue 150 | 151 | suitable_streams.add(stream.url) 152 | 153 | all_resolutions = set() 154 | for url in suitable_streams: 155 | resolutions = self._get_resolutions(url) 156 | all_resolutions.update(resolutions) 157 | 158 | current_width = self.video_height.currentData() 159 | self.video_height.clear() 160 | for resolution in sorted(all_resolutions, reverse=True): 161 | self.video_height.addItem(f"{resolution}p", resolution) 162 | current_width_index = self.video_height.findData(current_width) 163 | self._logger.debug("Using resolution index %s, current_width is %s", 164 | repr(current_width_index), current_width) 165 | if current_width_index != -1: 166 | self.video_height.setCurrentIndex(current_width_index) 167 | else: 168 | self._logger.debug("Set currnt index to 0") 169 | self.video_height.setCurrentIndex(0) 170 | 171 | def set_video_height(self, /): 172 | self.settings.video_height = self.video_height.currentData() 173 | 174 | @lru_cache 175 | def _get_resolutions(self, url, /): 176 | data = web_manager.get(url).decode() 177 | resolutions = m3u8.get_resolutions(data) 178 | return list(resolutions) 179 | -------------------------------------------------------------------------------- /kamyroll_gui/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grub4K/kamyroll-gui/c6e9875d8da82d65a51b81a9cf533b33822b33de/kamyroll_gui/utils/__init__.py -------------------------------------------------------------------------------- /kamyroll_gui/utils/api.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import logging 4 | 5 | from datetime import datetime, timedelta 6 | 7 | from .web_manager import web_manager 8 | from .blocking import wait 9 | from ..data_types import ( 10 | Channel, 11 | EpisodeMetadata, 12 | Locale, 13 | Stream, 14 | Subtitle, 15 | StreamType, 16 | StreamResponse, 17 | StreamResponseType, 18 | MovieMetadata, 19 | ) 20 | 21 | 22 | 23 | BASE_URL = "https://kamyroll-server.herokuapp.com" 24 | 25 | REGEXES = [ 26 | ("crunchyroll", re.compile(r"https://beta\.crunchyroll\.com/(?:[a-z]{2,}/)?watch/(?P[A-Z0-9]+)/")), 27 | ("funimation", re.compile(r"https://www\.funimation\.com/v/(?P[a-z\-]+)/(?P[a-z\-]+)")), 28 | ("adn", re.compile(r"https://animedigitalnetwork\.fr/video/[^/]+/(?P[0-9]+)-")), 29 | ] 30 | 31 | 32 | _logger = logging.getLogger(__name__) 33 | 34 | 35 | class ApiError(Exception): 36 | pass 37 | 38 | 39 | def parse_url(url): 40 | for name, regexp in REGEXES: 41 | match = regexp.match(url) 42 | if not match: 43 | continue 44 | 45 | return name, match.groupdict() 46 | return None 47 | 48 | 49 | def get_media(name, params, /, username=None, password=None, retries=3): 50 | use_login = username and password 51 | use_bypass = False 52 | 53 | params["channel_id"] = name 54 | 55 | if name=="adn": 56 | params["country"] = "fr" 57 | 58 | if use_login: 59 | params["email"] = username 60 | params["password"] = password 61 | else: 62 | use_bypass = True 63 | 64 | if retries <= 1: 65 | retries = 1 66 | data = {} 67 | for _ in range(retries): 68 | params["bypass"] = "true" 69 | if not use_bypass: 70 | del params["bypass"] 71 | 72 | data = call_api("/v1/streams", params=params) 73 | if "error" in data: 74 | return_val = _handle_error(data["code"], data["message"], 75 | use_login, name) 76 | if return_val is not None: 77 | use_bypass = return_val 78 | continue 79 | try: 80 | return _stream_response_from_response_dict(data) 81 | except Exception as error: 82 | message = f"Unknown error while parsing response: {error}" 83 | raise ApiError(message) 84 | 85 | _logger.error("Api call failed after too many retries") 86 | message = data.get("message") or "Unknown error" 87 | raise ApiError(message) 88 | 89 | 90 | def call_api(path, /, params=None): 91 | _logger.info("Calling api endpoing %s with %s", path, params) 92 | url = BASE_URL + path 93 | data = web_manager.get(url, params=params) 94 | 95 | # TEMP: this checks if we have internet 96 | if not data: 97 | raise ApiError("Internet or API not available") 98 | 99 | json_data = {} 100 | try: 101 | json_data = json.loads(data) 102 | except ValueError as exception: 103 | _logger.error("Error decoding returned json: %s", exception) 104 | 105 | if "error" in json_data: 106 | error_code = json_data.get("code", "unknown") 107 | error_message = json_data.get("message", "Unknown Error") 108 | _logger.error("Api call returned '%s': %s", error_code, error_message) 109 | json_data["code"] = error_code 110 | json_data["message"] = error_message 111 | 112 | return json_data 113 | 114 | 115 | def _handle_error(code, message, use_login, channel_id): 116 | _logger.error("Api returned error code %s: %s", code, message) 117 | 118 | match code: 119 | case "premium_only": 120 | if use_login: 121 | message += "\nConsider using the premium bypass" 122 | raise ApiError(message) 123 | 124 | if channel_id == "funimation": 125 | return False 126 | # this should only ever happen if 127 | # the api backend user runs out of premium 128 | raise ApiError("Unexpected bypass error, try again later") 129 | 130 | case "bad_player_connection": 131 | wait(2000) 132 | 133 | case "bad_initialize": 134 | wait(2000) 135 | 136 | case "unknown_id": 137 | raise ApiError("The provided id of the url is not valid") 138 | 139 | case _: 140 | wait(1000) 141 | return 142 | 143 | 144 | def _stream_response_from_response_dict(data, /): 145 | try: 146 | response_type_str = data["type"] 147 | channel_id = data["channel_id"] 148 | stream_dicts = data["streams"] 149 | subtitle_dicts = data["subtitles"] 150 | images_dict = data["images"] 151 | except KeyError as error: 152 | message = f"Key {error} missing in response json" 153 | raise ApiError(message) from None 154 | 155 | try: 156 | response_type = StreamResponseType(response_type_str) 157 | except ValueError as error: 158 | message = f"Unknown stream response type: {error}" 159 | raise ApiError(message) from None 160 | 161 | try: 162 | channel = Channel(channel_id) 163 | except ValueError as error: 164 | message = f"Unknown channel: {error}" 165 | raise ApiError(message) from None 166 | 167 | metadata = None 168 | if response_type is StreamResponseType.EPISODE: 169 | metadata = _episode_metadata_from_response_dict(data) 170 | elif response_type is StreamResponseType.MOVIE: 171 | metadata = _movie_metadata_from_response_dict(data) 172 | if metadata is None: 173 | raise ApiError("Error parsing metadata") 174 | 175 | images = _pictures_from_response_dict(images_dict) 176 | 177 | streams: list[Stream] = [] 178 | for stream_dict in stream_dicts: 179 | stream = _stream_from_response_dict(stream_dict) 180 | if stream is not None: 181 | streams.append(stream) 182 | 183 | subtitles = [] 184 | for subtitle_dict in subtitle_dicts: 185 | subtitle = _subtitle_from_response_dict(subtitle_dict) 186 | if subtitle is not None: 187 | subtitles.append(subtitle) 188 | 189 | return StreamResponse(type=response_type, images=images, metadata=metadata, 190 | channel=channel, streams=streams, subtitles=subtitles) 191 | 192 | 193 | def _pictures_from_response_dict(data): 194 | parsed_data = {} 195 | 196 | for name, value in data.items(): 197 | if value: 198 | url = value[-1].get("source") 199 | if url: 200 | parsed_data[name] = url 201 | 202 | return parsed_data 203 | 204 | 205 | def _episode_metadata_from_response_dict(data, /): 206 | try: 207 | series_meta = data["parent_metadata"] 208 | episode_meta = data["episode_metadata"] 209 | series = series_meta["title"] 210 | season = episode_meta["season_number"] 211 | season_name = episode_meta["season_title"] 212 | episode = episode_meta["episode_number"] 213 | episode_disp = episode_meta["episode"] 214 | title = episode_meta["title"] 215 | duration_ms = episode_meta["duration_ms"] 216 | description = episode_meta["description"] 217 | air_date: str = episode_meta["episode_air_date"] 218 | except KeyError as error: 219 | _logger.error("Error parsing metadata: Key %s does not exist in json", error) 220 | return None 221 | 222 | duration = timedelta(milliseconds=duration_ms) 223 | if air_date.endswith("Z"): 224 | air_date = air_date.removesuffix("Z") 225 | release_date = datetime.fromisoformat(air_date) 226 | 227 | return EpisodeMetadata(title=title, description=description, 228 | duration=duration, series=series, 229 | season=season, season_name=season_name, 230 | episode=episode, episode_disp=episode_disp, 231 | date=release_date, year=release_date.year) 232 | 233 | 234 | def _movie_metadata_from_response_dict(data, /): 235 | try: 236 | movie_meta = data["movie_metadata"] 237 | title = movie_meta["title"] 238 | duration_ms = movie_meta["duration_ms"] 239 | description = movie_meta["description"] 240 | year = movie_meta["movie_release_year"] 241 | except KeyError as error: 242 | _logger.error("Error parsing metadata: Key %s does not exist in json", error) 243 | return None 244 | 245 | duration = timedelta(milliseconds=duration_ms) 246 | 247 | return MovieMetadata(title=title, description=description, 248 | duration=duration, year=year) 249 | 250 | 251 | def _stream_from_response_dict(data, /): 252 | try: 253 | stream_type_str = data["type"] 254 | audio_locale_str = data["audio_locale"] 255 | hardsub_locale_str = data["hardsub_locale"] 256 | url = data["url"] 257 | except KeyError as error: 258 | _logger.error("Error parsing stream: Key %s does not exist in json", error) 259 | return None 260 | 261 | try: 262 | audio_locale = Locale(audio_locale_str) 263 | except ValueError as error: 264 | _logger.error("Error parsing stream: audio locale: %s", error) 265 | return None 266 | 267 | try: 268 | hardsub_locale = Locale(hardsub_locale_str) 269 | except ValueError as error: 270 | _logger.error("Error parsing stream: hardsub locale: %s", error) 271 | return None 272 | 273 | suffix = None 274 | try: 275 | stream_type = StreamType(stream_type_str) 276 | except ValueError as error: 277 | _logger.error("Error parsing stream: %s", error) 278 | return None 279 | 280 | if stream_type_str.startswith("simulcast"): 281 | suffix = " (Simulcast)" 282 | stream_type_str = stream_type_str.removeprefix("simulcast_") 283 | elif stream_type_str.startswith("uncut"): 284 | suffix = " (Uncut)" 285 | stream_type_str = stream_type_str.removeprefix("uncut_") 286 | 287 | type_name = str(stream_type) 288 | if suffix: 289 | type_name += suffix 290 | 291 | return Stream(type=stream_type, type_name=type_name, 292 | audio_locale=audio_locale, hardsub_locale=hardsub_locale, url=url) 293 | 294 | def _subtitle_from_response_dict(data, /): 295 | try: 296 | locale_str = data["locale"] 297 | url = data["url"] 298 | sub_format = data["format"] 299 | except KeyError as error: 300 | _logger.error("Error parsing subtitle: %s", error) 301 | return None 302 | 303 | try: 304 | locale = Locale(locale_str) 305 | except ValueError as error: 306 | _logger.error("Error parsing subtitle: %s", error) 307 | return None 308 | 309 | return Subtitle(locale=locale, url=url, format=sub_format) 310 | -------------------------------------------------------------------------------- /kamyroll_gui/utils/blocking.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import ( 2 | QEventLoop, 3 | QTimer, 4 | ) 5 | 6 | 7 | 8 | def wait(milliseconds, /): 9 | timer = QTimer() 10 | timer.start(milliseconds) 11 | wait_for_event(timer.timeout) 12 | 13 | def wait_for_event(event, /): 14 | loop = QEventLoop() 15 | event.connect(loop.quit) 16 | loop.exec() 17 | -------------------------------------------------------------------------------- /kamyroll_gui/utils/filename.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | 4 | 5 | SAFE_CHAR_VALUES = { 6 | *"[]()^ #%&!@+={}'`~-_", 7 | *string.ascii_letters, 8 | *string.digits, 9 | } 10 | 11 | 12 | def escape_name(name: str, escape="_"): 13 | return "".join(char if char in SAFE_CHAR_VALUES else escape 14 | for char in name) 15 | 16 | 17 | def format_name(fmt: str, data: dict): 18 | transformed_data = { 19 | key: escape_name(str(value)) 20 | for key, value in data.items() 21 | } 22 | return fmt.format(**transformed_data) 23 | -------------------------------------------------------------------------------- /kamyroll_gui/utils/m3u8.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | 5 | PROGRAM_INFO_REGEX = re.compile(r'[:,]([^=]*)=(?:"([^"]*)"|([^,]*))') 6 | 7 | 8 | def get_resolutions(data, /): 9 | audio_program_id = None 10 | resolutions = {} 11 | data_lines = ( 12 | line 13 | for line in data.splitlines(keepends=False) 14 | if line.startswith("#EXT-X-STREAM-INF:") or line.startswith("#EXT-X-MEDIA:") 15 | ) 16 | for program_id, line in enumerate(data_lines): 17 | matches = PROGRAM_INFO_REGEX.findall(line) 18 | info_dict = { 19 | key: first or second 20 | for key, first, second in matches 21 | } 22 | 23 | if "RESOLUTION" in info_dict: 24 | resolution = info_dict["RESOLUTION"] 25 | bandwidth = int(info_dict["BANDWIDTH"]) 26 | frame_rate = float(info_dict.get("FRAME-RATE", 30)) 27 | width, _, height = resolution.partition("x") 28 | width, height = int(width), int(height) 29 | item = (width, height, frame_rate, bandwidth, program_id) 30 | 31 | if height in resolutions: 32 | if resolutions[height] > item: 33 | continue 34 | 35 | resolutions[height] = item 36 | 37 | elif "TYPE" in info_dict: 38 | # VERIFY 39 | if info_dict["TYPE"].lower() == "audio": 40 | audio_program_id = program_id 41 | 42 | resolution_dict = {} 43 | for key, value in resolutions.items(): 44 | _, _, _, _, program_id = value 45 | program_ids = [program_id] 46 | if audio_program_id is not None: 47 | program_ids.append(audio_program_id) 48 | resolution_dict[key] = program_ids 49 | 50 | return resolution_dict 51 | -------------------------------------------------------------------------------- /kamyroll_gui/utils/web_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from PySide6.QtCore import QEventLoop, QUrl, QUrlQuery 5 | from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest 6 | 7 | from .blocking import wait_for_event 8 | 9 | 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | class WebManager: 14 | def __init__(self, /): 15 | self._network_manager = QNetworkAccessManager() 16 | 17 | def _get_request(self, url, /, params=None): 18 | q_url = QUrl(url) 19 | 20 | if params: 21 | query = QUrlQuery() 22 | for key, value in params.items(): 23 | query.addQueryItem(key, value) 24 | 25 | q_url.setQuery(query.query()) 26 | 27 | return QNetworkRequest(q_url) 28 | 29 | def get(self, /, url, params=None): 30 | _logger.info("GET %s", url) 31 | request = self._get_request(url, params) 32 | 33 | reply = self._network_manager.get(request) 34 | wait_for_event(reply.finished) 35 | 36 | data = bytes(reply.readAll()) 37 | _logger.debug("Web response: %s", data) 38 | return data 39 | 40 | def post(self, /, url, data=None, params=None): 41 | _logger.info("POST %s", url) 42 | data = data or {} 43 | bin_data = json.dumps(data).encode() 44 | 45 | request = self._get_request(url, params) 46 | 47 | reply = self._network_manager.post(request, bin_data) 48 | wait_for_event(reply.finished) 49 | 50 | data = bytes(reply.readAll()) 51 | _logger.debug("Web response: %s", data) 52 | return data 53 | 54 | web_manager = WebManager() 55 | -------------------------------------------------------------------------------- /kamyroll_gui/validated_url_input_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt 2 | from PySide6.QtWidgets import ( 3 | QDialog, 4 | QDialogButtonBox, 5 | QLabel, 6 | QLineEdit, 7 | QVBoxLayout, 8 | ) 9 | 10 | from .utils import api 11 | 12 | 13 | class ValidatedUrlInputDialog(QDialog): 14 | def __init__(self, /, parent=None, text=None): 15 | super().__init__(parent) 16 | self.setWindowTitle("URL Input - Kamyroll") 17 | self.setMinimumWidth(400) 18 | 19 | layout = QVBoxLayout() 20 | self.setLayout(layout) 21 | 22 | layout.addWidget(QLabel("Enter a valid url:")) 23 | 24 | text = text or "" 25 | self.line_edit = QLineEdit() 26 | self.line_edit.setPlaceholderText("URL") 27 | self.line_edit.textEdited.connect(self.validate) 28 | self.line_edit.setText(text) 29 | layout.addWidget(self.line_edit) 30 | 31 | self.validity_label = QLabel(" ") 32 | layout.addWidget(self.validity_label) 33 | 34 | buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 35 | self.ok_button = buttonBox.button(QDialogButtonBox.Ok) 36 | self.validate(text) 37 | layout.addWidget(buttonBox) 38 | 39 | buttonBox.accepted.connect(self.accept) 40 | buttonBox.rejected.connect(self.reject) 41 | 42 | 43 | def validate(self, url, /): 44 | if not url: 45 | self.validity_label.setText(" ") 46 | self.ok_button.setDisabled(True) 47 | return 48 | 49 | result = api.parse_url(url) 50 | if not result: 51 | self.ok_button.setDisabled(True) 52 | self.validity_label.setStyleSheet("color: red;") 53 | self.validity_label.setText(" Not a valid URL") 54 | return 55 | 56 | self.ok_button.setEnabled(True) 57 | self.name, self.params = result 58 | 59 | self.validity_label.setStyleSheet("color: green;") 60 | self.validity_label.setText(f" Valid URL for {self.name}") 61 | 62 | def get_text(self, /): 63 | if self.line_edit.hasAcceptableInput(): 64 | return self.line_edit.text() 65 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PySide6>=6,<7 --------------------------------------------------------------------------------