├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ ├── feature-request.md │ ├── provider-request.md │ └── question-support.md └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── create-shortcut.vbs ├── pyproject.toml ├── src └── gucken │ ├── __init__.py │ ├── __main__.py │ ├── aniskip.py │ ├── custom_widgets.py │ ├── gucken.py │ ├── hoster │ ├── __init__.py │ ├── _hosters.py │ ├── common.py │ ├── doodstream.py │ ├── filemoon.py │ ├── loadx.py │ ├── luluvdo.py │ ├── speedfiles.py │ ├── streamtape.py │ ├── veo.py │ ├── vidmoly.py │ └── vidoza.py │ ├── networking.py │ ├── packer.py │ ├── player │ ├── __init__.py │ ├── _players.py │ ├── android.py │ ├── common.py │ ├── ffplay.py │ ├── flatpak.py │ ├── mpv.py │ ├── vlc.py │ └── wmplayer.py │ ├── provider │ ├── __init__.py │ ├── aniworld.py │ ├── burningseries.py │ ├── common.py │ ├── crunchyroll.py │ ├── serienstream.py │ └── streamcloud.py │ ├── resources │ ├── default_settings.toml │ ├── gucken.css │ ├── mpv_gucken.lua │ └── vlc_gucken.lua │ ├── rome.py │ ├── settings.py │ ├── tracker │ ├── __init__.py │ ├── anilist.py │ ├── aniworld.py │ ├── common.py │ ├── myanimelist.py │ └── serienstream.py │ ├── update.py │ └── utils.py ├── stylua.toml └── test ├── benchmark ├── extract_html.py └── http │ ├── Cargo.toml │ ├── new_session.py │ ├── same_session.py │ └── src │ └── main.rs ├── google_recaptcha ├── bs.to.html ├── final.py ├── final_api.py ├── pypasser.py ├── reload.py ├── siteverify.py └── speech_recognition.py ├── headers.jsonc ├── networking.py ├── networking2.py ├── presence.py ├── proxy.py └── test.py /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug Report" 3 | about: "Report an issue." 4 | title: "[Bug] " 5 | labels: "Bug" 6 | 7 | --- 8 | 9 | # 🐞 Bug Report 10 | 11 | 12 | ### 📝 Describe the bug 13 | 14 | 15 | * 16 | 17 | --- 18 | 19 | #### 🔄 Is this a regression? 20 | 21 | 22 | 23 | --- 24 | 25 | #### 🐾 To Reproduce 26 | 33 | 34 | 35 | 36 | 1. 37 | 2. 38 | 3. 39 | 4. 40 | 41 | --- 42 | 43 | #### 🎯 Expected behaviour 44 | 45 | 46 | * 47 | 48 | --- 49 | 50 | #### 📷 Screenshots or Videos 51 | 52 | 53 | --- 54 | 55 | #### 📜 Logs 56 | 57 | 58 | * 59 | 60 | --- 61 | 62 | #### 💻 Your environment 63 | 65 | 66 | * 🖥️ OS: 67 | * 🐍 Python version: 68 | 69 | --- 70 | 71 | #### 📚 Additional context 72 | 73 | 74 | * 75 | 76 | 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🚀 Feature Request" 3 | about: "Suggest an idea or possible new feature." 4 | title: "[Feature Request] " 5 | labels: "Feature Request" 6 | 7 | --- 8 | 9 | # 🚀 Feature Request 10 | 11 | #### 🐞 Is your feature request related to a problem? Please describe. 12 | 13 | 14 | * 15 | 16 | --- 17 | 18 | #### 🌟 Describe the solution you'd like 19 | 20 | 21 | * 22 | 23 | --- 24 | 25 | #### 🔄 Describe alternatives you've considered 26 | 27 | 28 | * 29 | 30 | --- 31 | 32 | #### 📋 Additional context 33 | 34 | 35 | * 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/provider-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "📄 Provider Request" 3 | about: "Request support for a new provider." 4 | title: "[Provider Request] " 5 | labels: "Provider Request" 6 | 7 | --- 8 | 9 | # 📄 Provider Request 10 | 11 | 12 | #### 🌐 Domains and IPs 13 | 14 | 15 | * 16 | 17 | --- 18 | 19 | #### 🌍 Languages 20 | 21 | 22 | * 23 | 24 | --- 25 | 26 | #### 📺 Resolutions 27 | 28 | 29 | * 30 | 31 | --- 32 | 33 | #### 🔒 Captchas 34 | 35 | 36 | * 37 | 38 | --- 39 | 40 | #### 🖥️ Hosters 41 | 42 | 43 | * 44 | 45 | --- 46 | 47 | #### ℹ️ Additional Information 48 | 49 | 50 | * 51 | 52 | 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question-support.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "❓ Question or Support Request" 3 | about: "Questions and requests for support." 4 | title: "[Question/Support] " 5 | labels: "Question/Support" 6 | 7 | --- 8 | 9 | # ❓ Question or Support Request 10 | 11 | 12 | #### ✍️ Describe your question or ask for support 13 | 14 | 15 | * 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 🛎️ 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 17 | 18 | - name: Set up Python 🧰 19 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 20 | with: 21 | python-version: 3.x 22 | 23 | - name: Install dependencies 🧰 24 | env: 25 | PIP_ROOT_USER_ACTION: ignore 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install build 29 | 30 | - name: Build package 🔨 31 | run: python -m build 32 | 33 | - name: Publish package 🚀 34 | uses: Commandcracker/pypi-publish@ddf48cf80be92772ea5c647390693f488b015e23 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | github_token: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .idea 3 | *.LNK 4 | __pycache__ 5 | *.egg-info 6 | dist 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Commandcracker 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 | # Gucken 2 | 3 | Project state: **Pre-Alpha** 4 | 5 | ## Description 6 | 7 | Gucken is a Terminal User Interface which allows you to browse and watch your favorite anime's with style. 8 | 9 | ## Usage 10 | 11 | Search 12 | Watch 13 | 14 | ## Installation 15 | 16 |
Windows 17 | 18 | Install [Python] and if you are **on Windows 10** [Windows Terminal] for a better experience. 19 | 20 | ``` 21 | pip install gucken 22 | gucken 23 | ``` 24 | 25 |
26 | 27 |
Linux 28 | 29 | Install [Python] and then 30 | 31 | ``` 32 | pip install gucken 33 | gucken 34 | ``` 35 | 36 |
37 | 38 |
Android 39 | 40 | Install [Termux](https://termux.dev/en/) and run: 41 | 42 | ``` 43 | yes|pkg update 44 | pkg install python ffmpeg -y 45 | pip install gucken 46 | gucken 47 | ``` 48 | 49 | #### Shortcut 50 | 51 | Install [Termux:Widget](https://github.com/termux/termux-widget?tab=readme-ov-file#Installation). 52 | 53 | ``` 54 | mkdir ~/.shortcuts 55 | ``` 56 | 57 | ##### Lunch shortcut 58 | 59 | ``` 60 | echo gucken>~/.shortcuts/Gucken 61 | ``` 62 | 63 | ##### Update shortcut 64 | 65 | ``` 66 | echo pip install -U gucken>~/.shortcuts/Update\ Gucken 67 | ``` 68 | 69 | #### Custom Font 70 | 71 | If you want a custom font then just pace the ttf in `~/.termux/font.ttf`. Recommended fonts: [Nerd fonts](https://www.nerdfonts.com/font-downloads) (**Only use Mono fonts!**) 72 | 73 | #### Downloads 74 | 75 | Setup storage for downloads. (Default download location: `/data/data/com.termux/files/home/storage/movies`) 76 | 77 | ``` 78 | termux-setup-storage 79 | ``` 80 | 81 |
82 | 83 | ## Features 84 | 85 | - [x] Update checker 86 | - [x] Language priority list 87 | - [x] Hoster priority list 88 | - [x] Automatically use working hoster 89 | - [x] Browsing 90 | - [x] Descriptions 91 | - [x] Watching 92 | - [ ] Trailer 93 | - [x] Automatically start next episode 94 | - [x] Discord Presence **Very WIP** 95 | - [MPV] only 96 | - [X] [ani-skip](https://github.com/synacktraa/ani-skip) support 97 | - [x] [Syncplay](https://github.com/Syncplay/syncplay) support (almost out of WIP) 98 | - [ ] Remember watch time **WIP** 99 | - [ ] Remember completed Episodes (and series) 100 | - [ ] Tracker support 101 | - [ ] [MyAnimeList](https://myanimelist.net/) 102 | - [ ] [AniList](https://anilist.co/) 103 | - [ ] [AniWorld.to] & [SerienStream.to] 104 | - [ ] Downloading 105 | - [ ] Watch from download 106 | 107 | ## Provider 108 | 109 | List of supported Anime sites 110 | 111 | - [x] [AniWorld.to] & [SerienStream.to] 112 | - [ ] [bs.to](https://bs.to/) 113 | - [ ] [www3.streamcloud.info](https://www3.streamcloud.info/) 114 | - [ ] [www.crunchyroll.com](https://www.crunchyroll.com) 115 | - [ ] Add some from [International Piracy Sites German](https://fmhy.net/non-english#german-deutsch) 116 | 117 | ## Hoster 118 | 119 | List of supported video hoster. 120 | 121 | - [x] VEO 122 | - [x] Vidoza 123 | - [x] Doodstream 124 | - [x] SpeedFiles 125 | - [x] Vidmoly 126 | - [x] Streamtape (Removed from AniWorld & SerienStream) 127 | - [x] Luluvdo 128 | - [x] LoadX 129 | - [x] Filemoon 130 | 131 | ## Player 132 | 133 | List of supported video players 134 | 135 | - [x] [MPV] (most features, recommended) 136 | - [x] [VLC] 137 | - [x] [ffplay](https://www.ffmpeg.org/ffplay.html) 138 | - [ ] Custom 139 | - Windows 140 | - [x] [mpv.net](https://github.com/mpvnet-player/mpv.net) 141 | - [x] wmplayer.exe (fallback on Windows) 142 | - Android 143 | - [x] [mpv-android](https://github.com/mpv-android/mpv-android) 144 | - [x] [VLC] 145 | - [x] Choose 146 | - Linux (Flatpack) 147 | - [x] [MPV](https://flathub.org/apps/io.mpv.Mpv) 148 | - [x] [VLC](https://flathub.org/apps/org.videolan.VLC) 149 | - [x] [Celluloid](https://flathub.org/apps/io.github.celluloid_player.Celluloid) 150 | - Linux 151 | - [x] [Celluloid](https://celluloid-player.github.io/) 152 | - MacOS 153 | - [ ] [IINA](https://iina.io/) 154 | 155 | ## Custom CSS 156 | 157 | **For power users only** 158 | 159 | Place your custom CSS in `user_config_path("gucken").joinpath("custom.css")` and it will be automatically loaded by Gucken. 160 | 161 | - [Textual CSS Guide](https://textual.textualize.io/guide/CSS/) 162 | - [Textual CSS Reference](https://textual.textualize.io/css_types/) 163 | 164 | ## Optional dependencies 165 | 166 | - `speedups` (with: `gucken[speedups]`) 167 | - Faster fuzzy sort/search. (`levenshtein`) 168 | - Faster json parsing. (`orjson`) 169 | - `socks` - SOCKS proxy support. (with: `gucken[socks]`) 170 | 171 | ## Todo 172 | 173 | ### Privacy 174 | 175 | - [ ] Proxy support 176 | ``` 177 | Proxies can easiely be implented 178 | 179 | for the http client in python 180 | AsyncClient(proxy="http://...") 181 | 182 | for the player mpv Note: mpv dos not support socks5 183 | --http-proxy= 184 | FFmpeg: env.http_proxy 185 | ytdl: --ytdl-raw-options=proxy= 186 | 187 | yt-dlp 188 | --proxy URL 189 | ``` 190 | - [ ] [Tor](https://www.torproject.org/) as proxy 191 | ``` 192 | AniWorld.to need Cloudflare captcha and JS challange 193 | SerienStream.to can be bypassed by using diract ip 194 | 195 | Cloudflare captcha and JS challange can be solved by using something like 196 | selenium or playwright 197 | ``` 198 | - [ ] DoH support 199 | - [ ] Reverse proxy for player 200 | - [ ] DoH 201 | - [ ] proxy 202 | 203 | ### UX 204 | 205 | - [ ] Add hotkey to clear cache (F5) 206 | - [ ] Translation DE, EN 207 | - [ ] Improve settings design 208 | - [ ] Merge SerienStream.to and AniWorld.to search results 209 | - [ ] Focus window on autoplay popup 210 | - [ ] Utilize next and previous buttons in mpv 211 | - [ ] Chapters for VLC 212 | - [ ] Window in settings menu to show where files are located (data, logs, config, downloads) 213 | - [ ] s.to, aniworld.to scrape episode description 214 | - [ ] Search in episodes 215 | - [ ] Next and Cancel hotkeys 216 | - [ ] Show hotkeys in Footer 217 | - [ ] Create shortcut Windows & Linux 218 | - [ ] Installation helper 219 | - [ ] [MPV] 220 | - [ ] [Anime4k] 221 | - [ ] [VLC] 222 | - [ ] Colors themes 223 | 224 | ### Speedups 225 | 226 | - [ ] Pre-fetching 227 | - [ ] More threads and asyncio.gather to make everything faster 228 | - [ ] More Caching 229 | - [ ] Reuse Client 230 | 231 | ### Code 232 | 233 | - [ ] Do unescape and stripe only on render 234 | - [ ] Dont coppy code from SerienStream.to to AniWorld.to 235 | - [ ] BIG CODE CLEANUP 236 | 237 | ### Features 238 | 239 | - [ ] Update checker option to perform update 240 | - [ ] Watchlist 241 | - [ ] New anime/series Notifications 242 | - [ ] Use something like opencv to time match a sub from aniworld with a high quality video form another site. 243 | - [ ] Nix package 244 | - [ ] Docker image 245 | - [ ] Flatpack package 246 | - [ ] Detect existing chapters and use them for skip 247 | - [ ] Reverse proxy for players that do not support headers 248 | - [ ] Up-scaling (after download) 249 | - [ ] [video2x](https://github.com/k4yt3x/video2x) 250 | - [ ] [waifu2x](https://github.com/nagadomi/waifu2x) 251 | - [ ] [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) 252 | - [ ] [FSRCNN](https://github.com/igv/FSRCNN-TensorFlow) 253 | - [ ] [Anime4k] 254 | - [ ] Modular (Custom extractors/players, open API) 255 | - [ ] More CLI args 256 | - [ ] [MPV] Screen selection 257 | - [ ] Custom player args 258 | - [ ] Custom player 259 | - [ ] [Anime4k] options 260 | 261 | #### Support 262 | 263 | - [ ] Mac support 264 | - [ ] IOS support 265 | - [ ] Support textual-web 266 | - [ ] Syncplay on Android 267 | - [ ] Improve Flatpack support 268 | - [ ] Improve Snap support 269 | 270 | ### Bugs & DX 271 | 272 | - [ ] Logging and Crash reports 273 | - [ ] Blacklist detection & bypass 274 | - [ ] 404 detection inside Hoster and don't crash whole program on http error + crash reports/logs 275 | - [ ] CI Testing (Windows, Linux) 276 | 277 | [Anime4k]: https://github.com/bloc97/Anime4K 278 | [MPV]: https://mpv.io/ 279 | [VLC]: https://www.videolan.org/vlc/ 280 | [AniWorld.to]: https://aniworld.to 281 | [SerienStream.to]: https://186.2.175.5 282 | [Python]: https://www.python.org/downloads/ 283 | [Windows Terminal]: https://apps.microsoft.com/detail/9n0dx20hk701 284 | -------------------------------------------------------------------------------- /create-shortcut.vbs: -------------------------------------------------------------------------------- 1 | Dim fso, currentPath 2 | Set fso = CreateObject("Scripting.FileSystemObject") 3 | currentPath = fso.GetParentFolderName(WScript.ScriptFullName) 4 | 5 | Set oWS = WScript.CreateObject("WScript.Shell") 6 | sLinkFile = "Gucken.LNK" 7 | Set oLink = oWS.CreateShortcut(sLinkFile) 8 | oLink.TargetPath = "C:\Windows\py.exe" 9 | oLink.Arguments = "-m gucken" 10 | ' oLink.Description = "MyProgram" 11 | ' oLink.HotKey = "ALT+CTRL+F" 12 | ' oLink.IconLocation = "C:\Program Files\MyApp\MyProgram.EXE, 2" 13 | ' oLink.WindowStyle = "1" 14 | oLink.WorkingDirectory = currentPath & "\src" 15 | oLink.Save 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gucken" 3 | dynamic = ["version"] 4 | description = "Gucken is a Terminal User Interface which allows you to browse and watch your favorite anime's with style." 5 | authors = [{name="Commandcracker"}] 6 | maintainers = [{name="Commandcracker"}] 7 | license = {file = "LICENSE.txt"} 8 | readme = "README.md" 9 | dependencies = [ 10 | "textual>=3.3.0", 11 | "textual-image[textual]>=0.8.2", 12 | "beautifulsoup4>=4.13.4", 13 | "httpx[http2]>=0.28.1", 14 | "pypresence>=4.3.0", 15 | "packaging>=25.0", 16 | "platformdirs>=4.3.7", 17 | "toml>=0.10.2", 18 | "fuzzywuzzy>=0.18.0", 19 | "async_lru>=2.0.5", 20 | "rich-argparse>=1.7.0" 21 | #"yt-dlp>=2025.3.31", 22 | #"mpv>=1.0.8", 23 | ] 24 | keywords = [ 25 | "gucken", 26 | "anime", 27 | "serien", 28 | "series", 29 | "tui" 30 | ] 31 | classifiers = [ 32 | "Development Status :: 2 - Pre-Alpha", 33 | "Environment :: Console", 34 | "Intended Audience :: End Users/Desktop", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Natural Language :: English", 38 | "Natural Language :: German", 39 | "Programming Language :: Lua", 40 | "Programming Language :: Python", 41 | "Topic :: Games/Entertainment", 42 | "Topic :: Multimedia", 43 | "Topic :: Multimedia :: Sound/Audio", 44 | "Topic :: Multimedia :: Video", 45 | "Operating System :: Microsoft :: Windows", 46 | "Operating System :: POSIX :: Linux", 47 | "Operating System :: Android", 48 | #"Operating System :: MacOS", 49 | #"Operating System :: iOS", 50 | ] 51 | 52 | [project.optional-dependencies] 53 | speedups = [ 54 | "levenshtein>=0.27.1", 55 | "orjson>=3.10.16" 56 | ] 57 | socks = ["httpx[socks]>=0.28.1"] 58 | 59 | [project.urls] 60 | Repository = "https://github.com/Commandcracker/gucken" 61 | 62 | [project.scripts] 63 | gucken = "gucken.gucken:main" 64 | 65 | [build-system] 66 | requires = ["hatchling"] 67 | build-backend = "hatchling.build" 68 | 69 | [tool.hatch.build.targets.sdist] 70 | include = ["src/gucken/**", ] 71 | 72 | [tool.hatch.version] 73 | path = "src/gucken/__init__.py" 74 | -------------------------------------------------------------------------------- /src/gucken/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | warnings.filterwarnings('ignore', message='Using slow pure-python SequenceMatcher. Install python-Levenshtein to remove this warning') 3 | 4 | __version__ = "0.3.8" 5 | -------------------------------------------------------------------------------- /src/gucken/__main__.py: -------------------------------------------------------------------------------- 1 | from .gucken import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /src/gucken/aniskip.py: -------------------------------------------------------------------------------- 1 | from tempfile import NamedTemporaryFile 2 | from typing import Union 3 | from dataclasses import dataclass 4 | 5 | from fuzzywuzzy import process 6 | 7 | from .networking import AsyncClient 8 | from .tracker.myanimelist import search 9 | from .rome import replace_roman_numerals 10 | from .utils import json_loads 11 | 12 | 13 | @dataclass 14 | class SkipTimes: 15 | op_start: float 16 | op_end: float 17 | ed_start: float 18 | ed_end: float 19 | 20 | 21 | async def get_timings_from_id( 22 | anime_id: int, episode_number: int 23 | ) -> Union[SkipTimes, None]: 24 | async with AsyncClient() as client: 25 | response = await client.get( 26 | f"https://api.aniskip.com/v1/skip-times/{anime_id}/{episode_number}?types=op&types=ed" 27 | ) 28 | json = json_loads(response.content) 29 | if json.get("found") is not True: 30 | return 31 | op_start = 0 32 | op_end = 0 33 | ed_start = 0 34 | ed_end = 0 35 | for result in json["results"]: 36 | skip_type = result["skip_type"] 37 | start_time = result["interval"]["start_time"] 38 | end_time = result["interval"]["end_time"] 39 | if skip_type == "op": 40 | op_start = start_time 41 | op_end = end_time 42 | if skip_type == "ed": 43 | ed_start = start_time 44 | ed_end = end_time 45 | return SkipTimes( 46 | op_start=float(op_start), 47 | op_end=float(op_end), 48 | ed_start=float(ed_start), 49 | ed_end=float(ed_end) 50 | ) 51 | 52 | 53 | async def get_timings_from_search( 54 | keyword: str, episode_number: int 55 | ) -> Union[SkipTimes, None]: 56 | myanimelist_search_result = await search(keyword) 57 | animes = {} 58 | for anime in myanimelist_search_result["categories"][0]["items"]: 59 | animes[anime["id"]] = replace_roman_numerals(anime["name"]) 60 | search_result = process.extractOne(replace_roman_numerals(keyword), animes, score_cutoff=50) 61 | if search_result is not None: 62 | anime_id = search_result[2] 63 | return await get_timings_from_id(anime_id, episode_number) 64 | return None 65 | 66 | 67 | def chapter(start: float, end: float, title: str) -> str: 68 | return f"\n[CHAPTER]\nTIMEBASE=1/1000\nSTART={int(start * 1000)}\nEND={int(end * 1000)}\nTITLE={title}\n" 69 | 70 | 71 | def get_chapters_file_content(timings: SkipTimes) -> str: 72 | string_builder = [";FFMETADATA1"] 73 | if timings.op_start != timings.op_end: 74 | string_builder.append(chapter(timings.op_start, timings.op_end, "Opening")) 75 | if timings.ed_start != timings.ed_end: 76 | string_builder.append(chapter(timings.ed_start, timings.ed_end, "Ending")) 77 | if timings.op_end != 0 and timings.ed_start != 0: 78 | string_builder.append(chapter(timings.op_end, timings.ed_start, "Episode")) 79 | return "".join(string_builder) 80 | 81 | 82 | def generate_chapters_file(timings: SkipTimes) -> NamedTemporaryFile: 83 | temp_file = NamedTemporaryFile(mode="w", prefix="gucken-", delete=False) 84 | temp_file.write(get_chapters_file_content(timings)) 85 | temp_file.close() 86 | return temp_file 87 | -------------------------------------------------------------------------------- /src/gucken/custom_widgets.py: -------------------------------------------------------------------------------- 1 | from textual import events 2 | from textual.message import Message 3 | from textual.widgets import DataTable 4 | 5 | 6 | class SortableTable(DataTable): 7 | """ 8 | TODO: Add mouse support. 9 | TODO: Improve UX 10 | """ 11 | 12 | class SortChanged(Message): 13 | def __init__( 14 | self, sortable_table: "SortableTable", previous: int, now: int 15 | ) -> None: 16 | self.sortable_table = sortable_table 17 | self.previous = previous 18 | self.now = now 19 | super().__init__() 20 | 21 | @property 22 | def control(self) -> "SortableTable": 23 | return self.sortable_table 24 | 25 | def __init__(self, *args, **kwargs): 26 | self.move_mode = False 27 | super().__init__(cursor_type="row", *args, **kwargs) 28 | 29 | async def _on_key(self, event: events.Key) -> None: 30 | if event.key == "enter": 31 | self.move_mode = not self.move_mode 32 | 33 | def _move_item(self, offset: int) -> None: 34 | previous = self.cursor_row 35 | now = previous + offset 36 | 37 | i1 = self._row_locations.get_key(previous) 38 | i2 = self._row_locations.get_key(now) 39 | self._row_locations[i1] = now 40 | self._row_locations[i2] = previous 41 | self.cursor_coordinate = ( 42 | self.cursor_coordinate.down() if offset > 0 else self.cursor_coordinate.up() 43 | ) 44 | self._update_count += 1 45 | self.refresh() 46 | self.post_message(self.SortChanged(self, previous, now)) 47 | 48 | def action_cursor_up(self) -> None: 49 | if not self.move_mode: 50 | super().action_cursor_up() 51 | return 52 | if self.cursor_row - 1 >= 0: 53 | self._move_item(-1) 54 | 55 | def action_cursor_down(self) -> None: 56 | if not self.move_mode: 57 | super().action_cursor_down() 58 | return 59 | if self.cursor_row + 1 < self.row_count: 60 | self._move_item(1) 61 | -------------------------------------------------------------------------------- /src/gucken/gucken.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | from asyncio import gather, set_event_loop, new_event_loop 4 | from atexit import register as register_atexit 5 | from os import remove, name as os_name 6 | from os.path import join 7 | from pathlib import Path 8 | from random import choice 9 | from shutil import which 10 | from subprocess import DEVNULL, PIPE, Popen 11 | from time import sleep, time 12 | from typing import ClassVar, List, Union 13 | from async_lru import alru_cache 14 | from os import getenv 15 | from io import BytesIO 16 | 17 | from fuzzywuzzy import fuzz 18 | from platformdirs import user_config_path, user_log_path 19 | from pypresence import AioPresence, DiscordNotFound 20 | from rich.markup import escape 21 | from textual import events, on, work 22 | from textual.app import App, ComposeResult 23 | from textual.binding import Binding, BindingType 24 | from textual.containers import Center, Container, Horizontal, ScrollableContainer, Vertical, Grid 25 | from textual.reactive import reactive 26 | from textual.screen import ModalScreen 27 | from textual.widgets import ( 28 | Button, 29 | Checkbox, 30 | Collapsible, 31 | DataTable, 32 | Header, 33 | Input, 34 | Label, 35 | ListItem, 36 | ListView, 37 | Markdown, 38 | RadioButton, 39 | Select, 40 | TabbedContent, 41 | TabPane, 42 | ) 43 | from textual.worker import get_current_worker 44 | from textual_image.widget import Image 45 | from rich_argparse import RichHelpFormatter 46 | from .aniskip import ( 47 | generate_chapters_file, 48 | get_timings_from_search 49 | ) 50 | 51 | from .custom_widgets import SortableTable 52 | from .hoster._hosters import hoster 53 | from .hoster.common import DirectLink, Hoster 54 | from .player._players import all_players_keys, available_players_keys, player_map 55 | from .player.mpv import MPVPlayer 56 | from .player.vlc import VLCPlayer 57 | from .provider.aniworld import AniWorldProvider 58 | from .provider.common import Episode, Language, SearchResult, Series 59 | from .provider.serienstream import SerienStreamProvider 60 | from .settings import gucken_settings_manager 61 | from .update import check 62 | from .utils import detect_player, is_android, set_default_vlc_interface_cfg, get_vlc_intf_user_path 63 | from .networking import AsyncClient 64 | from . import __version__ 65 | 66 | 67 | def sort_favorite_lang( 68 | language_list: List[Language], pio_list: List[str] 69 | ) -> List[Language]: 70 | def lang_sort_key(hoster: Language) -> int: 71 | try: 72 | return pio_list.index(hoster.name) 73 | except ValueError: 74 | return len(pio_list) 75 | 76 | return sorted(language_list, key=lang_sort_key) 77 | 78 | 79 | def sort_favorite_hoster( 80 | hoster_list: List[Hoster], pio_list: List[str] 81 | ) -> List[Hoster]: 82 | def hoster_sort_key(_hoster: Hoster) -> int: 83 | try: 84 | return pio_list.index(hoster.get_key(type(_hoster))) 85 | except ValueError: 86 | return len(pio_list) 87 | 88 | return sorted(hoster_list, key=hoster_sort_key) 89 | 90 | 91 | def sort_favorite_hoster_by_key( 92 | hoster_list: List[str], pio_list: List[str] 93 | ) -> List[str]: 94 | def hoster_sort_key(_hoster: str) -> int: 95 | try: 96 | return pio_list.index(_hoster) 97 | except ValueError: 98 | return len(pio_list) 99 | 100 | return sorted(hoster_list, key=hoster_sort_key) 101 | 102 | 103 | async def get_working_direct_link(hosters: list[Hoster], app: "GuckenApp") -> Union[DirectLink, None]: 104 | for hoster in hosters: 105 | name = type(hoster).__name__ 106 | try: 107 | direct_link = await hoster.get_direct_link() 108 | except Exception: 109 | logging.warning( 110 | "%s: failed to retrieve video URL from: \"%s\"", 111 | name, 112 | hoster.url, 113 | exc_info=True 114 | ) 115 | app.notify( 116 | "Failed to retrieve video URL", 117 | title=f"{name} error", 118 | severity="warning", 119 | ) 120 | continue 121 | if not direct_link or not direct_link.url or not isinstance(direct_link.url, str) or not isinstance(direct_link, DirectLink): 122 | logging.warning( 123 | "%s: Returned empty URL: \"%s\"", 124 | name, 125 | hoster.url 126 | ) 127 | app.notify( 128 | "Returned empty URL", 129 | title=f"{name} error", 130 | severity="warning", 131 | ) 132 | continue 133 | is_working = await direct_link.check_is_working() 134 | if is_working: 135 | return direct_link 136 | else: 137 | logging.warning( 138 | "%s: Video URL is not working: \"%s\"", 139 | name, 140 | direct_link.url or "None" 141 | ) 142 | app.notify( 143 | "Video URL is not working", 144 | title=f"{name} error", 145 | severity="warning", 146 | ) 147 | return None 148 | 149 | 150 | class Next(ModalScreen): 151 | time = reactive(3) 152 | 153 | def __init__(self, question: str, no_time: bool = False): 154 | super().__init__() 155 | self.question = question 156 | self.no_time = no_time 157 | 158 | def compose(self) -> ComposeResult: 159 | with Container(): 160 | yield Label(self.question) 161 | with Horizontal(): 162 | yield Button.error("Cancel", id="cancel") 163 | yield Button.success("Next", id="next") 164 | 165 | def on_mount(self) -> None: 166 | if not self.no_time: 167 | self.set_interval(1, self.update_time) 168 | self.query_one(Label).update(self.question + " " + str(self.time)) 169 | 170 | def update_time(self) -> None: 171 | self.time = self.time - 1 172 | if self.time < 0: 173 | self.dismiss(True) 174 | self.query_one(Label).update(self.question + " " + str(self.time)) 175 | 176 | @on(Button.Pressed) 177 | def exit_screen(self, event): 178 | button_id = event.button.id 179 | self.dismiss(button_id == "next") 180 | 181 | 182 | class ClickableListItem(ListItem): 183 | def __init__(self, *args, **kwargs): 184 | super().__init__(*args, **kwargs) 185 | self.last_click = None 186 | 187 | def on_click(self) -> None: 188 | if self.last_click and time() - self.last_click < 0.5: 189 | self.app.open_info() 190 | self.last_click = time() 191 | 192 | 193 | class ClickableDataTable(DataTable): 194 | def __init__(self, *args, **kwargs): 195 | super().__init__(*args, **kwargs) 196 | self.last_click = {} 197 | 198 | def on_click(self, event: events.Click) -> None: 199 | meta = event.style.meta 200 | if not "row" in meta or not "column" in meta: 201 | return 202 | row_index = meta["row"] 203 | if row_index <= -1: 204 | return 205 | if self.last_click.get(row_index) and time() - self.last_click[row_index] < 0.5: 206 | self.app.play_selected() 207 | self.last_click[row_index] = time() 208 | 209 | 210 | def remove_duplicates(lst: list) -> list: 211 | """ 212 | Why this instead of a set you ask ? 213 | Because a set cant maintaining the original order. 214 | """ 215 | seen = set() 216 | result = [] 217 | for item in lst: 218 | if item not in seen: 219 | seen.add(item) 220 | result.append(item) 221 | return result 222 | 223 | 224 | def remove_none_lang_keys(lst: list) -> list: 225 | valid_languages = {lang.name for lang in Language} 226 | return [item for item in lst if item in valid_languages] 227 | 228 | 229 | def remove_none_host_keys(lst: list) -> list: 230 | valid_host = {h for h in hoster} 231 | return [item for item in lst if item in valid_host] 232 | 233 | 234 | def move_item(lst: list, from_index: int, to_index: int) -> list: 235 | item = lst.pop(from_index) 236 | lst.insert(to_index, item) 237 | return lst 238 | 239 | 240 | CLIENT_ID = "1238219157464416266" 241 | 242 | 243 | class GuckenApp(App): 244 | TITLE = f"Gucken {__version__}" 245 | CSS_PATH = [join("resources", "gucken.css")] 246 | custom_css = user_config_path("gucken").joinpath("custom.css") 247 | if custom_css.exists(): 248 | CSS_PATH.append(custom_css) 249 | BINDINGS: ClassVar[list[BindingType]] = [ 250 | Binding("q", "quit", "Quit", show=False, priority=False), 251 | ] 252 | 253 | # TODO: theme_changed_signal 254 | 255 | def __init__(self, debug: bool, search: str): 256 | super().__init__(watch_css=debug) 257 | self._debug = debug 258 | self._search = search 259 | 260 | self.current: Union[list[SearchResult], None] = None 261 | self.current_info: Union[Series, None] = None 262 | self.detected_player = detect_player() 263 | self.RPC: Union[AioPresence, None] = None 264 | 265 | language: list = gucken_settings_manager.settings["settings"]["language"] 266 | language = remove_none_lang_keys(language) 267 | 268 | for ll in Language: 269 | language.append(ll.name) 270 | 271 | gucken_settings_manager.settings["settings"]["language"] = remove_duplicates( 272 | language 273 | ) 274 | self.language = gucken_settings_manager.settings["settings"]["language"] 275 | 276 | _hoster: list = gucken_settings_manager.settings["settings"]["hoster"] 277 | _hoster = remove_none_host_keys(_hoster) 278 | 279 | for ll in hoster: 280 | _hoster.append(ll) 281 | 282 | gucken_settings_manager.settings["settings"]["hoster"] = remove_duplicates( 283 | _hoster 284 | ) 285 | self.hoster = gucken_settings_manager.settings["settings"]["hoster"] 286 | 287 | def compose(self) -> ComposeResult: 288 | settings = gucken_settings_manager.settings["settings"] 289 | providers = settings["providers"] 290 | 291 | player = settings["player"]["player"] 292 | if player not in all_players_keys: 293 | player = "AutomaticPlayer" 294 | 295 | yield Header() 296 | with TabbedContent(): 297 | with TabPane("Search", id="search"): # Search "🔎" 298 | with Horizontal(id="hosters"): 299 | yield Checkbox( 300 | "AniWorld.to", 301 | value=providers["aniworld_to"], 302 | id="aniworld_to", 303 | classes="provider" 304 | ) 305 | yield Checkbox( 306 | "SerienStream.to", 307 | value=providers["serienstream_to"], 308 | id="serienstream_to", 309 | classes="provider" 310 | ) 311 | yield Input(id="input", placeholder="Search for a Anime") 312 | yield ListView(id="results") 313 | 314 | with TabPane("Info", id="info", disabled=True): # Info "ℹ" 315 | with ScrollableContainer(id="res_con"): 316 | yield Horizontal( 317 | Image(id="image"), 318 | Markdown(id="markdown"), 319 | id="res_con_2" 320 | ) 321 | yield Select.from_values( 322 | [], # Leere Liste zu Beginn 323 | id="season_filter", 324 | prompt="Alle Staffeln" 325 | ) 326 | yield ClickableDataTable(id="season_list") 327 | 328 | with TabPane("Settings", id="setting"): # Settings "⚙" 329 | # TODO: dont show unneeded on android 330 | with ScrollableContainer(id="settings_container"): 331 | yield SortableTable(id="lang") 332 | yield SortableTable(id="host") 333 | yield RadioButton( 334 | "Image display", 335 | id="image_display", 336 | value=settings["image_display"], 337 | ) 338 | yield RadioButton( 339 | "Update checker", 340 | id="update_checker", 341 | value=settings["update_checker"], 342 | ) 343 | yield RadioButton( 344 | "Discord Presence", 345 | id="discord_presence", 346 | value=settings["discord_presence"], 347 | ) 348 | with Collapsible(title="Player", collapsed=False): 349 | yield RadioButton( 350 | "Fullscreen", id="fullscreen", value=settings["fullscreen"] 351 | ) 352 | yield RadioButton( 353 | "Syncplay", id="syncplay", value=settings["syncplay"] 354 | ) 355 | yield RadioButton( 356 | "Autoplay", 357 | id="autoplay", 358 | value=settings["autoplay"]["enabled"], 359 | ) 360 | yield RadioButton( 361 | "PiP Mode (MPV & VLC only)", 362 | id="pip", 363 | value=settings["pip"], 364 | ) 365 | yield Select.from_values( 366 | available_players_keys, 367 | id="player", 368 | prompt="AutomaticPlayer", 369 | value=( 370 | Select.BLANK if player == "AutomaticPlayer" else player 371 | ), 372 | ) 373 | with Collapsible(title="ani-skip (MPV & VLC only)", collapsed=False): 374 | yield RadioButton( 375 | "Skip opening", 376 | id="ani_skip_opening", 377 | value=settings["ani_skip"]["skip_opening"], 378 | ) 379 | yield RadioButton( 380 | "Skip ending", 381 | id="ani_skip_ending", 382 | value=settings["ani_skip"]["skip_ending"], 383 | ) 384 | yield RadioButton( 385 | "Get chapters (only MPV)", 386 | id="ani_skip_chapters", 387 | value=settings["ani_skip"]["chapters"], 388 | ) 389 | # yield Footer() 390 | with Center(id="footer"): 391 | yield Label("Made by Commandcracker with [red]❤[/red]") 392 | 393 | @on(Input.Changed) 394 | async def input_changed(self, event: Input.Changed): 395 | if event.control.id == "input": 396 | self.lookup_anime(event.value) 397 | 398 | @on(Select.Changed) 399 | def on_season_filter_changed(self, event: Select.Changed) -> None: 400 | if event.control.id == "season_filter": 401 | table = self.query_one("#season_list", DataTable) 402 | if not self.current_info: 403 | return 404 | 405 | table.clear(columns=True) 406 | table.add_columns("FT", "S", "F", "Title", "Hoster", "Sprache") 407 | 408 | # Liste der gefilterten Episoden speichern 409 | self.filtered_episodes = [] 410 | 411 | # Temporäre Listen für die Sortierung 412 | regular_episodes = [] 413 | movie_episodes = [] 414 | 415 | # Episoden in reguläre und Filme aufteilen 416 | for ep in self.current_info.episodes: 417 | if event.value == Select.BLANK or str(ep.season) == event.value: 418 | if str(ep.season) == "0": 419 | movie_episodes.append(ep) 420 | else: 421 | regular_episodes.append(ep) 422 | 423 | # Zusammenführen der Listen: erst reguläre Episoden, dann Filme 424 | sorted_episodes = regular_episodes + movie_episodes 425 | self.filtered_episodes = sorted_episodes 426 | 427 | # Anzeigen der sortierten Episoden 428 | c = 0 429 | for ep in sorted_episodes: 430 | hl = [] 431 | for h in ep.available_hoster: 432 | hl.append(hoster.get_key(h)) 433 | 434 | ll = [] 435 | for l in sort_favorite_lang(ep.available_language, self.language): 436 | ll.append(l.name) 437 | 438 | c += 1 439 | table.add_row( 440 | c, 441 | "F" if str(ep.season) == "0" else ep.season, 442 | ep.episode_number, 443 | escape(ep.title), 444 | " ".join(sort_favorite_hoster_by_key(hl, self.hoster)), 445 | " ".join(ll), 446 | ) 447 | 448 | @on(SortableTable.SortChanged) 449 | async def sortableTable_sortChanged( 450 | self, 451 | event: SortableTable.SortChanged 452 | ): 453 | id = event.control.id 454 | if id == "lang": 455 | move_item(self.language, event.previous, event.now) 456 | return 457 | 458 | if id == "host": 459 | move_item(self.hoster, event.previous, event.now) 460 | return 461 | 462 | @on(Checkbox.Changed) 463 | async def checkbox_changed(self, event: Checkbox.Changed): 464 | id = event.control.id 465 | settings = gucken_settings_manager.settings["settings"] 466 | 467 | if event.control.has_class("provider"): 468 | settings["providers"][id] = event.value 469 | self.lookup_anime(self.query_one("#input", Input).value) 470 | 471 | @on(RadioButton.Changed) 472 | async def radio_button_changed(self, event: RadioButton.Changed): 473 | id = event.control.id 474 | settings = gucken_settings_manager.settings["settings"] 475 | 476 | if id == "ani_skip_opening": 477 | settings["ani_skip"]["skip_opening"] = event.value 478 | return 479 | 480 | if id == "ani_skip_ending": 481 | settings["ani_skip"]["skip_ending"] = event.value 482 | return 483 | 484 | if id == "ani_skip_chapters": 485 | settings["ani_skip"]["chapters"] = event.value 486 | return 487 | 488 | if id == "autoplay": 489 | settings["autoplay"]["enabled"] = event.value 490 | return 491 | 492 | if id == "pip": 493 | settings["pip"] = event.value 494 | return 495 | 496 | if id == "image_display" and event.value == False: 497 | img: Image = self.query_one("#image", Image) 498 | img.image = None 499 | 500 | settings[id] = event.value 501 | 502 | if id == "discord_presence": 503 | if event.value is True: 504 | await self.enable_RPC() 505 | else: 506 | await self.disable_RPC() 507 | 508 | @on(Select.Changed) 509 | def select_changed(self, event: Select.Changed) -> None: 510 | id = event.control.id 511 | settings = gucken_settings_manager.settings["settings"] 512 | 513 | if id == "player": 514 | if event.value == Select.BLANK: 515 | settings["player"]["player"] = "AutomaticPlayer" 516 | else: 517 | settings["player"]["player"] = event.value 518 | 519 | # TODO: dont lock - no async 520 | async def on_mount(self) -> None: 521 | self.theme = getenv("TEXTUAL_THEME") or gucken_settings_manager.settings["settings"]["ui"]["theme"] 522 | 523 | def on_theme_change(old_value: str, new_value: str) -> None: 524 | gucken_settings_manager.settings["settings"]["ui"]["theme"] = new_value 525 | 526 | self.watch(self.app, "theme", on_theme_change, init=False) 527 | 528 | lang = self.query_one("#lang", DataTable) 529 | lang.add_columns("Language") 530 | for l in self.language: 531 | lang.add_row(l) 532 | 533 | host = self.query_one("#host", DataTable) 534 | host.add_columns("Host") 535 | for h in self.hoster: 536 | host.add_row(h) 537 | 538 | _input = self.query_one("#input", Input) 539 | _input.focus() 540 | 541 | if self._search is not None: 542 | def set_search(): 543 | _input.value = self._search 544 | 545 | self.call_later(set_search) 546 | 547 | self.query_one("#info", TabPane).set_loading(True) 548 | 549 | table = self.query_one("#season_list", DataTable) 550 | table.cursor_type = "row" 551 | 552 | if self.query_one("#update_checker", RadioButton).value is True: 553 | self.update_check() 554 | 555 | # TODO: dont lock 556 | if self.query_one("#discord_presence", RadioButton).value is True: 557 | await self.enable_RPC() 558 | else: 559 | await self.disable_RPC() 560 | 561 | async def enable_RPC(self): 562 | if self.RPC is None: 563 | self.RPC = AioPresence(CLIENT_ID) 564 | try: 565 | await self.RPC.connect() 566 | except DiscordNotFound: 567 | pass 568 | 569 | async def disable_RPC(self): 570 | if self.RPC is not None: 571 | await self.RPC.clear() 572 | # close without closing event loop 573 | self.RPC.send_data(2, {"v": 1, "client_id": self.RPC.client_id}) 574 | self.RPC.sock_writer.close() 575 | self.RPC = None 576 | 577 | @alru_cache(maxsize=64, ttl=600) # Cache 64 entries. Clear entry after 10 minutes. 578 | async def aniworld_search(self, keyword: str) -> Union[list[SearchResult], None]: 579 | return await AniWorldProvider.search(keyword) 580 | 581 | @alru_cache(maxsize=64, ttl=600) # Cache 64 entries. Clear entry after 10 minutes. 582 | async def serienstream_search(self, keyword: str) -> Union[list[SearchResult], None]: 583 | return await SerienStreamProvider.search(keyword) 584 | 585 | def sync_gather(self, tasks: list): 586 | async def gather_all(): 587 | return await gather(*tasks) 588 | 589 | loop = new_event_loop() 590 | set_event_loop(loop) 591 | return loop.run_until_complete(gather_all()) 592 | 593 | # TODO: Exit on error when debug = true 594 | # TODO: sometimes not removing loading state 595 | # TODO: FIX 596 | """ 597 | sys:1: RuntimeWarning: coroutine '_LRUCacheWrapperInstanceMethod.__call__' was never awaited 598 | RuntimeWarning: Enable tracemalloc to get the object allocation traceback 599 | """ 600 | 601 | @work(exclusive=True, thread=True, exit_on_error=False) 602 | def lookup_anime(self, keyword: str) -> None: 603 | results_list_view = self.query_one("#results", ListView) 604 | worker = get_current_worker() 605 | 606 | if keyword is None: 607 | if not worker.is_cancelled: 608 | self.call_from_thread(results_list_view.clear) 609 | self.call_from_thread(results_list_view.set_loading, False) 610 | return 611 | 612 | aniworld_to = self.query_one("#aniworld_to", Checkbox).value 613 | serienstream_to = self.query_one("#serienstream_to", Checkbox).value 614 | 615 | search_providers = [] 616 | 617 | if aniworld_to: 618 | search_providers.append(self.aniworld_search(keyword)) 619 | if serienstream_to: 620 | search_providers.append(self.serienstream_search(keyword)) 621 | 622 | if worker.is_cancelled: 623 | return 624 | self.call_from_thread(results_list_view.clear) 625 | self.call_from_thread(results_list_view.set_loading, True) 626 | if worker.is_cancelled: 627 | return 628 | results = self.sync_gather(search_providers) 629 | final_results = [] 630 | for l in results: 631 | if l is not None: 632 | for e in l: 633 | final_results.append(e) 634 | 635 | def fuzzy_sort_key(result): 636 | return fuzz.ratio(keyword, result.name) 637 | 638 | final_results = sorted(final_results, key=fuzzy_sort_key, reverse=True) 639 | if len(final_results) > 0: 640 | self.current = final_results 641 | items = [] 642 | for series in final_results: 643 | items.append(ClickableListItem( 644 | Markdown( 645 | f"##### {series.name} {series.production_year} [{series.provider_name}]" 646 | f"\n{series.description}" 647 | ) 648 | )) 649 | if worker.is_cancelled: 650 | return 651 | self.call_from_thread(results_list_view.extend, items) 652 | self.call_from_thread(results_list_view.set_loading, False) 653 | if len(final_results) > 0: 654 | 655 | def select_first_index(): 656 | try: 657 | results_list_view.index = 0 658 | except AssertionError: 659 | pass 660 | 661 | self.call_later(select_first_index) 662 | 663 | async def on_key(self, event: events.Key) -> None: 664 | key = event.key 665 | if self.screen.id == "_default": 666 | if self.query_one(TabbedContent).active == "search": 667 | lv = self.query_one("#results", ListView) 668 | inp = self.query_one("#input", Input) 669 | # Down to list 670 | if key == "down": 671 | if inp.has_focus: 672 | lv.focus() 673 | # Up to Input 674 | if key == "up": 675 | if not inp.has_focus and (lv.index == 0 or lv.index is None): 676 | inp.focus() 677 | # Selection 678 | if key == "enter": 679 | if lv.index is not None: 680 | self.open_info() 681 | # Type anywhere 682 | if key not in ["down", "up", "enter"]: 683 | if lv.has_focus: 684 | inp.focus() 685 | if key == "backspace": 686 | inp.action_delete_left() 687 | else: 688 | await inp.on_event(event) 689 | if key == "enter" and self.query_one("#season_list", DataTable).has_focus: 690 | self.play_selected() 691 | 692 | @work(exclusive=True) 693 | async def play_selected(self): 694 | dt = self.query_one("#season_list", DataTable) 695 | # TODO: show loading 696 | # dt.set_loading(True) 697 | index = self.app.query_one("#results", ListView).index 698 | series_search_result = self.current[index] 699 | 700 | # Verwende filtered_episodes falls vorhanden, sonst current_info.episodes 701 | if hasattr(self, 'filtered_episodes') and self.filtered_episodes: 702 | episodes_to_use = self.filtered_episodes 703 | # cursor_row entspricht direkt dem Index in der gefilterten Liste 704 | selected_index = dt.cursor_row 705 | else: 706 | episodes_to_use = self.current_info.episodes 707 | # Finde die entsprechende Episode basierend auf der Tabellenzeile 708 | selected_row = dt.get_row_at(dt.cursor_row) 709 | # Suche die passende Episode anhand der angezeigten Informationen 710 | selected_index = next((i for i, ep in enumerate(episodes_to_use) 711 | if 712 | (str(ep.season) if ep.season != 0 else "F") == str(selected_row[1]) # Prüfe Staffel 713 | and str(ep.episode_number) == str(selected_row[2])), 0) # Prüfe Episodennummer 714 | 715 | self.play( 716 | series_search_result=series_search_result, 717 | episodes=episodes_to_use, 718 | index=selected_index, 719 | ) 720 | #dt.set_loading(False) 721 | 722 | @alru_cache(maxsize=32, ttl=600) # Cache 32 entries. Clear entry after 10 minutes. 723 | async def get_series(self, series_search_result: SearchResult): 724 | return await series_search_result.get_series() 725 | 726 | @work(exclusive=True) 727 | async def open_info(self) -> None: 728 | series_search_result: SearchResult = self.current[ 729 | self.app.query_one("#results", ListView).index 730 | ] 731 | info_tab = self.query_one("#info", TabPane) 732 | info_tab.disabled = False 733 | info_tab.set_loading(True) 734 | table = self.query_one("#season_list", DataTable) 735 | table.focus(scroll_visible=False) 736 | md = self.query_one("#markdown", Markdown) 737 | 738 | series = await self.get_series(series_search_result) 739 | self.current_info = series 740 | 741 | season_filter = self.query_one("#season_filter", Select) 742 | unique_seasons = sorted(set(ep.season for ep in series.episodes)) 743 | 744 | # Sortiere die Staffeln so, dass Filme (Staffel 0) am Ende erscheint 745 | regular_seasons = [s for s in unique_seasons if s != 0] 746 | movies_season = [s for s in unique_seasons if s == 0] 747 | sorted_seasons = regular_seasons + movies_season 748 | 749 | season_filter_options = [] 750 | for season in sorted_seasons: 751 | label = "Filme" if season == 0 else f"Staffel {season}" 752 | season_filter_options.append((label, str(season))) 753 | season_filter.set_options(season_filter_options) 754 | season_filter.value = Select.BLANK 755 | 756 | await md.update(series.to_markdown()) 757 | 758 | if gucken_settings_manager.settings["settings"]["image_display"]: 759 | img: Image = self.query_one("#image", Image) 760 | async with AsyncClient(verify=False) as client: 761 | response = await client.get(series.cover) 762 | img.image = BytesIO(response.content) 763 | 764 | # make sure to reset colum spacing 765 | table.clear(columns=True) 766 | table.add_columns("FT", "S", "F", "Title", "Hoster", "Sprache") 767 | 768 | # Sortiere die Episoden entsprechend 769 | 770 | # Sortiere die Episoden entsprechend der gewünschten Reihenfolge 771 | sorted_episodes = [] 772 | # Zuerst Specials (S) 773 | for ep in series.episodes: 774 | if ep.season == "S": 775 | sorted_episodes.append(ep) 776 | # Dann numerische Staffeln 777 | for ep in series.episodes: 778 | if isinstance(ep.season, (int, str)) and ep.season not in ["S", 0]: 779 | sorted_episodes.append(ep) 780 | # Zum Schluss Filme (F) 781 | for ep in series.episodes: 782 | if ep.season == 0: 783 | sorted_episodes.append(ep) 784 | 785 | c = 0 786 | for ep in sorted_episodes: 787 | hl = [] 788 | for h in ep.available_hoster: 789 | hl.append(hoster.get_key(h)) 790 | 791 | ll = [] 792 | for l in sort_favorite_lang(ep.available_language, self.language): 793 | ll.append(l.name) 794 | 795 | c += 1 796 | # Zeige die Staffeln in der gewünschten Reihenfolge 797 | if ep.season == "S": 798 | season_display = "S" 799 | elif ep.season == 0: 800 | season_display = "F" 801 | else: 802 | season_display = ep.season 803 | 804 | table.add_row( 805 | c, 806 | season_display, 807 | ep.episode_number, 808 | escape(ep.title), 809 | " ".join(sort_favorite_hoster_by_key(hl, self.hoster)), 810 | " ".join(ll), 811 | ) 812 | info_tab.set_loading(False) 813 | 814 | @work(exclusive=True, thread=True) 815 | async def update_check(self): 816 | res = await check() 817 | if res: 818 | self.notify( 819 | f"{res.current} -> {res.latest}\npip install -U gucken", 820 | title="Update available", 821 | severity="information", 822 | ) 823 | 824 | @work(thread=True) 825 | async def play( 826 | self, series_search_result: SearchResult, episodes: list[Episode], index: int 827 | ) -> None: 828 | p = gucken_settings_manager.settings["settings"]["player"]["player"] 829 | if p == "AutomaticPlayer": 830 | _player = self.detected_player 831 | else: 832 | _player = player_map[p]() 833 | 834 | if _player is None: 835 | self.notify( 836 | "Please install a supported player!", 837 | title="No player detected", 838 | severity="error", 839 | ) 840 | return 841 | 842 | if p != "AutomaticPlayer": 843 | if not _player.is_available(): 844 | self.notify( 845 | "Your configured player has not been found!", 846 | title="Player not found", 847 | severity="error", 848 | ) 849 | return 850 | 851 | episode: Episode = episodes[index] 852 | processed_hoster = await episode.process_hoster() 853 | 854 | if len(episode.available_language) <= 0: 855 | self.notify( 856 | "The episode you are trying to watch has no stream available.", 857 | title="No stream available", 858 | severity="error", 859 | ) 860 | return 861 | 862 | lang = sort_favorite_lang(episode.available_language, self.language)[0] 863 | sorted_hoster = sort_favorite_hoster(processed_hoster.get(lang), self.hoster) 864 | direct_link = await get_working_direct_link(sorted_hoster, self) 865 | 866 | # TODO: check for header support 867 | syncplay = gucken_settings_manager.settings["settings"]["syncplay"] 868 | fullscreen = gucken_settings_manager.settings["settings"]["fullscreen"] 869 | 870 | title = f"{series_search_result.name} S{episode.season}E{episode.episode_number} - {episode.title}" 871 | args = _player.play(direct_link.url, title, fullscreen, direct_link.headers) 872 | 873 | if self.RPC and self.RPC.sock_writer: 874 | async def update(): 875 | await self.RPC.update( 876 | # state="00:20:00 / 00:25:00 57% complete", 877 | details=title[:128], 878 | large_text=title, 879 | large_image=series_search_result.cover, 880 | # small_image as playing or stopped ? 881 | # small_image="https://jooinn.com/images/lonely-tree-reflection-3.jpg", 882 | # small_text="ff 15", 883 | # start=time.time(), # for paused 884 | # end=time.time() + timedelta(minutes=20).seconds # for time left 885 | ) 886 | 887 | self.app.call_later(update) 888 | 889 | # Picture-in-Picture mode 890 | if gucken_settings_manager.settings["settings"]["pip"]: 891 | if isinstance(_player, MPVPlayer): 892 | args.append("--ontop") 893 | args.append("--no-border") 894 | args.append("--snap-window") 895 | 896 | if isinstance(_player, VLCPlayer): 897 | args.append("--video-on-top") 898 | args.append("--qt-minimal-view") 899 | args.append("--no-video-deco") 900 | 901 | if direct_link.force_hls: 902 | # TODO: make work for vlc and others 903 | if isinstance(_player, MPVPlayer): 904 | args.append("--demuxer=lavf") 905 | args.append("--demuxer-lavf-format=hls") 906 | 907 | if self._debug: 908 | logs_path = user_log_path("gucken", ensure_exists=True) 909 | if isinstance(_player, MPVPlayer): 910 | args.append("--log-file=" + str(logs_path.joinpath("mpv.log"))) 911 | elif isinstance(_player, VLCPlayer): 912 | args.append("--file-logging") 913 | args.append("--log-verbose=3") 914 | args.append("--logfile=" + str(logs_path.joinpath("vlc.log"))) 915 | 916 | chapters_file = None 917 | 918 | # TODO: cache more 919 | # TODO: Support based on mpv 920 | # TODO: recover start --start=00:56 921 | if isinstance(_player, MPVPlayer) or isinstance(_player, VLCPlayer): 922 | ani_skip_opening = gucken_settings_manager.settings["settings"]["ani_skip"]["skip_opening"] 923 | ani_skip_ending = gucken_settings_manager.settings["settings"]["ani_skip"]["skip_ending"] 924 | ani_skip_chapters = gucken_settings_manager.settings["settings"]["ani_skip"]["chapters"] 925 | 926 | if ani_skip_opening or ani_skip_ending or ani_skip_chapters: 927 | timings = await get_timings_from_search( 928 | series_search_result.name + " " + str(episode.season), episode.episode_number 929 | ) 930 | if timings: 931 | if isinstance(_player, MPVPlayer): 932 | if ani_skip_chapters: 933 | chapters_file = generate_chapters_file(timings) 934 | 935 | def delete_chapters_file(): 936 | try: 937 | remove(chapters_file.name) 938 | except FileNotFoundError: 939 | pass 940 | 941 | register_atexit(delete_chapters_file) 942 | args.append(f"--chapters-file={chapters_file.name}") 943 | 944 | script_opts = [] 945 | if ani_skip_opening: 946 | script_opts.append(f"skip-op_start={timings.op_start}") 947 | script_opts.append(f"skip-op_end={timings.op_end}") 948 | if ani_skip_ending: 949 | script_opts.append(f"skip-ed_start={timings.ed_start}") 950 | script_opts.append(f"skip-ed_end={timings.ed_end}") 951 | if len(script_opts) > 0: 952 | args.append(f"--script-opts={','.join(script_opts)}") 953 | 954 | args.append( 955 | "--scripts-append=" + str(Path(__file__).parent.joinpath("resources", "mpv_gucken.lua"))) 956 | 957 | if isinstance(_player, VLCPlayer): 958 | prepend_data = [] 959 | if ani_skip_opening: 960 | prepend_data.append(set_default_vlc_interface_cfg("op_start", timings.op_start)) 961 | prepend_data.append(set_default_vlc_interface_cfg("op_end", timings.op_end)) 962 | if ani_skip_ending: 963 | prepend_data.append(set_default_vlc_interface_cfg("ed_start", timings.ed_start)) 964 | prepend_data.append(set_default_vlc_interface_cfg("ed_end", timings.ed_end)) 965 | 966 | vlc_intf_user_path = get_vlc_intf_user_path(_player.executable).vlc_intf_user_path 967 | Path(vlc_intf_user_path).mkdir(mode=0o755, parents=True, exist_ok=True) 968 | 969 | vlc_skip_plugin = Path(__file__).parent.joinpath("resources", "vlc_gucken.lua") 970 | copy_to = join(vlc_intf_user_path, "vlc_gucken.lua") 971 | 972 | with open(vlc_skip_plugin, 'r') as f: 973 | original_content = f.read() 974 | 975 | with open(copy_to, 'w') as f: 976 | f.write("\n".join(prepend_data) + original_content) 977 | 978 | args.append("--control=luaintf{intf=vlc_gucken}") 979 | 980 | if syncplay: 981 | # TODO: make work with flatpak 982 | # TODO: make work with android 983 | syncplay_path = None 984 | if which("syncplay"): 985 | syncplay_path = "syncplay" 986 | if not syncplay_path: 987 | if os_name == "nt": 988 | if which(r"C:\Program Files (x86)\Syncplay\Syncplay.exe"): 989 | syncplay_path = r"C:\Program Files (x86)\Syncplay\Syncplay.exe" 990 | if not syncplay_path: 991 | self.notify( 992 | "Syncplay not found", 993 | title="Syncplay not found", 994 | severity="error", 995 | ) 996 | else: 997 | # TODO: add mpv.net, IINA, MPC-BE, MPC-HE, celluloid ? 998 | if isinstance(_player, MPVPlayer) or isinstance(_player, VLCPlayer): 999 | player_path = which(args[0]) 1000 | url = args[1] 1001 | args.pop(0) 1002 | args.pop(0) 1003 | args = [ 1004 | syncplay_path, 1005 | "--player-path", 1006 | player_path, 1007 | # "--debug", 1008 | url, 1009 | "--", 1010 | ] + args 1011 | else: 1012 | self.notify( 1013 | "Your player is not supported by Syncplay", 1014 | title="Player not supported", 1015 | severity="warning", 1016 | ) 1017 | 1018 | logging.info("Running: %s", args) 1019 | # TODO: detach on linux 1020 | # multiprocessing 1021 | # child_pid = os.fork() 1022 | # if child_pid == 0: 1023 | process = Popen(args, stderr=PIPE, stdout=DEVNULL, stdin=DEVNULL) 1024 | while not self.app._exit: 1025 | sleep(0.1) 1026 | 1027 | resume_time = None 1028 | 1029 | # only if mpv WIP 1030 | while not self.app._exit: 1031 | output = process.stderr.readline() 1032 | if process.poll() is not None: 1033 | break 1034 | if output: 1035 | out_s = output.strip().decode() 1036 | # AV: 00:11:57 / 00:24:38 (49%) A-V: 0.000 Cache: 89s/22MB 1037 | if out_s.startswith("AV:"): 1038 | sp = out_s.split(" ") 1039 | resume_time = sp[1] 1040 | 1041 | if resume_time: 1042 | logging.info("Resume: %s", resume_time) 1043 | 1044 | exit_code = process.poll() 1045 | 1046 | if exit_code is not None: 1047 | if chapters_file: 1048 | try: 1049 | remove(chapters_file.name) 1050 | except FileNotFoundError: 1051 | pass 1052 | if self.RPC and self.RPC.sock_writer: 1053 | self.app.call_later(self.RPC.clear) 1054 | 1055 | async def push_next_screen(): 1056 | async def play_next(should_next): 1057 | if should_next: 1058 | self.play( 1059 | series_search_result, 1060 | episodes, 1061 | index + 1, 1062 | ) 1063 | 1064 | await self.app.push_screen( 1065 | Next("Playing next episode in", no_time=is_android), 1066 | callback=play_next, 1067 | ) 1068 | 1069 | autoplay = gucken_settings_manager.settings["settings"]["autoplay"]["enabled"] 1070 | if not len(episodes) <= index + 1: 1071 | if autoplay is True: 1072 | self.app.call_later(push_next_screen) 1073 | else: 1074 | # TODO: ask to mark as completed 1075 | pass 1076 | return 1077 | 1078 | 1079 | exit_quotes = [ 1080 | "Closing one anime is just an invitation to open another.", 1081 | "You finished one, now finish the next.", 1082 | "Don't stop now, there's a whole universe waiting to be explored.", 1083 | "The end of one journey is just the beginning of another.", 1084 | "Like a phoenix rising from the ashes, the end of one episode ignites the flames of anticipation for the next.", 1085 | ] 1086 | 1087 | 1088 | def main(): 1089 | parser = argparse.ArgumentParser( 1090 | prog='gucken', 1091 | description="Gucken is a Terminal User Interface which allows you to browse and watch your favorite anime's with style.", 1092 | formatter_class=RichHelpFormatter 1093 | ) 1094 | parser.add_argument("search", nargs='?') 1095 | parser.add_argument( 1096 | "--debug", "--dev", 1097 | action="store_true", 1098 | help='enables logging and live tcss reload' 1099 | ) 1100 | parser.add_argument( 1101 | '-V', '--version', 1102 | action='store_true', 1103 | help='display version information.' 1104 | ) 1105 | args = parser.parse_args() 1106 | if args.version: 1107 | exit(f"gucken {__version__}") 1108 | if args.debug: 1109 | logs_path = user_log_path("gucken", ensure_exists=True) 1110 | logging.basicConfig( 1111 | filename=logs_path.joinpath("gucken.log"), encoding="utf-8", level=logging.INFO, force=True 1112 | ) 1113 | 1114 | register_atexit(gucken_settings_manager.save) 1115 | print(f"\033]0;Gucken {__version__}\007", end='', flush=True) 1116 | gucken_app = GuckenApp(debug=args.debug, search=args.search) 1117 | gucken_app.run() 1118 | print(choice(exit_quotes)) 1119 | 1120 | 1121 | if __name__ == "__main__": 1122 | main() 1123 | -------------------------------------------------------------------------------- /src/gucken/hoster/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Commandcracker/gucken/a44a1ef2722af1b048d4d292d8c5bd7010eeefb2/src/gucken/hoster/__init__.py -------------------------------------------------------------------------------- /src/gucken/hoster/_hosters.py: -------------------------------------------------------------------------------- 1 | from textual._two_way_dict import TwoWayDict 2 | 3 | from .loadx import LoadXHoster 4 | from .veo import VOEHoster 5 | from .vidoza import VidozaHoster 6 | from .speedfiles import SpeedFilesHoster 7 | from .doodstream import DoodstreamHoster 8 | from .vidmoly import VidmolyHoster 9 | from .filemoon import FilemoonHoster 10 | from .luluvdo import LuluvdoHoster 11 | from .streamtape import StreamtapeHoster 12 | 13 | hoster = TwoWayDict( 14 | { 15 | "VEO": VOEHoster, 16 | "VZ": VidozaHoster, 17 | "SF": SpeedFilesHoster, 18 | "DS": DoodstreamHoster, 19 | "VM": VidmolyHoster, 20 | "FM": FilemoonHoster, 21 | "LX": LoadXHoster, 22 | "LU": LuluvdoHoster, 23 | "ST": StreamtapeHoster 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /src/gucken/hoster/common.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from dataclasses import dataclass 3 | 4 | from httpx import HTTPError, AsyncClient 5 | 6 | 7 | @dataclass 8 | class DirectLink: 9 | url: str 10 | headers: dict[str, str] = None 11 | force_hls: bool = False 12 | 13 | async def check_is_working(self) -> bool: 14 | if not self.url: 15 | return False 16 | try: 17 | async with AsyncClient(verify=False) as client: 18 | response = await client.head( 19 | self.url, headers=self.headers 20 | ) 21 | return response.is_success 22 | except HTTPError: 23 | return False 24 | 25 | def __str__(self): 26 | return self.url 27 | 28 | def has_headers(self) -> bool: 29 | if self.headers is not None: 30 | return True 31 | return False 32 | 33 | 34 | @dataclass 35 | class Hoster: 36 | url: str 37 | requires_headers: bool = False 38 | 39 | @abstractmethod 40 | async def get_direct_link(self) -> DirectLink: 41 | raise NotImplementedError 42 | -------------------------------------------------------------------------------- /src/gucken/hoster/doodstream.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from random import choices 3 | from re import compile as re_compile 4 | from string import ascii_letters, digits 5 | from time import time 6 | 7 | from ..networking import AsyncClient 8 | from .common import DirectLink, Hoster 9 | 10 | EXTRACT_DOODSTREAM_HLS_PATTERN = re_compile(r"/pass_md5/[\w-]+/(?P[\w-]+)") 11 | 12 | 13 | def random_str(length: int = 10) -> str: 14 | return "".join(choices(ascii_letters + digits, k=length)) 15 | 16 | 17 | def js_date_now() -> int: 18 | return int(time() * 1000) 19 | 20 | 21 | @dataclass 22 | class DoodstreamHoster(Hoster): 23 | requires_headers = True 24 | 25 | async def get_direct_link(self) -> DirectLink: 26 | async with AsyncClient(verify=False, auto_referer=True) as client: 27 | response1 = await client.get(self.url) 28 | match = EXTRACT_DOODSTREAM_HLS_PATTERN.search(response1.text) 29 | 30 | # Require Referer 31 | response2 = await client.get(str(response1.url.copy_with(path=match.group()))) 32 | return DirectLink( 33 | url=f"{response2.text}{random_str()}?token={match.group('token')}&expiry={js_date_now()}", 34 | headers={"Referer": str(response2.url.copy_with(path="/"))}, 35 | ) 36 | -------------------------------------------------------------------------------- /src/gucken/hoster/filemoon.py: -------------------------------------------------------------------------------- 1 | from re import compile as re_compile 2 | 3 | from .common import DirectLink, Hoster 4 | from ..networking import AsyncClient 5 | from ..packer import unpack 6 | 7 | REDIRECT_REGEX = re_compile(r'